From 5249a386af5f107f9e1ae9ba506ed69f0870e80d Mon Sep 17 00:00:00 2001 From: Luca Zeuch Date: Tue, 4 Nov 2025 17:56:41 +0100 Subject: [PATCH 01/92] lib/template: don't evaluate '.' as float64 (#1971) This is taken directly from upstream at https://github.com/golang/go/commit/0f7b4e72a054f974489d8342cdae5a6a7ba7a31b with minor testing on my end via `evalcc` Unfortunately the patch does not apply cleanly to our codebase, so I had to resort to copy-and-paste, thereby obscuring authorship. Signed-off-by: Luca Zeuch --- lib/template/exec.go | 8 +++++++- lib/template/exec_test.go | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/template/exec.go b/lib/template/exec.go index 71efa8f750..e11c96e8c1 100644 --- a/lib/template/exec.go +++ b/lib/template/exec.go @@ -712,7 +712,9 @@ func (s *state) idealConstant(constant *parse.NumberNode) reflect.Value { switch { case constant.IsComplex: return reflect.ValueOf(constant.Complex128) // incontrovertible. - case constant.IsFloat && !isHexInt(constant.Text) && strings.ContainsAny(constant.Text, ".eEpP"): + case constant.IsFloat && + !isHexInt(constant.Text) && !isRuneInt(constant.Text) && + strings.ContainsAny(constant.Text, ".eEpP"): return reflect.ValueOf(constant.Float64) case constant.IsInt: n := int(constant.Int64) @@ -726,6 +728,10 @@ func (s *state) idealConstant(constant *parse.NumberNode) reflect.Value { return zero } +func isRuneInt(s string) bool { + return len(s) > 0 && s[0] == '\'' +} + func isHexInt(s string) bool { return len(s) > 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') && !strings.ContainsAny(s, "pP") } diff --git a/lib/template/exec_test.go b/lib/template/exec_test.go index b86f10408d..3ac21207cb 100644 --- a/lib/template/exec_test.go +++ b/lib/template/exec_test.go @@ -715,6 +715,12 @@ var execTests = []execTest{ {"bug17c", "{{len .NonEmptyInterfacePtS}}", "2", tVal, true}, {"bug17d", "{{index .NonEmptyInterfacePtS 0}}", "a", tVal, true}, {"bug17e", "{{range .NonEmptyInterfacePtS}}-{{.}}-{{end}}", "-a--b-", tVal, true}, + + // More variadic function corner cases. Some runes would get evaluated + // as constant floats instead of ints. Issue 34483. + {"bug18a", "{{eq . '.'}}", "true", '.', true}, + {"bug18b", "{{eq . 'e'}}", "true", 'e', true}, + {"bug18c", "{{eq . 'P'}}", "true", 'P', true}, } func zeroArgs() string { From 214ebc2da1ff7eb7201c3bd4ce2d7a64e7aa0ebc Mon Sep 17 00:00:00 2001 From: Luca Zeuch Date: Tue, 4 Nov 2025 17:57:08 +0100 Subject: [PATCH 02/92] templates: consider `uint`s for comparison in `in` (#1972) * templates: consider `uint`s for comparison in `in` For unknown reasons, `in` did not consider `uint`s for comparable types, forcing users to a) be extremely confused why their code doesn't work when it should and b) figuring a way around that (either by type conversion or some other arguably unnecessary things). Adjust the definition of `in` to consider `uint`s. Future considerations: can we somehow rewrite this using generics, elimiating the need to exhaustively list all possible comparable types? Signed-off-by: Luca Zeuch * templates: [in] use CanUint and friends, modernise for-loop Signed-off-by: Luca Zeuch --------- Signed-off-by: Luca Zeuch --- common/templates/general.go | 56 ++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/common/templates/general.go b/common/templates/general.go index 3a5a798a79..5d4ba2523e 100644 --- a/common/templates/general.go +++ b/common/templates/general.go @@ -767,39 +767,31 @@ func in(l interface{}, v interface{}) bool { lv, _ := indirect(reflect.ValueOf(l)) vv := reflect.ValueOf(v) - if !reflect.ValueOf(vv).IsZero() { - switch lv.Kind() { - case reflect.Array, reflect.Slice: - for i := 0; i < lv.Len(); i++ { - lvv := lv.Index(i) - lvv, isNil := indirect(lvv) - if isNil { - continue - } - switch lvv.Kind() { - case reflect.String: - if vv.Type() == lvv.Type() && vv.String() == lvv.String() { - return true - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - switch vv.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if vv.Int() == lvv.Int() { - return true - } - } - case reflect.Float32, reflect.Float64: - switch vv.Kind() { - case reflect.Float32, reflect.Float64: - if vv.Float() == lvv.Float() { - return true - } - } - } + if reflect.ValueOf(vv).IsZero() { + return false + } + + switch lv.Kind() { + case reflect.String: + if vv.Type() == lv.Type() && strings.Contains(lv.String(), vv.String()) { + return true + } + case reflect.Array, reflect.Slice: + for i := range lv.Len() { + lvv := lv.Index(i) + lvv, isNil := indirect(lvv) + if isNil { + continue } - case reflect.String: - if vv.Type() == lv.Type() && strings.Contains(lv.String(), vv.String()) { - return true + switch { + case lvv.Kind() == reflect.String: + return vv.Type() == lvv.Type() && vv.String() == lvv.String() + case lvv.CanInt() && vv.CanInt(): + return vv.Int() == lvv.Int() + case lvv.CanUint() && vv.CanUint(): + return vv.Uint() == lvv.Uint() + case lvv.CanFloat() && vv.CanFloat(): + return vv.Float() == lvv.Float() } } } From ad73bb5ea6a089f447a38b002daf26c000d6bc95 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:27:26 +0530 Subject: [PATCH 03/92] added stubs for events that spam the logs (#1974) --- bot/eventsystem/events.go | 226 ++++++++++++++++++++------------- go.mod | 11 +- go.sum | 17 +++ lib/discordgo/event.go | 10 +- lib/discordgo/eventhandlers.go | 128 +++++++++++++++++++ lib/discordgo/events.go | 24 ++++ 6 files changed, 323 insertions(+), 93 deletions(-) diff --git a/bot/eventsystem/events.go b/bot/eventsystem/events.go index 98fd063838..47d9616f77 100644 --- a/bot/eventsystem/events.go +++ b/bot/eventsystem/events.go @@ -54,48 +54,54 @@ const ( EventGuildRoleCreate Event = 39 EventGuildRoleDelete Event = 40 EventGuildRoleUpdate Event = 41 - EventGuildStickersUpdate Event = 42 - EventGuildUpdate Event = 43 - EventInteractionCreate Event = 44 - EventInviteCreate Event = 45 - EventInviteDelete Event = 46 - EventMessageAck Event = 47 - EventMessageCreate Event = 48 - EventMessageDelete Event = 49 - EventMessageDeleteBulk Event = 50 - EventMessageReactionAdd Event = 51 - EventMessageReactionRemove Event = 52 - EventMessageReactionRemoveAll Event = 53 - EventMessageReactionRemoveEmoji Event = 54 - EventMessageUpdate Event = 55 - EventPresenceUpdate Event = 56 - EventPresencesReplace Event = 57 - EventRateLimit Event = 58 - EventReady Event = 59 - EventRelationshipAdd Event = 60 - EventRelationshipRemove Event = 61 - EventResumed Event = 62 - EventStageInstanceCreate Event = 63 - EventStageInstanceDelete Event = 64 - EventStageInstanceUpdate Event = 65 - EventSubscriptionCreate Event = 66 - EventSubscriptionDelete Event = 67 - EventSubscriptionUpdate Event = 68 - EventThreadCreate Event = 69 - EventThreadDelete Event = 70 - EventThreadListSync Event = 71 - EventThreadMemberUpdate Event = 72 - EventThreadMembersUpdate Event = 73 - EventThreadUpdate Event = 74 - EventTypingStart Event = 75 - EventUserGuildSettingsUpdate Event = 76 - EventUserNoteUpdate Event = 77 - EventUserSettingsUpdate Event = 78 - EventUserUpdate Event = 79 - EventVoiceChannelStatusUpdate Event = 80 - EventVoiceServerUpdate Event = 81 - EventVoiceStateUpdate Event = 82 - EventWebhooksUpdate Event = 83 + EventGuildScheduledEventCreate Event = 42 + EventGuildScheduledEventDelete Event = 43 + EventGuildScheduledEventUpdate Event = 44 + EventGuildScheduledEventUserAdd Event = 45 + EventGuildScheduledEventUserRemove Event = 46 + EventGuildStickersUpdate Event = 47 + EventGuildUpdate Event = 48 + EventInteractionCreate Event = 49 + EventInviteCreate Event = 50 + EventInviteDelete Event = 51 + EventMessageAck Event = 52 + EventMessageCreate Event = 53 + EventMessageDelete Event = 54 + EventMessageDeleteBulk Event = 55 + EventMessageReactionAdd Event = 56 + EventMessageReactionRemove Event = 57 + EventMessageReactionRemoveAll Event = 58 + EventMessageReactionRemoveEmoji Event = 59 + EventMessageUpdate Event = 60 + EventPresenceUpdate Event = 61 + EventPresencesReplace Event = 62 + EventRateLimit Event = 63 + EventReady Event = 64 + EventRelationshipAdd Event = 65 + EventRelationshipRemove Event = 66 + EventResumed Event = 67 + EventStageInstanceCreate Event = 68 + EventStageInstanceDelete Event = 69 + EventStageInstanceUpdate Event = 70 + EventSubscriptionCreate Event = 71 + EventSubscriptionDelete Event = 72 + EventSubscriptionUpdate Event = 73 + EventThreadCreate Event = 74 + EventThreadDelete Event = 75 + EventThreadListSync Event = 76 + EventThreadMemberUpdate Event = 77 + EventThreadMembersUpdate Event = 78 + EventThreadUpdate Event = 79 + EventTypingStart Event = 80 + EventUserGuildSettingsUpdate Event = 81 + EventUserNoteUpdate Event = 82 + EventUserSettingsUpdate Event = 83 + EventUserUpdate Event = 84 + EventVoiceChannelStartTimeStatusUpdate Event = 85 + EventVoiceChannelStatusUpdate Event = 86 + EventVoiceServerUpdate Event = 87 + EventVoiceStateUpdate Event = 88 + EventWebhooksUpdate Event = 89 ) var EventNames = []string{ @@ -141,6 +147,11 @@ var EventNames = []string{ "GuildRoleCreate", "GuildRoleDelete", "GuildRoleUpdate", + "GuildScheduledEventCreate", + "GuildScheduledEventDelete", + "GuildScheduledEventUpdate", + "GuildScheduledEventUserAdd", + "GuildScheduledEventUserRemove", "GuildStickersUpdate", "GuildUpdate", "InteractionCreate", @@ -179,6 +190,7 @@ var EventNames = []string{ "UserNoteUpdate", "UserSettingsUpdate", "UserUpdate", + "VoiceChannelStartTimeStatusUpdate", "VoiceChannelStatusUpdate", "VoiceServerUpdate", "VoiceStateUpdate", @@ -224,6 +236,11 @@ var AllDiscordEvents = []Event{ EventGuildRoleCreate, EventGuildRoleDelete, EventGuildRoleUpdate, + EventGuildScheduledEventCreate, + EventGuildScheduledEventDelete, + EventGuildScheduledEventUpdate, + EventGuildScheduledEventUserAdd, + EventGuildScheduledEventUserRemove, EventGuildStickersUpdate, EventGuildUpdate, EventInteractionCreate, @@ -262,6 +279,7 @@ var AllDiscordEvents = []Event{ EventUserNoteUpdate, EventUserSettingsUpdate, EventUserUpdate, + EventVoiceChannelStartTimeStatusUpdate, EventVoiceChannelStatusUpdate, EventVoiceServerUpdate, EventVoiceStateUpdate, @@ -311,6 +329,11 @@ var AllEvents = []Event{ EventGuildRoleCreate, EventGuildRoleDelete, EventGuildRoleUpdate, + EventGuildScheduledEventCreate, + EventGuildScheduledEventDelete, + EventGuildScheduledEventUpdate, + EventGuildScheduledEventUserAdd, + EventGuildScheduledEventUserRemove, EventGuildStickersUpdate, EventGuildUpdate, EventInteractionCreate, @@ -349,13 +372,14 @@ var AllEvents = []Event{ EventUserNoteUpdate, EventUserSettingsUpdate, EventUserUpdate, + EventVoiceChannelStartTimeStatusUpdate, EventVoiceChannelStatusUpdate, EventVoiceServerUpdate, EventVoiceStateUpdate, EventWebhooksUpdate, } -var handlers = make([][][]*Handler, 84) +var handlers = make([][][]*Handler, 90) func (data *EventData) ApplicationCommandCreate() *discordgo.ApplicationCommandCreate { return data.EvtInterface.(*discordgo.ApplicationCommandCreate) @@ -459,6 +483,21 @@ func (data *EventData) GuildRoleDelete() *discordgo.GuildRoleDelete { func (data *EventData) GuildRoleUpdate() *discordgo.GuildRoleUpdate { return data.EvtInterface.(*discordgo.GuildRoleUpdate) } +func (data *EventData) GuildScheduledEventCreate() *discordgo.GuildScheduledEventCreate { + return data.EvtInterface.(*discordgo.GuildScheduledEventCreate) +} +func (data *EventData) GuildScheduledEventDelete() *discordgo.GuildScheduledEventDelete { + return data.EvtInterface.(*discordgo.GuildScheduledEventDelete) +} +func (data *EventData) GuildScheduledEventUpdate() *discordgo.GuildScheduledEventUpdate { + return data.EvtInterface.(*discordgo.GuildScheduledEventUpdate) +} +func (data *EventData) GuildScheduledEventUserAdd() *discordgo.GuildScheduledEventUserAdd { + return data.EvtInterface.(*discordgo.GuildScheduledEventUserAdd) +} +func (data *EventData) GuildScheduledEventUserRemove() *discordgo.GuildScheduledEventUserRemove { + return data.EvtInterface.(*discordgo.GuildScheduledEventUserRemove) +} func (data *EventData) GuildStickersUpdate() *discordgo.GuildStickersUpdate { return data.EvtInterface.(*discordgo.GuildStickersUpdate) } @@ -573,6 +612,9 @@ func (data *EventData) UserSettingsUpdate() *discordgo.UserSettingsUpdate { func (data *EventData) UserUpdate() *discordgo.UserUpdate { return data.EvtInterface.(*discordgo.UserUpdate) } +func (data *EventData) VoiceChannelStartTimeStatusUpdate() *discordgo.VoiceChannelStartTimeStatusUpdate { + return data.EvtInterface.(*discordgo.VoiceChannelStartTimeStatusUpdate) +} func (data *EventData) VoiceChannelStatusUpdate() *discordgo.VoiceChannelStatusUpdate { return data.EvtInterface.(*discordgo.VoiceChannelStatusUpdate) } @@ -657,91 +699,105 @@ func fillEvent(evtData *EventData) { evtData.Type = Event(40) case *discordgo.GuildRoleUpdate: evtData.Type = Event(41) - case *discordgo.GuildStickersUpdate: + case *discordgo.GuildScheduledEventCreate: evtData.Type = Event(42) - case *discordgo.GuildUpdate: + case *discordgo.GuildScheduledEventDelete: evtData.Type = Event(43) - case *discordgo.InteractionCreate: + case *discordgo.GuildScheduledEventUpdate: evtData.Type = Event(44) - case *discordgo.InviteCreate: + case *discordgo.GuildScheduledEventUserAdd: evtData.Type = Event(45) - case *discordgo.InviteDelete: + case *discordgo.GuildScheduledEventUserRemove: evtData.Type = Event(46) - case *discordgo.MessageAck: + case *discordgo.GuildStickersUpdate: evtData.Type = Event(47) - case *discordgo.MessageCreate: + case *discordgo.GuildUpdate: evtData.Type = Event(48) - case *discordgo.MessageDelete: + case *discordgo.InteractionCreate: evtData.Type = Event(49) - case *discordgo.MessageDeleteBulk: + case *discordgo.InviteCreate: evtData.Type = Event(50) - case *discordgo.MessageReactionAdd: + case *discordgo.InviteDelete: evtData.Type = Event(51) - case *discordgo.MessageReactionRemove: + case *discordgo.MessageAck: evtData.Type = Event(52) - case *discordgo.MessageReactionRemoveAll: + case *discordgo.MessageCreate: evtData.Type = Event(53) - case *discordgo.MessageReactionRemoveEmoji: + case *discordgo.MessageDelete: evtData.Type = Event(54) - case *discordgo.MessageUpdate: + case *discordgo.MessageDeleteBulk: evtData.Type = Event(55) - case *discordgo.PresenceUpdate: + case *discordgo.MessageReactionAdd: evtData.Type = Event(56) - case *discordgo.PresencesReplace: + case *discordgo.MessageReactionRemove: evtData.Type = Event(57) - case *discordgo.RateLimit: + case *discordgo.MessageReactionRemoveAll: evtData.Type = Event(58) - case *discordgo.Ready: + case *discordgo.MessageReactionRemoveEmoji: evtData.Type = Event(59) - case *discordgo.RelationshipAdd: + case *discordgo.MessageUpdate: evtData.Type = Event(60) - case *discordgo.RelationshipRemove: + case *discordgo.PresenceUpdate: evtData.Type = Event(61) - case *discordgo.Resumed: + case *discordgo.PresencesReplace: evtData.Type = Event(62) - case *discordgo.StageInstanceCreate: + case *discordgo.RateLimit: evtData.Type = Event(63) - case *discordgo.StageInstanceDelete: + case *discordgo.Ready: evtData.Type = Event(64) - case *discordgo.StageInstanceUpdate: + case *discordgo.RelationshipAdd: evtData.Type = Event(65) - case *discordgo.SubscriptionCreate: + case *discordgo.RelationshipRemove: evtData.Type = Event(66) - case *discordgo.SubscriptionDelete: + case *discordgo.Resumed: evtData.Type = Event(67) - case *discordgo.SubscriptionUpdate: + case *discordgo.StageInstanceCreate: evtData.Type = Event(68) - case *discordgo.ThreadCreate: + case *discordgo.StageInstanceDelete: evtData.Type = Event(69) - case *discordgo.ThreadDelete: + case *discordgo.StageInstanceUpdate: evtData.Type = Event(70) - case *discordgo.ThreadListSync: + case *discordgo.SubscriptionCreate: evtData.Type = Event(71) - case *discordgo.ThreadMemberUpdate: + case *discordgo.SubscriptionDelete: evtData.Type = Event(72) - case *discordgo.ThreadMembersUpdate: + case *discordgo.SubscriptionUpdate: evtData.Type = Event(73) - case *discordgo.ThreadUpdate: + case *discordgo.ThreadCreate: evtData.Type = Event(74) - case *discordgo.TypingStart: + case *discordgo.ThreadDelete: evtData.Type = Event(75) - case *discordgo.UserGuildSettingsUpdate: + case *discordgo.ThreadListSync: evtData.Type = Event(76) - case *discordgo.UserNoteUpdate: + case *discordgo.ThreadMemberUpdate: evtData.Type = Event(77) - case *discordgo.UserSettingsUpdate: + case *discordgo.ThreadMembersUpdate: evtData.Type = Event(78) - case *discordgo.UserUpdate: + case *discordgo.ThreadUpdate: evtData.Type = Event(79) - case *discordgo.VoiceChannelStatusUpdate: + case *discordgo.TypingStart: evtData.Type = Event(80) - case *discordgo.VoiceServerUpdate: + case *discordgo.UserGuildSettingsUpdate: evtData.Type = Event(81) - case *discordgo.VoiceStateUpdate: + case *discordgo.UserNoteUpdate: evtData.Type = Event(82) - case *discordgo.WebhooksUpdate: + case *discordgo.UserSettingsUpdate: evtData.Type = Event(83) + case *discordgo.UserUpdate: + evtData.Type = Event(84) + case *discordgo.VoiceChannelStartTimeStatusUpdate: + evtData.Type = Event(85) + case *discordgo.VoiceChannelStatusUpdate: + evtData.Type = Event(86) + case *discordgo.VoiceServerUpdate: + evtData.Type = Event(87) + case *discordgo.VoiceStateUpdate: + evtData.Type = Event(88) + case *discordgo.WebhooksUpdate: + evtData.Type = Event(89) default: return } + + return } diff --git a/go.mod b/go.mod index abbcc4203f..2d65064430 100644 --- a/go.mod +++ b/go.mod @@ -60,12 +60,12 @@ require ( github.com/volatiletech/sqlboiler/v4 v4.14.2 github.com/volatiletech/strmangle v0.0.6 goji.io v2.0.2+incompatible - golang.org/x/crypto v0.36.0 + golang.org/x/crypto v0.43.0 golang.org/x/image v0.18.0 - golang.org/x/net v0.38.0 + golang.org/x/net v0.46.0 golang.org/x/oauth2 v0.27.0 - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 golang.org/x/time v0.3.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/api v0.131.0 @@ -106,6 +106,9 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.3.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect ) diff --git a/go.sum b/go.sum index 5eb4979cb5..69189a7297 100644 --- a/go.sum +++ b/go.sum @@ -830,6 +830,8 @@ golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4 golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -870,6 +872,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -930,6 +934,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -972,6 +978,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1067,6 +1075,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -1075,6 +1085,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1091,6 +1102,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1157,6 +1170,10 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/lib/discordgo/event.go b/lib/discordgo/event.go index bec700b0a7..7e1a9ff752 100644 --- a/lib/discordgo/event.go +++ b/lib/discordgo/event.go @@ -119,12 +119,14 @@ func (s *Session) addEventHandlerOnce(eventHandler EventHandler) func() { // to a struct corresponding to the event for which you want to listen. // // eg: -// Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { -// }) +// +// Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { +// }) // // or: -// Session.AddHandler(func(s *discordgo.Session, m *discordgo.PresenceUpdate) { -// }) +// +// Session.AddHandler(func(s *discordgo.Session, m *discordgo.PresenceUpdate) { +// }) // // List of events can be found at this page, with corresponding names in the // library for each event: https://discordapp.com/developers/docs/topics/gateway#event-names diff --git a/lib/discordgo/eventhandlers.go b/lib/discordgo/eventhandlers.go index 50235b8a46..b060139f7c 100644 --- a/lib/discordgo/eventhandlers.go +++ b/lib/discordgo/eventhandlers.go @@ -42,6 +42,11 @@ const ( guildRoleCreateEventType = "GUILD_ROLE_CREATE" guildRoleDeleteEventType = "GUILD_ROLE_DELETE" guildRoleUpdateEventType = "GUILD_ROLE_UPDATE" + guildScheduledEventCreateEventType = "GUILD_SCHEDULED_EVENT_CREATE" + guildScheduledEventDeleteEventType = "GUILD_SCHEDULED_EVENT_DELETE" + guildScheduledEventUpdateEventType = "GUILD_SCHEDULED_EVENT_UPDATE" + guildScheduledEventUserAddEventType = "GUILD_SCHEDULED_EVENT_USER_ADD" + guildScheduledEventUserRemoveEventType = "GUILD_SCHEDULED_EVENT_USER_REMOVE" guildStickersUpdateEventType = "GUILD_STICKERS_UPDATE" guildUpdateEventType = "GUILD_UPDATE" interactionCreateEventType = "INTERACTION_CREATE" @@ -81,6 +86,7 @@ const ( userSettingsUpdateEventType = "USER_SETTINGS_UPDATE" userUpdateEventType = "USER_UPDATE" voiceChannelStatusUpdateEventType = "VOICE_CHANNEL_STATUS_UPDATE" + voiceChannelStartTimeUpdateEventType = "VOICE_CHANNEL_START_TIME_UPDATE" voiceServerUpdateEventType = "VOICE_SERVER_UPDATE" voiceStateUpdateEventType = "VOICE_STATE_UPDATE" webhooksUpdateEventType = "WEBHOOKS_UPDATE" @@ -771,6 +777,106 @@ func (eh guildRoleUpdateEventHandler) Handle(s *Session, i interface{}) { } } +// guildScheduledEventCreateEventHandler is an event handler for GuildScheduledEventCreate events. +type guildScheduledEventCreateEventHandler func(*Session, *GuildScheduledEventCreate) + +// Type returns the event type for GuildScheduledEventCreate events. +func (eh guildScheduledEventCreateEventHandler) Type() string { + return guildScheduledEventCreateEventType +} + +// New returns a new instance of GuildScheduledEventCreate. +func (eh guildScheduledEventCreateEventHandler) New() interface{} { + return &GuildScheduledEventCreate{} +} + +// Handle is the handler for GuildScheduledEventCreate events. +func (eh guildScheduledEventCreateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildScheduledEventCreate); ok { + eh(s, t) + } +} + +// guildScheduledEventDeleteEventHandler is an event handler for GuildScheduledEventDelete events. +type guildScheduledEventDeleteEventHandler func(*Session, *GuildScheduledEventDelete) + +// Type returns the event type for GuildScheduledEventDelete events. +func (eh guildScheduledEventDeleteEventHandler) Type() string { + return guildScheduledEventDeleteEventType +} + +// New returns a new instance of GuildScheduledEventDelete. +func (eh guildScheduledEventDeleteEventHandler) New() interface{} { + return &GuildScheduledEventDelete{} +} + +// Handle is the handler for GuildScheduledEventDelete events. +func (eh guildScheduledEventDeleteEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildScheduledEventDelete); ok { + eh(s, t) + } +} + +// guildScheduledEventUpdateEventHandler is an event handler for GuildScheduledEventUpdate events. +type guildScheduledEventUpdateEventHandler func(*Session, *GuildScheduledEventUpdate) + +// Type returns the event type for GuildScheduledEventUpdate events. +func (eh guildScheduledEventUpdateEventHandler) Type() string { + return guildScheduledEventUpdateEventType +} + +// New returns a new instance of GuildScheduledEventUpdate. +func (eh guildScheduledEventUpdateEventHandler) New() interface{} { + return &GuildScheduledEventUpdate{} +} + +// Handle is the handler for GuildScheduledEventUpdate events. +func (eh guildScheduledEventUpdateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildScheduledEventUpdate); ok { + eh(s, t) + } +} + +// guildScheduledEventUserAddEventHandler is an event handler for GuildScheduledEventUserAdd events. +type guildScheduledEventUserAddEventHandler func(*Session, *GuildScheduledEventUserAdd) + +// Type returns the event type for GuildScheduledEventUserAdd events. +func (eh guildScheduledEventUserAddEventHandler) Type() string { + return guildScheduledEventUserAddEventType +} + +// New returns a new instance of GuildScheduledEventUserAdd. +func (eh guildScheduledEventUserAddEventHandler) New() interface{} { + return &GuildScheduledEventUserAdd{} +} + +// Handle is the handler for GuildScheduledEventUserAdd events. +func (eh guildScheduledEventUserAddEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildScheduledEventUserAdd); ok { + eh(s, t) + } +} + +// guildScheduledEventUserRemoveEventHandler is an event handler for GuildScheduledEventUserRemove events. +type guildScheduledEventUserRemoveEventHandler func(*Session, *GuildScheduledEventUserRemove) + +// Type returns the event type for GuildScheduledEventUserRemove events. +func (eh guildScheduledEventUserRemoveEventHandler) Type() string { + return guildScheduledEventUserRemoveEventType +} + +// New returns a new instance of GuildScheduledEventUserRemove. +func (eh guildScheduledEventUserRemoveEventHandler) New() interface{} { + return &GuildScheduledEventUserRemove{} +} + +// Handle is the handler for GuildScheduledEventUserRemove events. +func (eh guildScheduledEventUserRemoveEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildScheduledEventUserRemove); ok { + eh(s, t) + } +} + // guildStickersUpdateEventHandler is an event handler for GuildStickersUpdate events. type guildStickersUpdateEventHandler func(*Session, *GuildStickersUpdate) @@ -1546,6 +1652,26 @@ func (eh voiceChannelStatusUpdateEventHandler) Handle(s *Session, i interface{}) } } +// voiceChannelStartTimeUpdateEventHandler is an event handler for VoiceChannelStartTimeStatusUpdate events. +type voiceChannelStartTimeUpdateEventHandler func(*Session, *VoiceChannelStartTimeStatusUpdate) + +// Type returns the event type for VoiceChannelStartTimeStatusUpdate events. +func (eh voiceChannelStartTimeUpdateEventHandler) Type() string { + return voiceChannelStartTimeUpdateEventType +} + +// New returns a new instance of VoiceChannelStartTimeStatusUpdate. +func (eh voiceChannelStartTimeUpdateEventHandler) New() interface{} { + return &VoiceChannelStartTimeStatusUpdate{} +} + +// Handle is the handler for VoiceChannelStartTimeStatusUpdate events. +func (eh voiceChannelStartTimeUpdateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*VoiceChannelStartTimeStatusUpdate); ok { + eh(s, t) + } +} + // voiceServerUpdateEventHandler is an event handler for VoiceServerUpdate events. type voiceServerUpdateEventHandler func(*Session, *VoiceServerUpdate) @@ -1760,6 +1886,8 @@ func handlerForInterface(handler interface{}) EventHandler { return voiceChannelStatusUpdateEventHandler(v) case func(*Session, *VoiceServerUpdate): return voiceServerUpdateEventHandler(v) + case func(*Session, *VoiceChannelStartTimeStatusUpdate): + return voiceChannelStartTimeUpdateEventHandler(v) case func(*Session, *VoiceStateUpdate): return voiceStateUpdateEventHandler(v) case func(*Session, *WebhooksUpdate): diff --git a/lib/discordgo/events.go b/lib/discordgo/events.go index 0b3b645091..578cf16c17 100644 --- a/lib/discordgo/events.go +++ b/lib/discordgo/events.go @@ -526,6 +526,29 @@ func (e *GuildStickersUpdate) GetGuildID() int64 { return e.GuildID } +// GuildScheduledEventCreate is the data for a GuildScheduledEventCreate event. +type GuildScheduledEventCreate struct{} + +// GuildScheduledEventUpdate is the data for a GuildScheduledEventUpdate event. +type GuildScheduledEventUpdate struct{} + +// GuildScheduledEventDelete is the data for a GuildScheduledEventDelete event. +type GuildScheduledEventDelete struct{} + +// GuildScheduledEventUserAdd is the data for a GuildScheduledEventUserAdd event. +type GuildScheduledEventUserAdd struct { + GuildScheduledEventID int64 `json:"guild_scheduled_event_id,string"` + UserID int64 `json:"user_id,string"` + GuildID int64 `json:"guild_id,string"` +} + +// GuildScheduledEventUserRemove is the data for a GuildScheduledEventUserRemove event. +type GuildScheduledEventUserRemove struct { + GuildScheduledEventID int64 `json:"guild_scheduled_event_id,string"` + UserID int64 `json:"user_id,string"` + GuildID int64 `json:"guild_id,string"` +} + // stage instance was created type StageInstanceCreate struct { } @@ -584,6 +607,7 @@ type GuildAuditLogEntryCreate struct { type GuildJoinRequestUpdate struct{} type GuildJoinRequestDelete struct{} type VoiceChannelStatusUpdate struct{} +type VoiceChannelStartTimeStatusUpdate struct{} type ChannelTopicUpdate struct{} // Monetization events From a58c38d794ab1e5635b973d5d812c09f8ca00639 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:21:02 +0530 Subject: [PATCH 04/92] add structs for undocumented activity component to prevent breaking message create events (#1975) --- lib/discordgo/components.go | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/lib/discordgo/components.go b/lib/discordgo/components.go index bbe3ac8f1f..48fd58ef87 100644 --- a/lib/discordgo/components.go +++ b/lib/discordgo/components.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "time" ) // ComponentType is type of component. @@ -25,6 +26,7 @@ const ( MediaGalleryComponent ComponentType = 12 FileComponent ComponentType = 13 SeparatorComponent ComponentType = 14 + ActivityContentComponent ComponentType = 16 ContainerComponent ComponentType = 17 ) @@ -49,6 +51,8 @@ func (umc *unmarshalableMessageComponent) UnmarshalJSON(src []byte) error { } switch v.Type { + case ActivityContentComponent: + umc.MessageComponent = &ActivityContent{} case ActionsRowComponent: umc.MessageComponent = &ActionsRow{} case ButtonComponent: @@ -100,6 +104,77 @@ type InteractiveComponent interface { IsInteractive() bool } +type ActivityContentInventoryTrait struct { + Type int `json:"type"` + DurationSeconds int `json:"duration_seconds"` + FirstTime bool `json:"first_time"` +} + +type ActivityContentInventorySignature struct { + Version int `json:"version"` + Signature string `json:"signature"` + Kid string `json:"kid"` +} + +type ActivityContentInventoryExtra struct { + Type int `json:"type"` + Platform int `json:"platform"` + GameName string `json:"game_name"` + ApplicationID string `json:"application_id"` +} + +type ActivityContentInventoryEntry struct { + ID int `json:"id"` + Traits []ActivityContentInventoryTrait `json:"traits"` + StartedAt time.Time `json:"started_at"` + Signature ActivityContentInventorySignature `json:"signature"` + Participants []string `json:"participants"` + EndedAt time.Time `json:"ended_at"` + ContentType int `json:"content_type"` + AuthorType int `json:"author_type"` + AuthorID string `json:"author_id"` + Extra ActivityContentInventoryExtra `json:"extra"` +} + +type ActivityContent struct { + InventoryEntry ActivityContentInventoryEntry `json:"inventory_entry"` +} + +// Type is a method to get the type of a component. +func (r ActivityContent) Type() ComponentType { + return ActionsRowComponent +} + +// IsTopLevel is a method to assert the component as top level. +func (ActivityContent) IsTopLevel() bool { + return true +} + +// MarshalJSON is a method for marshaling ActionsRow to a JSON object. +func (r ActivityContent) MarshalJSON() ([]byte, error) { + type activityContent ActivityContent + return json.Marshal(struct { + activityContent + Type ComponentType `json:"type"` + }{ + activityContent: activityContent(r), + Type: r.Type(), + }) +} + +// UnmarshalJSON is a helper function to unmarshal ActivityContent +func (r *ActivityContent) UnmarshalJSON(data []byte) error { + var v struct { + ActivityContent ActivityContent `json:"components"` + } + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + *r = v.ActivityContent + return err +} + // ActionsRow is a container for interactive components within one row. type ActionsRow struct { Components []InteractiveComponent `json:"components"` @@ -428,6 +503,8 @@ func GetTextDisplayContent(component TopLevelComponent) (contents []string) { switch typed := component.(type) { case ActionsRow: return + case ActivityContent: + return case Section: for _, c := range typed.Components { comp, ok := c.(TopLevelComponent) From ffe56e6bd7931fbe063d511421588d4d416b3cdf Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 6 Nov 2025 21:40:36 +0530 Subject: [PATCH 05/92] added more stubs for spammy events (#1976) --- bot/eventsystem/events.go | 330 ++++++++++++++++++++------------- lib/discordgo/components.go | 2 +- lib/discordgo/eventhandlers.go | 212 ++++++++++++++++++++- lib/discordgo/events.go | 8 + 4 files changed, 420 insertions(+), 132 deletions(-) diff --git a/bot/eventsystem/events.go b/bot/eventsystem/events.go index 47d9616f77..c8d3869c78 100644 --- a/bot/eventsystem/events.go +++ b/bot/eventsystem/events.go @@ -38,70 +38,78 @@ const ( EventEntitlementCreate Event = 23 EventEntitlementDelete Event = 24 EventEntitlementUpdate Event = 25 - EventGuildAuditLogEntryCreate Event = 26 - EventGuildBanAdd Event = 27 - EventGuildBanRemove Event = 28 - EventGuildCreate Event = 29 - EventGuildDelete Event = 30 - EventGuildEmojisUpdate Event = 31 - EventGuildIntegrationsUpdate Event = 32 - EventGuildJoinRequestDelete Event = 33 - EventGuildJoinRequestUpdate Event = 34 - EventGuildMemberAdd Event = 35 - EventGuildMemberRemove Event = 36 - EventGuildMemberUpdate Event = 37 - EventGuildMembersChunk Event = 38 - EventGuildRoleCreate Event = 39 - EventGuildRoleDelete Event = 40 - EventGuildRoleUpdate Event = 41 - EventGuildScheduledEventCreate Event = 42 - EventGuildScheduledEventDelete Event = 43 - EventGuildScheduledEventUpdate Event = 44 - EventGuildScheduledEventUserAdd Event = 45 - EventGuildScheduledEventUserRemove Event = 46 - EventGuildStickersUpdate Event = 47 - EventGuildUpdate Event = 48 - EventInteractionCreate Event = 49 - EventInviteCreate Event = 50 - EventInviteDelete Event = 51 - EventMessageAck Event = 52 - EventMessageCreate Event = 53 - EventMessageDelete Event = 54 - EventMessageDeleteBulk Event = 55 - EventMessageReactionAdd Event = 56 - EventMessageReactionRemove Event = 57 - EventMessageReactionRemoveAll Event = 58 - EventMessageReactionRemoveEmoji Event = 59 - EventMessageUpdate Event = 60 - EventPresenceUpdate Event = 61 - EventPresencesReplace Event = 62 - EventRateLimit Event = 63 - EventReady Event = 64 - EventRelationshipAdd Event = 65 - EventRelationshipRemove Event = 66 - EventResumed Event = 67 - EventStageInstanceCreate Event = 68 - EventStageInstanceDelete Event = 69 - EventStageInstanceUpdate Event = 70 - EventSubscriptionCreate Event = 71 - EventSubscriptionDelete Event = 72 - EventSubscriptionUpdate Event = 73 - EventThreadCreate Event = 74 - EventThreadDelete Event = 75 - EventThreadListSync Event = 76 - EventThreadMemberUpdate Event = 77 - EventThreadMembersUpdate Event = 78 - EventThreadUpdate Event = 79 - EventTypingStart Event = 80 - EventUserGuildSettingsUpdate Event = 81 - EventUserNoteUpdate Event = 82 - EventUserSettingsUpdate Event = 83 - EventUserUpdate Event = 84 - EventVoiceChannelStartTimeStatusUpdate Event = 85 - EventVoiceChannelStatusUpdate Event = 86 - EventVoiceServerUpdate Event = 87 - EventVoiceStateUpdate Event = 88 - EventWebhooksUpdate Event = 89 + EventGiftCodeUpdate Event = 26 + EventGuildAppliedBoostUpdate Event = 27 + EventGuildAuditLogEntryCreate Event = 28 + EventGuildBanAdd Event = 29 + EventGuildBanRemove Event = 30 + EventGuildCreate Event = 31 + EventGuildDelete Event = 32 + EventGuildEmojisUpdate Event = 33 + EventGuildIntegrationsUpdate Event = 34 + EventGuildJoinRequestCreate Event = 35 + EventGuildJoinRequestDelete Event = 36 + EventGuildJoinRequestUpdate Event = 37 + EventGuildMemberAdd Event = 38 + EventGuildMemberRemove Event = 39 + EventGuildMemberUpdate Event = 40 + EventGuildMembersChunk Event = 41 + EventGuildPowerupEntitlementsCreate Event = 42 + EventGuildRoleCreate Event = 43 + EventGuildRoleDelete Event = 44 + EventGuildRoleUpdate Event = 45 + EventGuildScheduledEventCreate Event = 46 + EventGuildScheduledEventDelete Event = 47 + EventGuildScheduledEventUpdate Event = 48 + EventGuildScheduledEventUserAdd Event = 49 + EventGuildScheduledEventUserRemove Event = 50 + EventGuildSoundboardSoundCreate Event = 51 + EventGuildSoundboardSoundDelete Event = 52 + EventGuildSoundboardSoundsUpdate Event = 53 + EventGuildStickersUpdate Event = 54 + EventGuildUpdate Event = 55 + EventInteractionCreate Event = 56 + EventInviteCreate Event = 57 + EventInviteDelete Event = 58 + EventMessageAck Event = 59 + EventMessageCreate Event = 60 + EventMessageDelete Event = 61 + EventMessageDeleteBulk Event = 62 + EventMessageReactionAdd Event = 63 + EventMessageReactionRemove Event = 64 + EventMessageReactionRemoveAll Event = 65 + EventMessageReactionRemoveEmoji Event = 66 + EventMessageUpdate Event = 67 + EventPresenceUpdate Event = 68 + EventPresencesReplace Event = 69 + EventRateLimit Event = 70 + EventReady Event = 71 + EventRelationshipAdd Event = 72 + EventRelationshipRemove Event = 73 + EventResumed Event = 74 + EventStageInstanceCreate Event = 75 + EventStageInstanceDelete Event = 76 + EventStageInstanceUpdate Event = 77 + EventSubscriptionCreate Event = 78 + EventSubscriptionDelete Event = 79 + EventSubscriptionUpdate Event = 80 + EventThreadCreate Event = 81 + EventThreadDelete Event = 82 + EventThreadListSync Event = 83 + EventThreadMemberUpdate Event = 84 + EventThreadMembersUpdate Event = 85 + EventThreadUpdate Event = 86 + EventTypingStart Event = 87 + EventUserGuildSettingsUpdate Event = 88 + EventUserNoteUpdate Event = 89 + EventUserSettingsUpdate Event = 90 + EventUserUpdate Event = 91 + EventVoiceChannelEffectSend Event = 92 + EventVoiceChannelStartTimeStatusUpdate Event = 93 + EventVoiceChannelStatusUpdate Event = 94 + EventVoiceServerUpdate Event = 95 + EventVoiceStateUpdate Event = 96 + EventWebhooksUpdate Event = 97 ) var EventNames = []string{ @@ -131,6 +139,8 @@ var EventNames = []string{ "EntitlementCreate", "EntitlementDelete", "EntitlementUpdate", + "GiftCodeUpdate", + "GuildAppliedBoostUpdate", "GuildAuditLogEntryCreate", "GuildBanAdd", "GuildBanRemove", @@ -138,12 +148,14 @@ var EventNames = []string{ "GuildDelete", "GuildEmojisUpdate", "GuildIntegrationsUpdate", + "GuildJoinRequestCreate", "GuildJoinRequestDelete", "GuildJoinRequestUpdate", "GuildMemberAdd", "GuildMemberRemove", "GuildMemberUpdate", "GuildMembersChunk", + "GuildPowerupEntitlementsCreate", "GuildRoleCreate", "GuildRoleDelete", "GuildRoleUpdate", @@ -152,6 +164,9 @@ var EventNames = []string{ "GuildScheduledEventUpdate", "GuildScheduledEventUserAdd", "GuildScheduledEventUserRemove", + "GuildSoundboardSoundCreate", + "GuildSoundboardSoundDelete", + "GuildSoundboardSoundsUpdate", "GuildStickersUpdate", "GuildUpdate", "InteractionCreate", @@ -190,6 +205,7 @@ var EventNames = []string{ "UserNoteUpdate", "UserSettingsUpdate", "UserUpdate", + "VoiceChannelEffectSend", "VoiceChannelStartTimeStatusUpdate", "VoiceChannelStatusUpdate", "VoiceServerUpdate", @@ -220,6 +236,8 @@ var AllDiscordEvents = []Event{ EventEntitlementCreate, EventEntitlementDelete, EventEntitlementUpdate, + EventGiftCodeUpdate, + EventGuildAppliedBoostUpdate, EventGuildAuditLogEntryCreate, EventGuildBanAdd, EventGuildBanRemove, @@ -227,12 +245,14 @@ var AllDiscordEvents = []Event{ EventGuildDelete, EventGuildEmojisUpdate, EventGuildIntegrationsUpdate, + EventGuildJoinRequestCreate, EventGuildJoinRequestDelete, EventGuildJoinRequestUpdate, EventGuildMemberAdd, EventGuildMemberRemove, EventGuildMemberUpdate, EventGuildMembersChunk, + EventGuildPowerupEntitlementsCreate, EventGuildRoleCreate, EventGuildRoleDelete, EventGuildRoleUpdate, @@ -241,6 +261,9 @@ var AllDiscordEvents = []Event{ EventGuildScheduledEventUpdate, EventGuildScheduledEventUserAdd, EventGuildScheduledEventUserRemove, + EventGuildSoundboardSoundCreate, + EventGuildSoundboardSoundDelete, + EventGuildSoundboardSoundsUpdate, EventGuildStickersUpdate, EventGuildUpdate, EventInteractionCreate, @@ -279,6 +302,7 @@ var AllDiscordEvents = []Event{ EventUserNoteUpdate, EventUserSettingsUpdate, EventUserUpdate, + EventVoiceChannelEffectSend, EventVoiceChannelStartTimeStatusUpdate, EventVoiceChannelStatusUpdate, EventVoiceServerUpdate, @@ -313,6 +337,8 @@ var AllEvents = []Event{ EventEntitlementCreate, EventEntitlementDelete, EventEntitlementUpdate, + EventGiftCodeUpdate, + EventGuildAppliedBoostUpdate, EventGuildAuditLogEntryCreate, EventGuildBanAdd, EventGuildBanRemove, @@ -320,12 +346,14 @@ var AllEvents = []Event{ EventGuildDelete, EventGuildEmojisUpdate, EventGuildIntegrationsUpdate, + EventGuildJoinRequestCreate, EventGuildJoinRequestDelete, EventGuildJoinRequestUpdate, EventGuildMemberAdd, EventGuildMemberRemove, EventGuildMemberUpdate, EventGuildMembersChunk, + EventGuildPowerupEntitlementsCreate, EventGuildRoleCreate, EventGuildRoleDelete, EventGuildRoleUpdate, @@ -334,6 +362,9 @@ var AllEvents = []Event{ EventGuildScheduledEventUpdate, EventGuildScheduledEventUserAdd, EventGuildScheduledEventUserRemove, + EventGuildSoundboardSoundCreate, + EventGuildSoundboardSoundDelete, + EventGuildSoundboardSoundsUpdate, EventGuildStickersUpdate, EventGuildUpdate, EventInteractionCreate, @@ -372,6 +403,7 @@ var AllEvents = []Event{ EventUserNoteUpdate, EventUserSettingsUpdate, EventUserUpdate, + EventVoiceChannelEffectSend, EventVoiceChannelStartTimeStatusUpdate, EventVoiceChannelStatusUpdate, EventVoiceServerUpdate, @@ -379,7 +411,7 @@ var AllEvents = []Event{ EventWebhooksUpdate, } -var handlers = make([][][]*Handler, 90) +var handlers = make([][][]*Handler, 98) func (data *EventData) ApplicationCommandCreate() *discordgo.ApplicationCommandCreate { return data.EvtInterface.(*discordgo.ApplicationCommandCreate) @@ -435,6 +467,12 @@ func (data *EventData) EntitlementDelete() *discordgo.EntitlementDelete { func (data *EventData) EntitlementUpdate() *discordgo.EntitlementUpdate { return data.EvtInterface.(*discordgo.EntitlementUpdate) } +func (data *EventData) GiftCodeUpdate() *discordgo.GiftCodeUpdate { + return data.EvtInterface.(*discordgo.GiftCodeUpdate) +} +func (data *EventData) GuildAppliedBoostUpdate() *discordgo.GuildAppliedBoostUpdate { + return data.EvtInterface.(*discordgo.GuildAppliedBoostUpdate) +} func (data *EventData) GuildAuditLogEntryCreate() *discordgo.GuildAuditLogEntryCreate { return data.EvtInterface.(*discordgo.GuildAuditLogEntryCreate) } @@ -456,6 +494,9 @@ func (data *EventData) GuildEmojisUpdate() *discordgo.GuildEmojisUpdate { func (data *EventData) GuildIntegrationsUpdate() *discordgo.GuildIntegrationsUpdate { return data.EvtInterface.(*discordgo.GuildIntegrationsUpdate) } +func (data *EventData) GuildJoinRequestCreate() *discordgo.GuildJoinRequestCreate { + return data.EvtInterface.(*discordgo.GuildJoinRequestCreate) +} func (data *EventData) GuildJoinRequestDelete() *discordgo.GuildJoinRequestDelete { return data.EvtInterface.(*discordgo.GuildJoinRequestDelete) } @@ -474,6 +515,9 @@ func (data *EventData) GuildMemberUpdate() *discordgo.GuildMemberUpdate { func (data *EventData) GuildMembersChunk() *discordgo.GuildMembersChunk { return data.EvtInterface.(*discordgo.GuildMembersChunk) } +func (data *EventData) GuildPowerupEntitlementsCreate() *discordgo.GuildPowerupEntitlementsCreate { + return data.EvtInterface.(*discordgo.GuildPowerupEntitlementsCreate) +} func (data *EventData) GuildRoleCreate() *discordgo.GuildRoleCreate { return data.EvtInterface.(*discordgo.GuildRoleCreate) } @@ -498,6 +542,15 @@ func (data *EventData) GuildScheduledEventUserAdd() *discordgo.GuildScheduledEve func (data *EventData) GuildScheduledEventUserRemove() *discordgo.GuildScheduledEventUserRemove { return data.EvtInterface.(*discordgo.GuildScheduledEventUserRemove) } +func (data *EventData) GuildSoundboardSoundCreate() *discordgo.GuildSoundboardSoundCreate { + return data.EvtInterface.(*discordgo.GuildSoundboardSoundCreate) +} +func (data *EventData) GuildSoundboardSoundDelete() *discordgo.GuildSoundboardSoundDelete { + return data.EvtInterface.(*discordgo.GuildSoundboardSoundDelete) +} +func (data *EventData) GuildSoundboardSoundsUpdate() *discordgo.GuildSoundboardSoundsUpdate { + return data.EvtInterface.(*discordgo.GuildSoundboardSoundsUpdate) +} func (data *EventData) GuildStickersUpdate() *discordgo.GuildStickersUpdate { return data.EvtInterface.(*discordgo.GuildStickersUpdate) } @@ -612,6 +665,9 @@ func (data *EventData) UserSettingsUpdate() *discordgo.UserSettingsUpdate { func (data *EventData) UserUpdate() *discordgo.UserUpdate { return data.EvtInterface.(*discordgo.UserUpdate) } +func (data *EventData) VoiceChannelEffectSend() *discordgo.VoiceChannelEffectSend { + return data.EvtInterface.(*discordgo.VoiceChannelEffectSend) +} func (data *EventData) VoiceChannelStartTimeStatusUpdate() *discordgo.VoiceChannelStartTimeStatusUpdate { return data.EvtInterface.(*discordgo.VoiceChannelStartTimeStatusUpdate) } @@ -667,134 +723,150 @@ func fillEvent(evtData *EventData) { evtData.Type = Event(24) case *discordgo.EntitlementUpdate: evtData.Type = Event(25) - case *discordgo.GuildAuditLogEntryCreate: + case *discordgo.GiftCodeUpdate: evtData.Type = Event(26) - case *discordgo.GuildBanAdd: + case *discordgo.GuildAppliedBoostUpdate: evtData.Type = Event(27) - case *discordgo.GuildBanRemove: + case *discordgo.GuildAuditLogEntryCreate: evtData.Type = Event(28) - case *discordgo.GuildCreate: + case *discordgo.GuildBanAdd: evtData.Type = Event(29) - case *discordgo.GuildDelete: + case *discordgo.GuildBanRemove: evtData.Type = Event(30) - case *discordgo.GuildEmojisUpdate: + case *discordgo.GuildCreate: evtData.Type = Event(31) - case *discordgo.GuildIntegrationsUpdate: + case *discordgo.GuildDelete: evtData.Type = Event(32) - case *discordgo.GuildJoinRequestDelete: + case *discordgo.GuildEmojisUpdate: evtData.Type = Event(33) - case *discordgo.GuildJoinRequestUpdate: + case *discordgo.GuildIntegrationsUpdate: evtData.Type = Event(34) - case *discordgo.GuildMemberAdd: + case *discordgo.GuildJoinRequestCreate: evtData.Type = Event(35) - case *discordgo.GuildMemberRemove: + case *discordgo.GuildJoinRequestDelete: evtData.Type = Event(36) - case *discordgo.GuildMemberUpdate: + case *discordgo.GuildJoinRequestUpdate: evtData.Type = Event(37) - case *discordgo.GuildMembersChunk: + case *discordgo.GuildMemberAdd: evtData.Type = Event(38) - case *discordgo.GuildRoleCreate: + case *discordgo.GuildMemberRemove: evtData.Type = Event(39) - case *discordgo.GuildRoleDelete: + case *discordgo.GuildMemberUpdate: evtData.Type = Event(40) - case *discordgo.GuildRoleUpdate: + case *discordgo.GuildMembersChunk: evtData.Type = Event(41) - case *discordgo.GuildScheduledEventCreate: + case *discordgo.GuildPowerupEntitlementsCreate: evtData.Type = Event(42) - case *discordgo.GuildScheduledEventDelete: + case *discordgo.GuildRoleCreate: evtData.Type = Event(43) - case *discordgo.GuildScheduledEventUpdate: + case *discordgo.GuildRoleDelete: evtData.Type = Event(44) - case *discordgo.GuildScheduledEventUserAdd: + case *discordgo.GuildRoleUpdate: evtData.Type = Event(45) - case *discordgo.GuildScheduledEventUserRemove: + case *discordgo.GuildScheduledEventCreate: evtData.Type = Event(46) - case *discordgo.GuildStickersUpdate: + case *discordgo.GuildScheduledEventDelete: evtData.Type = Event(47) - case *discordgo.GuildUpdate: + case *discordgo.GuildScheduledEventUpdate: evtData.Type = Event(48) - case *discordgo.InteractionCreate: + case *discordgo.GuildScheduledEventUserAdd: evtData.Type = Event(49) - case *discordgo.InviteCreate: + case *discordgo.GuildScheduledEventUserRemove: evtData.Type = Event(50) - case *discordgo.InviteDelete: + case *discordgo.GuildSoundboardSoundCreate: evtData.Type = Event(51) - case *discordgo.MessageAck: + case *discordgo.GuildSoundboardSoundDelete: evtData.Type = Event(52) - case *discordgo.MessageCreate: + case *discordgo.GuildSoundboardSoundsUpdate: evtData.Type = Event(53) - case *discordgo.MessageDelete: + case *discordgo.GuildStickersUpdate: evtData.Type = Event(54) - case *discordgo.MessageDeleteBulk: + case *discordgo.GuildUpdate: evtData.Type = Event(55) - case *discordgo.MessageReactionAdd: + case *discordgo.InteractionCreate: evtData.Type = Event(56) - case *discordgo.MessageReactionRemove: + case *discordgo.InviteCreate: evtData.Type = Event(57) - case *discordgo.MessageReactionRemoveAll: + case *discordgo.InviteDelete: evtData.Type = Event(58) - case *discordgo.MessageReactionRemoveEmoji: + case *discordgo.MessageAck: evtData.Type = Event(59) - case *discordgo.MessageUpdate: + case *discordgo.MessageCreate: evtData.Type = Event(60) - case *discordgo.PresenceUpdate: + case *discordgo.MessageDelete: evtData.Type = Event(61) - case *discordgo.PresencesReplace: + case *discordgo.MessageDeleteBulk: evtData.Type = Event(62) - case *discordgo.RateLimit: + case *discordgo.MessageReactionAdd: evtData.Type = Event(63) - case *discordgo.Ready: + case *discordgo.MessageReactionRemove: evtData.Type = Event(64) - case *discordgo.RelationshipAdd: + case *discordgo.MessageReactionRemoveAll: evtData.Type = Event(65) - case *discordgo.RelationshipRemove: + case *discordgo.MessageReactionRemoveEmoji: evtData.Type = Event(66) - case *discordgo.Resumed: + case *discordgo.MessageUpdate: evtData.Type = Event(67) - case *discordgo.StageInstanceCreate: + case *discordgo.PresenceUpdate: evtData.Type = Event(68) - case *discordgo.StageInstanceDelete: + case *discordgo.PresencesReplace: evtData.Type = Event(69) - case *discordgo.StageInstanceUpdate: + case *discordgo.RateLimit: evtData.Type = Event(70) - case *discordgo.SubscriptionCreate: + case *discordgo.Ready: evtData.Type = Event(71) - case *discordgo.SubscriptionDelete: + case *discordgo.RelationshipAdd: evtData.Type = Event(72) - case *discordgo.SubscriptionUpdate: + case *discordgo.RelationshipRemove: evtData.Type = Event(73) - case *discordgo.ThreadCreate: + case *discordgo.Resumed: evtData.Type = Event(74) - case *discordgo.ThreadDelete: + case *discordgo.StageInstanceCreate: evtData.Type = Event(75) - case *discordgo.ThreadListSync: + case *discordgo.StageInstanceDelete: evtData.Type = Event(76) - case *discordgo.ThreadMemberUpdate: + case *discordgo.StageInstanceUpdate: evtData.Type = Event(77) - case *discordgo.ThreadMembersUpdate: + case *discordgo.SubscriptionCreate: evtData.Type = Event(78) - case *discordgo.ThreadUpdate: + case *discordgo.SubscriptionDelete: evtData.Type = Event(79) - case *discordgo.TypingStart: + case *discordgo.SubscriptionUpdate: evtData.Type = Event(80) - case *discordgo.UserGuildSettingsUpdate: + case *discordgo.ThreadCreate: evtData.Type = Event(81) - case *discordgo.UserNoteUpdate: + case *discordgo.ThreadDelete: evtData.Type = Event(82) - case *discordgo.UserSettingsUpdate: + case *discordgo.ThreadListSync: evtData.Type = Event(83) - case *discordgo.UserUpdate: + case *discordgo.ThreadMemberUpdate: evtData.Type = Event(84) - case *discordgo.VoiceChannelStartTimeStatusUpdate: + case *discordgo.ThreadMembersUpdate: evtData.Type = Event(85) - case *discordgo.VoiceChannelStatusUpdate: + case *discordgo.ThreadUpdate: evtData.Type = Event(86) - case *discordgo.VoiceServerUpdate: + case *discordgo.TypingStart: evtData.Type = Event(87) - case *discordgo.VoiceStateUpdate: + case *discordgo.UserGuildSettingsUpdate: evtData.Type = Event(88) - case *discordgo.WebhooksUpdate: + case *discordgo.UserNoteUpdate: evtData.Type = Event(89) + case *discordgo.UserSettingsUpdate: + evtData.Type = Event(90) + case *discordgo.UserUpdate: + evtData.Type = Event(91) + case *discordgo.VoiceChannelEffectSend: + evtData.Type = Event(92) + case *discordgo.VoiceChannelStartTimeStatusUpdate: + evtData.Type = Event(93) + case *discordgo.VoiceChannelStatusUpdate: + evtData.Type = Event(94) + case *discordgo.VoiceServerUpdate: + evtData.Type = Event(95) + case *discordgo.VoiceStateUpdate: + evtData.Type = Event(96) + case *discordgo.WebhooksUpdate: + evtData.Type = Event(97) default: return } diff --git a/lib/discordgo/components.go b/lib/discordgo/components.go index 48fd58ef87..a5083ef294 100644 --- a/lib/discordgo/components.go +++ b/lib/discordgo/components.go @@ -562,7 +562,7 @@ func (TextDisplay) IsSectionComponent() bool { type UnfurledMediaItem struct { URL string `json:"url"` // Supports arbitrary urls and attachment:// references - ProxyURL string `json"proxy_url,omitempty"` // The proxied url of the media item. This field is ignored and provided by the API as part of the response + ProxyURL string `json:"proxy_url,omitempty"` // The proxied url of the media item. This field is ignored and provided by the API as part of the response Height int `json:"height,omitempty"` // The height of the media item. This field is ignored and provided by the API as part of the response Width int `json:"width,omitempty"` // The width of the media item. This field is ignored and provided by the API as part of the response ContentType string `json:"content_type,omitempty"` // The media type of the content. This field is ignored and provided by the API as part of the response diff --git a/lib/discordgo/eventhandlers.go b/lib/discordgo/eventhandlers.go index b060139f7c..073b4e326a 100644 --- a/lib/discordgo/eventhandlers.go +++ b/lib/discordgo/eventhandlers.go @@ -49,6 +49,13 @@ const ( guildScheduledEventUserRemoveEventType = "GUILD_SCHEDULED_EVENT_USER_REMOVE" guildStickersUpdateEventType = "GUILD_STICKERS_UPDATE" guildUpdateEventType = "GUILD_UPDATE" + guildAppliedBoostUpdateEventType = "GUILD_APPLIED_BOOSTS_UPDATE" + guildPowerupEntitlementsCreateEventType = "GUILD_POWERUP_ENTITLEMENTS_CREATE" + guildSoundboardSoundCreateEventType = "GUILD_SOUNDBOARD_SOUND_CREATE" + guildSoundboardSoundDeleteEventType = "GUILD_SOUNDBOARD_SOUND_DELETE" + guildSoundboardSoundsUpdateEventType = "GUILD_SOUNDBOARD_SOUNDS_UPDATE" + guildJoinRequestCreateEventType = "GUILD_JOIN_REQUEST_CREATE" + giftCodeUpdateEventType = "GIFT_CODE_UPDATE" interactionCreateEventType = "INTERACTION_CREATE" inviteCreateEventType = "INVITE_CREATE" inviteDeleteEventType = "INVITE_DELETE" @@ -87,6 +94,7 @@ const ( userUpdateEventType = "USER_UPDATE" voiceChannelStatusUpdateEventType = "VOICE_CHANNEL_STATUS_UPDATE" voiceChannelStartTimeUpdateEventType = "VOICE_CHANNEL_START_TIME_UPDATE" + voiceChannelEffectSendEventType = "VOICE_CHANNEL_EFFECT_SEND" voiceServerUpdateEventType = "VOICE_SERVER_UPDATE" voiceStateUpdateEventType = "VOICE_STATE_UPDATE" webhooksUpdateEventType = "WEBHOOKS_UPDATE" @@ -597,6 +605,26 @@ func (eh guildIntegrationsUpdateEventHandler) Handle(s *Session, i interface{}) } } +// guildJoinRequestCreateEventHandler is an event handler for GuildJoinRequestCreate events. +type guildJoinRequestCreateEventHandler func(*Session, *GuildJoinRequestCreate) + +// Type returns the event type for GuildJoinRequestCreate events. +func (eh guildJoinRequestCreateEventHandler) Type() string { + return guildJoinRequestCreateEventType +} + +// New returns a new instance of GuildJoinRequestCreate. +func (eh guildJoinRequestCreateEventHandler) New() interface{} { + return &GuildJoinRequestCreate{} +} + +// Handle is the handler for GuildJoinRequestCreate events. +func (eh guildJoinRequestCreateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildJoinRequestCreate); ok { + eh(s, t) + } +} + // guildJoinRequestDeleteEventHandler is an event handler for GuildJoinRequestDelete events. type guildJoinRequestDeleteEventHandler func(*Session, *GuildJoinRequestDelete) @@ -917,6 +945,126 @@ func (eh guildUpdateEventHandler) Handle(s *Session, i interface{}) { } } +// guildAppliedBoostUpdateEventHandler is an event handler for GuildAppliedBoostUpdate events. +type guildAppliedBoostUpdateEventHandler func(*Session, *GuildAppliedBoostUpdate) + +// Type returns the event type for GuildAppliedBoostUpdate events. +func (eh guildAppliedBoostUpdateEventHandler) Type() string { + return guildAppliedBoostUpdateEventType +} + +// New returns a new instance of GuildAppliedBoostUpdate. +func (eh guildAppliedBoostUpdateEventHandler) New() interface{} { + return &GuildAppliedBoostUpdate{} +} + +// Handle is the handler for GuildAppliedBoostUpdate events. +func (eh guildAppliedBoostUpdateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildAppliedBoostUpdate); ok { + eh(s, t) + } +} + +// guildPowerupEntitlementsCreateEventHandler is an event handler for GuildPowerupEntitlementsCreate events. +type guildPowerupEntitlementsCreateEventHandler func(*Session, *GuildPowerupEntitlementsCreate) + +// Type returns the event type for GuildPowerupEntitlementsCreate events. +func (eh guildPowerupEntitlementsCreateEventHandler) Type() string { + return guildPowerupEntitlementsCreateEventType +} + +// New returns a new instance of GuildPowerupEntitlementsCreate. +func (eh guildPowerupEntitlementsCreateEventHandler) New() interface{} { + return &GuildPowerupEntitlementsCreate{} +} + +// Handle is the handler for GuildPowerupEntitlementsCreate events. +func (eh guildPowerupEntitlementsCreateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildPowerupEntitlementsCreate); ok { + eh(s, t) + } +} + +// guildSoundboardSoundCreateEventHandler is an event handler for GuildSoundboardSoundCreate events. +type guildSoundboardSoundCreateEventHandler func(*Session, *GuildSoundboardSoundCreate) + +// Type returns the event type for GuildSoundboardSoundCreate events. +func (eh guildSoundboardSoundCreateEventHandler) Type() string { + return guildSoundboardSoundCreateEventType +} + +// New returns a new instance of GuildSoundboardSoundCreate. +func (eh guildSoundboardSoundCreateEventHandler) New() interface{} { + return &GuildSoundboardSoundCreate{} +} + +// Handle is the handler for GuildSoundboardSoundCreate events. +func (eh guildSoundboardSoundCreateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildSoundboardSoundCreate); ok { + eh(s, t) + } +} + +// guildSoundboardSoundDeleteEventHandler is an event handler for GuildSoundboardSoundDelete events. +type guildSoundboardSoundDeleteEventHandler func(*Session, *GuildSoundboardSoundDelete) + +// Type returns the event type for GuildSoundboardSoundDelete events. +func (eh guildSoundboardSoundDeleteEventHandler) Type() string { + return guildSoundboardSoundDeleteEventType +} + +// New returns a new instance of GuildSoundboardSoundDelete. +func (eh guildSoundboardSoundDeleteEventHandler) New() interface{} { + return &GuildSoundboardSoundDelete{} +} + +// Handle is the handler for GuildSoundboardSoundDelete events. +func (eh guildSoundboardSoundDeleteEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildSoundboardSoundDelete); ok { + eh(s, t) + } +} + +// guildSoundboardSoundsUpdateEventHandler is an event handler for GuildSoundboardSoundsUpdate events. +type guildSoundboardSoundsUpdateEventHandler func(*Session, *GuildSoundboardSoundsUpdate) + +// Type returns the event type for GuildSoundboardSoundsUpdate events. +func (eh guildSoundboardSoundsUpdateEventHandler) Type() string { + return guildSoundboardSoundsUpdateEventType +} + +// New returns a new instance of GuildSoundboardSoundsUpdate. +func (eh guildSoundboardSoundsUpdateEventHandler) New() interface{} { + return &GuildSoundboardSoundsUpdate{} +} + +// Handle is the handler for GuildSoundboardSoundsUpdate events. +func (eh guildSoundboardSoundsUpdateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GuildSoundboardSoundsUpdate); ok { + eh(s, t) + } +} + +// giftCodeUpdateEventHandler is an event handler for GiftCodeUpdate events. +type giftCodeUpdateEventHandler func(*Session, *GiftCodeUpdate) + +// Type returns the event type for GiftCodeUpdate events. +func (eh giftCodeUpdateEventHandler) Type() string { + return giftCodeUpdateEventType +} + +// New returns a new instance of GiftCodeUpdate. +func (eh giftCodeUpdateEventHandler) New() interface{} { + return &GiftCodeUpdate{} +} + +// Handle is the handler for GiftCodeUpdate events. +func (eh giftCodeUpdateEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*GiftCodeUpdate); ok { + eh(s, t) + } +} + // interactionCreateEventHandler is an event handler for InteractionCreate events. type interactionCreateEventHandler func(*Session, *InteractionCreate) @@ -1632,6 +1780,26 @@ func (eh userUpdateEventHandler) Handle(s *Session, i interface{}) { } } +// voiceChannelEffectSendEventHandler is an event handler for VoiceChannelEffectSend events. +type voiceChannelEffectSendEventHandler func(*Session, *VoiceChannelEffectSend) + +// Type returns the event type for VoiceChannelEffectSend events. +func (eh voiceChannelEffectSendEventHandler) Type() string { + return voiceChannelEffectSendEventType +} + +// New returns a new instance of VoiceChannelEffectSend. +func (eh voiceChannelEffectSendEventHandler) New() interface{} { + return &VoiceChannelEffectSend{} +} + +// Handle is the handler for VoiceChannelEffectSend events. +func (eh voiceChannelEffectSendEventHandler) Handle(s *Session, i interface{}) { + if t, ok := i.(*VoiceChannelEffectSend); ok { + eh(s, t) + } +} + // voiceChannelStatusUpdateEventHandler is an event handler for VoiceChannelStatusUpdate events. type voiceChannelStatusUpdateEventHandler func(*Session, *VoiceChannelStatusUpdate) @@ -1788,6 +1956,8 @@ func handlerForInterface(handler interface{}) EventHandler { return guildEmojisUpdateEventHandler(v) case func(*Session, *GuildIntegrationsUpdate): return guildIntegrationsUpdateEventHandler(v) + case func(*Session, *GuildJoinRequestCreate): + return guildJoinRequestCreateEventHandler(v) case func(*Session, *GuildJoinRequestDelete): return guildJoinRequestDeleteEventHandler(v) case func(*Session, *GuildJoinRequestUpdate): @@ -1810,6 +1980,28 @@ func handlerForInterface(handler interface{}) EventHandler { return guildStickersUpdateEventHandler(v) case func(*Session, *GuildUpdate): return guildUpdateEventHandler(v) + case func(*Session, *GuildAppliedBoostUpdate): + return guildAppliedBoostUpdateEventHandler(v) + case func(*Session, *GuildPowerupEntitlementsCreate): + return guildPowerupEntitlementsCreateEventHandler(v) + case func(*Session, *GuildSoundboardSoundCreate): + return guildSoundboardSoundCreateEventHandler(v) + case func(*Session, *GuildSoundboardSoundDelete): + return guildSoundboardSoundDeleteEventHandler(v) + case func(*Session, *GuildSoundboardSoundsUpdate): + return guildSoundboardSoundsUpdateEventHandler(v) + case func(*Session, *GiftCodeUpdate): + return giftCodeUpdateEventHandler(v) + case func(*Session, *GuildScheduledEventCreate): + return guildScheduledEventCreateEventHandler(v) + case func(*Session, *GuildScheduledEventDelete): + return guildScheduledEventDeleteEventHandler(v) + case func(*Session, *GuildScheduledEventUpdate): + return guildScheduledEventUpdateEventHandler(v) + case func(*Session, *GuildScheduledEventUserAdd): + return guildScheduledEventUserAddEventHandler(v) + case func(*Session, *GuildScheduledEventUserRemove): + return guildScheduledEventUserRemoveEventHandler(v) case func(*Session, *InteractionCreate): return interactionCreateEventHandler(v) case func(*Session, *InviteCreate): @@ -1882,12 +2074,14 @@ func handlerForInterface(handler interface{}) EventHandler { return userSettingsUpdateEventHandler(v) case func(*Session, *UserUpdate): return userUpdateEventHandler(v) + case func(*Session, *VoiceChannelEffectSend): + return voiceChannelEffectSendEventHandler(v) case func(*Session, *VoiceChannelStatusUpdate): return voiceChannelStatusUpdateEventHandler(v) - case func(*Session, *VoiceServerUpdate): - return voiceServerUpdateEventHandler(v) case func(*Session, *VoiceChannelStartTimeStatusUpdate): return voiceChannelStartTimeUpdateEventHandler(v) + case func(*Session, *VoiceServerUpdate): + return voiceServerUpdateEventHandler(v) case func(*Session, *VoiceStateUpdate): return voiceStateUpdateEventHandler(v) case func(*Session, *WebhooksUpdate): @@ -1921,6 +2115,7 @@ func init() { registerInterfaceProvider(guildDeleteEventHandler(nil)) registerInterfaceProvider(guildEmojisUpdateEventHandler(nil)) registerInterfaceProvider(guildIntegrationsUpdateEventHandler(nil)) + registerInterfaceProvider(guildJoinRequestCreateEventHandler(nil)) registerInterfaceProvider(guildJoinRequestDeleteEventHandler(nil)) registerInterfaceProvider(guildJoinRequestUpdateEventHandler(nil)) registerInterfaceProvider(guildMemberAddEventHandler(nil)) @@ -1932,6 +2127,17 @@ func init() { registerInterfaceProvider(guildRoleUpdateEventHandler(nil)) registerInterfaceProvider(guildStickersUpdateEventHandler(nil)) registerInterfaceProvider(guildUpdateEventHandler(nil)) + registerInterfaceProvider(guildAppliedBoostUpdateEventHandler(nil)) + registerInterfaceProvider(guildPowerupEntitlementsCreateEventHandler(nil)) + registerInterfaceProvider(guildSoundboardSoundCreateEventHandler(nil)) + registerInterfaceProvider(guildSoundboardSoundDeleteEventHandler(nil)) + registerInterfaceProvider(guildSoundboardSoundsUpdateEventHandler(nil)) + registerInterfaceProvider(giftCodeUpdateEventHandler(nil)) + registerInterfaceProvider(guildScheduledEventCreateEventHandler(nil)) + registerInterfaceProvider(guildScheduledEventDeleteEventHandler(nil)) + registerInterfaceProvider(guildScheduledEventUpdateEventHandler(nil)) + registerInterfaceProvider(guildScheduledEventUserAddEventHandler(nil)) + registerInterfaceProvider(guildScheduledEventUserRemoveEventHandler(nil)) registerInterfaceProvider(interactionCreateEventHandler(nil)) registerInterfaceProvider(inviteCreateEventHandler(nil)) registerInterfaceProvider(inviteDeleteEventHandler(nil)) @@ -1967,7 +2173,9 @@ func init() { registerInterfaceProvider(userNoteUpdateEventHandler(nil)) registerInterfaceProvider(userSettingsUpdateEventHandler(nil)) registerInterfaceProvider(userUpdateEventHandler(nil)) + registerInterfaceProvider(voiceChannelEffectSendEventHandler(nil)) registerInterfaceProvider(voiceChannelStatusUpdateEventHandler(nil)) + registerInterfaceProvider(voiceChannelStartTimeUpdateEventHandler(nil)) registerInterfaceProvider(voiceServerUpdateEventHandler(nil)) registerInterfaceProvider(voiceStateUpdateEventHandler(nil)) registerInterfaceProvider(webhooksUpdateEventHandler(nil)) diff --git a/lib/discordgo/events.go b/lib/discordgo/events.go index 578cf16c17..e95a5e20f3 100644 --- a/lib/discordgo/events.go +++ b/lib/discordgo/events.go @@ -604,11 +604,19 @@ type GuildAuditLogEntryCreate struct { *AuditLogEntry } +type GuildAppliedBoostUpdate struct{} +type GuildPowerupEntitlementsCreate struct{} +type GuildSoundboardSoundCreate struct{} +type GuildSoundboardSoundDelete struct{} +type GuildSoundboardSoundsUpdate struct{} +type GuildJoinRequestCreate struct{} type GuildJoinRequestUpdate struct{} type GuildJoinRequestDelete struct{} type VoiceChannelStatusUpdate struct{} type VoiceChannelStartTimeStatusUpdate struct{} +type VoiceChannelEffectSend struct{} type ChannelTopicUpdate struct{} +type GiftCodeUpdate struct{} // Monetization events type EntitlementCreate struct { From f50d81f738c57a9712b77b78d6c9a0891fa4f152 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 6 Nov 2025 22:05:44 +0530 Subject: [PATCH 06/92] fix broken in, virtual smack for poor testing on the maintainer --- common/templates/general.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/common/templates/general.go b/common/templates/general.go index 5d4ba2523e..6fbdebb671 100644 --- a/common/templates/general.go +++ b/common/templates/general.go @@ -785,13 +785,21 @@ func in(l interface{}, v interface{}) bool { } switch { case lvv.Kind() == reflect.String: - return vv.Type() == lvv.Type() && vv.String() == lvv.String() + if vv.Type() == lvv.Type() && vv.String() == lvv.String() { + return true + } case lvv.CanInt() && vv.CanInt(): - return vv.Int() == lvv.Int() + if vv.Int() == lvv.Int() { + return true + } case lvv.CanUint() && vv.CanUint(): - return vv.Uint() == lvv.Uint() + if vv.Uint() == lvv.Uint() { + return true + } case lvv.CanFloat() && vv.CanFloat(): - return vv.Float() == lvv.Float() + if vv.Float() == lvv.Float() { + return true + } } } } From 6ac05c3059de7df267ce7c858f63c5ca3130b821 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:44:03 +0530 Subject: [PATCH 07/92] added support for more component types in modals (#1977) --- common/templates/components.go | 175 +++++++++++++++++------ common/templates/context.go | 93 +++++++++--- common/templates/context_interactions.go | 43 +++++- common/templates/general.go | 4 +- customcommands/bot.go | 43 ++++-- customcommands/customcommands.go | 15 -- frontend/static/js/yagFuncs.js | 6 +- lib/discordgo/components.go | 117 ++++++++++++++- lib/discordgo/interactions.go | 3 +- 9 files changed, 397 insertions(+), 102 deletions(-) diff --git a/common/templates/components.go b/common/templates/components.go index 3cfaabbf8c..24406c08d6 100644 --- a/common/templates/components.go +++ b/common/templates/components.go @@ -16,18 +16,18 @@ import ( "github.com/botlabs-gg/yagpdb/v2/lib/discordgo" ) -func CreateComponent(expectedType discordgo.ComponentType, values ...interface{}) (discordgo.MessageComponent, error) { +func CreateComponent(expectedType discordgo.ComponentType, values ...any) (discordgo.MessageComponent, error) { if len(values) < 1 && expectedType != discordgo.ActionsRowComponent { return discordgo.ActionsRow{}, errors.New("no values passed to component builder") } - var m map[string]interface{} + var m map[string]any switch t := values[0].(type) { case SDict: m = t case *SDict: m = *t - case map[string]interface{}: + case map[string]any: m = t default: dict, err := StringKeyDictionary(values...) @@ -102,6 +102,10 @@ func CreateComponent(expectedType discordgo.ComponentType, values ...interface{} comp := discordgo.Container{} err = json.Unmarshal(encoded, &comp) component = comp + case discordgo.LabelComponent: + comp := discordgo.Label{} + err = json.Unmarshal(encoded, &comp) + component = comp } if err != nil { @@ -111,14 +115,63 @@ func CreateComponent(expectedType discordgo.ComponentType, values ...interface{} return component, nil } -func CreateButton(values ...interface{}) (*discordgo.Button, error) { - var messageSdict map[string]interface{} +func CreateLabel(values ...any) (*discordgo.Label, error) { + var messageSdict map[string]any + switch t := values[0].(type) { + case SDict: + messageSdict = t + case *SDict: + messageSdict = *t + case map[string]any: + messageSdict = t + case *discordgo.Label: + return t, nil + default: + dict, err := StringKeyDictionary(values...) + if err != nil { + return nil, err + } + messageSdict = dict + } + + convertedLabel := make(map[string]any) + for k, v := range messageSdict { + switch strings.ToLower(k) { + case "custom_id": + c, err := validateCustomID(ToString(v), nil) + if err != nil { + return nil, err + } + convertedLabel[k] = c + case "label": + convertedLabel[k] = v + case "description": + convertedLabel[k] = v + case "component": + if c, ok := v.(discordgo.InteractiveComponent); ok && c.IsAllowedInLabel() { + convertedLabel[k] = c + } else { + return nil, errors.New("unsupported component in label") + } + } + } + + l, err := CreateComponent(discordgo.LabelComponent, convertedLabel) + if err != nil { + return nil, err + } + label := l.(discordgo.Label) + return &label, nil +} + +func CreateButton(values ...any) (*discordgo.Button, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Button: return t, nil @@ -130,7 +183,7 @@ func CreateButton(values ...interface{}) (*discordgo.Button, error) { messageSdict = dict } - convertedButton := make(map[string]interface{}) + convertedButton := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "style": @@ -188,14 +241,14 @@ func CreateButton(values ...interface{}) (*discordgo.Button, error) { return &button, err } -func CreateSelectMenu(values ...interface{}) (*discordgo.SelectMenu, error) { - var messageSdict map[string]interface{} +func CreateSelectMenu(values ...any) (*discordgo.SelectMenu, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.SelectMenu: return t, nil @@ -209,7 +262,7 @@ func CreateSelectMenu(values ...interface{}) (*discordgo.SelectMenu, error) { menuType := discordgo.SelectMenuComponent - convertedMenu := make(map[string]interface{}) + convertedMenu := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "type": @@ -263,15 +316,15 @@ func CreateSelectMenu(values ...interface{}) (*discordgo.SelectMenu, error) { return &menu, err } -func createThumbnail(values ...interface{}) (discordgo.Thumbnail, error) { +func createThumbnail(values ...any) (discordgo.Thumbnail, error) { thumb := discordgo.Thumbnail{} - var messageSdict map[string]interface{} + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Thumbnail: return *t, nil @@ -285,7 +338,7 @@ func createThumbnail(values ...interface{}) (discordgo.Thumbnail, error) { messageSdict = dict } - convertedThumbnail := make(map[string]interface{}) + convertedThumbnail := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "media": @@ -302,14 +355,14 @@ func createThumbnail(values ...interface{}) (discordgo.Thumbnail, error) { return thumb, err } -func CreateSection(values ...interface{}) (*discordgo.Section, error) { - var messageSdict map[string]interface{} +func CreateSection(values ...any) (*discordgo.Section, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Section: return t, nil @@ -321,7 +374,7 @@ func CreateSection(values ...interface{}) (*discordgo.Section, error) { messageSdict = dict } - convertedSection := make(map[string]interface{}) + convertedSection := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "text": @@ -368,9 +421,9 @@ func CreateSection(values ...interface{}) (*discordgo.Section, error) { return §ion, err } -func CreateTextDisplay(value interface{}) (*discordgo.TextDisplay, error) { +func CreateTextDisplay(value any) (*discordgo.TextDisplay, error) { var display discordgo.TextDisplay - d, err := CreateComponent(discordgo.TextDisplayComponent, map[string]interface{}{ + d, err := CreateComponent(discordgo.TextDisplayComponent, map[string]any{ "content": ToString(value), }) if err == nil { @@ -379,20 +432,59 @@ func CreateTextDisplay(value interface{}) (*discordgo.TextDisplay, error) { return &display, err } -func createUnfurledMedia(value interface{}) discordgo.UnfurledMediaItem { +func CreateTextInput(values ...any) (*discordgo.TextInput, error) { + var messageSdict map[string]any + switch t := values[0].(type) { + case SDict: + messageSdict = t + case *SDict: + messageSdict = *t + case map[string]any: + messageSdict = t + default: + dict, err := StringKeyDictionary(values...) + if err != nil { + return nil, err + } + messageSdict = dict + } + var textInput discordgo.TextInput + convertedTextInput := make(map[string]any) + for k, v := range messageSdict { + switch strings.ToLower(k) { + case "custom_id": + c, err := validateCustomID(TemplateCustomIDPrefix+ToString(v), nil) + if err != nil { + return nil, err + } + convertedTextInput[k] = c + default: + convertedTextInput[k] = v + } + } + + t, err := CreateComponent(discordgo.TextInputComponent, convertedTextInput) + if err == nil { + textInput = t.(discordgo.TextInput) + } + + return &textInput, err +} + +func createUnfurledMedia(value any) discordgo.UnfurledMediaItem { return discordgo.UnfurledMediaItem{ URL: ToString(value), } } -func createGalleryItem(values ...interface{}) (item discordgo.MediaGalleryItem, err error) { - var messageSdict map[string]interface{} +func createGalleryItem(values ...any) (item discordgo.MediaGalleryItem, err error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case discordgo.MediaGalleryItem: item = t @@ -423,7 +515,7 @@ func createGalleryItem(values ...interface{}) (item discordgo.MediaGalleryItem, return } -func CreateGallery(values interface{}) (*discordgo.MediaGallery, error) { +func CreateGallery(values any) (*discordgo.MediaGallery, error) { convertedGallery := &discordgo.MediaGallery{} val, _ := indirect(reflect.ValueOf(values)) if val.Kind() == reflect.Slice { @@ -447,14 +539,14 @@ func CreateGallery(values interface{}) (*discordgo.MediaGallery, error) { return convertedGallery, nil } -func CreateFile(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo.ComponentFile, error) { - var messageSdict map[string]interface{} +func CreateFile(msgFiles *[]*discordgo.File, values ...any) (*discordgo.ComponentFile, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.ComponentFile: return t, nil @@ -470,7 +562,7 @@ func CreateFile(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo. ContentType: "text/plain", Name: "attachment_" + time.Now().Format("2006-01-02_15-04-05") + ".txt", } - convertedFile := make(map[string]interface{}) + convertedFile := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "content": @@ -501,7 +593,7 @@ func CreateFile(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo. return &cFile, err } -func CreateSeparator(large interface{}) *discordgo.Separator { +func CreateSeparator(large any) *discordgo.Separator { spacing := discordgo.SeparatorSpacingSmall if large != nil && large != false { spacing = discordgo.SeparatorSpacingLarge @@ -511,7 +603,7 @@ func CreateSeparator(large interface{}) *discordgo.Separator { } } -func CreateComponentArray(msgFiles *[]*discordgo.File, values ...interface{}) ([]discordgo.TopLevelComponent, error) { +func CreateComponentArray(msgFiles *[]*discordgo.File, values ...any) ([]discordgo.TopLevelComponent, error) { if len(values) < 1 { return nil, nil } @@ -733,14 +825,14 @@ func CreateComponentArray(msgFiles *[]*discordgo.File, values ...interface{}) ([ return components, nil } -func CreateContainer(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo.Container, error) { - var messageSdict map[string]interface{} +func CreateContainer(msgFiles *[]*discordgo.File, values ...any) (*discordgo.Container, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Container: return t, nil @@ -855,22 +947,15 @@ func validateCustomID(id string, used map[string]bool) (string, error) { id = fmt.Sprint(len(used)) } - if !strings.HasPrefix(id, "templates-") { - id = fmt.Sprint("templates-", id) + if !strings.HasPrefix(id, TemplateCustomIDPrefix) { + id = fmt.Sprint(TemplateCustomIDPrefix, id) } const maxCIDLength = 100 // discord limitation if len(id) > maxCIDLength { - return "", errors.New("custom id too long (max 90 chars)") // maxCIDLength - len("templates-") + return "", fmt.Errorf("custom id too long (max %d chars)", maxCIDLength-len(TemplateCustomIDPrefix)) } - if used == nil { - return id, nil - } - - if _, ok := used[id]; ok { - return "", errors.New("duplicate custom ids used") - } return id, nil } diff --git a/common/templates/context.go b/common/templates/context.go index f6ccfaf27b..c3fd15ee55 100644 --- a/common/templates/context.go +++ b/common/templates/context.go @@ -26,13 +26,17 @@ import ( "github.com/botlabs-gg/yagpdb/v2/web/discorddata" "github.com/sirupsen/logrus" "github.com/vmihailenco/msgpack" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) var ( - StandardFuncMap = map[string]interface{}{ + titleCaser = cases.Title(language.Und) + + StandardFuncMap = map[string]any{ // conversion functions "str": ToString, - "toString": ToString, // don't ask why we have 2 of these + "toString": ToString, // don't ask why we have 2 of these, update: the answer is always "deprecated but not removed" "toInt": tmplToInt, "toInt64": ToInt64, "toFloat": ToFloat64, @@ -47,7 +51,7 @@ var ( "lower": strings.ToLower, "slice": slice, "split": strings.Split, - "title": strings.Title, + "title": titleCaser.String, "trimSpace": strings.TrimSpace, "upper": strings.ToUpper, "urlescape": url.PathEscape, @@ -89,20 +93,30 @@ var ( "bitwiseLeftShift": tmplBitwiseLeftShift, "bitwiseRightShift": tmplBitwiseRightShift, - // misc - "humanizeThousands": tmplHumanizeThousands, - "dict": Dictionary, - "sdict": StringKeyDictionary, - "structToSdict": StructToSdict, - "componentBuilder": CreateComponentBuilder, + // message component builders + "componentBuilder": CreateComponentBuilder, + "modalBuilder": CreateModalBuilder, + "cbutton": CreateButton, + "cmenu": CreateSelectMenu, + "cmodal": CreateModal, + "clabel": CreateLabel, + "ctextInput": CreateTextInput, + "ctextDisplay": CreateTextDisplay, + + // message builders "cembed": CreateEmbed, - "cbutton": CreateButton, - "cmenu": CreateSelectMenu, - "cmodal": CreateModal, - "cslice": CreateSlice, "complexMessage": CreateMessageSend, "complexMessageEdit": CreateMessageEdit, - "kindOf": KindOf, + + // misc + "humanizeThousands": tmplHumanizeThousands, + "dict": Dictionary, + "sdict": StringKeyDictionary, + "structToSdict": StructToSdict, + + "cslice": CreateSlice, + + "kindOf": KindOf, "adjective": common.RandomAdjective, "in": in, @@ -675,11 +689,6 @@ func baseContextFuncs(c *Context) { // Message send functions c.addContextFunc("sendDM", c.tmplSendDM) - c.addContextFunc("sendComponentMessageRetID", c.tmplSendComponentsMessage(true, true)) - c.addContextFunc("sendComponentMessage", c.tmplSendComponentsMessage(true, false)) - c.addContextFunc("sendComponentMessageNoEscape", c.tmplSendComponentsMessage(false, false)) - c.addContextFunc("sendComponentMessageNoEscapeRetID", c.tmplSendComponentsMessage(false, true)) - c.addContextFunc("sendComponentMessageRetID", c.tmplSendComponentsMessage(true, true)) c.addContextFunc("sendMessage", c.tmplSendMessage(true, false)) c.addContextFunc("sendMessageNoEscape", c.tmplSendMessage(false, false)) c.addContextFunc("sendMessageNoEscapeRetID", c.tmplSendMessage(false, true)) @@ -1140,9 +1149,53 @@ func (s Slice) StringSlice(flag ...bool) interface{} { return StringSlice } +type ModalBuilder struct { + Title string + CustomID string + Components []discordgo.TopLevelComponent +} + +func (s *ModalBuilder) addComponent(comp any) (*ModalBuilder, error) { + if len(s.Components) == 5 { + return nil, errors.New("modal builder can only have maximum 5 top level components") + } + + if comp, ok := comp.(discordgo.TopLevelComponent); ok { + if !comp.IsModalSupported() { + return nil, errors.New("invalid top level component passed to modal builder") + } + s.Components = append(s.Components, comp) + } else { + return nil, errors.New("invalid top level component passed to modal builder") + } + + return s, nil +} + +func (s *ModalBuilder) AddComponents(comps ...any) (*ModalBuilder, error) { + for _, comp := range comps { + _, err := s.addComponent(comp) + if err != nil { + return nil, err + } + } + return s, nil +} + +func (s *ModalBuilder) toModal() (*discordgo.InteractionResponse, error) { + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + Title: s.Title, + CustomID: s.CustomID, + Components: s.Components, + }, + }, nil +} + type ComponentBuilder struct { Components []string - Values []interface{} + Values []any } func (s *ComponentBuilder) Add(key string, value interface{}) (interface{}, error) { diff --git a/common/templates/context_interactions.go b/common/templates/context_interactions.go index 3ccbbd6b61..38027cc9ae 100644 --- a/common/templates/context_interactions.go +++ b/common/templates/context_interactions.go @@ -12,6 +12,8 @@ import ( var ErrTooManyInteractionResponses = errors.New("cannot respond to an interaction > 1 time; consider using a followup") +const TemplateCustomIDPrefix = "templates-" + func interactionContextFuncs(c *Context) { c.addContextFunc("deleteInteractionResponse", c.tmplDeleteInteractionResponse) c.addContextFunc("editResponse", c.tmplEditInteractionResponse(true)) @@ -27,7 +29,26 @@ func interactionContextFuncs(c *Context) { c.addContextFunc("updateMessageNoEscape", c.tmplUpdateMessage(false)) } -func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) { +func CreateModalBuilder(customID string, title string, components ...any) (*ModalBuilder, error) { + cid, err := validateCustomID(customID, nil) + if err != nil { + return nil, err + } + modal := &ModalBuilder{ + Title: title, + CustomID: cid, + } + for _, component := range components { + _, err := modal.addComponent(component) + if err != nil { + return nil, err + } + } + + return modal, nil +} + +func CreateModal(values ...any) (*discordgo.InteractionResponse, error) { if len(values) < 1 { return &discordgo.InteractionResponse{}, errors.New("no values passed to component builder") } @@ -38,7 +59,11 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) m = t case *SDict: m = *t - case map[string]interface{}: + case ModalBuilder: + return t.toModal() + case *ModalBuilder: + return t.toModal() + case map[string]any: m = t default: dict, err := StringKeyDictionary(values...) @@ -48,14 +73,18 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) m = dict } - modal := &discordgo.InteractionResponseData{CustomID: "templates-0"} // default cID if not set + modal := &discordgo.InteractionResponseData{CustomID: TemplateCustomIDPrefix + "-0"} // default cID if not set for key, val := range m { switch key { case "title": modal.Title = ToString(val) case "custom_id": - modal.CustomID = "templates-" + ToString(val) + cid, err := validateCustomID(ToString(val), nil) + if err != nil { + return nil, err + } + modal.CustomID = cid case "fields": if val == nil { continue @@ -276,11 +305,15 @@ func (c *Context) tmplSendModal(modal interface{}) (interface{}, error) { var typedModal *discordgo.InteractionResponse var err error switch m := modal.(type) { + case ModalBuilder: + typedModal, err = m.toModal() + case *ModalBuilder: + typedModal, err = m.toModal() case *discordgo.InteractionResponse: typedModal = m case discordgo.InteractionResponse: typedModal = &m - case SDict, *SDict, map[string]interface{}: + case SDict, *SDict, map[string]any: typedModal, err = CreateModal(m) default: return "", errors.New("invalid modal passed to sendModal") diff --git a/common/templates/general.go b/common/templates/general.go index 6fbdebb671..492f217a75 100644 --- a/common/templates/general.go +++ b/common/templates/general.go @@ -381,7 +381,7 @@ func CreateMessageSend(values ...interface{}) (*discordgo.MessageSend, error) { v, _ := indirect(reflect.ValueOf(val)) if v.Kind() == reflect.Slice { buttons := []*discordgo.Button{} - const maxButtons = 25 // Discord limitation + const maxButtons = 40 // Discord limitation for i := 0; i < v.Len() && i < maxButtons; i++ { button, err := CreateButton(v.Index(i).Interface()) if err != nil { @@ -1335,7 +1335,7 @@ func sequence(start, stop int) ([]int, error) { } if stop-start > MaxSliceLength { - return nil, fmt.Errorf("Sequence max length is %d", MaxSliceLength) + return nil, fmt.Errorf("sequence max length is %d", MaxSliceLength) } out := make([]int, stop-start) diff --git a/customcommands/bot.go b/customcommands/bot.go index 7be3d55e9f..2a61b23b2d 100644 --- a/customcommands/bot.go +++ b/customcommands/bot.go @@ -858,7 +858,7 @@ func handleInteractionCreate(evt *eventsystem.EventData) { cID := interaction.MessageComponentData().CustomID // continue only if this component was created by a cc - cID, ok := strings.CutPrefix(cID, "templates-") + cID, ok := strings.CutPrefix(cID, templates.TemplateCustomIDPrefix) if !ok { return } @@ -884,7 +884,7 @@ func handleInteractionCreate(evt *eventsystem.EventData) { cID := interaction.ModalSubmitData().CustomID // continue only if this modal was created by a cc - cID, ok := strings.CutPrefix(cID, "templates-") + cID, ok := strings.CutPrefix(cID, templates.TemplateCustomIDPrefix) if !ok { return } @@ -1337,7 +1337,7 @@ func ExecuteCustomCommandFromComponent(cc *models.CustomCommand, gs *dstate.Guil tmplCtx.Data["Interaction"] = interaction tmplCtx.Data["InteractionData"] = interaction.MessageComponentData() - cid := strings.TrimPrefix(interaction.MessageComponentData().CustomID, "templates-") + cid := strings.TrimPrefix(interaction.MessageComponentData().CustomID, templates.TemplateCustomIDPrefix) tmplCtx.Data["CustomID"] = cid tmplCtx.Data["Cmd"] = cmdArgs[0] if len(cmdArgs) > 1 { @@ -1385,7 +1385,7 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet tmplCtx.Data["Interaction"] = interaction tmplCtx.Data["InteractionData"] = interaction.ModalSubmitData() - cid := strings.TrimPrefix(interaction.ModalSubmitData().CustomID, "templates-") + cid := strings.TrimPrefix(interaction.ModalSubmitData().CustomID, templates.TemplateCustomIDPrefix) tmplCtx.Data["CustomID"] = cid tmplCtx.Data["Cmd"] = cmdArgs[0] if len(cmdArgs) > 1 { @@ -1397,13 +1397,40 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet tmplCtx.Data["StrippedMsg"] = stripped tmplCtx.Data["IsModal"] = true cmdValues := []string{} + type modalValue struct { + CustomID string + Type discordgo.ComponentType + value string + values []string + } + modalValues := map[string]modalValue{} for i := 0; i < len(interaction.ModalSubmitData().Components); i++ { - row := interaction.ModalSubmitData().Components[i].(*discordgo.ActionsRow) - field := row.Components[0].(*discordgo.TextInput) - cmdValues = append(cmdValues, field.Value) + switch comp := interaction.ModalSubmitData().Components[i].(type) { + case *discordgo.ActionsRow: + for j := 0; j < len(comp.Components); j++ { + field, ok := comp.Components[j].(*discordgo.TextInput) + if ok { + cmdValues = append(cmdValues, field.Value) + } + } + case *discordgo.Label: + if t, ok := comp.Component.(*discordgo.TextInput); ok { + modalValues[t.CustomID] = modalValue{ + CustomID: t.CustomID, + Type: t.Type(), + value: t.Value, + } + } else if sm, ok := comp.Component.(*discordgo.SelectMenu); ok { + modalValues[sm.CustomID] = modalValue{ + CustomID: sm.CustomID, + Type: sm.Type(), + values: sm.Values, + } + } + } } tmplCtx.Data["Values"] = cmdValues - + tmplCtx.Data["ModalValues"] = modalValues msg := interaction.Message msg.Member = ms.DgoMember() msg.Author = msg.Member.User diff --git a/customcommands/customcommands.go b/customcommands/customcommands.go index 7bdba8eda6..dc61f25f33 100644 --- a/customcommands/customcommands.go +++ b/customcommands/customcommands.go @@ -353,21 +353,6 @@ func (c CustomCommandSlice) Swap(i, j int) { c[j] = temp } -func filterEmptyResponses(s string, ss ...string) []string { - result := make([]string, 0, len(ss)+1) - if s != "" { - result = append(result, s) - } - - for _, s := range ss { - if s != "" { - result = append(result, s) - } - } - - return result -} - const ( MaxCommands = 100 MaxCommandsPremium = 250 diff --git a/frontend/static/js/yagFuncs.js b/frontend/static/js/yagFuncs.js index 7af10a6314..cf55cb6d01 100644 --- a/frontend/static/js/yagFuncs.js +++ b/frontend/static/js/yagFuncs.js @@ -137,7 +137,11 @@ var yagFuncs = { cembed: true, cbutton: true, cmenu: true, - cmodal: true, + ctextInput: true, + ctextDisplay: true, + clabel: true, + componentBuilder: true, + modalBuilder: true, cslice: true, complexMessage: true, complexMessageEdit: true, diff --git a/lib/discordgo/components.go b/lib/discordgo/components.go index a5083ef294..957817020f 100644 --- a/lib/discordgo/components.go +++ b/lib/discordgo/components.go @@ -28,6 +28,7 @@ const ( SeparatorComponent ComponentType = 14 ActivityContentComponent ComponentType = 16 ContainerComponent ComponentType = 17 + LabelComponent ComponentType = 18 ) // MessageComponent is a base interface for all message components. @@ -76,6 +77,8 @@ func (umc *unmarshalableMessageComponent) UnmarshalJSON(src []byte) error { umc.MessageComponent = &Separator{} case ContainerComponent: umc.MessageComponent = &Container{} + case LabelComponent: + umc.MessageComponent = &Label{} default: return fmt.Errorf("unknown component type: %d", v.Type) } @@ -96,12 +99,14 @@ func MessageComponentFromJSON(b []byte) (MessageComponent, error) { type TopLevelComponent interface { MessageComponent IsTopLevel() bool + IsModalSupported() bool } // InteractiveComponent is an interface for message components which can be interacted with. type InteractiveComponent interface { MessageComponent IsInteractive() bool + IsAllowedInLabel() bool } type ActivityContentInventoryTrait struct { @@ -150,6 +155,11 @@ func (ActivityContent) IsTopLevel() bool { return true } +// IsModalSupported is a method to assert the component as modal supported. +func (ActivityContent) IsModalSupported() bool { + return true +} + // MarshalJSON is a method for marshaling ActionsRow to a JSON object. func (r ActivityContent) MarshalJSON() ([]byte, error) { type activityContent ActivityContent @@ -225,6 +235,11 @@ func (ActionsRow) IsTopLevel() bool { return true } +// IsModalSupported is a method to assert the component as modal supported. +func (ActionsRow) IsModalSupported() bool { + return false +} + // ButtonStyle is style of button. type ButtonStyle uint @@ -288,6 +303,10 @@ func (Button) IsInteractive() bool { return true } +func (Button) IsAllowedInLabel() bool { + return false +} + // IsAccessory is a method to assert the component as an accessory. func (Button) IsAccessory() bool { return true @@ -350,6 +369,9 @@ type SelectMenu struct { // NOTE: Number of entries should be in the range defined by MinValues and MaxValues. DefaultValues []SelectMenuDefaultValue `json:"default_values,omitempty"` + // Values is a list of values selected by the user, only filled when the select menu is submitted. + Values []string `json:"values,omitempty"` + Options []SelectMenuOption `json:"options,omitempty"` Disabled bool `json:"disabled"` Required bool `json:"required"` @@ -366,6 +388,10 @@ func (s SelectMenu) Type() ComponentType { return SelectMenuComponent } +func (SelectMenu) IsAllowedInLabel() bool { + return true +} + // MarshalJSON is a method for marshaling SelectMenu to a JSON object. func (s SelectMenu) MarshalJSON() ([]byte, error) { type selectMenu SelectMenu @@ -387,7 +413,7 @@ func (SelectMenu) IsInteractive() bool { // TextInput represents text input component. type TextInput struct { CustomID string `json:"custom_id"` - Label string `json:"label"` + Label string `json:"label,omitempty"` Style TextInputStyle `json:"style"` Placeholder string `json:"placeholder,omitempty"` Value string `json:"value,omitempty"` @@ -401,6 +427,10 @@ func (TextInput) Type() ComponentType { return TextInputComponent } +func (TextInput) IsAllowedInLabel() bool { + return true +} + // MarshalJSON is a method for marshaling TextInput to a JSON object. func (m TextInput) MarshalJSON() ([]byte, error) { type inputText TextInput @@ -499,6 +529,10 @@ func (Section) IsTopLevel() bool { return true } +func (Section) IsModalSupported() bool { + return false +} + func GetTextDisplayContent(component TopLevelComponent) (contents []string) { switch typed := component.(type) { case ActionsRow: @@ -514,10 +548,7 @@ func GetTextDisplayContent(component TopLevelComponent) (contents []string) { } case Container: for _, c := range typed.Components { - comp, ok := c.(TopLevelComponent) - if ok { - contents = append(contents, GetTextDisplayContent(comp)...) - } + contents = append(contents, GetTextDisplayContent(c)...) } case TextDisplay: contents = append(contents, typed.Content) @@ -526,6 +557,62 @@ func GetTextDisplayContent(component TopLevelComponent) (contents []string) { return } +// Label is a top-level layout component. +// Labels wrap modal components with text as a label and optional description. +type Label struct { + // Unique identifier for the component; auto populated through increment if not provided. + ID int `json:"id,omitempty"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Component InteractiveComponent `json:"component"` +} + +// Type is a method to get the type of a component. + +func (Label) Type() ComponentType { + return LabelComponent +} + +// UnmarshalJSON is a method for unmarshaling Label from JSON + +func (l *Label) UnmarshalJSON(data []byte) error { + type label Label + var v struct { + label + RawComponent unmarshalableMessageComponent `json:"component"` + } + + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + + *l = Label(v.label) + l.Component = v.RawComponent.MessageComponent.(InteractiveComponent) + return nil +} + +// MarshalJSON is a method for marshaling Label to a JSON object. +func (l Label) MarshalJSON() ([]byte, error) { + type label Label + + return json.Marshal(struct { + label + Type ComponentType `json:"type"` + }{ + label: label(l), + Type: l.Type(), + }) +} + +func (Label) IsTopLevel() bool { + return true +} + +func (Label) IsModalSupported() bool { + return true +} + // TextDisplay represents text display component. type TextDisplay struct { ID int `json:"id,omitempty"` @@ -550,6 +637,10 @@ func (TextDisplay) Type() ComponentType { return TextDisplayComponent } +func (TextDisplay) IsModalSupported() bool { + return true +} + // IsTopLevel is a method to assert the component as top level. func (TextDisplay) IsTopLevel() bool { return true @@ -634,6 +725,10 @@ func (MediaGallery) IsTopLevel() bool { return true } +func (MediaGallery) IsModalSupported() bool { + return false +} + // ComponentFile represents file component. type ComponentFile struct { ID int `json:"id,omitempty"` @@ -664,6 +759,10 @@ func (ComponentFile) IsTopLevel() bool { return true } +func (ComponentFile) IsModalSupported() bool { + return false +} + // SeparatorSpacing is the size of padding in a separator type SeparatorSpacing int @@ -702,6 +801,10 @@ func (Separator) IsTopLevel() bool { return true } +func (Separator) IsModalSupported() bool { + return false +} + // Container is a top-level layout component that allows you to join text contextually with an accessory. type Container struct { ID int `json:"id,omitempty"` @@ -754,3 +857,7 @@ func (c Container) Type() ComponentType { func (Container) IsTopLevel() bool { return true } + +func (Container) IsModalSupported() bool { + return false +} diff --git a/lib/discordgo/interactions.go b/lib/discordgo/interactions.go index 35fd082c5c..3efb96eeb2 100644 --- a/lib/discordgo/interactions.go +++ b/lib/discordgo/interactions.go @@ -366,10 +366,11 @@ func (d *ModalSubmitInteractionData) UnmarshalJSON(data []byte) error { for i, v := range v.RawComponents { var ok bool comp := v.MessageComponent - d.Components[i], ok = comp.(TopLevelComponent) + c, ok := comp.(TopLevelComponent) if !ok { return errors.New("non top level component passed to modal interaction data unmarshaller") } + d.Components[i] = c } return err } From 29da0d0d12929d48ee990cdfd3bbf8b770ff0e53 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:18:16 +0530 Subject: [PATCH 08/92] Bugfix: use templates.SDict for modal values (#1978) * use templates.SDict for modal values * component functions re-org --- common/templates/context.go | 13 +++++++++--- customcommands/bot.go | 41 +++++++++++++++++++++---------------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/common/templates/context.go b/common/templates/context.go index c3fd15ee55..de46e5ebf3 100644 --- a/common/templates/context.go +++ b/common/templates/context.go @@ -677,8 +677,6 @@ func baseContextFuncs(c *Context) { c.addContextFunc("deleteResponse", c.tmplDelResponse) c.addContextFunc("deleteTrigger", c.tmplDelTrigger) - c.addContextFunc("editComponentMessage", c.tmplEditComponentsMessage(true)) - c.addContextFunc("editComponentMessageNoEscape", c.tmplEditComponentsMessage(false)) c.addContextFunc("editMessage", c.tmplEditMessage(true)) c.addContextFunc("editMessageNoEscape", c.tmplEditMessage(false)) c.addContextFunc("getMessage", c.tmplGetMessage) @@ -689,6 +687,15 @@ func baseContextFuncs(c *Context) { // Message send functions c.addContextFunc("sendDM", c.tmplSendDM) + + //TODO: Remove these component functions + c.addContextFunc("sendComponentMessageRetID", c.tmplSendComponentsMessage(true, true)) + c.addContextFunc("sendComponentMessage", c.tmplSendComponentsMessage(true, false)) + c.addContextFunc("sendComponentMessageNoEscape", c.tmplSendComponentsMessage(false, false)) + c.addContextFunc("sendComponentMessageNoEscapeRetID", c.tmplSendComponentsMessage(false, true)) + c.addContextFunc("editComponentMessage", c.tmplEditComponentsMessage(true)) + c.addContextFunc("editComponentMessageNoEscape", c.tmplEditComponentsMessage(false)) + c.addContextFunc("sendMessage", c.tmplSendMessage(true, false)) c.addContextFunc("sendMessageNoEscape", c.tmplSendMessage(false, false)) c.addContextFunc("sendMessageNoEscapeRetID", c.tmplSendMessage(false, true)) @@ -1024,7 +1031,7 @@ func (d Dict) MarshalJSON() ([]byte, error) { return json.Marshal(md) } -type SDict map[string]interface{} +type SDict map[string]any func (d SDict) Set(key string, value interface{}) (string, error) { d[key] = value diff --git a/customcommands/bot.go b/customcommands/bot.go index 2a61b23b2d..cb32751239 100644 --- a/customcommands/bot.go +++ b/customcommands/bot.go @@ -1396,14 +1396,9 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet tmplCtx.Data["StrippedID"] = stripped tmplCtx.Data["StrippedMsg"] = stripped tmplCtx.Data["IsModal"] = true - cmdValues := []string{} - type modalValue struct { - CustomID string - Type discordgo.ComponentType - value string - values []string - } - modalValues := map[string]modalValue{} + cmdValues := []any{} + + modalValues := templates.SDict{} for i := 0; i < len(interaction.ModalSubmitData().Components); i++ { switch comp := interaction.ModalSubmitData().Components[i].(type) { case *discordgo.ActionsRow: @@ -1412,20 +1407,30 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet if ok { cmdValues = append(cmdValues, field.Value) } + cID, _ := strings.CutPrefix(field.CustomID, templates.TemplateCustomIDPrefix) + modalValues.Set(cID, templates.SDict{ + "type": field.Type(), + "value": field.Value, + "custom_id": cID, + }) } case *discordgo.Label: if t, ok := comp.Component.(*discordgo.TextInput); ok { - modalValues[t.CustomID] = modalValue{ - CustomID: t.CustomID, - Type: t.Type(), - value: t.Value, - } + cID, _ := strings.CutPrefix(t.CustomID, templates.TemplateCustomIDPrefix) + cmdValues = append(cmdValues, t.Value) + modalValues.Set(cid, templates.SDict{ + "type": t.Type(), + "value": t.Value, + "custom_id": cID, + }) } else if sm, ok := comp.Component.(*discordgo.SelectMenu); ok { - modalValues[sm.CustomID] = modalValue{ - CustomID: sm.CustomID, - Type: sm.Type(), - values: sm.Values, - } + cID, _ := strings.CutPrefix(sm.CustomID, templates.TemplateCustomIDPrefix) + cmdValues = append(cmdValues, sm.Values) + modalValues.Set(cid, templates.SDict{ + "type": sm.Type(), + "value": sm.Values, + "custom_id": cID, + }) } } } From e2aa7fd466909ce3080aecfaeded6e18a2b42574 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:49:24 +0530 Subject: [PATCH 09/92] resolved conflicts --- customcommands/bot.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/customcommands/bot.go b/customcommands/bot.go index cb32751239..9df869695e 100644 --- a/customcommands/bot.go +++ b/customcommands/bot.go @@ -1385,8 +1385,8 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet tmplCtx.Data["Interaction"] = interaction tmplCtx.Data["InteractionData"] = interaction.ModalSubmitData() - cid := strings.TrimPrefix(interaction.ModalSubmitData().CustomID, templates.TemplateCustomIDPrefix) - tmplCtx.Data["CustomID"] = cid + modalCustomID := strings.TrimPrefix(interaction.ModalSubmitData().CustomID, templates.TemplateCustomIDPrefix) + tmplCtx.Data["CustomID"] = modalCustomID tmplCtx.Data["Cmd"] = cmdArgs[0] if len(cmdArgs) > 1 { tmplCtx.Data["CmdArgs"] = cmdArgs[1:] @@ -1418,7 +1418,7 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet if t, ok := comp.Component.(*discordgo.TextInput); ok { cID, _ := strings.CutPrefix(t.CustomID, templates.TemplateCustomIDPrefix) cmdValues = append(cmdValues, t.Value) - modalValues.Set(cid, templates.SDict{ + modalValues.Set(cID, templates.SDict{ "type": t.Type(), "value": t.Value, "custom_id": cID, @@ -1426,7 +1426,7 @@ func ExecuteCustomCommandFromModal(cc *models.CustomCommand, gs *dstate.GuildSet } else if sm, ok := comp.Component.(*discordgo.SelectMenu); ok { cID, _ := strings.CutPrefix(sm.CustomID, templates.TemplateCustomIDPrefix) cmdValues = append(cmdValues, sm.Values) - modalValues.Set(cid, templates.SDict{ + modalValues.Set(cID, templates.SDict{ "type": sm.Type(), "value": sm.Values, "custom_id": cID, From 8913effccabc4a507a3bef8c95fa2119fad0a6b8 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:07:04 +0530 Subject: [PATCH 10/92] set identify ratelimit to 5 shards per 5 seconds --- bot/bot.go | 90 +++++++++++++++++++++++++----------------------------- 1 file changed, 42 insertions(+), 48 deletions(-) diff --git a/bot/bot.go b/bot/bot.go index 81c5463564..e135347389 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -260,56 +260,50 @@ type identifyRatelimiter struct { lastRatelimitAt time.Time } -var identifyMaxConcurrency int -var identifyConcurrencyOnce sync.Once - -func (rl *identifyRatelimiter) getIdentifyMaxConcurrency() int { - identifyConcurrencyOnce.Do(func() { - identifyMaxConcurrency = 0 - const redisKey = "yagpdb.gateway.identify.max_concurrency" - lockKey := redisKey + ":lock" - err := common.BlockingLockRedisKey(lockKey, 0, 30) - if err != nil { - logger.WithError(err).Warn("failed to acquire lock for fetching gateway bot info") - return - } - defer common.UnlockRedisKey(lockKey) - var cached string - if err := common.RedisPool.Do(radix.Cmd(&cached, "GET", redisKey)); err == nil && cached != "" { - if parsed, perr := strconv.Atoi(cached); perr == nil && parsed > 0 { - identifyMaxConcurrency = parsed - logger.Infof("Gateway identify max_concurrency (cached): %d", identifyMaxConcurrency) - return - } - } - s, err := discordgo.New(common.GetBotToken()) - if err != nil { - logger.WithError(err).Warn("failed to create session to fetch gateway bot info") - return - } - resp, err := s.GatewayBot() - if err != nil { - logger.WithError(err).Warn("failed to fetch gateway bot info") - return - } - if resp != nil && resp.SessionStartLimit.MaxConcurrency > 0 { - identifyMaxConcurrency = resp.SessionStartLimit.MaxConcurrency - } - const ttlSeconds = 21600 // cache for 6 hours, this doesn't change often. - _ = common.RedisPool.Do(radix.FlatCmd(nil, "SETEX", redisKey, ttlSeconds, strconv.Itoa(identifyMaxConcurrency))) - logger.Infof("Gateway identify max_concurrency: %d", identifyMaxConcurrency) - }) - return identifyMaxConcurrency -} +// var identifyMaxConcurrency int +// var identifyConcurrencyOnce sync.Once + +// func (rl *identifyRatelimiter) getIdentifyMaxConcurrency() int { +// identifyConcurrencyOnce.Do(func() { +// identifyMaxConcurrency = 0 +// const redisKey = "yagpdb.gateway.identify.max_concurrency" +// lockKey := redisKey + ":lock" +// err := common.BlockingLockRedisKey(lockKey, 0, 30) +// if err != nil { +// logger.WithError(err).Warn("failed to acquire lock for fetching gateway bot info") +// return +// } +// defer common.UnlockRedisKey(lockKey) +// var cached string +// if err := common.RedisPool.Do(radix.Cmd(&cached, "GET", redisKey)); err == nil && cached != "" { +// if parsed, perr := strconv.Atoi(cached); perr == nil && parsed > 0 { +// identifyMaxConcurrency = parsed +// logger.Infof("Gateway identify max_concurrency (cached): %d", identifyMaxConcurrency) +// return +// } +// } +// s, err := discordgo.New(common.GetBotToken()) +// if err != nil { +// logger.WithError(err).Warn("failed to create session to fetch gateway bot info") +// return +// } +// resp, err := s.GatewayBot() +// if err != nil { +// logger.WithError(err).Warn("failed to fetch gateway bot info") +// return +// } +// if resp != nil && resp.SessionStartLimit.MaxConcurrency > 0 { +// identifyMaxConcurrency = resp.SessionStartLimit.MaxConcurrency +// } +// const ttlSeconds = 21600 // cache for 6 hours, this doesn't change often. +// _ = common.RedisPool.Do(radix.FlatCmd(nil, "SETEX", redisKey, ttlSeconds, strconv.Itoa(identifyMaxConcurrency))) +// logger.Infof("Gateway identify max_concurrency: %d", identifyMaxConcurrency) +// }) +// return identifyMaxConcurrency +// } func (rl *identifyRatelimiter) RatelimitIdentify(shardID int) { - mc := 0 - for mc == 0 { - mc = rl.getIdentifyMaxConcurrency() - if mc == 0 { - time.Sleep(5 * time.Second) - } - } + mc := 5 // total buckets is equal to the value of max_concurrency. bucket := shardID % mc key := "yagpdb.gateway.identify.bucket." + strconv.Itoa(bucket) From a87eb83a2a356bd9bf2625307b21391e737f0d9b Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:04:01 +0530 Subject: [PATCH 11/92] Changed Control Panel button on home page to a Login button if the user is logged out (#1979) --- autorole/autorole.go | 5 +++-- frontend/templates/cp_nav.html | 2 +- frontend/templates/index.html | 6 +++++- logs/web.go | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/autorole/autorole.go b/autorole/autorole.go index 129f07b268..73fe4e5838 100644 --- a/autorole/autorole.go +++ b/autorole/autorole.go @@ -33,8 +33,9 @@ func RegisterPlugin() { } type AutoroleConfig struct { - Role int64 `json:",string" valid:"role,true"` - RequiredDuration int `valid:"0,"` + Role int64 `json:",string" valid:"role,true"` + Roles []int64 `json:"role" valid:"role,true"` + RequiredDuration int `valid:"0,"` RequiredRoles []int64 `valid:"role,true"` IgnoreRoles []int64 `valid:"role,true"` diff --git a/frontend/templates/cp_nav.html b/frontend/templates/cp_nav.html index 14835239d5..1e27f39c45 100644 --- a/frontend/templates/cp_nav.html +++ b/frontend/templates/cp_nav.html @@ -137,7 +137,7 @@ {{else}} {{end}} diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 216d96a1e8..91aada8de5 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -66,13 +66,17 @@ Premium + {{if .User}}
Control Panel
- {{if .User}}
Logout
+ {{else}} +
+ Login with Discord +
{{end}} diff --git a/logs/web.go b/logs/web.go index 7b32e15d8e..a19698120e 100644 --- a/logs/web.go +++ b/logs/web.go @@ -241,7 +241,7 @@ func CheckCanAccessLogs(w http.ResponseWriter, r *http.Request, config *models.G member := web.ContextMember(ctx) if member == nil { goTo := url.QueryEscape(r.RequestURI) - alertLink := fmt.Sprintf(`log in with Discord`, web.BaseURL(), goTo) + alertLink := fmt.Sprintf(`Login with Discord`, web.BaseURL(), goTo) alertMsg := fmt.Sprintf("This server has restricted log access to members only. Please %s to view this log.", alertLink) tmpl.AddAlerts(web.ErrorAlert(alertMsg)) From af598c1a5c08037cccd20ea02d489689724c7d94 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Sat, 15 Nov 2025 23:37:49 +0530 Subject: [PATCH 12/92] added Set on modalBuilder (#1980) --- common/templates/context.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/common/templates/context.go b/common/templates/context.go index de46e5ebf3..4eff33de85 100644 --- a/common/templates/context.go +++ b/common/templates/context.go @@ -1162,6 +1162,35 @@ type ModalBuilder struct { Components []discordgo.TopLevelComponent } +func (s *ModalBuilder) Set(key string, value any) (*ModalBuilder, error) { + switch key { + case "title": + s.Title = ToString(value) + case "custom_id": + cID, err := validateCustomID(ToString(value), nil) + if err != nil { + return nil, err + } + s.CustomID = cID + case "components": + val, _ := indirect(reflect.ValueOf(value)) + if val.Kind() == reflect.Slice { + for i := 0; i < val.Len(); i++ { + _, err := s.addComponent(val.Index(i).Interface()) + if err != nil { + return nil, err + } + } + } else { + return nil, errors.New("components must be a slice of Labels or TextFields") + } + s.Components = value.([]discordgo.TopLevelComponent) + default: + return nil, errors.New("invalid key, accepted keys are: title, custom_id, components") + } + return s, nil +} + func (s *ModalBuilder) addComponent(comp any) (*ModalBuilder, error) { if len(s.Components) == 5 { return nil, errors.New("modal builder can only have maximum 5 top level components") @@ -1205,7 +1234,7 @@ type ComponentBuilder struct { Values []any } -func (s *ComponentBuilder) Add(key string, value interface{}) (interface{}, error) { +func (s *ComponentBuilder) Add(key string, value any) (interface{}, error) { if len(s.Components)+1 > MaxSliceLength { return nil, errors.New("resulting slice exceeds slice size limit") } From 04f784da47fbe28cb19d70fdcdb4390911b810eb Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Sun, 16 Nov 2025 14:45:09 +0530 Subject: [PATCH 13/92] fix overwriting components in modalbuilder.Set and add support for slice in AddComponents --- common/templates/context.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/common/templates/context.go b/common/templates/context.go index 4eff33de85..571c51526e 100644 --- a/common/templates/context.go +++ b/common/templates/context.go @@ -1174,6 +1174,7 @@ func (s *ModalBuilder) Set(key string, value any) (*ModalBuilder, error) { s.CustomID = cID case "components": val, _ := indirect(reflect.ValueOf(value)) + s.Components = make([]discordgo.TopLevelComponent, 0) if val.Kind() == reflect.Slice { for i := 0; i < val.Len(); i++ { _, err := s.addComponent(val.Index(i).Interface()) @@ -1184,7 +1185,6 @@ func (s *ModalBuilder) Set(key string, value any) (*ModalBuilder, error) { } else { return nil, errors.New("components must be a slice of Labels or TextFields") } - s.Components = value.([]discordgo.TopLevelComponent) default: return nil, errors.New("invalid key, accepted keys are: title, custom_id, components") } @@ -1210,9 +1210,19 @@ func (s *ModalBuilder) addComponent(comp any) (*ModalBuilder, error) { func (s *ModalBuilder) AddComponents(comps ...any) (*ModalBuilder, error) { for _, comp := range comps { - _, err := s.addComponent(comp) - if err != nil { - return nil, err + val, _ := indirect(reflect.ValueOf(comp)) + if val.Kind() == reflect.Slice { + for i := 0; i < val.Len(); i++ { + _, err := s.addComponent(val.Index(i).Interface()) + if err != nil { + return nil, err + } + } + } else { + _, err := s.addComponent(comp) + if err != nil { + return nil, err + } } } return s, nil From 5defd3f333cda59245c072e36837215f7b2f6309 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:28:08 +0530 Subject: [PATCH 14/92] allow cmodal to be used for newer gen components (#1982) --- common/templates/context_interactions.go | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/common/templates/context_interactions.go b/common/templates/context_interactions.go index 38027cc9ae..3493b89bdd 100644 --- a/common/templates/context_interactions.go +++ b/common/templates/context_interactions.go @@ -73,18 +73,29 @@ func CreateModal(values ...any) (*discordgo.InteractionResponse, error) { m = dict } - modal := &discordgo.InteractionResponseData{CustomID: TemplateCustomIDPrefix + "-0"} // default cID if not set + modalBuilder := &ModalBuilder{ + CustomID: TemplateCustomIDPrefix + "-0", + } + _, hasComponentsKey := m["components"] + _, hasFieldsKey := m["fields"] + if hasComponentsKey && hasFieldsKey { + return nil, errors.New("cannot have both 'components' and 'fields' in a cmodal") + } for key, val := range m { switch key { case "title": - modal.Title = ToString(val) + modalBuilder.Title = ToString(val) case "custom_id": cid, err := validateCustomID(ToString(val), nil) if err != nil { return nil, err } - modal.CustomID = cid + modalBuilder.CustomID = cid + case "components": + modalBuilder.Set("components", val) + + //TODO: Deprecate this key in future versions case "fields": if val == nil { continue @@ -108,7 +119,7 @@ func CreateModal(values ...any) (*discordgo.InteractionResponse, error) { return nil, err } usedCustomIDs[field.CustomID] = true - modal.Components = append(modal.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) + modalBuilder.Components = append(modalBuilder.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) } } else { f, err := CreateComponent(discordgo.TextInputComponent, val) @@ -123,7 +134,7 @@ func CreateModal(values ...any) (*discordgo.InteractionResponse, error) { if err != nil { return nil, err } - modal.Components = append(modal.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) + modalBuilder.Components = append(modalBuilder.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) } default: return nil, errors.New(`invalid key "` + key + `" passed to send message builder`) @@ -131,10 +142,7 @@ func CreateModal(values ...any) (*discordgo.InteractionResponse, error) { } - return &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseModal, - Data: modal, - }, nil + return modalBuilder.toModal() } func (c *Context) tmplDeleteInteractionResponse(interactionToken, msgID interface{}, delaySeconds ...interface{}) (interface{}, error) { From 91681698bad562bf93c9f71cb858ccbcdec25d5b Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:28:36 +0530 Subject: [PATCH 15/92] fixes to handling mute overrides (#1981) --- lib/discordgo/restapi.go | 15 +++++++++++++-- moderation/plugin_bot.go | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/discordgo/restapi.go b/lib/discordgo/restapi.go index 3212ef68f4..699df04b5b 100644 --- a/lib/discordgo/restapi.go +++ b/lib/discordgo/restapi.go @@ -162,8 +162,19 @@ func (s *Session) doRequest(method, urlStr, contentType string, b []byte, header rl := TooManyRequests{} err = json.Unmarshal(response, &rl) if err != nil { - s.log(LogError, "rate limit unmarshal error, %s, %q, url: %s", err, string(response), urlStr) - return + s.log(LogError, "request body rate limit unmarshal error, %s, %q, url: %s", err, string(response), urlStr) + // get ratelimit from headers in case body doesn't have it. + retryAfter := resp.Header.Get("Retry-After") + if retryAfter != "" { + rl.RetryAfter, err = strconv.ParseFloat(retryAfter, 64) + if err != nil { + s.log(LogError, "request header rate limit unmarshal error, %s, %q, url: %s", err, string(response), urlStr) + return nil, true, false, err + } + rl.Global = true + } else { + return nil, true, false, err + } } rl.Bucket = bucket.Key diff --git a/moderation/plugin_bot.go b/moderation/plugin_bot.go index 9f9b8eac3a..dce76540e2 100644 --- a/moderation/plugin_bot.go +++ b/moderation/plugin_bot.go @@ -189,6 +189,7 @@ func RefreshMuteOverrides(guildID int64, createRole bool) { for _, v := range guild.Channels { RefreshMuteOverrideForChannel(config, v) + time.Sleep(1 * time.Second) } } From 8a5f64e98f1172d13375ad63950b6dce60efb7d3 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:39:23 +0530 Subject: [PATCH 16/92] added more error logs around verification (#1983) --- verification/verification_bot.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/verification/verification_bot.go b/verification/verification_bot.go index 5813c89157..bbf99a94d2 100644 --- a/verification/verification_bot.go +++ b/verification/verification_bot.go @@ -66,6 +66,7 @@ func memberPresentInVerificationPendingSet(guildID int64, userID int64) bool { // Function to check if member is present in verification pending set, and add if not present func addMemberToVerificationPendingSet(guildID int64, userID int64) { + logger.WithField("guild", guildID).WithField("user", userID).Info("Adding member to verification pending set") if memberPresentInVerificationPendingSet(guildID, userID) { // Member is already in the set return @@ -86,6 +87,7 @@ func RandStringRunes(n int) string { } func (p *Plugin) handleVerificationAfterScreening(member *discordgo.Member) { + conf, err := models.FindVerificationConfigG(context.Background(), member.GuildID) if err != nil { if err != sql.ErrNoRows { @@ -97,6 +99,7 @@ func (p *Plugin) handleVerificationAfterScreening(member *discordgo.Member) { if !conf.Enabled { return } + logger.WithField("guild", member.GuildID).WithField("user", member.User.ID).Info("Initiating verification!") gs := bot.State.GetGuild(member.GuildID) roleInvalid := true for _, role := range gs.Roles { @@ -109,15 +112,18 @@ func (p *Plugin) handleVerificationAfterScreening(member *discordgo.Member) { cop := *conf cop.Enabled = false cop.UpdateG(context.Background(), boil.Whitelist("enabled")) + logger.WithField("guild", member.GuildID).WithField("user", member.User.ID).Error("Verification config disabled, because the verified role is invalid or deleted") return } // Check if member is already verified, if yes then remove any scheduled events if common.ContainsInt64Slice(member.Roles, conf.VerifiedRole) { + logger.WithField("guild", member.GuildID).WithField("user", member.User.ID).Info("Member already verified, clearing scheduled events") err = p.clearScheduledEvents(context.Background(), member.GuildID, member.User.ID) if err != nil { logger.WithError(err).WithField("guild", member.GuildID).WithField("user", member.User.ID).Error("failed clearing past scheduled warn/kick events") } + return } @@ -189,7 +195,7 @@ func (p *Plugin) createVerificationSession(userID, guildID int64) (string, error } func (p *Plugin) startVerificationProcess(conf *models.VerificationConfig, guildID int64, target *discordgo.User) { - + logger.WithField("guild", guildID).WithField("user", target.ID).Info("Creating verification session") token, err := p.createVerificationSession(target.ID, guildID) if err != nil { logger.WithError(err).WithField("user", target.ID).WithField("guild", guildID).Error("failed creating verification session") @@ -198,7 +204,7 @@ func (p *Plugin) startVerificationProcess(conf *models.VerificationConfig, guild gs := bot.State.GetGuild(guildID) if gs == nil { - logger.Error("guild not available") + logger.WithField("guild", guildID).WithField("user", target.ID).Error("guild not available") return } @@ -209,13 +215,13 @@ func (p *Plugin) startVerificationProcess(conf *models.VerificationConfig, guild ms, err := bot.GetMember(guildID, target.ID) if err != nil { - logger.WithError(err).Error("failed retrieving member") + logger.WithError(err).WithField("guild", guildID).WithField("user", target.ID).Error("failed retrieving member") return } channel, err := common.BotSession.UserChannelCreate(ms.User.ID) if err != nil { - logger.WithError(err).Error("failed creating user channel") + logger.WithError(err).WithField("guild", guildID).WithField("user", target.ID).Error("failed creating user channel") return } @@ -531,7 +537,7 @@ func (p *Plugin) logAction(guildID int64, channelID int64, author *discordgo.Use if common.IsDiscordErr(err, discordgo.ErrCodeMissingPermissions, discordgo.ErrCodeUnknownChannel) { go p.disableLogChannel(guildID) } else { - logger.WithError(err).WithField("channel", channelID).Error("failed sending log message") + logger.WithError(err).WithField("guild", guildID).WithField("channel", channelID).Error("failed sending log message") } } } From 1f00cce018a00c84bf30a9a839b45724b9b24bff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Nov 2025 12:16:01 +0530 Subject: [PATCH 17/92] build(deps): bump golang.org/x/crypto from 0.43.0 to 0.45.0 (#1984) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.43.0 to 0.45.0. - [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.45.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.45.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 11 ++++------- go.sum | 41 ++++++++++++----------------------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 2d65064430..f4411157e1 100644 --- a/go.mod +++ b/go.mod @@ -60,12 +60,12 @@ require ( github.com/volatiletech/sqlboiler/v4 v4.14.2 github.com/volatiletech/strmangle v0.0.6 goji.io v2.0.2+incompatible - golang.org/x/crypto v0.43.0 + golang.org/x/crypto v0.45.0 golang.org/x/image v0.18.0 - golang.org/x/net v0.46.0 + golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.27.0 - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 golang.org/x/time v0.3.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/api v0.131.0 @@ -106,9 +106,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect ) diff --git a/go.sum b/go.sum index 69189a7297..e3ac4ad75a 100644 --- a/go.sum +++ b/go.sum @@ -828,10 +828,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -872,8 +870,6 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -932,10 +928,8 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -976,10 +970,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/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/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1073,19 +1065,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1100,10 +1089,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1170,10 +1157,6 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From db9478bd61da6a4b201a2b52fac5109f6941bdf6 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Sat, 22 Nov 2025 12:16:17 +0530 Subject: [PATCH 18/92] return error from sendMessage to handle it in try catch in template (#1985) * return error from sendMessage to handle it in try catch in template * return error from sendMessage to handle it in try catch in template --- common/templates/context_funcs.go | 26 +++++++++++++++----------- lib/discordgo/components.go | 9 ++++++--- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/common/templates/context_funcs.go b/common/templates/context_funcs.go index 0c6dc1ba5c..394113a8a3 100644 --- a/common/templates/context_funcs.go +++ b/common/templates/context_funcs.go @@ -327,17 +327,17 @@ func (c *Context) checkSafeDictNoRecursion(d Dict, n int) bool { return true } -func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) func(channel interface{}, msg interface{}) interface{} { +func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) func(channel any, msg any) (any, error) { - return func(channel interface{}, msg interface{}) interface{} { + return func(channel any, msg any) (any, error) { if c.IncreaseCheckGenericAPICall() { - return "" + return "", nil } sendType := sendMessageGuildChannel cid := c.ChannelArg(channel) if cid == 0 { - return "" + return "", nil } if cid != c.ChannelArgNoDM(channel) { @@ -363,10 +363,9 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun msgSend.Reference.ChannelID = cid } case *ComponentBuilder: - msgSend, _ = typedMsg.ToComplexMessage() - if msgSend.Reference != nil { - msgSend.Reference.GuildID = c.GS.ID - msgSend.Reference.ChannelID = cid + msgSend, err = typedMsg.ToComplexMessage() + if err != nil { + return "", err } default: msgSend.Content = ToString(msg) @@ -390,6 +389,8 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun } if msgSend.Reference != nil { + msgSend.Reference.GuildID = c.GS.ID + msgSend.Reference.ChannelID = cid if msgSend.Reference.Type == discordgo.MessageReferenceTypeForward { if originChannel := c.ChannelArgNoDM(msgSend.Reference.ChannelID); originChannel != 0 { hasPerms, _ := bot.BotHasPermissionGS(c.GS, originChannel, discordgo.PermissionViewChannel|discordgo.PermissionReadMessageHistory) @@ -403,12 +404,15 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun } m, err = common.BotSession.ChannelMessageSendComplex(cid, msgSend) + if err != nil { + return "", err + } - if err == nil && returnID { - return m.ID + if returnID { + return m.ID, nil } - return "" + return "", nil } } diff --git a/lib/discordgo/components.go b/lib/discordgo/components.go index 957817020f..8807ea2b7f 100644 --- a/lib/discordgo/components.go +++ b/lib/discordgo/components.go @@ -494,7 +494,7 @@ func (s Section) MarshalJSON() ([]byte, error) { func (s *Section) UnmarshalJSON(data []byte) error { var v struct { RawComponents []unmarshalableMessageComponent `json:"components"` - RawAccessory unmarshalableMessageComponent `json:"accessory"` + RawAccessory *unmarshalableMessageComponent `json:"accessory"` } err := json.Unmarshal(data, &v) if err != nil { @@ -506,14 +506,17 @@ func (s *Section) UnmarshalJSON(data []byte) error { comp := v.MessageComponent s.Components[i], ok = comp.(SectionComponentPart) if !ok { - return errors.New("non text display passed to section component unmarshaller") + return errors.New("non text display passed to section component") } } + if v.RawAccessory == nil { + return errors.New("missing accessory component in section") + } accessory := v.RawAccessory.MessageComponent s.Accessory, ok = accessory.(AccessoryComponent) if !ok { - return errors.New("non accessory component passed to section component unmarshaller") + return errors.New("non accessory component passed to section component") } return err From 604513808e3b13fe1a0569e05897ca8b1a1d167e Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:25:37 +0530 Subject: [PATCH 19/92] handle panic on converting db entries (#1986) --- customcommands/tmplextensions.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/customcommands/tmplextensions.go b/customcommands/tmplextensions.go index 389f014abf..e9972f3424 100644 --- a/customcommands/tmplextensions.go +++ b/customcommands/tmplextensions.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "database/sql" + "fmt" "time" "emperror.dev/errors" @@ -867,7 +868,17 @@ type LightDBEntry struct { func ToLightDBEntry(m *models.TemplatesUserDatabase) (*LightDBEntry, error) { var dst interface{} dec := newDecoder(bytes.NewBuffer(m.ValueRaw)) - err := dec.Decode(&dst) + + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = errors.New(fmt.Sprint("panic decoding db entry: ", r)) + } + }() + err = dec.Decode(&dst) + }() + if err != nil { return nil, err } @@ -910,7 +921,7 @@ func newDecoder(buf *bytes.Buffer) *msgpack.Decoder { mi := make(map[interface{}]interface{}, n) ms := make(map[string]interface{}) - for i := 0; i < n; i++ { + for range n { mk, err := d.DecodeInterface() if err != nil { return nil, err @@ -938,6 +949,7 @@ func newDecoder(buf *bytes.Buffer) *msgpack.Decoder { mi[mk] = mv } } + if isStringKeysOnly { return ms, nil } From 89e99ab02d05621cf8a0600acb09268d589f0015 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:38:19 +0530 Subject: [PATCH 20/92] bumped to go v1.25.4 --- .circleci/config.yml | 2 +- go.mod | 2 +- yagpdb_docker/Dockerfile | 2 +- yagpdb_docker/Dockerfile.debug | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3625054532..09d36d6daa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ jobs: # basic units of work in a run build: # runs not using Workflows must have a `build` job as entry point docker: # run the steps with Docker # CircleCI Go images available at: https://hub.docker.com/r/circleci/golang/ - - image: cimg/go:1.25.0 # + - image: cimg/go:1.25.4 # # directory where steps are run. Path must conform to the Go Workspace requirements working_directory: ~/app diff --git a/go.mod b/go.mod index f4411157e1..294657a286 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/botlabs-gg/yagpdb/v2 -go 1.25.0 +go 1.25.4 require ( emperror.dev/errors v0.8.1 diff --git a/yagpdb_docker/Dockerfile b/yagpdb_docker/Dockerfile index d15676a5a8..3ddc4dc9a2 100644 --- a/yagpdb_docker/Dockerfile +++ b/yagpdb_docker/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/golang:1.25.0 AS builder +FROM docker.io/golang:1.25.4 AS builder WORKDIR /appbuild/yagpdb COPY go.mod go.sum ./ diff --git a/yagpdb_docker/Dockerfile.debug b/yagpdb_docker/Dockerfile.debug index d3707bf3f2..3f0f4205d9 100644 --- a/yagpdb_docker/Dockerfile.debug +++ b/yagpdb_docker/Dockerfile.debug @@ -1,4 +1,4 @@ -FROM docker.io/golang:1.25.0-alpine +FROM docker.io/golang:1.25.4-alpine # Dependencies: ca-certificates for client TLS, tzdata for timezone and ffmpeg for soundboard support RUN apk --no-cache add ca-certificates ffmpeg tzdata RUN GOEXPERIMENT=jsonv2 CGO_ENABLED=0 go install -ldflags "-s -w -extldflags '-static'" github.com/go-delve/delve/cmd/dlv@latest From f67af796c2426979e6236e73bd6d1d517872630c Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:12:33 +0530 Subject: [PATCH 21/92] Fix bug with message forwards not working --- common/templates/context_funcs.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/templates/context_funcs.go b/common/templates/context_funcs.go index 394113a8a3..681bfcebdb 100644 --- a/common/templates/context_funcs.go +++ b/common/templates/context_funcs.go @@ -388,9 +388,11 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun msgSend.AllowedMentions = discordgo.AllowedMentions{Parse: parseMentions, RepliedUser: repliedUser} } - if msgSend.Reference != nil { + if msgSend.Reference != nil && msgSend.Reference.MessageID != 0 { msgSend.Reference.GuildID = c.GS.ID - msgSend.Reference.ChannelID = cid + if msgSend.Reference.ChannelID == 0 { + msgSend.Reference.ChannelID = cid + } if msgSend.Reference.Type == discordgo.MessageReferenceTypeForward { if originChannel := c.ChannelArgNoDM(msgSend.Reference.ChannelID); originChannel != 0 { hasPerms, _ := bot.BotHasPermissionGS(c.GS, originChannel, discordgo.PermissionViewChannel|discordgo.PermissionReadMessageHistory) From 1f555c1d1de7cbb8ea547dc3141133ce0a82bfec Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:00:51 +0530 Subject: [PATCH 22/92] Added twitch feeds! (#1987) * added twitch feeds * added env vars to example docker * simplified logic to send notifications * Fix bugs with VOD --- cmd/yagpdb/main.go | 2 + go.mod | 2 + go.sum | 4 + twitch/assets/twitch.html | 219 +++++ twitch/bot.go | 156 +++ twitch/feed.go | 307 ++++++ twitch/models/boil_queries.go | 38 + twitch/models/boil_table_names.go | 12 + twitch/models/boil_types.go | 52 + twitch/models/boil_view_names.go | 7 + twitch/models/psql_upsert.go | 99 ++ twitch/models/twitch_announcements.go | 835 ++++++++++++++++ twitch/models/twitch_channel_subscriptions.go | 921 ++++++++++++++++++ twitch/schema.go | 28 + twitch/sqlboiler.toml | 15 + twitch/twitch.go | 85 ++ twitch/web.go | 340 +++++++ yagpdb_docker/app.example.env | 7 +- yagpdb_docker/docker-compose.debug.yml | 6 +- 19 files changed, 3133 insertions(+), 2 deletions(-) create mode 100644 twitch/assets/twitch.html create mode 100644 twitch/bot.go create mode 100644 twitch/feed.go create mode 100644 twitch/models/boil_queries.go create mode 100644 twitch/models/boil_table_names.go create mode 100644 twitch/models/boil_types.go create mode 100644 twitch/models/boil_view_names.go create mode 100644 twitch/models/psql_upsert.go create mode 100644 twitch/models/twitch_announcements.go create mode 100644 twitch/models/twitch_channel_subscriptions.go create mode 100644 twitch/schema.go create mode 100644 twitch/sqlboiler.toml create mode 100644 twitch/twitch.go create mode 100644 twitch/web.go diff --git a/cmd/yagpdb/main.go b/cmd/yagpdb/main.go index 5e6e570906..3435ddb0b2 100644 --- a/cmd/yagpdb/main.go +++ b/cmd/yagpdb/main.go @@ -9,6 +9,7 @@ import ( "github.com/botlabs-gg/yagpdb/v2/common/run" "github.com/botlabs-gg/yagpdb/v2/lib/confusables" "github.com/botlabs-gg/yagpdb/v2/trivia" + "github.com/botlabs-gg/yagpdb/v2/twitch" "github.com/botlabs-gg/yagpdb/v2/web/discorddata" // Core yagpdb packages @@ -100,6 +101,7 @@ func main() { rss.RegisterPlugin() bulkrole.RegisterPlugin() personalizer.RegisterPlugin() + twitch.RegisterPlugin() // Register confusables replacer confusables.Init() diff --git a/go.mod b/go.mod index 294657a286..97e44bb45e 100644 --- a/go.mod +++ b/go.mod @@ -79,6 +79,7 @@ require ( github.com/jarcoal/httpmock v1.0.4 github.com/justinian/dice v1.0.2 github.com/n0madic/twitter-scraper v0.0.0-20230711213008-94503a2bc36c + github.com/nicklaw5/helix/v2 v2.32.0 github.com/robfig/cron/v3 v3.0.1 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c ) @@ -93,6 +94,7 @@ require ( github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/s2a-go v0.1.4 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect diff --git a/go.sum b/go.sum index e3ac4ad75a..139a711234 100644 --- a/go.sum +++ b/go.sum @@ -259,6 +259,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= @@ -556,6 +558,8 @@ github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4 github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/nicklaw5/helix/v2 v2.32.0 h1:ZRPt+wRUMQqpny6yZKVY9rUGNwv+ZmIh75fSiopMXuY= +github.com/nicklaw5/helix/v2 v2.32.0/go.mod h1:KaXa2mb2kBzsDana9RbXevTgnfU95DMoSORWo2hqlWA= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= diff --git a/twitch/assets/twitch.html b/twitch/assets/twitch.html new file mode 100644 index 0000000000..f21ca6b2c3 --- /dev/null +++ b/twitch/assets/twitch.html @@ -0,0 +1,219 @@ +{{define "cp_twitch"}} +{{template "cp_head" .}} + + + + + +{{template "cp_alerts" .}} + +
+
+
+
+

Add New Feed

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ {{checkbox "MentionEveryone" "mention-everyone" "Mention Everyone" false}} +
+
+ {{checkbox "PublishVOD" "publish-vod" "Publish VOD (when stream goes offline)" false}} +
+
+ +
+
+
+
+
+ +
+
+
+

Custom Announcement

+
+
+ {{if not .IsGuildPremium}} +
+ Custom announcements are a premium feature. Upgrade to Premium to unlock this + feature. +
+ {{end}} +
+
+
+ {{$announcementEnabled := false}} + {{if .TwitchAnnouncement}} + {{$announcementEnabled = .TwitchAnnouncement.Enabled}} + {{end}} + {{checkbox "Enabled" "announcement-enabled" `

Enable

` + $announcementEnabled}} + + + + + + + +

+ Note: using Custom Announcement will override the default announcement message + mention settings, you will have to do mentions like they are done in custom commands +

+

+ In addition to the full Custom Command syntax , the following templates are also + supported:
+

    +
  • {{"{{"}} .User {{"}}"}} - The Twitch username.
  • +
  • {{"{{"}} .URL {{"}}"}} - The link to the stream.
  • +
  • {{"{{"}} .Title {{"}}"}} - The stream title.
  • +
  • {{"{{"}} .Game {{"}}"}} - The game being played.
  • +
  • {{"{{"}} .IsLive {{"}}"}} - Boolean, true if live.
  • +
  • {{"{{"}} .VODUrl {{"}}"}} - The URL to the VOD (if offline).
  • +
+

+
+
+
+
+
+
+
+ + + +
+
+
+
+

Current Feeds

+
+
+ {{$dot := .}} + {{range .TwitchSubs}} +
+ {{end}} + + + + + + + + + + + + + + {{range .TwitchSubs}} + + + + + + + + + + {{end}} + +
Twitch UserDiscord ChannelMention EveryoneMention RolesPublish VODEnabledActions
+

{{.TwitchUsername}}

+
+ + + {{checkbox "MentionEveryone" (print "mention-everyone-" .ID) `Mention everyone` + .MentionEveryone (print `form="feed-item-` .ID `"`)}} + + + + {{checkbox "PublishVOD" (print "publish-vod-" .ID) `Publish VOD` .PublishVod (print + `form="feed-item-` .ID `"`)}} + + {{checkbox "Enabled" (print "enabled-" .ID) `` .Enabled (print `form="feed-item-` .ID + `"`)}} + + + +
+
+
+
+
+ +{{template "cp_footer" .}} +{{end}} \ No newline at end of file diff --git a/twitch/bot.go b/twitch/bot.go new file mode 100644 index 0000000000..0428573647 --- /dev/null +++ b/twitch/bot.go @@ -0,0 +1,156 @@ +package twitch + +import ( + "context" + "fmt" + "strconv" + + "github.com/botlabs-gg/yagpdb/v2/analytics" + "github.com/botlabs-gg/yagpdb/v2/bot" + "github.com/botlabs-gg/yagpdb/v2/common/mqueue" + "github.com/botlabs-gg/yagpdb/v2/common/pubsub" + "github.com/botlabs-gg/yagpdb/v2/common/templates" + "github.com/botlabs-gg/yagpdb/v2/feeds" + "github.com/botlabs-gg/yagpdb/v2/lib/discordgo" + "github.com/botlabs-gg/yagpdb/v2/twitch/models" + "github.com/nicklaw5/helix/v2" + "github.com/prometheus/client_golang/prometheus" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" +) + +type CustomTwitchAnnouncement struct { + GuildID int64 `json:"guild_id"` + Subscription models.TwitchChannelSubscription `json:"subscription"` + Stream helix.Stream `json:"stream"` + IsLive bool `json:"is_live"` + VODUrl string `json:"vod_url"` +} + +func (p *Plugin) BotInit() { + pubsub.AddHandler("custom_twitch_announcement", func(evt *pubsub.Event) { + if evt.Data == nil { + return + } + data := evt.Data.(*CustomTwitchAnnouncement) + p.handleCustomAnnouncement(data) + }, CustomTwitchAnnouncement{}) +} + +func (p *Plugin) Status() (string, string) { + total, _ := models.TwitchChannelSubscriptions().CountG(context.Background()) + return "Total Subs", fmt.Sprintf("%d", total) +} + +func (p *Plugin) OnRemovedPremiumGuild(guildID int64) error { + ctx := context.Background() + + // 1. Disable Custom Announcements (Premium Only) + announcement, err := models.FindTwitchAnnouncementG(ctx, guildID) + if err == nil { + announcement.Enabled = false + _, err = announcement.UpdateG(ctx, boil.Whitelist("enabled")) + if err != nil { + logger.WithError(err).WithField("guild", guildID).Error("failed disabling custom twitch announcement") + } + } + + // 2. Enforce Feed Limits (Free Limit) + // Fetch all enabled subscriptions ordered by ID DESC + subs, err := models.TwitchChannelSubscriptions( + models.TwitchChannelSubscriptionWhere.GuildID.EQ(discordgo.StrID(guildID)), + models.TwitchChannelSubscriptionWhere.Enabled.EQ(true), + qm.OrderBy("id DESC"), + ).AllG(ctx) + + if err != nil { + logger.WithError(err).WithField("guild", guildID).Error("failed retrieving twitch subscriptions for limit enforcement") + return err + } + + if len(subs) > GuildMaxEnabledFeeds { + // Disable excess feeds + excessSubs := subs[GuildMaxEnabledFeeds:] + for _, sub := range excessSubs { + sub.Enabled = false + _, err := sub.UpdateG(ctx, boil.Whitelist("enabled")) + if err != nil { + logger.WithError(err).WithField("guild", guildID).WithField("sub_id", sub.ID).Error("failed disabling excess twitch subscription") + } + } + } + + return nil +} + +func (p *Plugin) handleCustomAnnouncement(notif *CustomTwitchAnnouncement) { + sub := notif.Subscription + stream := notif.Stream + guildID := notif.GuildID + isLive := notif.IsLive + vodUrl := notif.VODUrl + + logger.WithField("guild_id", guildID).WithField("channel_id", sub.ChannelID).WithField("stream_id", stream.ID).WithField("is_live", isLive).Info("handling custom twitch announcement") + + guildState := bot.State.GetGuild(guildID) + if guildState == nil { + return + } + + channelID, _ := strconv.ParseInt(sub.ChannelID, 10, 64) + channelState := guildState.GetChannel(channelID) + if channelState == nil { + return + } + + announcement, err := models.FindTwitchAnnouncementG(context.Background(), guildID) + if err != nil { + return + } + + if !announcement.Enabled { + return + } + + ctx := templates.NewContext(guildState, channelState, nil) + + ctx.Data["URL"] = "https://twitch.tv/" + stream.UserLogin + ctx.Data["User"] = stream.UserName + ctx.Data["Title"] = stream.Title + ctx.Data["Game"] = stream.GameName + ctx.Data["Stream"] = stream + ctx.Data["IsLive"] = isLive + ctx.Data["VODUrl"] = vodUrl + if isLive { + ctx.Data["Status"] = "live" + } else { + ctx.Data["Status"] = "offline" + } + + content, err := ctx.Execute(announcement.Message) + if err != nil { + logger.WithError(err).WithField("guild", guildID).Errorf("custom announcement parsing failed") + return + } + + if content == "" { + return + } + + go analytics.RecordActiveUnit(guildID, p, "posted_twitch_message") + feeds.MetricPostedMessages.With(prometheus.Labels{"source": "twitch"}).Inc() + + parseMentions := []discordgo.AllowedMentionType{discordgo.AllowedMentionTypeRoles, discordgo.AllowedMentionTypeEveryone} + mqueue.QueueMessage(&mqueue.QueuedElement{ + GuildID: guildID, + ChannelID: channelID, + Source: "twitch", + SourceItemID: stream.ID, + MessageStr: content, + Priority: 2, + PublishAnnouncement: false, + AllowedMentions: discordgo.AllowedMentions{ + Parse: parseMentions, + }, + }) +} diff --git a/twitch/feed.go b/twitch/feed.go new file mode 100644 index 0000000000..ee75e836d5 --- /dev/null +++ b/twitch/feed.go @@ -0,0 +1,307 @@ +package twitch + +import ( + "context" + "fmt" + "strconv" + "sync" + "time" + + "github.com/botlabs-gg/yagpdb/v2/analytics" + "github.com/botlabs-gg/yagpdb/v2/common" + "github.com/botlabs-gg/yagpdb/v2/common/mqueue" + "github.com/botlabs-gg/yagpdb/v2/common/pubsub" + "github.com/botlabs-gg/yagpdb/v2/feeds" + "github.com/botlabs-gg/yagpdb/v2/lib/discordgo" + "github.com/botlabs-gg/yagpdb/v2/twitch/models" + "github.com/mediocregopher/radix/v3" + "github.com/nicklaw5/helix/v2" + "github.com/prometheus/client_golang/prometheus" + "github.com/volatiletech/sqlboiler/v4/queries/qm" +) + +const ( + // PollingInterval is how often we check for new streams + PollingInterval = time.Minute * 1 +) + +func (p *Plugin) StartFeed() { + p.Stop = make(chan *sync.WaitGroup) + go p.runPoller() +} + +func (p *Plugin) StopFeed(wg *sync.WaitGroup) { + if p.Stop != nil { + p.Stop <- wg + } else { + wg.Done() + } +} + +func (p *Plugin) SetupClient() error { + clientID := confClientId.GetString() + clientSecret := confClientSecret.GetString() + + if clientID == "" || clientSecret == "" { + return fmt.Errorf("twitch client id or secret not set") + } + + client, err := helix.NewClient(&helix.Options{ + ClientID: clientID, + ClientSecret: clientSecret, + }) + if err != nil { + return err + } + + resp, err := client.RequestAppAccessToken([]string{}) + if err != nil { + return err + } + client.SetAppAccessToken(resp.Data.AccessToken) + + p.HelixClient = client + return nil +} + +func (p *Plugin) runPoller() { + ticker := time.NewTicker(PollingInterval) + for { + select { + case wg := <-p.Stop: + wg.Done() + return + case <-ticker.C: + p.checkStreams() + } + } +} + +func (p *Plugin) checkStreams() { + var items models.TwitchChannelSubscriptionSlice + err := models.TwitchChannelSubscriptions( + qm.Select("DISTINCT "+models.TwitchChannelSubscriptionColumns.TwitchUserID), + models.TwitchChannelSubscriptionWhere.Enabled.EQ(true), + ).BindG(context.Background(), &items) + + if err != nil { + logger.WithError(err).Error("Failed retrieving twitch subscriptions") + return + } + + var allUserIDs []string + for _, item := range items { + if item.TwitchUserID != "" { + allUserIDs = append(allUserIDs, item.TwitchUserID) + } + } + + if len(allUserIDs) == 0 { + return + } + + // Process in chunks of 100 + chunks := chunkStringSlice(allUserIDs, 100) + for i, chunk := range chunks { + logger.Infof("Total: %d streams, Processing chunk %d/%d", len(allUserIDs), i+1, len(chunks)) + p.processStreamsBatch(chunk) + time.Sleep(time.Second * 1) + } +} + +func (p *Plugin) processStreamsBatch(userIDs []string) { + // Check if token is valid, refresh if needed + isValid, _, _ := p.HelixClient.ValidateToken(p.HelixClient.GetAppAccessToken()) + if !isValid { + resp, err := p.HelixClient.RequestAppAccessToken([]string{}) + if err != nil { + logger.WithError(err).Error("Failed refreshing twitch token") + return + } + p.HelixClient.SetAppAccessToken(resp.Data.AccessToken) + } + + // Get current stream status from Twitch + resp, err := p.HelixClient.GetStreams(&helix.StreamsParams{ + UserIDs: userIDs, + Type: "live", + }) + if err != nil { + logger.WithError(err).Error("Failed getting streams from twitch") + return + } + + // Create map of currently live users + liveMap := make(map[string]helix.Stream) + for _, stream := range resp.Data.Streams { + liveMap[stream.UserID] = stream + } + + now := time.Now() + for _, userID := range userIDs { + stream, isLive := liveMap[userID] + // Check if user is in the online set + var onlineScore string + err := common.RedisPool.Do(radix.Cmd(&onlineScore, "ZSCORE", "twitch_online_users", userID)) + wasOnline := err == nil && onlineScore != "" + + if !isLive && !wasOnline { + continue + } + if isLive && wasOnline { + // Past cooldown, update timestamp and continue (no new notification) + common.RedisPool.Do(radix.Cmd(nil, "ZADD", "twitch_online_users", strconv.FormatInt(now.Unix(), 10), userID)) + continue + } + if isLive { + common.RedisPool.Do(radix.Cmd(nil, "ZADD", "twitch_online_users", strconv.FormatInt(now.Unix(), 10), userID)) + // New stream - add to online set and notify + logger.Infof("Twitch stream live: %s (Stream ID: %s)", stream.UserName, stream.ID) + } else { + // Stream went offline - remove from set and notify + logger.Infof("Twitch stream offline: %s", userID) + lastTime, err := strconv.ParseInt(onlineScore, 10, 64) + if err != nil { + logger.WithError(err).Error("Failed parsing online score") + continue + } + if timeSinceOffline := now.Unix() - lastTime; timeSinceOffline < 300 { + // 5 minutes cooldown before sending offline notification + logger.Infof("Skipping notification for %s - only been offline for %d seconds", stream.UserName, timeSinceOffline) + continue + } + common.RedisPool.Do(radix.Cmd(nil, "ZREM", "twitch_online_users", userID)) + stream = helix.Stream{ + UserID: userID, + } + } + p.notifySubscribers(userID, stream, isLive) + } +} + +func chunkStringSlice(slice []string, chunkSize int) [][]string { + var chunks [][]string + for i := 0; i < len(slice); i += chunkSize { + end := min(i+chunkSize, len(slice)) + chunks = append(chunks, slice[i:end]) + } + return chunks +} + +func (p *Plugin) notifySubscribers(twitchUserID string, stream helix.Stream, isLive bool) { + userSubs, err := models.TwitchChannelSubscriptions( + models.TwitchChannelSubscriptionWhere.TwitchUserID.EQ(twitchUserID), + models.TwitchChannelSubscriptionWhere.Enabled.EQ(true), + ).AllG(context.Background()) + if err != nil { + logger.WithError(err).Error("Failed retrieving twitch subscriptions for notification") + return + } + + if len(userSubs) == 0 { + return + } + + // Fill in username if missing (offline case) + if stream.UserName == "" { + stream.UserName = userSubs[0].TwitchUsername + stream.UserLogin = userSubs[0].TwitchUsername + } + + // Fetch VOD if offline and needed + var vodUrl string + if !isLive { + // Check if any sub needs VOD + needsVOD := false + for _, sub := range userSubs { + if sub.PublishVod { + needsVOD = true + break + } + } + + if needsVOD { + // Fetch videos + resp, err := p.HelixClient.GetVideos(&helix.VideosParams{ + UserID: twitchUserID, + Type: "archive", + Period: "day", + First: 1, + }) + if err == nil && len(resp.Data.Videos) > 0 { + // Check if video is recent (created in last 6 hours to be safe) + video := resp.Data.Videos[0] + created, _ := time.Parse(time.RFC3339, video.CreatedAt) + if time.Since(created) < 6*time.Hour { + vodUrl = video.URL + } + } + } + } + + for _, sub := range userSubs { + p.sendStreamMessage(sub, stream, isLive, vodUrl) + } +} + +func (p *Plugin) sendStreamMessage(sub *models.TwitchChannelSubscription, stream helix.Stream, isLive bool, vodUrl string) { + parsedGuild, _ := strconv.ParseInt(sub.GuildID, 10, 64) + parsedChannel, _ := strconv.ParseInt(sub.ChannelID, 10, 64) + + // Check for custom announcement + announcement, err := models.FindTwitchAnnouncementG(context.Background(), parsedGuild) + if err == nil && announcement.Enabled && len(announcement.Message) > 0 { + // Publish event for custom announcement + pubsub.Publish("custom_twitch_announcement", parsedGuild, CustomTwitchAnnouncement{ + GuildID: parsedGuild, + Subscription: *sub, + Stream: stream, + IsLive: isLive, + VODUrl: vodUrl, + }) + return + } + + // If standard message, only send for Live events unless VOD is present + if !isLive && (!sub.PublishVod || vodUrl == "") { + return + } + + var content string + if isLive { + streamUrl := "https://www.twitch.tv/" + stream.UserLogin + content = fmt.Sprintf("**%s** is live now playing **%s**!\n%s", stream.UserName, stream.GameName, streamUrl) + } else { + content = fmt.Sprintf("**%s** has gone offline. Catch the VOD here: %s", stream.UserName, vodUrl) + } + + parseMentions := []discordgo.AllowedMentionType{} + + if sub.MentionEveryone { + content = "Hey @everyone " + content + parseMentions = []discordgo.AllowedMentionType{discordgo.AllowedMentionTypeEveryone} + } else if len(sub.MentionRoles) > 0 { + mentions := "Hey" + for _, roleId := range sub.MentionRoles { + mentions += fmt.Sprintf(" <@&%d>", roleId) + } + content = mentions + " " + content + parseMentions = []discordgo.AllowedMentionType{discordgo.AllowedMentionTypeRoles} + } + + go analytics.RecordActiveUnit(parsedGuild, p, "posted_twitch_message") + feeds.MetricPostedMessages.With(prometheus.Labels{"source": "twitch"}).Inc() + + mqueue.QueueMessage(&mqueue.QueuedElement{ + GuildID: parsedGuild, + ChannelID: parsedChannel, + Source: "twitch", + SourceItemID: stream.ID, + MessageStr: content, + Priority: 2, + PublishAnnouncement: false, + AllowedMentions: discordgo.AllowedMentions{ + Parse: parseMentions, + }, + }) +} diff --git a/twitch/models/boil_queries.go b/twitch/models/boil_queries.go new file mode 100644 index 0000000000..26694dc7df --- /dev/null +++ b/twitch/models/boil_queries.go @@ -0,0 +1,38 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "regexp" + + "github.com/volatiletech/sqlboiler/v4/drivers" + "github.com/volatiletech/sqlboiler/v4/queries" + "github.com/volatiletech/sqlboiler/v4/queries/qm" +) + +var dialect = drivers.Dialect{ + LQ: 0x22, + RQ: 0x22, + + UseIndexPlaceholders: true, + UseLastInsertID: false, + UseSchema: false, + UseDefaultKeyword: true, + UseAutoColumns: false, + UseTopClause: false, + UseOutputClause: false, + UseCaseWhenExistsClause: false, +} + +// This is a dummy variable to prevent unused regexp import error +var _ = ®exp.Regexp{} + +// NewQuery initializes a new Query using the passed in QueryMods +func NewQuery(mods ...qm.QueryMod) *queries.Query { + q := &queries.Query{} + queries.SetDialect(q, &dialect) + qm.Apply(q, mods...) + + return q +} diff --git a/twitch/models/boil_table_names.go b/twitch/models/boil_table_names.go new file mode 100644 index 0000000000..b0c24d22c4 --- /dev/null +++ b/twitch/models/boil_table_names.go @@ -0,0 +1,12 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +var TableNames = struct { + TwitchAnnouncements string + TwitchChannelSubscriptions string +}{ + TwitchAnnouncements: "twitch_announcements", + TwitchChannelSubscriptions: "twitch_channel_subscriptions", +} diff --git a/twitch/models/boil_types.go b/twitch/models/boil_types.go new file mode 100644 index 0000000000..13f92c42c3 --- /dev/null +++ b/twitch/models/boil_types.go @@ -0,0 +1,52 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "strconv" + + "github.com/friendsofgo/errors" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/strmangle" +) + +// M type is for providing columns and column values to UpdateAll. +type M map[string]interface{} + +// ErrSyncFail occurs during insert when the record could not be retrieved in +// order to populate default value information. This usually happens when LastInsertId +// fails or there was a primary key configuration that was not resolvable. +var ErrSyncFail = errors.New("models: failed to synchronize data after insert") + +type insertCache struct { + query string + retQuery string + valueMapping []uint64 + retMapping []uint64 +} + +type updateCache struct { + query string + valueMapping []uint64 +} + +func makeCacheKey(cols boil.Columns, nzDefaults []string) string { + buf := strmangle.GetBuffer() + + buf.WriteString(strconv.Itoa(cols.Kind)) + for _, w := range cols.Cols { + buf.WriteString(w) + } + + if len(nzDefaults) != 0 { + buf.WriteByte('.') + } + for _, nz := range nzDefaults { + buf.WriteString(nz) + } + + str := buf.String() + strmangle.PutBuffer(buf) + return str +} diff --git a/twitch/models/boil_view_names.go b/twitch/models/boil_view_names.go new file mode 100644 index 0000000000..25ff551e62 --- /dev/null +++ b/twitch/models/boil_view_names.go @@ -0,0 +1,7 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +var ViewNames = struct { +}{} diff --git a/twitch/models/psql_upsert.go b/twitch/models/psql_upsert.go new file mode 100644 index 0000000000..cd00500c2d --- /dev/null +++ b/twitch/models/psql_upsert.go @@ -0,0 +1,99 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "fmt" + "strings" + + "github.com/volatiletech/sqlboiler/v4/drivers" + "github.com/volatiletech/strmangle" +) + +type UpsertOptions struct { + conflictTarget string + updateSet string +} + +type UpsertOptionFunc func(o *UpsertOptions) + +func UpsertConflictTarget(conflictTarget string) UpsertOptionFunc { + return func(o *UpsertOptions) { + o.conflictTarget = conflictTarget + } +} + +func UpsertUpdateSet(updateSet string) UpsertOptionFunc { + return func(o *UpsertOptions) { + o.updateSet = updateSet + } +} + +// buildUpsertQueryPostgres builds a SQL statement string using the upsertData provided. +func buildUpsertQueryPostgres(dia drivers.Dialect, tableName string, updateOnConflict bool, ret, update, conflict, whitelist []string, opts ...UpsertOptionFunc) string { + conflict = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, conflict) + whitelist = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, whitelist) + ret = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, ret) + + upsertOpts := &UpsertOptions{} + for _, o := range opts { + o(upsertOpts) + } + + buf := strmangle.GetBuffer() + defer strmangle.PutBuffer(buf) + + columns := "DEFAULT VALUES" + if len(whitelist) != 0 { + columns = fmt.Sprintf("(%s) VALUES (%s)", + strings.Join(whitelist, ", "), + strmangle.Placeholders(dia.UseIndexPlaceholders, len(whitelist), 1, 1)) + } + + fmt.Fprintf( + buf, + "INSERT INTO %s %s ON CONFLICT ", + tableName, + columns, + ) + + if upsertOpts.conflictTarget != "" { + buf.WriteString(upsertOpts.conflictTarget) + } else if len(conflict) != 0 { + buf.WriteByte('(') + buf.WriteString(strings.Join(conflict, ", ")) + buf.WriteByte(')') + } + buf.WriteByte(' ') + + if !updateOnConflict || len(update) == 0 { + buf.WriteString("DO NOTHING") + } else { + buf.WriteString("DO UPDATE SET ") + + if upsertOpts.updateSet != "" { + buf.WriteString(upsertOpts.updateSet) + } else { + for i, v := range update { + if len(v) == 0 { + continue + } + if i != 0 { + buf.WriteByte(',') + } + quoted := strmangle.IdentQuote(dia.LQ, dia.RQ, v) + buf.WriteString(quoted) + buf.WriteString(" = EXCLUDED.") + buf.WriteString(quoted) + } + } + } + + if len(ret) != 0 { + buf.WriteString(" RETURNING ") + buf.WriteString(strings.Join(ret, ", ")) + } + + return buf.String() +} diff --git a/twitch/models/twitch_announcements.go b/twitch/models/twitch_announcements.go new file mode 100644 index 0000000000..c4dd7d3ecf --- /dev/null +++ b/twitch/models/twitch_announcements.go @@ -0,0 +1,835 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/friendsofgo/errors" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + "github.com/volatiletech/sqlboiler/v4/queries/qmhelper" + "github.com/volatiletech/strmangle" +) + +// TwitchAnnouncement is an object representing the database table. +type TwitchAnnouncement struct { + GuildID int64 `boil:"guild_id" json:"guild_id" toml:"guild_id" yaml:"guild_id"` + Message string `boil:"message" json:"message" toml:"message" yaml:"message"` + Enabled bool `boil:"enabled" json:"enabled" toml:"enabled" yaml:"enabled"` + + R *twitchAnnouncementR `boil:"-" json:"-" toml:"-" yaml:"-"` + L twitchAnnouncementL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var TwitchAnnouncementColumns = struct { + GuildID string + Message string + Enabled string +}{ + GuildID: "guild_id", + Message: "message", + Enabled: "enabled", +} + +var TwitchAnnouncementTableColumns = struct { + GuildID string + Message string + Enabled string +}{ + GuildID: "twitch_announcements.guild_id", + Message: "twitch_announcements.message", + Enabled: "twitch_announcements.enabled", +} + +// Generated where + +type whereHelperint64 struct{ field string } + +func (w whereHelperint64) EQ(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperint64) NEQ(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperint64) LT(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperint64) LTE(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperint64) GT(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperint64) GTE(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } +func (w whereHelperint64) IN(slice []int64) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...) +} +func (w whereHelperint64) NIN(slice []int64) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...) +} + +type whereHelperstring struct{ field string } + +func (w whereHelperstring) EQ(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperstring) NEQ(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperstring) LT(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperstring) LTE(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperstring) GT(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperstring) GTE(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } +func (w whereHelperstring) LIKE(x string) qm.QueryMod { return qm.Where(w.field+" LIKE ?", x) } +func (w whereHelperstring) NLIKE(x string) qm.QueryMod { return qm.Where(w.field+" NOT LIKE ?", x) } +func (w whereHelperstring) ILIKE(x string) qm.QueryMod { return qm.Where(w.field+" ILIKE ?", x) } +func (w whereHelperstring) NILIKE(x string) qm.QueryMod { return qm.Where(w.field+" NOT ILIKE ?", x) } +func (w whereHelperstring) SIMILAR(x string) qm.QueryMod { return qm.Where(w.field+" SIMILAR TO ?", x) } +func (w whereHelperstring) NSIMILAR(x string) qm.QueryMod { + return qm.Where(w.field+" NOT SIMILAR TO ?", x) +} +func (w whereHelperstring) IN(slice []string) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...) +} +func (w whereHelperstring) NIN(slice []string) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...) +} + +type whereHelperbool struct{ field string } + +func (w whereHelperbool) EQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperbool) NEQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperbool) LT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperbool) LTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } + +var TwitchAnnouncementWhere = struct { + GuildID whereHelperint64 + Message whereHelperstring + Enabled whereHelperbool +}{ + GuildID: whereHelperint64{field: "\"twitch_announcements\".\"guild_id\""}, + Message: whereHelperstring{field: "\"twitch_announcements\".\"message\""}, + Enabled: whereHelperbool{field: "\"twitch_announcements\".\"enabled\""}, +} + +// TwitchAnnouncementRels is where relationship names are stored. +var TwitchAnnouncementRels = struct { +}{} + +// twitchAnnouncementR is where relationships are stored. +type twitchAnnouncementR struct { +} + +// NewStruct creates a new relationship struct +func (*twitchAnnouncementR) NewStruct() *twitchAnnouncementR { + return &twitchAnnouncementR{} +} + +// twitchAnnouncementL is where Load methods for each relationship are stored. +type twitchAnnouncementL struct{} + +var ( + twitchAnnouncementAllColumns = []string{"guild_id", "message", "enabled"} + twitchAnnouncementColumnsWithoutDefault = []string{"guild_id", "message"} + twitchAnnouncementColumnsWithDefault = []string{"enabled"} + twitchAnnouncementPrimaryKeyColumns = []string{"guild_id"} + twitchAnnouncementGeneratedColumns = []string{} +) + +type ( + // TwitchAnnouncementSlice is an alias for a slice of pointers to TwitchAnnouncement. + // This should almost always be used instead of []TwitchAnnouncement. + TwitchAnnouncementSlice []*TwitchAnnouncement + + twitchAnnouncementQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + twitchAnnouncementType = reflect.TypeOf(&TwitchAnnouncement{}) + twitchAnnouncementMapping = queries.MakeStructMapping(twitchAnnouncementType) + twitchAnnouncementPrimaryKeyMapping, _ = queries.BindMapping(twitchAnnouncementType, twitchAnnouncementMapping, twitchAnnouncementPrimaryKeyColumns) + twitchAnnouncementInsertCacheMut sync.RWMutex + twitchAnnouncementInsertCache = make(map[string]insertCache) + twitchAnnouncementUpdateCacheMut sync.RWMutex + twitchAnnouncementUpdateCache = make(map[string]updateCache) + twitchAnnouncementUpsertCacheMut sync.RWMutex + twitchAnnouncementUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// OneG returns a single twitchAnnouncement record from the query using the global executor. +func (q twitchAnnouncementQuery) OneG(ctx context.Context) (*TwitchAnnouncement, error) { + return q.One(ctx, boil.GetContextDB()) +} + +// One returns a single twitchAnnouncement record from the query. +func (q twitchAnnouncementQuery) One(ctx context.Context, exec boil.ContextExecutor) (*TwitchAnnouncement, error) { + o := &TwitchAnnouncement{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for twitch_announcements") + } + + return o, nil +} + +// AllG returns all TwitchAnnouncement records from the query using the global executor. +func (q twitchAnnouncementQuery) AllG(ctx context.Context) (TwitchAnnouncementSlice, error) { + return q.All(ctx, boil.GetContextDB()) +} + +// All returns all TwitchAnnouncement records from the query. +func (q twitchAnnouncementQuery) All(ctx context.Context, exec boil.ContextExecutor) (TwitchAnnouncementSlice, error) { + var o []*TwitchAnnouncement + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to TwitchAnnouncement slice") + } + + return o, nil +} + +// CountG returns the count of all TwitchAnnouncement records in the query using the global executor +func (q twitchAnnouncementQuery) CountG(ctx context.Context) (int64, error) { + return q.Count(ctx, boil.GetContextDB()) +} + +// Count returns the count of all TwitchAnnouncement records in the query. +func (q twitchAnnouncementQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count twitch_announcements rows") + } + + return count, nil +} + +// ExistsG checks if the row exists in the table using the global executor. +func (q twitchAnnouncementQuery) ExistsG(ctx context.Context) (bool, error) { + return q.Exists(ctx, boil.GetContextDB()) +} + +// Exists checks if the row exists in the table. +func (q twitchAnnouncementQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if twitch_announcements exists") + } + + return count > 0, nil +} + +// TwitchAnnouncements retrieves all the records using an executor. +func TwitchAnnouncements(mods ...qm.QueryMod) twitchAnnouncementQuery { + mods = append(mods, qm.From("\"twitch_announcements\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"twitch_announcements\".*"}) + } + + return twitchAnnouncementQuery{q} +} + +// FindTwitchAnnouncementG retrieves a single record by ID. +func FindTwitchAnnouncementG(ctx context.Context, guildID int64, selectCols ...string) (*TwitchAnnouncement, error) { + return FindTwitchAnnouncement(ctx, boil.GetContextDB(), guildID, selectCols...) +} + +// FindTwitchAnnouncement retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindTwitchAnnouncement(ctx context.Context, exec boil.ContextExecutor, guildID int64, selectCols ...string) (*TwitchAnnouncement, error) { + twitchAnnouncementObj := &TwitchAnnouncement{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"twitch_announcements\" where \"guild_id\"=$1", sel, + ) + + q := queries.Raw(query, guildID) + + err := q.Bind(ctx, exec, twitchAnnouncementObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from twitch_announcements") + } + + return twitchAnnouncementObj, nil +} + +// InsertG a single record. See Insert for whitelist behavior description. +func (o *TwitchAnnouncement) InsertG(ctx context.Context, columns boil.Columns) error { + return o.Insert(ctx, boil.GetContextDB(), columns) +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *TwitchAnnouncement) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no twitch_announcements provided for insertion") + } + + var err error + + nzDefaults := queries.NonZeroDefaultSet(twitchAnnouncementColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + twitchAnnouncementInsertCacheMut.RLock() + cache, cached := twitchAnnouncementInsertCache[key] + twitchAnnouncementInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + twitchAnnouncementAllColumns, + twitchAnnouncementColumnsWithDefault, + twitchAnnouncementColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(twitchAnnouncementType, twitchAnnouncementMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(twitchAnnouncementType, twitchAnnouncementMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"twitch_announcements\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"twitch_announcements\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into twitch_announcements") + } + + if !cached { + twitchAnnouncementInsertCacheMut.Lock() + twitchAnnouncementInsertCache[key] = cache + twitchAnnouncementInsertCacheMut.Unlock() + } + + return nil +} + +// UpdateG a single TwitchAnnouncement record using the global executor. +// See Update for more documentation. +func (o *TwitchAnnouncement) UpdateG(ctx context.Context, columns boil.Columns) (int64, error) { + return o.Update(ctx, boil.GetContextDB(), columns) +} + +// Update uses an executor to update the TwitchAnnouncement. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *TwitchAnnouncement) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + var err error + key := makeCacheKey(columns, nil) + twitchAnnouncementUpdateCacheMut.RLock() + cache, cached := twitchAnnouncementUpdateCache[key] + twitchAnnouncementUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + twitchAnnouncementAllColumns, + twitchAnnouncementPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update twitch_announcements, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"twitch_announcements\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, twitchAnnouncementPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(twitchAnnouncementType, twitchAnnouncementMapping, append(wl, twitchAnnouncementPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update twitch_announcements row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for twitch_announcements") + } + + if !cached { + twitchAnnouncementUpdateCacheMut.Lock() + twitchAnnouncementUpdateCache[key] = cache + twitchAnnouncementUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAllG updates all rows with the specified column values. +func (q twitchAnnouncementQuery) UpdateAllG(ctx context.Context, cols M) (int64, error) { + return q.UpdateAll(ctx, boil.GetContextDB(), cols) +} + +// UpdateAll updates all rows with the specified column values. +func (q twitchAnnouncementQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for twitch_announcements") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for twitch_announcements") + } + + return rowsAff, nil +} + +// UpdateAllG updates all rows with the specified column values. +func (o TwitchAnnouncementSlice) UpdateAllG(ctx context.Context, cols M) (int64, error) { + return o.UpdateAll(ctx, boil.GetContextDB(), cols) +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o TwitchAnnouncementSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), twitchAnnouncementPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"twitch_announcements\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, twitchAnnouncementPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in twitchAnnouncement slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all twitchAnnouncement") + } + return rowsAff, nil +} + +// UpsertG attempts an insert, and does an update or ignore on conflict. +func (o *TwitchAnnouncement) UpsertG(ctx context.Context, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + return o.Upsert(ctx, boil.GetContextDB(), updateOnConflict, conflictColumns, updateColumns, insertColumns, opts...) +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *TwitchAnnouncement) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no twitch_announcements provided for upsert") + } + + nzDefaults := queries.NonZeroDefaultSet(twitchAnnouncementColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + twitchAnnouncementUpsertCacheMut.RLock() + cache, cached := twitchAnnouncementUpsertCache[key] + twitchAnnouncementUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + twitchAnnouncementAllColumns, + twitchAnnouncementColumnsWithDefault, + twitchAnnouncementColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + twitchAnnouncementAllColumns, + twitchAnnouncementPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert twitch_announcements, could not build update column list") + } + + ret := strmangle.SetComplement(twitchAnnouncementAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(twitchAnnouncementPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert twitch_announcements, could not build conflict column list") + } + + conflict = make([]string, len(twitchAnnouncementPrimaryKeyColumns)) + copy(conflict, twitchAnnouncementPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"twitch_announcements\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(twitchAnnouncementType, twitchAnnouncementMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(twitchAnnouncementType, twitchAnnouncementMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert twitch_announcements") + } + + if !cached { + twitchAnnouncementUpsertCacheMut.Lock() + twitchAnnouncementUpsertCache[key] = cache + twitchAnnouncementUpsertCacheMut.Unlock() + } + + return nil +} + +// DeleteG deletes a single TwitchAnnouncement record. +// DeleteG will match against the primary key column to find the record to delete. +func (o *TwitchAnnouncement) DeleteG(ctx context.Context) (int64, error) { + return o.Delete(ctx, boil.GetContextDB()) +} + +// Delete deletes a single TwitchAnnouncement record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *TwitchAnnouncement) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no TwitchAnnouncement provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), twitchAnnouncementPrimaryKeyMapping) + sql := "DELETE FROM \"twitch_announcements\" WHERE \"guild_id\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from twitch_announcements") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for twitch_announcements") + } + + return rowsAff, nil +} + +func (q twitchAnnouncementQuery) DeleteAllG(ctx context.Context) (int64, error) { + return q.DeleteAll(ctx, boil.GetContextDB()) +} + +// DeleteAll deletes all matching rows. +func (q twitchAnnouncementQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no twitchAnnouncementQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from twitch_announcements") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for twitch_announcements") + } + + return rowsAff, nil +} + +// DeleteAllG deletes all rows in the slice. +func (o TwitchAnnouncementSlice) DeleteAllG(ctx context.Context) (int64, error) { + return o.DeleteAll(ctx, boil.GetContextDB()) +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o TwitchAnnouncementSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), twitchAnnouncementPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"twitch_announcements\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, twitchAnnouncementPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from twitchAnnouncement slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for twitch_announcements") + } + + return rowsAff, nil +} + +// ReloadG refetches the object from the database using the primary keys. +func (o *TwitchAnnouncement) ReloadG(ctx context.Context) error { + if o == nil { + return errors.New("models: no TwitchAnnouncement provided for reload") + } + + return o.Reload(ctx, boil.GetContextDB()) +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *TwitchAnnouncement) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindTwitchAnnouncement(ctx, exec, o.GuildID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAllG refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *TwitchAnnouncementSlice) ReloadAllG(ctx context.Context) error { + if o == nil { + return errors.New("models: empty TwitchAnnouncementSlice provided for reload all") + } + + return o.ReloadAll(ctx, boil.GetContextDB()) +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *TwitchAnnouncementSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := TwitchAnnouncementSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), twitchAnnouncementPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"twitch_announcements\".* FROM \"twitch_announcements\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, twitchAnnouncementPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in TwitchAnnouncementSlice") + } + + *o = slice + + return nil +} + +// TwitchAnnouncementExistsG checks if the TwitchAnnouncement row exists. +func TwitchAnnouncementExistsG(ctx context.Context, guildID int64) (bool, error) { + return TwitchAnnouncementExists(ctx, boil.GetContextDB(), guildID) +} + +// TwitchAnnouncementExists checks if the TwitchAnnouncement row exists. +func TwitchAnnouncementExists(ctx context.Context, exec boil.ContextExecutor, guildID int64) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"twitch_announcements\" where \"guild_id\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, guildID) + } + row := exec.QueryRowContext(ctx, sql, guildID) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if twitch_announcements exists") + } + + return exists, nil +} + +// Exists checks if the TwitchAnnouncement row exists. +func (o *TwitchAnnouncement) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return TwitchAnnouncementExists(ctx, exec, o.GuildID) +} diff --git a/twitch/models/twitch_channel_subscriptions.go b/twitch/models/twitch_channel_subscriptions.go new file mode 100644 index 0000000000..f9af1bd4e2 --- /dev/null +++ b/twitch/models/twitch_channel_subscriptions.go @@ -0,0 +1,921 @@ +// Code generated by SQLBoiler 4.19.5 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/friendsofgo/errors" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + "github.com/volatiletech/sqlboiler/v4/queries/qmhelper" + "github.com/volatiletech/sqlboiler/v4/types" + "github.com/volatiletech/strmangle" +) + +// TwitchChannelSubscription is an object representing the database table. +type TwitchChannelSubscription struct { + ID int `boil:"id" json:"id" toml:"id" yaml:"id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + GuildID string `boil:"guild_id" json:"guild_id" toml:"guild_id" yaml:"guild_id"` + ChannelID string `boil:"channel_id" json:"channel_id" toml:"channel_id" yaml:"channel_id"` + TwitchUserID string `boil:"twitch_user_id" json:"twitch_user_id" toml:"twitch_user_id" yaml:"twitch_user_id"` + TwitchUsername string `boil:"twitch_username" json:"twitch_username" toml:"twitch_username" yaml:"twitch_username"` + MentionEveryone bool `boil:"mention_everyone" json:"mention_everyone" toml:"mention_everyone" yaml:"mention_everyone"` + MentionRoles types.Int64Array `boil:"mention_roles" json:"mention_roles,omitempty" toml:"mention_roles" yaml:"mention_roles,omitempty"` + PublishVod bool `boil:"publish_vod" json:"publish_vod" toml:"publish_vod" yaml:"publish_vod"` + Enabled bool `boil:"enabled" json:"enabled" toml:"enabled" yaml:"enabled"` + + R *twitchChannelSubscriptionR `boil:"-" json:"-" toml:"-" yaml:"-"` + L twitchChannelSubscriptionL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var TwitchChannelSubscriptionColumns = struct { + ID string + CreatedAt string + UpdatedAt string + GuildID string + ChannelID string + TwitchUserID string + TwitchUsername string + MentionEveryone string + MentionRoles string + PublishVod string + Enabled string +}{ + ID: "id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + GuildID: "guild_id", + ChannelID: "channel_id", + TwitchUserID: "twitch_user_id", + TwitchUsername: "twitch_username", + MentionEveryone: "mention_everyone", + MentionRoles: "mention_roles", + PublishVod: "publish_vod", + Enabled: "enabled", +} + +var TwitchChannelSubscriptionTableColumns = struct { + ID string + CreatedAt string + UpdatedAt string + GuildID string + ChannelID string + TwitchUserID string + TwitchUsername string + MentionEveryone string + MentionRoles string + PublishVod string + Enabled string +}{ + ID: "twitch_channel_subscriptions.id", + CreatedAt: "twitch_channel_subscriptions.created_at", + UpdatedAt: "twitch_channel_subscriptions.updated_at", + GuildID: "twitch_channel_subscriptions.guild_id", + ChannelID: "twitch_channel_subscriptions.channel_id", + TwitchUserID: "twitch_channel_subscriptions.twitch_user_id", + TwitchUsername: "twitch_channel_subscriptions.twitch_username", + MentionEveryone: "twitch_channel_subscriptions.mention_everyone", + MentionRoles: "twitch_channel_subscriptions.mention_roles", + PublishVod: "twitch_channel_subscriptions.publish_vod", + Enabled: "twitch_channel_subscriptions.enabled", +} + +// Generated where + +type whereHelperint struct{ field string } + +func (w whereHelperint) EQ(x int) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperint) NEQ(x int) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperint) LT(x int) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperint) LTE(x int) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperint) GT(x int) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperint) GTE(x int) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } +func (w whereHelperint) IN(slice []int) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...) +} +func (w whereHelperint) NIN(slice []int) qm.QueryMod { + values := make([]interface{}, 0, len(slice)) + for _, value := range slice { + values = append(values, value) + } + return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...) +} + +type whereHelpertime_Time struct{ field string } + +func (w whereHelpertime_Time) EQ(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.EQ, x) +} +func (w whereHelpertime_Time) NEQ(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.NEQ, x) +} +func (w whereHelpertime_Time) LT(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpertime_Time) LTE(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpertime_Time) GT(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpertime_Time) GTE(x time.Time) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +type whereHelpertypes_Int64Array struct{ field string } + +func (w whereHelpertypes_Int64Array) EQ(x types.Int64Array) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, false, x) +} +func (w whereHelpertypes_Int64Array) NEQ(x types.Int64Array) qm.QueryMod { + return qmhelper.WhereNullEQ(w.field, true, x) +} +func (w whereHelpertypes_Int64Array) LT(x types.Int64Array) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LT, x) +} +func (w whereHelpertypes_Int64Array) LTE(x types.Int64Array) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.LTE, x) +} +func (w whereHelpertypes_Int64Array) GT(x types.Int64Array) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GT, x) +} +func (w whereHelpertypes_Int64Array) GTE(x types.Int64Array) qm.QueryMod { + return qmhelper.Where(w.field, qmhelper.GTE, x) +} + +func (w whereHelpertypes_Int64Array) IsNull() qm.QueryMod { return qmhelper.WhereIsNull(w.field) } +func (w whereHelpertypes_Int64Array) IsNotNull() qm.QueryMod { return qmhelper.WhereIsNotNull(w.field) } + +var TwitchChannelSubscriptionWhere = struct { + ID whereHelperint + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time + GuildID whereHelperstring + ChannelID whereHelperstring + TwitchUserID whereHelperstring + TwitchUsername whereHelperstring + MentionEveryone whereHelperbool + MentionRoles whereHelpertypes_Int64Array + PublishVod whereHelperbool + Enabled whereHelperbool +}{ + ID: whereHelperint{field: "\"twitch_channel_subscriptions\".\"id\""}, + CreatedAt: whereHelpertime_Time{field: "\"twitch_channel_subscriptions\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"twitch_channel_subscriptions\".\"updated_at\""}, + GuildID: whereHelperstring{field: "\"twitch_channel_subscriptions\".\"guild_id\""}, + ChannelID: whereHelperstring{field: "\"twitch_channel_subscriptions\".\"channel_id\""}, + TwitchUserID: whereHelperstring{field: "\"twitch_channel_subscriptions\".\"twitch_user_id\""}, + TwitchUsername: whereHelperstring{field: "\"twitch_channel_subscriptions\".\"twitch_username\""}, + MentionEveryone: whereHelperbool{field: "\"twitch_channel_subscriptions\".\"mention_everyone\""}, + MentionRoles: whereHelpertypes_Int64Array{field: "\"twitch_channel_subscriptions\".\"mention_roles\""}, + PublishVod: whereHelperbool{field: "\"twitch_channel_subscriptions\".\"publish_vod\""}, + Enabled: whereHelperbool{field: "\"twitch_channel_subscriptions\".\"enabled\""}, +} + +// TwitchChannelSubscriptionRels is where relationship names are stored. +var TwitchChannelSubscriptionRels = struct { +}{} + +// twitchChannelSubscriptionR is where relationships are stored. +type twitchChannelSubscriptionR struct { +} + +// NewStruct creates a new relationship struct +func (*twitchChannelSubscriptionR) NewStruct() *twitchChannelSubscriptionR { + return &twitchChannelSubscriptionR{} +} + +// twitchChannelSubscriptionL is where Load methods for each relationship are stored. +type twitchChannelSubscriptionL struct{} + +var ( + twitchChannelSubscriptionAllColumns = []string{"id", "created_at", "updated_at", "guild_id", "channel_id", "twitch_user_id", "twitch_username", "mention_everyone", "mention_roles", "publish_vod", "enabled"} + twitchChannelSubscriptionColumnsWithoutDefault = []string{"created_at", "updated_at", "guild_id", "channel_id", "twitch_user_id", "twitch_username", "mention_everyone"} + twitchChannelSubscriptionColumnsWithDefault = []string{"id", "mention_roles", "publish_vod", "enabled"} + twitchChannelSubscriptionPrimaryKeyColumns = []string{"id"} + twitchChannelSubscriptionGeneratedColumns = []string{} +) + +type ( + // TwitchChannelSubscriptionSlice is an alias for a slice of pointers to TwitchChannelSubscription. + // This should almost always be used instead of []TwitchChannelSubscription. + TwitchChannelSubscriptionSlice []*TwitchChannelSubscription + + twitchChannelSubscriptionQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + twitchChannelSubscriptionType = reflect.TypeOf(&TwitchChannelSubscription{}) + twitchChannelSubscriptionMapping = queries.MakeStructMapping(twitchChannelSubscriptionType) + twitchChannelSubscriptionPrimaryKeyMapping, _ = queries.BindMapping(twitchChannelSubscriptionType, twitchChannelSubscriptionMapping, twitchChannelSubscriptionPrimaryKeyColumns) + twitchChannelSubscriptionInsertCacheMut sync.RWMutex + twitchChannelSubscriptionInsertCache = make(map[string]insertCache) + twitchChannelSubscriptionUpdateCacheMut sync.RWMutex + twitchChannelSubscriptionUpdateCache = make(map[string]updateCache) + twitchChannelSubscriptionUpsertCacheMut sync.RWMutex + twitchChannelSubscriptionUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +// OneG returns a single twitchChannelSubscription record from the query using the global executor. +func (q twitchChannelSubscriptionQuery) OneG(ctx context.Context) (*TwitchChannelSubscription, error) { + return q.One(ctx, boil.GetContextDB()) +} + +// One returns a single twitchChannelSubscription record from the query. +func (q twitchChannelSubscriptionQuery) One(ctx context.Context, exec boil.ContextExecutor) (*TwitchChannelSubscription, error) { + o := &TwitchChannelSubscription{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for twitch_channel_subscriptions") + } + + return o, nil +} + +// AllG returns all TwitchChannelSubscription records from the query using the global executor. +func (q twitchChannelSubscriptionQuery) AllG(ctx context.Context) (TwitchChannelSubscriptionSlice, error) { + return q.All(ctx, boil.GetContextDB()) +} + +// All returns all TwitchChannelSubscription records from the query. +func (q twitchChannelSubscriptionQuery) All(ctx context.Context, exec boil.ContextExecutor) (TwitchChannelSubscriptionSlice, error) { + var o []*TwitchChannelSubscription + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to TwitchChannelSubscription slice") + } + + return o, nil +} + +// CountG returns the count of all TwitchChannelSubscription records in the query using the global executor +func (q twitchChannelSubscriptionQuery) CountG(ctx context.Context) (int64, error) { + return q.Count(ctx, boil.GetContextDB()) +} + +// Count returns the count of all TwitchChannelSubscription records in the query. +func (q twitchChannelSubscriptionQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count twitch_channel_subscriptions rows") + } + + return count, nil +} + +// ExistsG checks if the row exists in the table using the global executor. +func (q twitchChannelSubscriptionQuery) ExistsG(ctx context.Context) (bool, error) { + return q.Exists(ctx, boil.GetContextDB()) +} + +// Exists checks if the row exists in the table. +func (q twitchChannelSubscriptionQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if twitch_channel_subscriptions exists") + } + + return count > 0, nil +} + +// TwitchChannelSubscriptions retrieves all the records using an executor. +func TwitchChannelSubscriptions(mods ...qm.QueryMod) twitchChannelSubscriptionQuery { + mods = append(mods, qm.From("\"twitch_channel_subscriptions\"")) + q := NewQuery(mods...) + if len(queries.GetSelect(q)) == 0 { + queries.SetSelect(q, []string{"\"twitch_channel_subscriptions\".*"}) + } + + return twitchChannelSubscriptionQuery{q} +} + +// FindTwitchChannelSubscriptionG retrieves a single record by ID. +func FindTwitchChannelSubscriptionG(ctx context.Context, iD int, selectCols ...string) (*TwitchChannelSubscription, error) { + return FindTwitchChannelSubscription(ctx, boil.GetContextDB(), iD, selectCols...) +} + +// FindTwitchChannelSubscription retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindTwitchChannelSubscription(ctx context.Context, exec boil.ContextExecutor, iD int, selectCols ...string) (*TwitchChannelSubscription, error) { + twitchChannelSubscriptionObj := &TwitchChannelSubscription{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"twitch_channel_subscriptions\" where \"id\"=$1", sel, + ) + + q := queries.Raw(query, iD) + + err := q.Bind(ctx, exec, twitchChannelSubscriptionObj) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from twitch_channel_subscriptions") + } + + return twitchChannelSubscriptionObj, nil +} + +// InsertG a single record. See Insert for whitelist behavior description. +func (o *TwitchChannelSubscription) InsertG(ctx context.Context, columns boil.Columns) error { + return o.Insert(ctx, boil.GetContextDB(), columns) +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *TwitchChannelSubscription) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no twitch_channel_subscriptions provided for insertion") + } + + var err error + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = currTime + } + } + + nzDefaults := queries.NonZeroDefaultSet(twitchChannelSubscriptionColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + twitchChannelSubscriptionInsertCacheMut.RLock() + cache, cached := twitchChannelSubscriptionInsertCache[key] + twitchChannelSubscriptionInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + twitchChannelSubscriptionAllColumns, + twitchChannelSubscriptionColumnsWithDefault, + twitchChannelSubscriptionColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(twitchChannelSubscriptionType, twitchChannelSubscriptionMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(twitchChannelSubscriptionType, twitchChannelSubscriptionMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"twitch_channel_subscriptions\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"twitch_channel_subscriptions\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + queryReturning = fmt.Sprintf(" RETURNING \"%s\"", strings.Join(returnColumns, "\",\"")) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + + if err != nil { + return errors.Wrap(err, "models: unable to insert into twitch_channel_subscriptions") + } + + if !cached { + twitchChannelSubscriptionInsertCacheMut.Lock() + twitchChannelSubscriptionInsertCache[key] = cache + twitchChannelSubscriptionInsertCacheMut.Unlock() + } + + return nil +} + +// UpdateG a single TwitchChannelSubscription record using the global executor. +// See Update for more documentation. +func (o *TwitchChannelSubscription) UpdateG(ctx context.Context, columns boil.Columns) (int64, error) { + return o.Update(ctx, boil.GetContextDB(), columns) +} + +// Update uses an executor to update the TwitchChannelSubscription. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *TwitchChannelSubscription) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + o.UpdatedAt = currTime + } + + var err error + key := makeCacheKey(columns, nil) + twitchChannelSubscriptionUpdateCacheMut.RLock() + cache, cached := twitchChannelSubscriptionUpdateCache[key] + twitchChannelSubscriptionUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + twitchChannelSubscriptionAllColumns, + twitchChannelSubscriptionPrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update twitch_channel_subscriptions, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"twitch_channel_subscriptions\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, wl), + strmangle.WhereClause("\"", "\"", len(wl)+1, twitchChannelSubscriptionPrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(twitchChannelSubscriptionType, twitchChannelSubscriptionMapping, append(wl, twitchChannelSubscriptionPrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update twitch_channel_subscriptions row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for twitch_channel_subscriptions") + } + + if !cached { + twitchChannelSubscriptionUpdateCacheMut.Lock() + twitchChannelSubscriptionUpdateCache[key] = cache + twitchChannelSubscriptionUpdateCacheMut.Unlock() + } + + return rowsAff, nil +} + +// UpdateAllG updates all rows with the specified column values. +func (q twitchChannelSubscriptionQuery) UpdateAllG(ctx context.Context, cols M) (int64, error) { + return q.UpdateAll(ctx, boil.GetContextDB(), cols) +} + +// UpdateAll updates all rows with the specified column values. +func (q twitchChannelSubscriptionQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for twitch_channel_subscriptions") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for twitch_channel_subscriptions") + } + + return rowsAff, nil +} + +// UpdateAllG updates all rows with the specified column values. +func (o TwitchChannelSubscriptionSlice) UpdateAllG(ctx context.Context, cols M) (int64, error) { + return o.UpdateAll(ctx, boil.GetContextDB(), cols) +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o TwitchChannelSubscriptionSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), twitchChannelSubscriptionPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"twitch_channel_subscriptions\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 1, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), len(colNames)+1, twitchChannelSubscriptionPrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in twitchChannelSubscription slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all twitchChannelSubscription") + } + return rowsAff, nil +} + +// UpsertG attempts an insert, and does an update or ignore on conflict. +func (o *TwitchChannelSubscription) UpsertG(ctx context.Context, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + return o.Upsert(ctx, boil.GetContextDB(), updateOnConflict, conflictColumns, updateColumns, insertColumns, opts...) +} + +// Upsert attempts an insert using an executor, and does an update or ignore on conflict. +// See boil.Columns documentation for how to properly use updateColumns and insertColumns. +func (o *TwitchChannelSubscription) Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...UpsertOptionFunc) error { + if o == nil { + return errors.New("models: no twitch_channel_subscriptions provided for upsert") + } + if !boil.TimestampsAreSkipped(ctx) { + currTime := time.Now().In(boil.GetLocation()) + + if o.CreatedAt.IsZero() { + o.CreatedAt = currTime + } + o.UpdatedAt = currTime + } + + nzDefaults := queries.NonZeroDefaultSet(twitchChannelSubscriptionColumnsWithDefault, o) + + // Build cache key in-line uglily - mysql vs psql problems + buf := strmangle.GetBuffer() + if updateOnConflict { + buf.WriteByte('t') + } else { + buf.WriteByte('f') + } + buf.WriteByte('.') + for _, c := range conflictColumns { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(updateColumns.Kind)) + for _, c := range updateColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + buf.WriteString(strconv.Itoa(insertColumns.Kind)) + for _, c := range insertColumns.Cols { + buf.WriteString(c) + } + buf.WriteByte('.') + for _, c := range nzDefaults { + buf.WriteString(c) + } + key := buf.String() + strmangle.PutBuffer(buf) + + twitchChannelSubscriptionUpsertCacheMut.RLock() + cache, cached := twitchChannelSubscriptionUpsertCache[key] + twitchChannelSubscriptionUpsertCacheMut.RUnlock() + + var err error + + if !cached { + insert, _ := insertColumns.InsertColumnSet( + twitchChannelSubscriptionAllColumns, + twitchChannelSubscriptionColumnsWithDefault, + twitchChannelSubscriptionColumnsWithoutDefault, + nzDefaults, + ) + + update := updateColumns.UpdateColumnSet( + twitchChannelSubscriptionAllColumns, + twitchChannelSubscriptionPrimaryKeyColumns, + ) + + if updateOnConflict && len(update) == 0 { + return errors.New("models: unable to upsert twitch_channel_subscriptions, could not build update column list") + } + + ret := strmangle.SetComplement(twitchChannelSubscriptionAllColumns, strmangle.SetIntersect(insert, update)) + + conflict := conflictColumns + if len(conflict) == 0 && updateOnConflict && len(update) != 0 { + if len(twitchChannelSubscriptionPrimaryKeyColumns) == 0 { + return errors.New("models: unable to upsert twitch_channel_subscriptions, could not build conflict column list") + } + + conflict = make([]string, len(twitchChannelSubscriptionPrimaryKeyColumns)) + copy(conflict, twitchChannelSubscriptionPrimaryKeyColumns) + } + cache.query = buildUpsertQueryPostgres(dialect, "\"twitch_channel_subscriptions\"", updateOnConflict, ret, update, conflict, insert, opts...) + + cache.valueMapping, err = queries.BindMapping(twitchChannelSubscriptionType, twitchChannelSubscriptionMapping, insert) + if err != nil { + return err + } + if len(ret) != 0 { + cache.retMapping, err = queries.BindMapping(twitchChannelSubscriptionType, twitchChannelSubscriptionMapping, ret) + if err != nil { + return err + } + } + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + var returns []interface{} + if len(cache.retMapping) != 0 { + returns = queries.PtrsFromMapping(value, cache.retMapping) + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + if len(cache.retMapping) != 0 { + err = exec.QueryRowContext(ctx, cache.query, vals...).Scan(returns...) + if errors.Is(err, sql.ErrNoRows) { + err = nil // Postgres doesn't return anything when there's no update + } + } else { + _, err = exec.ExecContext(ctx, cache.query, vals...) + } + if err != nil { + return errors.Wrap(err, "models: unable to upsert twitch_channel_subscriptions") + } + + if !cached { + twitchChannelSubscriptionUpsertCacheMut.Lock() + twitchChannelSubscriptionUpsertCache[key] = cache + twitchChannelSubscriptionUpsertCacheMut.Unlock() + } + + return nil +} + +// DeleteG deletes a single TwitchChannelSubscription record. +// DeleteG will match against the primary key column to find the record to delete. +func (o *TwitchChannelSubscription) DeleteG(ctx context.Context) (int64, error) { + return o.Delete(ctx, boil.GetContextDB()) +} + +// Delete deletes a single TwitchChannelSubscription record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *TwitchChannelSubscription) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no TwitchChannelSubscription provided for delete") + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), twitchChannelSubscriptionPrimaryKeyMapping) + sql := "DELETE FROM \"twitch_channel_subscriptions\" WHERE \"id\"=$1" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from twitch_channel_subscriptions") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for twitch_channel_subscriptions") + } + + return rowsAff, nil +} + +func (q twitchChannelSubscriptionQuery) DeleteAllG(ctx context.Context) (int64, error) { + return q.DeleteAll(ctx, boil.GetContextDB()) +} + +// DeleteAll deletes all matching rows. +func (q twitchChannelSubscriptionQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no twitchChannelSubscriptionQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from twitch_channel_subscriptions") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for twitch_channel_subscriptions") + } + + return rowsAff, nil +} + +// DeleteAllG deletes all rows in the slice. +func (o TwitchChannelSubscriptionSlice) DeleteAllG(ctx context.Context) (int64, error) { + return o.DeleteAll(ctx, boil.GetContextDB()) +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o TwitchChannelSubscriptionSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), twitchChannelSubscriptionPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"twitch_channel_subscriptions\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, twitchChannelSubscriptionPrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from twitchChannelSubscription slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for twitch_channel_subscriptions") + } + + return rowsAff, nil +} + +// ReloadG refetches the object from the database using the primary keys. +func (o *TwitchChannelSubscription) ReloadG(ctx context.Context) error { + if o == nil { + return errors.New("models: no TwitchChannelSubscription provided for reload") + } + + return o.Reload(ctx, boil.GetContextDB()) +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *TwitchChannelSubscription) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindTwitchChannelSubscription(ctx, exec, o.ID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAllG refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *TwitchChannelSubscriptionSlice) ReloadAllG(ctx context.Context) error { + if o == nil { + return errors.New("models: empty TwitchChannelSubscriptionSlice provided for reload all") + } + + return o.ReloadAll(ctx, boil.GetContextDB()) +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *TwitchChannelSubscriptionSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := TwitchChannelSubscriptionSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), twitchChannelSubscriptionPrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"twitch_channel_subscriptions\".* FROM \"twitch_channel_subscriptions\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 1, twitchChannelSubscriptionPrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in TwitchChannelSubscriptionSlice") + } + + *o = slice + + return nil +} + +// TwitchChannelSubscriptionExistsG checks if the TwitchChannelSubscription row exists. +func TwitchChannelSubscriptionExistsG(ctx context.Context, iD int) (bool, error) { + return TwitchChannelSubscriptionExists(ctx, boil.GetContextDB(), iD) +} + +// TwitchChannelSubscriptionExists checks if the TwitchChannelSubscription row exists. +func TwitchChannelSubscriptionExists(ctx context.Context, exec boil.ContextExecutor, iD int) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"twitch_channel_subscriptions\" where \"id\"=$1 limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, iD) + } + row := exec.QueryRowContext(ctx, sql, iD) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if twitch_channel_subscriptions exists") + } + + return exists, nil +} + +// Exists checks if the TwitchChannelSubscription row exists. +func (o *TwitchChannelSubscription) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + return TwitchChannelSubscriptionExists(ctx, exec, o.ID) +} diff --git a/twitch/schema.go b/twitch/schema.go new file mode 100644 index 0000000000..117d3c2ef7 --- /dev/null +++ b/twitch/schema.go @@ -0,0 +1,28 @@ +package twitch + +var DBSchemas = []string{` +CREATE TABLE IF NOT EXISTS twitch_channel_subscriptions ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + twitch_user_id TEXT NOT NULL, + twitch_username TEXT NOT NULL, + + mention_everyone BOOLEAN NOT NULL, + mention_roles BIGINT[], + + publish_vod BOOLEAN NOT NULL DEFAULT false, + enabled BOOLEAN NOT NULL DEFAULT TRUE +); +`, ` +CREATE INDEX IF NOT EXISTS idx_twitch_user_id ON twitch_channel_subscriptions (twitch_user_id); +`, ` +CREATE TABLE IF NOT EXISTS twitch_announcements ( + guild_id BIGINT PRIMARY KEY, + message TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE +); +`} diff --git a/twitch/sqlboiler.toml b/twitch/sqlboiler.toml new file mode 100644 index 0000000000..bafeb234b3 --- /dev/null +++ b/twitch/sqlboiler.toml @@ -0,0 +1,15 @@ +add-global-variants = true +no-hooks = true +no-tests = true + +[psql] +dbname = "yagpdb" +host = "localhost" +user="yagpdb" +pass="ihateducks" +sslmode = "disable" +whitelist = ["twitch_channel_subscriptions", "twitch_announcements"] + +[auto-columns] +created = "created_at" +updated = "updated_at" diff --git a/twitch/twitch.go b/twitch/twitch.go new file mode 100644 index 0000000000..753ac84340 --- /dev/null +++ b/twitch/twitch.go @@ -0,0 +1,85 @@ +package twitch + +import ( + "context" + "sync" + + "github.com/botlabs-gg/yagpdb/v2/common" + "github.com/botlabs-gg/yagpdb/v2/common/config" + "github.com/botlabs-gg/yagpdb/v2/common/mqueue" + "github.com/botlabs-gg/yagpdb/v2/lib/discordgo" + "github.com/botlabs-gg/yagpdb/v2/premium" + "github.com/botlabs-gg/yagpdb/v2/twitch/models" + "github.com/nicklaw5/helix/v2" +) + +//go:generate sqlboiler --no-hooks psql + +var ( + confClientId = config.RegisterOption("yagpdb.twitch.clientid", "Twitch Client ID", "") + confClientSecret = config.RegisterOption("yagpdb.twitch.clientsecret", "Twitch Client Secret", "") + logger = common.GetPluginLogger(&Plugin{}) +) + +const ( + GuildMaxFeeds = 25 + GuildMaxEnabledFeeds = 1 + GuildMaxEnabledFeedsPremium = 25 +) + +func MaxFeedsForContext(ctx context.Context) int { + if premium.ContextPremium(ctx) { + return GuildMaxEnabledFeedsPremium + } + return GuildMaxEnabledFeeds +} + +type Plugin struct { + HelixClient *helix.Client + Stop chan *sync.WaitGroup +} + +func (p *Plugin) PluginInfo() *common.PluginInfo { + return &common.PluginInfo{ + Name: "Twitch", + SysName: "twitch", + Category: common.PluginCategoryFeeds, + } +} + +func RegisterPlugin() { + p := &Plugin{} + + mqueue.RegisterSource("twitch", p) + + err := p.SetupClient() + if err != nil { + logger.WithError(err).Error("Failed setting up twitch plugin, twitch plugin will not be enabled.") + return + } + common.RegisterPlugin(p) + + common.InitSchemas("twitch", DBSchemas...) +} + +var _ mqueue.PluginWithSourceDisabler = (*Plugin)(nil) + +func (p *Plugin) DisableFeed(elem *mqueue.QueuedElement, err error) { + p.DisableChannelFeeds(elem.ChannelID) +} + +func (p *Plugin) DisableChannelFeeds(channelID int64) error { + _, err := models.TwitchChannelSubscriptions(models.TwitchChannelSubscriptionWhere.ChannelID.EQ(discordgo.StrID(channelID))).UpdateAllG(context.Background(), models.M{"enabled": false}) + if err != nil { + logger.WithError(err).WithField("channel", channelID).Error("failed disabling feeds in channel") + } + return err +} + +func (p *Plugin) DisableGuildFeeds(guildID int64) error { + _, err := models.TwitchChannelSubscriptions(models.TwitchChannelSubscriptionWhere.GuildID.EQ(discordgo.StrID(guildID))).UpdateAllG(context.Background(), models.M{"enabled": false}) + if err != nil { + logger.WithError(err).WithField("guild", guildID).Error("failed disabling feeds in guild") + } + return err +} diff --git a/twitch/web.go b/twitch/web.go new file mode 100644 index 0000000000..e77244ff12 --- /dev/null +++ b/twitch/web.go @@ -0,0 +1,340 @@ +package twitch + +import ( + "context" + _ "embed" + "fmt" + "html/template" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/botlabs-gg/yagpdb/v2/common" + "github.com/botlabs-gg/yagpdb/v2/common/cplogs" + "github.com/botlabs-gg/yagpdb/v2/lib/discordgo" + "github.com/botlabs-gg/yagpdb/v2/premium" + "github.com/botlabs-gg/yagpdb/v2/twitch/models" + "github.com/botlabs-gg/yagpdb/v2/web" + + "github.com/nicklaw5/helix/v2" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + "goji.io" + "goji.io/pat" +) + +//go:embed assets/twitch.html +var PageHTML string + +var ( + panelLogKeyAddedFeed = cplogs.RegisterActionFormat(&cplogs.ActionFormat{Key: "twitch_added_feed", FormatString: "Added twitch feed from %s"}) + panelLogKeyAnnouncement = cplogs.RegisterActionFormat(&cplogs.ActionFormat{Key: "twitch_announcement", FormatString: "Updated Twitch Announcement"}) + panelLogKeyRemovedFeed = cplogs.RegisterActionFormat(&cplogs.ActionFormat{Key: "twitch_removed_feed", FormatString: "Removed twitch feed from %s"}) + panelLogKeyUpdatedFeed = cplogs.RegisterActionFormat(&cplogs.ActionFormat{Key: "twitch_updated_feed", FormatString: "Updated twitch feed from %s"}) +) + +type TwitchFeedForm struct { + TwitchUsername string + DiscordChannel int64 `valid:"channel,false"` + MentionEveryone bool + MentionRoles []int64 + PublishVOD bool + Enabled bool +} + +type TwitchAnnouncementForm struct { + Message string `json:"message" valid:"template,5000"` + Enabled bool +} + +type ContextKey int + +const ( + ContextKeySub ContextKey = iota +) + +func (p *Plugin) InitWeb() { + web.AddHTMLTemplate("twitch/assets/twitch.html", PageHTML) + web.AddSidebarItem(web.SidebarCategoryFeeds, &web.SidebarItem{ + Name: "Twitch", + URL: "twitch", + Icon: "fab fa-twitch", + }) + + mux := goji.SubMux() + web.CPMux.Handle(pat.New("/twitch/*"), mux) + web.CPMux.Handle(pat.New("/twitch"), mux) + + // All handlers here require guild channels present + mux.Use(web.RequireBotMemberMW) + mux.Use(web.RequirePermMW(discordgo.PermissionMentionEveryone)) + mux.Use(premium.PremiumGuildMW) + + mainGetHandler := web.ControllerHandler(p.HandleTwitch, "cp_twitch") + + mux.Handle(pat.Get("/"), mainGetHandler) + mux.Handle(pat.Get(""), mainGetHandler) + + addHandler := web.ControllerPostHandler(p.HandleNew, mainGetHandler, TwitchFeedForm{}) + + mux.Handle(pat.Post(""), addHandler) + mux.Handle(pat.Post("/"), addHandler) + mux.Handle(pat.Post("/announcement"), web.ControllerPostHandler(p.HandleTwitchAnnouncement, mainGetHandler, TwitchAnnouncementForm{})) + mux.Handle(pat.Post("/:item/update"), web.ControllerPostHandler(BaseEditHandler(p.HandleEdit), mainGetHandler, TwitchFeedForm{})) + mux.Handle(pat.Post("/:item/delete"), web.ControllerPostHandler(BaseEditHandler(p.HandleRemove), mainGetHandler, nil)) + mux.Handle(pat.Get("/:item/delete"), web.ControllerPostHandler(BaseEditHandler(p.HandleRemove), mainGetHandler, nil)) +} + +func (p *Plugin) HandleTwitch(w http.ResponseWriter, r *http.Request) (web.TemplateData, error) { + ctx := r.Context() + activeGuild, templateData := web.GetBaseCPContextData(ctx) + + subs, err := models.TwitchChannelSubscriptions(models.TwitchChannelSubscriptionWhere.GuildID.EQ(discordgo.StrID(activeGuild.ID)), qm.OrderBy("id DESC")).AllG(ctx) + if err != nil { + return templateData, err + } + + templateData["TwitchSubs"] = subs + + announcement, err := models.FindTwitchAnnouncementG(ctx, activeGuild.ID) + if err != nil { + announcement = &models.TwitchAnnouncement{ + GuildID: activeGuild.ID, + Message: "{{.User}} is now live on Twitch! {{.URL}}", + Enabled: false, + } + } + templateData["TwitchAnnouncement"] = announcement + + return templateData, nil +} + +func (p *Plugin) HandleTwitchAnnouncement(w http.ResponseWriter, r *http.Request) (web.TemplateData, error) { + ctx := r.Context() + activeGuild, templateData := web.GetBaseCPContextData(ctx) + + // Check premium for custom announcements + if !premium.ContextPremium(ctx) { + return templateData.AddAlerts(web.ErrorAlert("Custom announcements are premium only")), nil + } + + form := ctx.Value(common.ContextKeyParsedForm).(*TwitchAnnouncementForm) + + announcement := &models.TwitchAnnouncement{ + GuildID: activeGuild.ID, + Message: form.Message, + Enabled: form.Enabled, + } + + err := announcement.UpsertG(ctx, true, []string{"guild_id"}, boil.Whitelist("message", "enabled"), boil.Infer()) + if err != nil { + return templateData, err + } + + go cplogs.RetryAddEntry(web.NewLogEntryFromContext(r.Context(), panelLogKeyAnnouncement)) + + return templateData, nil +} + +func (p *Plugin) HandleNew(w http.ResponseWriter, r *http.Request) (web.TemplateData, error) { + ctx := r.Context() + activeGuild, templateData := web.GetBaseCPContextData(ctx) + + // Check limits + maxFeeds := MaxFeedsForContext(ctx) + + count, err := models.TwitchChannelSubscriptions(models.TwitchChannelSubscriptionWhere.GuildID.EQ(discordgo.StrID(activeGuild.ID))).CountG(ctx) + if err != nil { + return templateData, err + } + + if int(count) >= maxFeeds { + return templateData.AddAlerts(web.ErrorAlert(fmt.Sprintf("Max %d feeds allowed (upgrade to premium for more)", maxFeeds))), nil + } + + data := ctx.Value(common.ContextKeyParsedForm).(*TwitchFeedForm) + + // Extract username from URL if a URL was provided + username := extractTwitchUsername(data.TwitchUsername) + + // Validate Twitch User + // We need to get the user ID from Twitch + users, err := p.HelixClient.GetUsers(&helix.UsersParams{ + Logins: []string{username}, + }) + if err != nil { + logger.Error("Failed to get twitch user", "err", err) + return templateData.AddAlerts(web.ErrorAlert("Failed to get twitch user")), nil + } + + if len(users.Data.Users) == 0 { + return templateData.AddAlerts(web.ErrorAlert("Twitch user not found")), nil + } + + twitchUser := users.Data.Users[0] + + sub := &models.TwitchChannelSubscription{ + GuildID: discordgo.StrID(activeGuild.ID), + ChannelID: discordgo.StrID(data.DiscordChannel), + TwitchUserID: twitchUser.ID, + TwitchUsername: twitchUser.Login, + MentionEveryone: data.MentionEveryone, + MentionRoles: data.MentionRoles, + PublishVod: data.PublishVOD, + } + + err = sub.InsertG(ctx, boil.Infer()) + if err != nil { + return templateData, err + } + + go cplogs.RetryAddEntry(web.NewLogEntryFromContext(r.Context(), panelLogKeyAddedFeed, &cplogs.Param{Type: cplogs.ParamTypeString, Value: sub.TwitchUsername})) + + return templateData, nil +} + +// extractTwitchUsername extracts the username from a Twitch URL or returns the input if it's already a username +// Supports formats like: +// - https://twitch.tv/username +// - https://www.twitch.tv/username +// - twitch.tv/username +// - username +func extractTwitchUsername(input string) string { + input = strings.TrimSpace(input) + + // If it doesn't contain a slash, assume it's already a username + if !strings.Contains(input, "/") { + return input + } + + // Parse as URL + // Add scheme if missing + urlStr := input + if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { + urlStr = "https://" + input + } + + parsedURL, err := url.Parse(urlStr) + if err != nil { + // If parsing fails, return the original input + return input + } + + // Check if it's a Twitch domain + if parsedURL.Host != "twitch.tv" && parsedURL.Host != "www.twitch.tv" { + return input + } + + // Extract the first path segment as the username + path := strings.Trim(parsedURL.Path, "/") + parts := strings.Split(path, "/") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + + return input +} + +func BaseEditHandler(inner web.ControllerHandlerFunc) web.ControllerHandlerFunc { + return func(w http.ResponseWriter, r *http.Request) (web.TemplateData, error) { + ctx := r.Context() + activeGuild, templateData := web.GetBaseCPContextData(ctx) + + id, err := strconv.Atoi(pat.Param(r, "item")) + if err != nil { + return templateData.AddAlerts(web.ErrorAlert("Invalid feed ID")), err + } + + sub, err := models.FindTwitchChannelSubscriptionG(ctx, id) + if err != nil { + return templateData.AddAlerts(web.ErrorAlert("Failed retrieving that feed item")), err + } + + if sub.GuildID != discordgo.StrID(activeGuild.ID) { + return templateData.AddAlerts(web.ErrorAlert("This appears to belong somewhere else...")), nil + } + + ctx = context.WithValue(ctx, ContextKeySub, sub) + + return inner(w, r.WithContext(ctx)) + } +} + +func (p *Plugin) HandleEdit(w http.ResponseWriter, r *http.Request) (web.TemplateData, error) { + ctx := r.Context() + activeGuild, templateData := web.GetBaseCPContextData(ctx) + + data := ctx.Value(common.ContextKeyParsedForm).(*TwitchFeedForm) + sub := ctx.Value(ContextKeySub).(*models.TwitchChannelSubscription) + + // Check if we're trying to enable a disabled feed + if !sub.Enabled && data.Enabled { + // Count currently enabled feeds + enabledCount, err := models.TwitchChannelSubscriptions( + models.TwitchChannelSubscriptionWhere.GuildID.EQ(discordgo.StrID(activeGuild.ID)), + models.TwitchChannelSubscriptionWhere.Enabled.EQ(true), + ).CountG(ctx) + if err != nil { + return templateData, err + } + + maxFeeds := MaxFeedsForContext(ctx) + if int(enabledCount) >= maxFeeds { + return templateData.AddAlerts(web.ErrorAlert(fmt.Sprintf("Max %d enabled feeds allowed (%d for premium servers)", GuildMaxEnabledFeeds, GuildMaxEnabledFeedsPremium))), nil + } + } + + sub.MentionEveryone = data.MentionEveryone + sub.MentionRoles = data.MentionRoles + sub.PublishVod = data.PublishVOD + sub.Enabled = data.Enabled + sub.ChannelID = discordgo.StrID(data.DiscordChannel) + + _, err := sub.UpdateG(ctx, boil.Infer()) + if err != nil { + return templateData, err + } + + go cplogs.RetryAddEntry(web.NewLogEntryFromContext(r.Context(), panelLogKeyUpdatedFeed, &cplogs.Param{Type: cplogs.ParamTypeString, Value: sub.TwitchUsername})) + + return templateData, nil +} + +func (p *Plugin) HandleRemove(w http.ResponseWriter, r *http.Request) (templateData web.TemplateData, err error) { + ctx := r.Context() + sub := ctx.Value(ContextKeySub).(*models.TwitchChannelSubscription) + + _, err = sub.DeleteG(ctx) + if err != nil { + return + } + + go cplogs.RetryAddEntry(web.NewLogEntryFromContext(r.Context(), panelLogKeyRemovedFeed, &cplogs.Param{Type: cplogs.ParamTypeString, Value: sub.TwitchUsername})) + return +} + +var _ web.PluginWithServerHomeWidget = (*Plugin)(nil) + +func (p *Plugin) LoadServerHomeWidget(w http.ResponseWriter, r *http.Request) (web.TemplateData, error) { + activeGuild, templateData := web.GetBaseCPContextData(r.Context()) + + templateData["WidgetTitle"] = "Twitch feeds" + templateData["SettingsPath"] = "/twitch" + + numFeeds, err := models.TwitchChannelSubscriptions(models.TwitchChannelSubscriptionWhere.GuildID.EQ(discordgo.StrID(activeGuild.ID)), models.TwitchChannelSubscriptionWhere.Enabled.EQ(true)).CountG(r.Context()) + if err != nil { + return templateData, err + } + + if numFeeds > 0 { + templateData["WidgetEnabled"] = true + } else { + templateData["WidgetDisabled"] = true + } + + const format = `

Active Twitch feeds: %d

` + templateData["WidgetBody"] = template.HTML(fmt.Sprintf(format, numFeeds)) + + return templateData, nil +} diff --git a/yagpdb_docker/app.example.env b/yagpdb_docker/app.example.env index 34a5d18a36..545b352ca9 100644 --- a/yagpdb_docker/app.example.env +++ b/yagpdb_docker/app.example.env @@ -59,4 +59,9 @@ GOOGLE_APPLICATION_CREDENTIALS=path/to/credentials.json # This will be used as the pubsubhubbub (websub) verify token when receiving callbacks on new video uploads # if this gets leaked, people could spam feeds YAGPDB_YOUTUBE_VERIFY_TOKEN= -YAGPDB_DISCORD_PREMIUM_SKU_ID= \ No newline at end of file +YAGPDB_DISCORD_PREMIUM_SKU_ID= + + +#client id and secret to poll live status for users +YAGPDB_TWITCH_CLIENTID= +YAGPDB_TWITCH_CLIENTSECRET= \ No newline at end of file diff --git a/yagpdb_docker/docker-compose.debug.yml b/yagpdb_docker/docker-compose.debug.yml index 844c4fff2e..21f453e396 100644 --- a/yagpdb_docker/docker-compose.debug.yml +++ b/yagpdb_docker/docker-compose.debug.yml @@ -4,9 +4,11 @@ volumes: cert_cache: soundboard: + networks: default: + services: app: build: @@ -27,7 +29,7 @@ services: ports: - '5000:5000' - '4000:4000' - - '5100-5999:5100-5999' + #- '5100-5999:5100-5999' env_file: - app.env security_opt: @@ -44,6 +46,8 @@ services: db: image: docker.io/postgres:11 restart: unless-stopped + ports: + - 5432:5432 volumes: - db:/var/lib/postgresql/data networks: From 4f9363356f4404a249f2c76804224fbff4f71d91 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:24:19 +0530 Subject: [PATCH 23/92] change premium limits for twitch and add twitch to premium perks --- premium/assets/premium-perks.html | 29 ++++++++++++++++++++--------- twitch/twitch.go | 6 +++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/premium/assets/premium-perks.html b/premium/assets/premium-perks.html index 7ffe2a051d..0b354b6b47 100644 --- a/premium/assets/premium-perks.html +++ b/premium/assets/premium-perks.html @@ -2,7 +2,7 @@ {{template "cp_head" .}} {{template "cp_alerts" .}} @@ -20,18 +20,23 @@

Premium Features

  • Change Bot Avatar (profile picture) and Banner for your server
  • Trigger CCs on Message Edit
  • Bulk Role Removal and Assignment
  • -
  • Custom Thanks Detection Regex
  • +
  • Custom Thanks Detection Regex for Reputation System
  • +
  • Custom Announcement for Twitch Notifications
  • Retroactive AutoRole Scan and Assignment
  • Increased Limits
      -
    • Message cache duration increased from 1 hour to 12 hours, which means you will be able to see the removed messages in the logs if the message was sent in the previous 12 hours
    • +
    • Message cache duration increased from 1 hour to 12 hours, which means + you will be able to see the removed messages in the logs if the message was sent in + the previous 12 hours
    • Max custom commands increased from 100 to 250
    • Max Reddit feeds increased from 20 to 1000
    • Max Youtube feeds increased from 10 to 250
    • +
    • Max Twitch feeds increased from 3 to 15
    • Max Soundboard sounds increased from 50 to 250
    • Max RSS Feeds increased from 2 to 10
    • -
    • Max Custom Command Length increased to 20,000 characters from 10,000
    • +
    • Max Custom Command Length increased to 20,000 characters from 10,000 +
    • Increased database storage and interaction limits.
    • Various custom command function limits increased
    • Advanced Automod limits increased
    • @@ -44,12 +49,18 @@

      How to get premium?

      - Manage your subscription: Manage Premium + Manage your subscription: Manage Premium
      - Note: Premium slots are not transferable between Patreon and Discord Purchases + Note: Premium slots are not transferable between Patreon and + Discord Purchases
      @@ -60,4 +71,4 @@

      How to get premium?

      {{template "cp_footer" .}} -{{end}} +{{end}} \ No newline at end of file diff --git a/twitch/twitch.go b/twitch/twitch.go index 753ac84340..91162bb833 100644 --- a/twitch/twitch.go +++ b/twitch/twitch.go @@ -22,9 +22,9 @@ var ( ) const ( - GuildMaxFeeds = 25 - GuildMaxEnabledFeeds = 1 - GuildMaxEnabledFeedsPremium = 25 + GuildMaxFeeds = 15 + GuildMaxEnabledFeeds = 3 + GuildMaxEnabledFeedsPremium = 15 ) func MaxFeedsForContext(ctx context.Context) int { From 2203cb31d3613d6caf5d76525df0d2c960daeeac Mon Sep 17 00:00:00 2001 From: Borbot33 <58076174+Borbot33@users.noreply.github.com> Date: Thu, 27 Nov 2025 05:15:27 +0100 Subject: [PATCH 24/92] delete two annoying spaces [Twitch & YouTube] (#1988) * Deleted an annoying space * Second annoying space --- twitch/assets/twitch.html | 4 ++-- youtube/assets/youtube.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/twitch/assets/twitch.html b/twitch/assets/twitch.html index f21ca6b2c3..30509f2ca2 100644 --- a/twitch/assets/twitch.html +++ b/twitch/assets/twitch.html @@ -82,7 +82,7 @@

      Custom Announcement

      {{checkbox "Enabled" "announcement-enabled" `

      Enable

      ` $announcementEnabled}} -
    Increased Limits
      @@ -38,6 +39,7 @@

      Premium Features

    • Max Voice Roles increased from 1 to 10
    • Max Custom Command Length increased to 20,000 characters from 10,000
    • +
    • Reduced Cooldown on Role Change Custom Commands and Increased limit to 5 from 1
    • Increased database storage and interaction limits.
    • Various custom command function limits increased
    • Advanced Automod limits increased
    • From fde6c2d19b26b60a19d99c0c69ae25bf34b0f781 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:03:25 +0530 Subject: [PATCH 68/92] Add more details to premium claim instructions --- premium/assets/premium-perks.html | 3 +-- premium/assets/premium.html | 30 ++++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/premium/assets/premium-perks.html b/premium/assets/premium-perks.html index 5431c7f055..7ed92e60a5 100644 --- a/premium/assets/premium-perks.html +++ b/premium/assets/premium-perks.html @@ -62,8 +62,7 @@

      How to get premium?

      Manage your subscription: Manage Premium
      - Note: Premium slots are not transferable between Patreon and - Discord Purchases + Note: Premium slots are not transferable between Patreon and Discord Purchases diff --git a/premium/assets/premium.html b/premium/assets/premium.html index ced019df8f..40e8e0362b 100644 --- a/premium/assets/premium.html +++ b/premium/assets/premium.html @@ -12,23 +12,36 @@

      Manage Premium

      -

      Redeem code

      +

      How to claim your premium slots

      +
      +
        +
      • If you've subscribed from Patreon, you need to connect your discord to patreon. + Once that is done, your slots will appear below automatically with-in 10 minutes.
      • +
      • If you've subscribed from Discord, your slots should automatically show below as soon as discord processes your payment.
      • +
      • Premium slots are assigned to servers, you can assign them to any server you have manage server permissions on.
      • +
      • If you don't see your slots even after 10 minutes, please contact us on our support server.
      • +
      • Premium slots are not transferable between Patreon and Discord Purchases
      • +
      • To learn more about premium features and to subscribe to premium, check our premium perks list.
      • +
      • + For cancellation, read the instructions below for your subscription source. + +
      • +
      +
      +
      + -
      -

      If you're coming here after making a payment on patreon, you need to connect your discord to patreon - Once that is done, your slots will appear below automatically with-in 10 minutes. - Premium slots are assigned to servers, you can assign them to any server you have manage server permissions on. - If you don't see your slots after 10 minutes, please contact us on the support server.

      -

      To learn more about what premium features and how to get premium, check the premium perks list.

      -
      {{if .QueriedCode}}
      + {{template "cp_footer" .}} {{end}} From 67d52a7f50702649dda5896d2ce367d54175f819 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:13:37 +0530 Subject: [PATCH 69/92] fix issues with ticket close from button --- tickets/tickets_bot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/tickets_bot.go b/tickets/tickets_bot.go index a951bb4eb6..69d4d40334 100644 --- a/tickets/tickets_bot.go +++ b/tickets/tickets_bot.go @@ -498,7 +498,7 @@ func (p *Plugin) handleInteractionCreate(evt *eventsystem.EventData) (retry bool }, } - var currentChannel *dstate.ChannelState = evt.GS.GetChannel(ic.ChannelID) + var currentChannel *dstate.ChannelState = evt.GS.GetChannelOrThread(ic.ChannelID) switch ic.Type { case discordgo.InteractionMessageComponent: From 397de85d76114c7f98ac4ad3c7b32b36a7833c6e Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:28:25 +0530 Subject: [PATCH 70/92] Fixed issues with adminonly toggle for tickets (#2010) --- tickets/models/tickets.go | 11 +- tickets/schema.go | 6 +- tickets/tickets_bot.go | 2 +- tickets/tickets_commands.go | 205 +++++++++++++++++++++++------------- 4 files changed, 147 insertions(+), 77 deletions(-) diff --git a/tickets/models/tickets.go b/tickets/models/tickets.go index 6709be1735..62abc483c9 100644 --- a/tickets/models/tickets.go +++ b/tickets/models/tickets.go @@ -33,6 +33,7 @@ type Ticket struct { LogsID int64 `boil:"logs_id" json:"logs_id" toml:"logs_id" yaml:"logs_id"` AuthorID int64 `boil:"author_id" json:"author_id" toml:"author_id" yaml:"author_id"` AuthorUsernameDiscrim string `boil:"author_username_discrim" json:"author_username_discrim" toml:"author_username_discrim" yaml:"author_username_discrim"` + IsAdminOnly bool `boil:"is_admin_only" json:"is_admin_only" toml:"is_admin_only" yaml:"is_admin_only"` R *ticketR `boil:"-" json:"-" toml:"-" yaml:"-"` L ticketL `boil:"-" json:"-" toml:"-" yaml:"-"` @@ -48,6 +49,7 @@ var TicketColumns = struct { LogsID string AuthorID string AuthorUsernameDiscrim string + IsAdminOnly string }{ GuildID: "guild_id", LocalID: "local_id", @@ -58,6 +60,7 @@ var TicketColumns = struct { LogsID: "logs_id", AuthorID: "author_id", AuthorUsernameDiscrim: "author_username_discrim", + IsAdminOnly: "is_admin_only", } var TicketTableColumns = struct { @@ -70,6 +73,7 @@ var TicketTableColumns = struct { LogsID string AuthorID string AuthorUsernameDiscrim string + IsAdminOnly string }{ GuildID: "tickets.guild_id", LocalID: "tickets.local_id", @@ -80,6 +84,7 @@ var TicketTableColumns = struct { LogsID: "tickets.logs_id", AuthorID: "tickets.author_id", AuthorUsernameDiscrim: "tickets.author_username_discrim", + IsAdminOnly: "tickets.is_admin_only", } // Generated where @@ -139,6 +144,7 @@ var TicketWhere = struct { LogsID whereHelperint64 AuthorID whereHelperint64 AuthorUsernameDiscrim whereHelperstring + IsAdminOnly whereHelperbool }{ GuildID: whereHelperint64{field: "\"tickets\".\"guild_id\""}, LocalID: whereHelperint64{field: "\"tickets\".\"local_id\""}, @@ -149,6 +155,7 @@ var TicketWhere = struct { LogsID: whereHelperint64{field: "\"tickets\".\"logs_id\""}, AuthorID: whereHelperint64{field: "\"tickets\".\"author_id\""}, AuthorUsernameDiscrim: whereHelperstring{field: "\"tickets\".\"author_username_discrim\""}, + IsAdminOnly: whereHelperbool{field: "\"tickets\".\"is_admin_only\""}, } // TicketRels is where relationship names are stored. @@ -168,9 +175,9 @@ func (*ticketR) NewStruct() *ticketR { type ticketL struct{} var ( - ticketAllColumns = []string{"guild_id", "local_id", "channel_id", "title", "created_at", "closed_at", "logs_id", "author_id", "author_username_discrim"} + ticketAllColumns = []string{"guild_id", "local_id", "channel_id", "title", "created_at", "closed_at", "logs_id", "author_id", "author_username_discrim", "is_admin_only"} ticketColumnsWithoutDefault = []string{"guild_id", "local_id", "channel_id", "title", "created_at", "logs_id", "author_id", "author_username_discrim"} - ticketColumnsWithDefault = []string{"closed_at"} + ticketColumnsWithDefault = []string{"closed_at", "is_admin_only"} ticketPrimaryKeyColumns = []string{"guild_id", "local_id"} ticketGeneratedColumns = []string{} ) diff --git a/tickets/schema.go b/tickets/schema.go index 891961a6a5..2198575bbf 100644 --- a/tickets/schema.go +++ b/tickets/schema.go @@ -43,10 +43,10 @@ CREATE TABLE IF NOT EXISTS tickets ( author_id BIGINT NOT NULL, author_username_discrim TEXT NOT NULL, - - PRIMARY KEY(guild_id, local_id) + PRIMARY KEY(guild_id, local_id ); - +`, ` +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS is_admin_only BOOLEAN NOT NULL DEFAULT false; `, ` CREATE INDEX IF NOT EXISTS tickets_guild_id_channel_id_idx ON tickets(guild_id, channel_id); diff --git a/tickets/tickets_bot.go b/tickets/tickets_bot.go index 69d4d40334..8a8a24dd86 100644 --- a/tickets/tickets_bot.go +++ b/tickets/tickets_bot.go @@ -254,7 +254,7 @@ func closeTicket(gs *dstate.GuildSet, currentTicket *Ticket, ticketCS *dstate.Ch currentTicket.Ticket.ClosedAt.Time = time.Now() currentTicket.Ticket.ClosedAt.Valid = true - isAdminsOnly := ticketIsAdminOnly(conf, ticketCS) + isAdminsOnly := isTicketAdminOnly(conf, currentTicket, ticketCS) // create the logs, download the attachments err := createLogs(gs, conf, currentTicket.Ticket, isAdminsOnly) diff --git a/tickets/tickets_commands.go b/tickets/tickets_commands.go index b48f80caa9..a77be0b07d 100644 --- a/tickets/tickets_commands.go +++ b/tickets/tickets_commands.go @@ -218,84 +218,29 @@ func (p *Plugin) AddCommands() { RunFunc: func(parsed *dcmd.Data) (any, error) { conf := parsed.Context().Value(CtxKeyConfig).(*models.TicketConfig) + currentTicket := parsed.Context().Value(CtxKeyCurrentTicket).(*Ticket) if len(conf.ModRoles) == 0 { return "No mod roles set to add or remove from ticket", nil } - isAdminsOnlyCurrently := true - modOverwrites := make([]discordgo.PermissionOverwrite, 0) - cs := parsed.GuildData.CS - - for _, ow := range cs.PermissionOverwrites { - if ow.Type == discordgo.PermissionOverwriteTypeRole && common.ContainsInt64Slice(conf.ModRoles, ow.ID) { - if (ow.Allow & InTicketPerms) == InTicketPerms { - // one of the mod roles has ticket perms, this is not a admin ticket currently - isAdminsOnlyCurrently = false - } - - modOverwrites = append(modOverwrites, ow) - } - } - - isThreadedTicket := cs.Type == discordgo.ChannelTypeGuildPrivateThread - if !isAdminsOnlyCurrently && !isThreadedTicket { - var mentions strings.Builder - mentions.WriteString("Added the following roles to the ticket: ") - for _, roleID := range conf.ModRoles { - mentions.WriteString(" <@&" + strconv.FormatInt(roleID, 10) + ">") - } - return mentions.String(), nil - } - - // update existing overwrites - for _, v := range modOverwrites { - var err error - if isAdminsOnlyCurrently { - // add back the mods to this ticket - if (v.Allow & InTicketPerms) != InTicketPerms { - // add it back to allows, remove from denies - newAllows := v.Allow | InTicketPerms - newDenies := v.Deny & (^InTicketPerms) - err = common.BotSession.ChannelPermissionSet(parsed.ChannelID, v.ID, discordgo.PermissionOverwriteTypeRole, newAllows, newDenies) - } - } else { - // remove the mods from this ticket - if (v.Allow & InTicketPerms) == InTicketPerms { - // remove it from allows - newAllows := v.Allow & (^InTicketPerms) - err = common.BotSession.ChannelPermissionSet(parsed.ChannelID, v.ID, discordgo.PermissionOverwriteTypeRole, newAllows, v.Deny) - } - } - + isAdminsOnlyCurrently := isTicketAdminOnly(conf, currentTicket, parsed.GuildData.CS) + if !isAdminsOnlyCurrently { + _, err := setTicketAdminOnly(conf, parsed.GuildData.CS) if err != nil { - logger.WithError(err).WithField("guild", parsed.GuildData.GS.ID).Error("[tickets] failed to update channel overwrite") - } - } - - if isAdminsOnlyCurrently { - // add the missing overwrites for the missing roles - OUTER: - for _, v := range conf.ModRoles { - for _, ow := range modOverwrites { - if ow.ID == v { - // already handled above - continue OUTER - } - } - - // need to create a new overwrite - err := common.BotSession.ChannelPermissionSet(parsed.ChannelID, v, discordgo.PermissionOverwriteTypeRole, InTicketPerms, 0) - if err != nil { - logger.WithError(err).WithField("guild", parsed.GuildData.GS.ID).Error("[tickets] failed to create channel overwrite") - } + return "Failed to make ticket admin only", err } + currentTicket.Ticket.IsAdminOnly = true + currentTicket.Ticket.UpdateG(context.Background(), boil.Whitelist("is_admin_only")) + return "Ticket Set to Admin Only", nil } - if isAdminsOnlyCurrently { - return "Added back mods to the ticket", nil + resp, err := unsetTicketAdminOnly(conf, currentTicket, parsed.GuildData.CS) + if err != nil { + return "Failed to remove admin only from tickets", err } - - return "Removed mods from this ticket", nil + currentTicket.Ticket.IsAdminOnly = false + currentTicket.Ticket.UpdateG(context.Background(), boil.Whitelist("is_admin_only")) + return resp, nil }, } @@ -715,10 +660,128 @@ func createTXTTranscript(ticket *models.Ticket, msgs []*discordgo.Message) *byte return &buf } -func ticketIsAdminOnly(conf *models.TicketConfig, cs *dstate.ChannelState) bool { +func unsetTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs *dstate.ChannelState) (string, error) { + isThreadedTicket := cs.Type == discordgo.ChannelTypeGuildPrivateThread + if !isThreadedTicket { + modOverwrites := make([]discordgo.PermissionOverwrite, 0) + for _, ow := range cs.PermissionOverwrites { + if ow.Type == discordgo.PermissionOverwriteTypeRole && common.ContainsInt64Slice(conf.ModRoles, ow.ID) { + modOverwrites = append(modOverwrites, ow) + } + } + for _, v := range modOverwrites { + var err error + // remove the mods from this ticket + if (v.Allow & InTicketPerms) == InTicketPerms { + // remove it from allows + newAllows := v.Allow & (^InTicketPerms) + err = common.BotSession.ChannelPermissionSet(cs.ID, v.ID, discordgo.PermissionOverwriteTypeRole, newAllows, v.Deny) + } + if err != nil { + logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to remove channel overwrite") + return "", err + } + } + return "Removed all mod roles from the ticket, ticket is no longer admin only.", nil + } + lastMemberID := "" + memberList := make([]*discordgo.ThreadMember, 0) + endReached := false + for !endReached { + nextMemberlist, err := common.BotSession.ThreadMembers(cs.ID, 100, true, lastMemberID) + if err != nil { + return "", err + } + memberList = append(memberList, nextMemberlist...) + if len(nextMemberlist) < 100 { + endReached = true + } + lastMemberID = discordgo.StrID(nextMemberlist[len(nextMemberlist)-1].ID) + } - isAdminsOnlyCurrently := true + participants := make(map[int64]*models.TicketParticipant) + for _, v := range currentTicket.Participants { + participants[v.UserID] = v + } + for _, v := range memberList { + _, isParticipant := participants[v.UserID] + isAuthor := v.UserID == currentTicket.Ticket.AuthorID + isBot := v.UserID == common.BotUser.ID + if isParticipant || isAuthor || isBot { + continue + } + if common.ContainsInt64SliceOneOf(v.Member.Roles, conf.ModRoles) { + err := common.BotSession.ThreadMemberRemove(cs.ID, discordgo.StrID(v.UserID)) + if err != nil { + logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to remove thread member") + return "", err + } + } + } + + return "Removed all mod roles from the ticket, ticket is no longer admin only.", nil +} + +func setTicketAdminOnly(conf *models.TicketConfig, cs *dstate.ChannelState) (string, error) { + isThreadedTicket := cs.Type == discordgo.ChannelTypeGuildPrivateThread + response := "" + if isThreadedTicket { + var mentions strings.Builder + mentions.WriteString("Added the following roles to the ticket: ") + for _, roleID := range conf.ModRoles { + mentions.WriteString(" <@&" + strconv.FormatInt(roleID, 10) + ">") + } + response = mentions.String() + message := &discordgo.MessageSend{ + Content: response, + AllowedMentions: discordgo.AllowedMentions{ + Roles: discordgo.IDSlice(conf.ModRoles), + }, + } + _, err := common.BotSession.ChannelMessageSendComplex(cs.ID, message) + if err != nil { + logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to send message to thread") + return "", err + } + } else { + modOverwrites := make([]discordgo.PermissionOverwrite, 0) + for _, ow := range cs.PermissionOverwrites { + if ow.Type == discordgo.PermissionOverwriteTypeRole && common.ContainsInt64Slice(conf.ModRoles, ow.ID) { + modOverwrites = append(modOverwrites, ow) + } + } + + // update existing overwrites + for _, v := range modOverwrites { + var err error + // add back the mods to this ticket + if (v.Allow & InTicketPerms) != InTicketPerms { + // add it back to allows, remove from denies + newAllows := v.Allow | InTicketPerms + newDenies := v.Deny & (^InTicketPerms) + err = common.BotSession.ChannelPermissionSet(cs.ID, v.ID, discordgo.PermissionOverwriteTypeRole, newAllows, newDenies) + } + + if err != nil { + logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to add channel overwrite") + return "", err + } + response = "Ticket is now admin only, added all mod roles to the ticket." + } + } + + return response, nil +} + +func isTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs *dstate.ChannelState) bool { + isThreadedTicket := cs.Type == discordgo.ChannelTypeGuildPrivateThread + if isThreadedTicket || currentTicket.Ticket.IsAdminOnly { + return currentTicket.Ticket.IsAdminOnly + } + + //legacy check + isAdminsOnlyCurrently := true for _, ow := range cs.PermissionOverwrites { if ow.Type == discordgo.PermissionOverwriteTypeRole && common.ContainsInt64Slice(conf.ModRoles, ow.ID) { if (ow.Allow & InTicketPerms) == InTicketPerms { From e107cbadeeb78d413758b0bc770f30de6bb0d9e5 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:04:44 +0530 Subject: [PATCH 71/92] tweak attachment check in SpamTrigger --- automod/triggers.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/automod/triggers.go b/automod/triggers.go index f57a176817..df31e03614 100644 --- a/automod/triggers.go +++ b/automod/triggers.go @@ -1074,6 +1074,7 @@ func (spam *SpamTrigger) CheckMessage(triggerCtx *TriggerContext, cs *dstate.Cha settingsCast := triggerCtx.Data.(*SpamTriggerData) mToCheckAgainst := strings.TrimSpace(strings.ToLower(m.Content)) + totalAttachments := len(m.GetMessageAttachments()) count := 1 @@ -1102,8 +1103,8 @@ func (spam *SpamTrigger) CheckMessage(triggerCtx *TriggerContext, cs *dstate.Cha break } - if len(v.GetMessageAttachments()) > 0 { - break // treat any attachment as a different message, in the future i may download them and check hash or something? maybe too much + if len(v.GetMessageAttachments()) != totalAttachments { + break // attachment count don't match } contentStripped := strings.TrimSpace(v.Content) From 891d1207c26cd345d377e0fd599b005567d8e7e1 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:36:48 +0530 Subject: [PATCH 72/92] fix weird logic in ticket ao --- tickets/tickets_commands.go | 93 +++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/tickets/tickets_commands.go b/tickets/tickets_commands.go index a77be0b07d..57180876de 100644 --- a/tickets/tickets_commands.go +++ b/tickets/tickets_commands.go @@ -216,7 +216,6 @@ func (p *Plugin) AddCommands() { Aliases: []string{"adminonly", "ao"}, Description: "Toggle admins only mode for this ticket", RunFunc: func(parsed *dcmd.Data) (any, error) { - conf := parsed.Context().Value(CtxKeyConfig).(*models.TicketConfig) currentTicket := parsed.Context().Value(CtxKeyCurrentTicket).(*Ticket) if len(conf.ModRoles) == 0 { @@ -225,21 +224,27 @@ func (p *Plugin) AddCommands() { isAdminsOnlyCurrently := isTicketAdminOnly(conf, currentTicket, parsed.GuildData.CS) if !isAdminsOnlyCurrently { - _, err := setTicketAdminOnly(conf, parsed.GuildData.CS) + resp, err := setTicketAdminOnly(conf, currentTicket, parsed.GuildData.CS) if err != nil { return "Failed to make ticket admin only", err } currentTicket.Ticket.IsAdminOnly = true - currentTicket.Ticket.UpdateG(context.Background(), boil.Whitelist("is_admin_only")) - return "Ticket Set to Admin Only", nil + _, err = currentTicket.Ticket.UpdateG(context.Background(), boil.Whitelist("is_admin_only")) + if err != nil { + return "Failed to update ticket admin only", err + } + return resp, nil } - resp, err := unsetTicketAdminOnly(conf, currentTicket, parsed.GuildData.CS) + resp, err := unsetTicketAdminOnly(conf, parsed.GuildData.CS) if err != nil { return "Failed to remove admin only from tickets", err } currentTicket.Ticket.IsAdminOnly = false - currentTicket.Ticket.UpdateG(context.Background(), boil.Whitelist("is_admin_only")) + _, err = currentTicket.Ticket.UpdateG(context.Background(), boil.Whitelist("is_admin_only")) + if err != nil { + return "Failed to update ticket admin only", err + } return resp, nil }, } @@ -660,7 +665,7 @@ func createTXTTranscript(ticket *models.Ticket, msgs []*discordgo.Message) *byte return &buf } -func unsetTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs *dstate.ChannelState) (string, error) { +func setTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs *dstate.ChannelState) (string, error) { isThreadedTicket := cs.Type == discordgo.ChannelTypeGuildPrivateThread if !isThreadedTicket { modOverwrites := make([]discordgo.PermissionOverwrite, 0) @@ -682,7 +687,7 @@ func unsetTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs * return "", err } } - return "Removed all mod roles from the ticket, ticket is no longer admin only.", nil + return "All mods roles removed, ticket is now admin only.", nil } lastMemberID := "" memberList := make([]*discordgo.ThreadMember, 0) @@ -699,42 +704,34 @@ func unsetTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs * lastMemberID = discordgo.StrID(nextMemberlist[len(nextMemberlist)-1].ID) } - participants := make(map[int64]*models.TicketParticipant) - for _, v := range currentTicket.Participants { - participants[v.UserID] = v - } - for _, v := range memberList { - _, isParticipant := participants[v.UserID] isAuthor := v.UserID == currentTicket.Ticket.AuthorID isBot := v.UserID == common.BotUser.ID - if isParticipant || isAuthor || isBot { + isAdmin := common.ContainsInt64SliceOneOf(v.Member.Roles, conf.AdminRoles) + isMod := common.ContainsInt64SliceOneOf(v.Member.Roles, conf.ModRoles) + if isAuthor || isBot || isAdmin || !isMod { continue } - if common.ContainsInt64SliceOneOf(v.Member.Roles, conf.ModRoles) { - err := common.BotSession.ThreadMemberRemove(cs.ID, discordgo.StrID(v.UserID)) - if err != nil { - logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to remove thread member") - return "", err - } + err := common.BotSession.ThreadMemberRemove(cs.ID, discordgo.StrID(v.UserID)) + if err != nil { + logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to remove thread member") + return "", err } } - return "Removed all mod roles from the ticket, ticket is no longer admin only.", nil + return "All mods removed, ticket is now admin only.", nil } -func setTicketAdminOnly(conf *models.TicketConfig, cs *dstate.ChannelState) (string, error) { +func unsetTicketAdminOnly(conf *models.TicketConfig, cs *dstate.ChannelState) (string, error) { isThreadedTicket := cs.Type == discordgo.ChannelTypeGuildPrivateThread - response := "" if isThreadedTicket { var mentions strings.Builder - mentions.WriteString("Added the following roles to the ticket: ") + mentions.WriteString("Adding the mod roles back to the ticket: ") for _, roleID := range conf.ModRoles { mentions.WriteString(" <@&" + strconv.FormatInt(roleID, 10) + ">") } - response = mentions.String() message := &discordgo.MessageSend{ - Content: response, + Content: mentions.String(), AllowedMentions: discordgo.AllowedMentions{ Roles: discordgo.IDSlice(conf.ModRoles), }, @@ -767,11 +764,11 @@ func setTicketAdminOnly(conf *models.TicketConfig, cs *dstate.ChannelState) (str logger.WithError(err).WithField("guild", cs.GuildID).Error("[tickets] failed to add channel overwrite") return "", err } - response = "Ticket is now admin only, added all mod roles to the ticket." + } } - return response, nil + return "Added all mod roles back to the ticket.", nil } func isTicketAdminOnly(conf *models.TicketConfig, currentTicket *Ticket, cs *dstate.ChannelState) bool { @@ -825,6 +822,44 @@ func createTicketThread(conf *models.TicketConfig, gs *dstate.GuildSet, authorID logger.WithError(err).Error("Failed adding user to thread") } + // add all the mod and admin roles + var modMention strings.Builder + modMention.WriteString("Added the following mod roles to the ticket: ") + for _, v := range conf.ModRoles { + modMention.WriteString(" <@&" + strconv.FormatInt(v, 10) + ">") + } + message := &discordgo.MessageSend{ + Content: modMention.String(), + AllowedMentions: discordgo.AllowedMentions{ + Roles: discordgo.IDSlice(conf.ModRoles), + }, + } + _, err = common.BotSession.ChannelMessageSendComplex(channel.ID, message) + if err != nil { + logger.WithError(err).WithField("guild", gs.ID).Error("[tickets] failed to send message to thread") + return 0, nil, err + } + + // add admin roles + var adminMention strings.Builder + adminMention.WriteString("Added the following admin roles to the ticket: ") + for _, v := range conf.AdminRoles { + adminMention.WriteString(" <@&" + strconv.FormatInt(v, 10) + ">") + } + + message = &discordgo.MessageSend{ + Content: adminMention.String(), + AllowedMentions: discordgo.AllowedMentions{ + Roles: discordgo.IDSlice(conf.AdminRoles), + }, + } + + _, err = common.BotSession.ChannelMessageSendComplex(channel.ID, message) + if err != nil { + logger.WithError(err).WithField("guild", gs.ID).Error("[tickets] failed to send message to thread") + return 0, nil, err + } + return id, channel, nil } From 042be910734390c1ad490791d7cc7c7a4e00683b Mon Sep 17 00:00:00 2001 From: savage4618 Date: Wed, 21 Jan 2026 08:38:47 -0500 Subject: [PATCH 73/92] Add aliases to cleardm command (#2014) * add aliases to cleardm command * ran go fmt on the file --- stdcommands/cleardm/cleardm.go | 1 + 1 file changed, 1 insertion(+) diff --git a/stdcommands/cleardm/cleardm.go b/stdcommands/cleardm/cleardm.go index fe259e4558..65fb8afe0b 100644 --- a/stdcommands/cleardm/cleardm.go +++ b/stdcommands/cleardm/cleardm.go @@ -17,6 +17,7 @@ var Command = &commands.YAGCommand{ HideFromCommandsPage: true, Name: "cleardm", Description: "clears the DM chat with a user, bot owner only command.", + Aliases: []string{"cleardms", "cleandm", "cleandms"}, HideFromHelp: true, RequiredArgs: 1, Arguments: []*dcmd.ArgDef{ From 0188c8169bb4017f42265dce4f73516e64b09586 Mon Sep 17 00:00:00 2001 From: Luca Zeuch Date: Wed, 21 Jan 2026 14:39:14 +0100 Subject: [PATCH 74/92] customcommands: enable dcct as a slash command (#2012) This allows users to at least bypass silly permission restrictions for debugging their custom command triggers. Joe suggested it, I was faster than him. Signed-off-by: Luca Zeuch --- customcommands/bot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/customcommands/bot.go b/customcommands/bot.go index 0f676ac57b..cc3cbe8777 100644 --- a/customcommands/bot.go +++ b/customcommands/bot.go @@ -197,7 +197,7 @@ var cmdDiagnoseCCTriggers = &commands.YAGCommand{ {Name: "input", Type: dcmd.String}, }, RequireDiscordPerms: []int64{discordgo.PermissionManageGuild}, - SlashCommandEnabled: false, + SlashCommandEnabled: true, DefaultEnabled: true, RunFunc: func(data *dcmd.Data) (interface{}, error) { cmds, err := BotCachedGetCommandsWithMessageTriggers(data.GuildData.GS.ID, data.Context()) From 9e3aaf1a991543f902cd8f862882f1b4fb4866fd Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:09:24 +0530 Subject: [PATCH 75/92] don't send custom announcement for vod if vod notification is disabled, modified the default custom announcement to share vod link instead of stream link (#2011) --- tickets/schema.go | 2 +- twitch/assets/twitch.html | 6 +++--- twitch/feed.go | 16 ++++++++++------ twitch/web.go | 8 +++++++- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/tickets/schema.go b/tickets/schema.go index 2198575bbf..5bc7f3f246 100644 --- a/tickets/schema.go +++ b/tickets/schema.go @@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS tickets ( author_id BIGINT NOT NULL, author_username_discrim TEXT NOT NULL, - PRIMARY KEY(guild_id, local_id + PRIMARY KEY(guild_id, local_id) ); `, ` ALTER TABLE tickets ADD COLUMN IF NOT EXISTS is_admin_only BOOLEAN NOT NULL DEFAULT false; diff --git a/twitch/assets/twitch.html b/twitch/assets/twitch.html index 12da71e0d4..e8abf40e30 100644 --- a/twitch/assets/twitch.html +++ b/twitch/assets/twitch.html @@ -105,9 +105,9 @@

      Custom Announcement

      • {{"{{"}} .User {{"}}"}} - The Twitch username.
      • {{"{{"}} .URL {{"}}"}} - The link to the stream.
      • -
      • {{"{{"}} .Title {{"}}"}} - The stream title.
      • -
      • {{"{{"}} .Game {{"}}"}} - The game being played.
      • -
      • {{"{{"}} .IsLive {{"}}"}} - Boolean, true if live.
      • +
      • {{"{{"}} .Title {{"}}"}} - The stream title, empty in case of a VOD.
      • +
      • {{"{{"}} .Game {{"}}"}} - The game being played, empty in case of a VOD.
      • +
      • {{"{{"}} .IsLive {{"}}"}} - Boolean, true if live, false if notification is for a VOD.
      • {{"{{"}} .VODUrl {{"}}"}} - The URL to the VOD (if offline).

      diff --git a/twitch/feed.go b/twitch/feed.go index ee75e836d5..2e00998e70 100644 --- a/twitch/feed.go +++ b/twitch/feed.go @@ -248,6 +248,11 @@ func (p *Plugin) sendStreamMessage(sub *models.TwitchChannelSubscription, stream parsedGuild, _ := strconv.ParseInt(sub.GuildID, 10, 64) parsedChannel, _ := strconv.ParseInt(sub.ChannelID, 10, 64) + // only send for Live events, for VODs only send if publish vod is enabled + if !isLive && (!sub.PublishVod || vodUrl == "") { + return + } + // Check for custom announcement announcement, err := models.FindTwitchAnnouncementG(context.Background(), parsedGuild) if err == nil && announcement.Enabled && len(announcement.Message) > 0 { @@ -262,15 +267,14 @@ func (p *Plugin) sendStreamMessage(sub *models.TwitchChannelSubscription, stream return } - // If standard message, only send for Live events unless VOD is present - if !isLive && (!sub.PublishVod || vodUrl == "") { - return - } - var content string if isLive { streamUrl := "https://www.twitch.tv/" + stream.UserLogin - content = fmt.Sprintf("**%s** is live now playing **%s**!\n%s", stream.UserName, stream.GameName, streamUrl) + if len(stream.GameName) > 0 { + content = fmt.Sprintf("**%s** is live now and playing **%s**!\n%s", stream.UserName, stream.GameName, streamUrl) + } else { + content = fmt.Sprintf("**%s** is live now!\n%s", stream.UserName, streamUrl) + } } else { content = fmt.Sprintf("**%s** has gone offline. Catch the VOD here: %s", stream.UserName, vodUrl) } diff --git a/twitch/web.go b/twitch/web.go index e77244ff12..d3b34b5b15 100644 --- a/twitch/web.go +++ b/twitch/web.go @@ -101,7 +101,13 @@ func (p *Plugin) HandleTwitch(w http.ResponseWriter, r *http.Request) (web.Templ if err != nil { announcement = &models.TwitchAnnouncement{ GuildID: activeGuild.ID, - Message: "{{.User}} is now live on Twitch! {{.URL}}", + Message: `{{if not .IsLive}} +{{.User}} went offline! Catch the VOD here: +{{.VODUrl}} +{{else}} +{{.User}} is now live! +{{.URL}} +{{end}}`, Enabled: false, } } From 4bc381d8d540db12a888b07b5755a8a4c4cc4d71 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:29:50 +0530 Subject: [PATCH 76/92] change to showing text-only channels for channel selections where bot has to send a message (#2015) --- automod/assets/automod.html | 2 +- bulkrole/assets/bulkrole.html | 2 +- customcommands/assets/customcommands-editcmd.html | 6 +++--- moderation/assets/moderation.html | 6 +++--- notifications/assets/notifications_general.html | 6 +++--- reddit/assets/reddit.html | 4 ++-- rss/assets/rss.html | 4 ++-- streaming/assets/streaming.html | 2 +- twitch/assets/twitch.html | 4 ++-- twitter/assets/twitter.html | 4 ++-- verification/assets/verification_control_panel.html | 2 +- youtube/assets/youtube.html | 4 ++-- 12 files changed, 23 insertions(+), 23 deletions(-) diff --git a/automod/assets/automod.html b/automod/assets/automod.html index 1fac951c5e..0991083b77 100644 --- a/automod/assets/automod.html +++ b/automod/assets/automod.html @@ -338,7 +338,7 @@

      List: - {{textChannelOptions .ActiveGuild.Channels .BulkRole.NotificationChannel true "None"}} + {{textOnlyChannelOptions .ActiveGuild.Channels .BulkRole.NotificationChannel true "None"}}

      diff --git a/customcommands/assets/customcommands-editcmd.html b/customcommands/assets/customcommands-editcmd.html index e42a8f48d3..fb44c42a71 100644 --- a/customcommands/assets/customcommands-editcmd.html +++ b/customcommands/assets/customcommands-editcmd.html @@ -290,7 +290,7 @@

      @@ -309,7 +309,7 @@

      @@ -468,7 +468,7 @@

      Next scheduled run
      diff --git a/moderation/assets/moderation.html b/moderation/assets/moderation.html index d4a7341011..f6fc16b33c 100644 --- a/moderation/assets/moderation.html +++ b/moderation/assets/moderation.html @@ -124,7 +124,7 @@

      Delete ALL server warnings?


      @@ -132,7 +132,7 @@

      Delete ALL server warnings?


      @@ -170,7 +170,7 @@

      Delete ALL server warnings?


      diff --git a/notifications/assets/notifications_general.html b/notifications/assets/notifications_general.html index 004910ea6e..34aa077bce 100644 --- a/notifications/assets/notifications_general.html +++ b/notifications/assets/notifications_general.html @@ -38,7 +38,7 @@

      General

      @@ -79,7 +79,7 @@

      General

      @@ -139,7 +139,7 @@

      General

      diff --git a/reddit/assets/reddit.html b/reddit/assets/reddit.html index 07f2ab6f60..f7adf47027 100644 --- a/reddit/assets/reddit.html +++ b/reddit/assets/reddit.html @@ -63,7 +63,7 @@

      New feed

      @@ -107,7 +107,7 @@

      Current reddit feeds

      diff --git a/rss/assets/rss.html b/rss/assets/rss.html index 4adad91d3a..5424453572 100644 --- a/rss/assets/rss.html +++ b/rss/assets/rss.html @@ -38,7 +38,7 @@

      New feed

      @@ -87,7 +87,7 @@

      Current RSS feeds

      diff --git a/streaming/assets/streaming.html b/streaming/assets/streaming.html index a9b6016b3e..908086383d 100644 --- a/streaming/assets/streaming.html +++ b/streaming/assets/streaming.html @@ -37,7 +37,7 @@

      Streaming role and announcements

      diff --git a/twitch/assets/twitch.html b/twitch/assets/twitch.html index e8abf40e30..f293151def 100644 --- a/twitch/assets/twitch.html +++ b/twitch/assets/twitch.html @@ -35,7 +35,7 @@

      Add New Feed

      @@ -178,7 +178,7 @@

      Current Feeds

      diff --git a/twitter/assets/twitter.html b/twitter/assets/twitter.html index 7036084044..52a35633b0 100644 --- a/twitter/assets/twitter.html +++ b/twitter/assets/twitter.html @@ -29,7 +29,7 @@

      New feed

      @@ -61,7 +61,7 @@

      Current twitter feeds

      diff --git a/verification/assets/verification_control_panel.html b/verification/assets/verification_control_panel.html index e353da72fe..a073773e20 100644 --- a/verification/assets/verification_control_panel.html +++ b/verification/assets/verification_control_panel.html @@ -58,7 +58,7 @@

      Verification


      diff --git a/youtube/assets/youtube.html b/youtube/assets/youtube.html index d833f97130..84f022b4eb 100644 --- a/youtube/assets/youtube.html +++ b/youtube/assets/youtube.html @@ -66,7 +66,7 @@

      Add New Feed

      @@ -161,7 +161,7 @@

      Current subscribed channels

      From 0867d34ad436af887b47c030d28b941faa239a6b Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:34:31 +0530 Subject: [PATCH 77/92] Added option to have a dedicated error channel for a custom command group, preference is cc-error-channel, group-error-channel, trigger channel (#2016) --- customcommands/assets/customcommands.html | 93 +++++++------ customcommands/bot.go | 6 + .../models/custom_command_groups.go | 123 +++++++++--------- customcommands/schema.go | 2 + customcommands/web.go | 9 +- 5 files changed, 130 insertions(+), 103 deletions(-) diff --git a/customcommands/assets/customcommands.html b/customcommands/assets/customcommands.html index 2c7c38aa31..666a3137d5 100644 --- a/customcommands/assets/customcommands.html +++ b/customcommands/assets/customcommands.html @@ -65,73 +65,84 @@

      Custom commands

      - {{if not .CurrentCommandGroup}}

      Create a new group to put allowed/denied roles/channels on - multiple commands at the same time, as well as keeping things organized.

      {{else}} + {{if not .CurrentCommandGroup}} +

      Create a new group to put allowed/denied roles/channels on multiple commands at the same time, as well as keeping things organized.

      + {{else}}
      -
      -
      +
      -
      - -
      -
      -
      - -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      - + {{textOnlyChannelOptions .ActiveGuild.Channels .CurrentCommandGroup.RedirectErrorsChannel true "None"}}

      {{checkbox "IsEnabled" "IsEnabled" "Enables or Disables the Group" (not .CurrentCommandGroup.Disabled) }}
      -
      - +
      +
      +
      + +
      +
      +
      + +
      +
      +
      +
      +
      + +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      {{end}} +

      You have created {{.CCCount}} custom commands against the total limit of {{.CCLimit}} {{.AdditionalMessage}}

      -
      +
      diff --git a/customcommands/bot.go b/customcommands/bot.go index cc3cbe8777..9b53ed077c 100644 --- a/customcommands/bot.go +++ b/customcommands/bot.go @@ -615,6 +615,8 @@ func ExecuteCustomCommand(cmd *models.CustomCommand, tmplCtx *templates.Context) errChannel := tmplCtx.CurrentFrame.CS.ID if cmd.RedirectErrorsChannel != 0 { errChannel = cmd.RedirectErrorsChannel + } else if cmd.R.Group.RedirectErrorsChannel != 0 { + errChannel = cmd.R.Group.RedirectErrorsChannel } if cmd.ShowErrors { @@ -650,6 +652,8 @@ func ExecuteCustomCommand(cmd *models.CustomCommand, tmplCtx *templates.Context) errChannel := tmplCtx.CurrentFrame.CS.ID if cmd.RedirectErrorsChannel != 0 { errChannel = cmd.RedirectErrorsChannel + } else if cmd.R.Group.RedirectErrorsChannel != 0 { + errChannel = cmd.R.Group.RedirectErrorsChannel } if cmd.ShowErrors { @@ -813,6 +817,8 @@ func onExecPanic(cmd *models.CustomCommand, err error, tmplCtx *templates.Contex errChannel := tmplCtx.CurrentFrame.CS.ID if cmd.RedirectErrorsChannel != 0 { errChannel = cmd.RedirectErrorsChannel + } else if cmd.R.Group.RedirectErrorsChannel != 0 { + errChannel = cmd.R.Group.RedirectErrorsChannel } if cmd.ShowErrors { diff --git a/customcommands/models/custom_command_groups.go b/customcommands/models/custom_command_groups.go index 0caf0e31c4..04d3d52270 100644 --- a/customcommands/models/custom_command_groups.go +++ b/customcommands/models/custom_command_groups.go @@ -24,57 +24,62 @@ import ( // CustomCommandGroup is an object representing the database table. type CustomCommandGroup struct { - ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"` - GuildID int64 `boil:"guild_id" json:"guild_id" toml:"guild_id" yaml:"guild_id"` - Name string `boil:"name" json:"name" toml:"name" yaml:"name"` - IgnoreRoles types.Int64Array `boil:"ignore_roles" json:"ignore_roles,omitempty" toml:"ignore_roles" yaml:"ignore_roles,omitempty"` - IgnoreChannels types.Int64Array `boil:"ignore_channels" json:"ignore_channels,omitempty" toml:"ignore_channels" yaml:"ignore_channels,omitempty"` - WhitelistRoles types.Int64Array `boil:"whitelist_roles" json:"whitelist_roles,omitempty" toml:"whitelist_roles" yaml:"whitelist_roles,omitempty"` - WhitelistChannels types.Int64Array `boil:"whitelist_channels" json:"whitelist_channels,omitempty" toml:"whitelist_channels" yaml:"whitelist_channels,omitempty"` - Disabled bool `boil:"disabled" json:"disabled" toml:"disabled" yaml:"disabled"` + ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"` + GuildID int64 `boil:"guild_id" json:"guild_id" toml:"guild_id" yaml:"guild_id"` + Name string `boil:"name" json:"name" toml:"name" yaml:"name"` + IgnoreRoles types.Int64Array `boil:"ignore_roles" json:"ignore_roles,omitempty" toml:"ignore_roles" yaml:"ignore_roles,omitempty"` + IgnoreChannels types.Int64Array `boil:"ignore_channels" json:"ignore_channels,omitempty" toml:"ignore_channels" yaml:"ignore_channels,omitempty"` + WhitelistRoles types.Int64Array `boil:"whitelist_roles" json:"whitelist_roles,omitempty" toml:"whitelist_roles" yaml:"whitelist_roles,omitempty"` + WhitelistChannels types.Int64Array `boil:"whitelist_channels" json:"whitelist_channels,omitempty" toml:"whitelist_channels" yaml:"whitelist_channels,omitempty"` + Disabled bool `boil:"disabled" json:"disabled" toml:"disabled" yaml:"disabled"` + RedirectErrorsChannel int64 `boil:"redirect_errors_channel" json:"redirect_errors_channel" toml:"redirect_errors_channel" yaml:"redirect_errors_channel"` R *customCommandGroupR `boil:"-" json:"-" toml:"-" yaml:"-"` L customCommandGroupL `boil:"-" json:"-" toml:"-" yaml:"-"` } var CustomCommandGroupColumns = struct { - ID string - GuildID string - Name string - IgnoreRoles string - IgnoreChannels string - WhitelistRoles string - WhitelistChannels string - Disabled string + ID string + GuildID string + Name string + IgnoreRoles string + IgnoreChannels string + WhitelistRoles string + WhitelistChannels string + Disabled string + RedirectErrorsChannel string }{ - ID: "id", - GuildID: "guild_id", - Name: "name", - IgnoreRoles: "ignore_roles", - IgnoreChannels: "ignore_channels", - WhitelistRoles: "whitelist_roles", - WhitelistChannels: "whitelist_channels", - Disabled: "disabled", + ID: "id", + GuildID: "guild_id", + Name: "name", + IgnoreRoles: "ignore_roles", + IgnoreChannels: "ignore_channels", + WhitelistRoles: "whitelist_roles", + WhitelistChannels: "whitelist_channels", + Disabled: "disabled", + RedirectErrorsChannel: "redirect_errors_channel", } var CustomCommandGroupTableColumns = struct { - ID string - GuildID string - Name string - IgnoreRoles string - IgnoreChannels string - WhitelistRoles string - WhitelistChannels string - Disabled string + ID string + GuildID string + Name string + IgnoreRoles string + IgnoreChannels string + WhitelistRoles string + WhitelistChannels string + Disabled string + RedirectErrorsChannel string }{ - ID: "custom_command_groups.id", - GuildID: "custom_command_groups.guild_id", - Name: "custom_command_groups.name", - IgnoreRoles: "custom_command_groups.ignore_roles", - IgnoreChannels: "custom_command_groups.ignore_channels", - WhitelistRoles: "custom_command_groups.whitelist_roles", - WhitelistChannels: "custom_command_groups.whitelist_channels", - Disabled: "custom_command_groups.disabled", + ID: "custom_command_groups.id", + GuildID: "custom_command_groups.guild_id", + Name: "custom_command_groups.name", + IgnoreRoles: "custom_command_groups.ignore_roles", + IgnoreChannels: "custom_command_groups.ignore_channels", + WhitelistRoles: "custom_command_groups.whitelist_roles", + WhitelistChannels: "custom_command_groups.whitelist_channels", + Disabled: "custom_command_groups.disabled", + RedirectErrorsChannel: "custom_command_groups.redirect_errors_channel", } // Generated where @@ -167,23 +172,25 @@ func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } var CustomCommandGroupWhere = struct { - ID whereHelperint64 - GuildID whereHelperint64 - Name whereHelperstring - IgnoreRoles whereHelpertypes_Int64Array - IgnoreChannels whereHelpertypes_Int64Array - WhitelistRoles whereHelpertypes_Int64Array - WhitelistChannels whereHelpertypes_Int64Array - Disabled whereHelperbool + ID whereHelperint64 + GuildID whereHelperint64 + Name whereHelperstring + IgnoreRoles whereHelpertypes_Int64Array + IgnoreChannels whereHelpertypes_Int64Array + WhitelistRoles whereHelpertypes_Int64Array + WhitelistChannels whereHelpertypes_Int64Array + Disabled whereHelperbool + RedirectErrorsChannel whereHelperint64 }{ - ID: whereHelperint64{field: "\"custom_command_groups\".\"id\""}, - GuildID: whereHelperint64{field: "\"custom_command_groups\".\"guild_id\""}, - Name: whereHelperstring{field: "\"custom_command_groups\".\"name\""}, - IgnoreRoles: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"ignore_roles\""}, - IgnoreChannels: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"ignore_channels\""}, - WhitelistRoles: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"whitelist_roles\""}, - WhitelistChannels: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"whitelist_channels\""}, - Disabled: whereHelperbool{field: "\"custom_command_groups\".\"disabled\""}, + ID: whereHelperint64{field: "\"custom_command_groups\".\"id\""}, + GuildID: whereHelperint64{field: "\"custom_command_groups\".\"guild_id\""}, + Name: whereHelperstring{field: "\"custom_command_groups\".\"name\""}, + IgnoreRoles: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"ignore_roles\""}, + IgnoreChannels: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"ignore_channels\""}, + WhitelistRoles: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"whitelist_roles\""}, + WhitelistChannels: whereHelpertypes_Int64Array{field: "\"custom_command_groups\".\"whitelist_channels\""}, + Disabled: whereHelperbool{field: "\"custom_command_groups\".\"disabled\""}, + RedirectErrorsChannel: whereHelperint64{field: "\"custom_command_groups\".\"redirect_errors_channel\""}, } // CustomCommandGroupRels is where relationship names are stored. @@ -223,9 +230,9 @@ func (r *customCommandGroupR) GetGroupCustomCommands() CustomCommandSlice { type customCommandGroupL struct{} var ( - customCommandGroupAllColumns = []string{"id", "guild_id", "name", "ignore_roles", "ignore_channels", "whitelist_roles", "whitelist_channels", "disabled"} + customCommandGroupAllColumns = []string{"id", "guild_id", "name", "ignore_roles", "ignore_channels", "whitelist_roles", "whitelist_channels", "disabled", "redirect_errors_channel"} customCommandGroupColumnsWithoutDefault = []string{"guild_id", "name"} - customCommandGroupColumnsWithDefault = []string{"id", "ignore_roles", "ignore_channels", "whitelist_roles", "whitelist_channels", "disabled"} + customCommandGroupColumnsWithDefault = []string{"id", "ignore_roles", "ignore_channels", "whitelist_roles", "whitelist_channels", "disabled", "redirect_errors_channel"} customCommandGroupPrimaryKeyColumns = []string{"id"} customCommandGroupGeneratedColumns = []string{} ) diff --git a/customcommands/schema.go b/customcommands/schema.go index 118c24b8cd..94ffab9902 100644 --- a/customcommands/schema.go +++ b/customcommands/schema.go @@ -16,6 +16,8 @@ CREATE TABLE IF NOT EXISTS custom_command_groups ( `, ` ALTER TABLE custom_command_groups ADD COLUMN IF NOT EXISTS disabled BOOLEAN NOT NULL DEFAULT false; `, ` +ALTER TABLE custom_command_groups ADD COLUMN IF NOT EXISTS redirect_errors_channel BIGINT NOT NULL DEFAULT 0; +`, ` CREATE TABLE IF NOT EXISTS custom_commands ( local_id BIGINT NOT NULL, guild_id BIGINT NOT NULL, diff --git a/customcommands/web.go b/customcommands/web.go index 3d5e293961..b06be19c23 100644 --- a/customcommands/web.go +++ b/customcommands/web.go @@ -55,9 +55,10 @@ type GroupForm struct { WhitelistChannels []int64 `valid:"channel,true"` BlacklistChannels []int64 `valid:"channel,true"` - WhitelistRoles []int64 `valid:"role,true"` - BlacklistRoles []int64 `valid:"role,true"` - IsEnabled bool + WhitelistRoles []int64 `valid:"role,true"` + BlacklistRoles []int64 `valid:"role,true"` + RedirectErrorsChannel int64 `valid:"channel,true"` + IsEnabled bool } type SearchForm struct { @@ -838,7 +839,6 @@ func handleUpdateGroup(w http.ResponseWriter, r *http.Request) (web.TemplateData if err != nil { return templateData, err } - logrus.Infof("groupForm.IsEnabled %#v", groupForm.IsEnabled) model.WhitelistChannels = groupForm.WhitelistChannels model.IgnoreChannels = groupForm.BlacklistChannels @@ -846,6 +846,7 @@ func handleUpdateGroup(w http.ResponseWriter, r *http.Request) (web.TemplateData model.IgnoreRoles = groupForm.BlacklistRoles model.Name = groupForm.Name model.Disabled = !groupForm.IsEnabled + model.RedirectErrorsChannel = groupForm.RedirectErrorsChannel _, err = model.UpdateG(ctx, boil.Infer()) if err == nil { From 20edaf3b1950183ea913ff174ea2e09081e95ac8 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:04:49 +0530 Subject: [PATCH 78/92] added check for member joined at to prevent sending join messages in case an event for an already joined member is received (#2018) --- notifications/plugin_bot.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/notifications/plugin_bot.go b/notifications/plugin_bot.go index 0d1d0f5af3..180ab67a86 100644 --- a/notifications/plugin_bot.go +++ b/notifications/plugin_bot.go @@ -70,6 +70,15 @@ func HandleGuildMemberAdd(evtData *eventsystem.EventData) (retry bool, err error ms := dstate.MemberStateFromMember(evt.Member) ms.GuildID = evt.GuildID + joinedAt, err := ms.Member.JoinedAt.Parse() + if err != nil { + return true, errors.WithStackIf(err) + } + + if time.Since(joinedAt) > 1*time.Minute { + return + } + // Beware of the pyramid and its curses if config.JoinDMEnabled && !evt.User.Bot { cid, err := common.BotSession.UserChannelCreate(evt.User.ID) From 18bd783d2e7636a708112161db55f3220db7bd58 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:05:06 +0530 Subject: [PATCH 79/92] allow removing components and embeds while editing a message (#2019) --- common/templates/components.go | 2 +- common/templates/general.go | 1 + lib/discordgo/message.go | 15 +++++++++------ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/common/templates/components.go b/common/templates/components.go index 24406c08d6..7c8805e72c 100644 --- a/common/templates/components.go +++ b/common/templates/components.go @@ -870,7 +870,7 @@ func CreateContainer(msgFiles *[]*discordgo.File, values ...any) (*discordgo.Con func distributeComponentsIntoActionsRows(components reflect.Value) (returnComponents []discordgo.TopLevelComponent, err error) { if components.Len() < 1 { - return + return make([]discordgo.TopLevelComponent, 0), nil } const maxRows = 5 // Discord limitation diff --git a/common/templates/general.go b/common/templates/general.go index 246a6ea86a..c038f827ee 100644 --- a/common/templates/general.go +++ b/common/templates/general.go @@ -535,6 +535,7 @@ func CreateMessageEdit(values ...interface{}) (*discordgo.MessageEdit, error) { v, _ := indirect(reflect.ValueOf(val)) if v.Kind() == reflect.Slice { const maxEmbeds = 10 // Discord limitation + msg.Embeds = make([]*discordgo.MessageEmbed, 0, maxEmbeds) for i := 0; i < v.Len() && i < maxEmbeds; i++ { embed, err := CreateEmbed(v.Index(i).Interface()) if err != nil { diff --git a/lib/discordgo/message.go b/lib/discordgo/message.go index 83d9e444be..f4012be5ce 100644 --- a/lib/discordgo/message.go +++ b/lib/discordgo/message.go @@ -355,19 +355,22 @@ func (m *MessageEdit) MarshalJSON() ([]byte, error) { type MessageEditAlias MessageEdit temp := struct { *MessageEditAlias - Content *string `json:"content,omitempty"` - Components []TopLevelComponent `json:"components"` - Embeds *[]*MessageEmbed `json:"embeds,omitempty"` - AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"` - Flags *MessageFlags `json:"flags,omitempty"` + Content *string `json:"content,omitempty"` + Components *[]TopLevelComponent `json:"components,omitempty"` + Embeds *[]*MessageEmbed `json:"embeds,omitempty"` + AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"` + Flags *MessageFlags `json:"flags,omitempty"` }{ MessageEditAlias: (*MessageEditAlias)(m), Content: m.Content, - Components: m.Components, AllowedMentions: &m.AllowedMentions, Flags: &m.Flags, } + if m.Components != nil { + temp.Components = &m.Components + } + if m.Embeds != nil { temp.Embeds = &m.Embeds } From 33a37ce3a42f48308baaa989fd964b5b4557da9a Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:34:36 +0530 Subject: [PATCH 80/92] add announcement channel in textOnlyChannelOptions --- web/web.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/web.go b/web/web.go index bf57b89dea..7a8623b693 100644 --- a/web/web.go +++ b/web/web.go @@ -99,7 +99,7 @@ func init() { "roleOptionsMulti": tmplRoleDropdownMulti, "roleOptionsExclude": tmplRoleDropdownExclude, "roleOptionsMultiExclude": tmplRoleDropdownMultiExclude, - "textOnlyChannelOptions": tmplChannelOpts([]discordgo.ChannelType{discordgo.ChannelTypeGuildText}), + "textOnlyChannelOptions": tmplChannelOpts([]discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}), "textChannelOptions": tmplChannelOpts([]discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildForum, discordgo.ChannelTypeGuildStageVoice}), "textChannelOptionsMulti": tmplChannelOptsMulti([]discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildForum, From c8a627d560d20dbeaeadbbaab8611ad8290a5c3e Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:08:09 +0530 Subject: [PATCH 81/92] add icon for announcement-channel --- web/template.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/template.go b/web/template.go index a7b1f46aca..5966465a34 100644 --- a/web/template.go +++ b/web/template.go @@ -460,6 +460,8 @@ func (g *channelOptsHTMLGenState) outputChannel(id int64, name string, channelTy prefix = "🎤" case discordgo.ChannelTypeGuildForum: prefix = "📃" + case discordgo.ChannelTypeGuildNews: + prefix = "📢" default: prefix = "" } From 6fbe926afc460a68e69a1dcbfa98551180f604e2 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:12:48 +0530 Subject: [PATCH 82/92] Fix panic when custom command has no group --- customcommands/bot.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/customcommands/bot.go b/customcommands/bot.go index 9b53ed077c..175a8da95e 100644 --- a/customcommands/bot.go +++ b/customcommands/bot.go @@ -615,7 +615,7 @@ func ExecuteCustomCommand(cmd *models.CustomCommand, tmplCtx *templates.Context) errChannel := tmplCtx.CurrentFrame.CS.ID if cmd.RedirectErrorsChannel != 0 { errChannel = cmd.RedirectErrorsChannel - } else if cmd.R.Group.RedirectErrorsChannel != 0 { + } else if cmd.R.Group != nil && cmd.R.Group.RedirectErrorsChannel != 0 { errChannel = cmd.R.Group.RedirectErrorsChannel } @@ -652,7 +652,7 @@ func ExecuteCustomCommand(cmd *models.CustomCommand, tmplCtx *templates.Context) errChannel := tmplCtx.CurrentFrame.CS.ID if cmd.RedirectErrorsChannel != 0 { errChannel = cmd.RedirectErrorsChannel - } else if cmd.R.Group.RedirectErrorsChannel != 0 { + } else if cmd.R.Group != nil && cmd.R.Group.RedirectErrorsChannel != 0 { errChannel = cmd.R.Group.RedirectErrorsChannel } @@ -817,7 +817,7 @@ func onExecPanic(cmd *models.CustomCommand, err error, tmplCtx *templates.Contex errChannel := tmplCtx.CurrentFrame.CS.ID if cmd.RedirectErrorsChannel != 0 { errChannel = cmd.RedirectErrorsChannel - } else if cmd.R.Group.RedirectErrorsChannel != 0 { + } else if cmd.R.Group != nil && cmd.R.Group.RedirectErrorsChannel != 0 { errChannel = cmd.R.Group.RedirectErrorsChannel } From a498b8921ae78e2fe3d74d410efe458cf8eab3f6 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:33:07 +0530 Subject: [PATCH 83/92] fix ratelimiter deadlock issues --- lib/discordgo/discord.go | 10 ++++- lib/discordgo/ratelimit.go | 65 +++++++++++++++++++++------------ lib/discordgo/ratelimit_test.go | 12 +++--- lib/discordgo/restapi.go | 27 +++++++++----- 4 files changed, 73 insertions(+), 41 deletions(-) diff --git a/lib/discordgo/discord.go b/lib/discordgo/discord.go index 78b3b910e6..f2bb7c430c 100644 --- a/lib/discordgo/discord.go +++ b/lib/discordgo/discord.go @@ -62,7 +62,7 @@ func New(args ...interface{}) (s *Session, err error) { ShardID: 0, ShardCount: 1, MaxRestRetries: 10, - Client: cleanhttp.DefaultPooledClient(), + Client: createHTTPClient(), LastHeartbeatAck: time.Now().UTC(), tokenInvalid: new(int32), } @@ -143,6 +143,14 @@ func New(args ...interface{}) (s *Session, err error) { return } +// createHTTPClient creates an HTTP client with a reasonable timeout +// to prevent requests from hanging indefinitely. +func createHTTPClient() *http.Client { + client := cleanhttp.DefaultPooledClient() + client.Timeout = 30 * time.Second + return client +} + func CheckRetry(_ context.Context, resp *http.Response, err error) (bool, error) { if err != nil { return true, err diff --git a/lib/discordgo/ratelimit.go b/lib/discordgo/ratelimit.go index b21bcaa73f..9c495e7af2 100644 --- a/lib/discordgo/ratelimit.go +++ b/lib/discordgo/ratelimit.go @@ -1,12 +1,15 @@ package discordgo import ( + "context" "net/http" "strconv" "strings" "sync" "sync/atomic" "time" + + "github.com/sirupsen/logrus" ) // customRateLimit holds information for defining a custom rate limit @@ -60,10 +63,10 @@ func (r *RateLimiter) GetBucket(key string) *Bucket { } b := &Bucket{ - Remaining: 1, - Key: key, - global: r.global, - lockCounter: new(int64), + Remaining: 1, + Key: key, + global: r.global, + sem: make(chan struct{}, 1), // Semaphore to serialize requests per bucket } if r.MaxConcurrentRequests > 0 { @@ -114,14 +117,30 @@ func (r *RateLimiter) GetWaitTime(b *Bucket, minRemaining int) time.Duration { } // LockBucket Locks until a request can be made -func (r *RateLimiter) LockBucket(bucketID string) (b *Bucket, lockID int64) { +func (r *RateLimiter) LockBucket(bucketID string) *Bucket { bucket := r.GetBucket(bucketID) - id := r.LockBucketObject(bucket) - return bucket, id + r.LockBucketObject(bucket) + return bucket } -// LockBucketObject Locks an already resolved bucket until a request can be made -func (r *RateLimiter) LockBucketObject(b *Bucket) (lockID int64) { +// LockBucketObject waits for any rate limits on the bucket, acquires the per-bucket +// semaphore, decrements remaining, and returns. The semaphore serializes requests +// to prevent racing 429s, while a timeout prevents deadlock if a request hangs. +func (r *RateLimiter) LockBucketObject(b *Bucket) { + // First, acquire the per-bucket semaphore to serialize requests. + // Use a timeout to prevent deadlock if a request never completes. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + select { + case b.sem <- struct{}{}: + // Acquired semaphore, proceed + case <-ctx.Done(): + // Timeout waiting for semaphore - proceed anyway to prevent deadlock + // This indicates a previous request is hanging + logrus.Warnf("WARNING: Semaphore timeout (30s) for bucket %s - previous request may be hanging", b.Key) + } + b.Lock() if wait := r.GetWaitTime(b, 1); wait > 0 { @@ -151,9 +170,7 @@ func (r *RateLimiter) LockBucketObject(b *Bucket) (lockID int64) { } b.Remaining-- - - counter := atomic.AddInt64(b.lockCounter, 1) - return counter + b.Unlock() // Release mutex before HTTP request (semaphore still held) } func (r *RateLimiter) SetGlobalTriggered(to time.Time) { @@ -168,26 +185,26 @@ type Bucket struct { reset time.Time global *int64 numConcurrentLocks *int32 + sem chan struct{} // Semaphore to serialize requests per bucket lastReset time.Time customRateLimit *customRateLimit Userdata interface{} - - lockCounter *int64 } -// Release unlocks the bucket and reads the headers to update the buckets ratelimit info -// and locks up the whole thing in case if there's a global ratelimit. -func (b *Bucket) Release(headers http.Header, lockCounter int64) error { - if atomic.LoadInt64(b.lockCounter) != lockCounter { - // attempted double unlock - return nil - } - +// Release updates the bucket's ratelimit info from response headers. +// This acquires the lock, updates state, then releases the lock and semaphore. +func (b *Bucket) Release(headers http.Header) error { + b.Lock() defer b.Unlock() - // make sure that we can no longer unlock with the same ID - atomic.AddInt64(b.lockCounter, 1) + // Release the per-bucket semaphore to allow next request + select { + case <-b.sem: + // Released semaphore + default: + // Semaphore wasn't held (timeout case in LockBucketObject) + } if b.numConcurrentLocks != nil { atomic.AddInt32(b.numConcurrentLocks, -1) diff --git a/lib/discordgo/ratelimit_test.go b/lib/discordgo/ratelimit_test.go index 2f597a0a5c..a69362e4b7 100644 --- a/lib/discordgo/ratelimit_test.go +++ b/lib/discordgo/ratelimit_test.go @@ -12,7 +12,7 @@ func TestRatelimitReset(t *testing.T) { rl := NewRatelimiter() sendReq := func(endpoint string) { - bucket, id := rl.LockBucket(endpoint) + bucket := rl.LockBucket(endpoint) headers := http.Header(make(map[string][]string)) @@ -21,7 +21,7 @@ func TestRatelimitReset(t *testing.T) { headers.Set("X-RateLimit-Reset-After", "2") headers.Set("Date", time.Now().Format(time.RFC850)) - err := bucket.Release(headers, id) + err := bucket.Release(headers) if err != nil { t.Errorf("Release returned error: %v", err) } @@ -50,7 +50,7 @@ func TestRatelimitGlobal(t *testing.T) { rl := NewRatelimiter() sendReq := func(endpoint string) { - bucket, id := rl.LockBucket(endpoint) + bucket := rl.LockBucket(endpoint) headers := http.Header(make(map[string][]string)) @@ -58,7 +58,7 @@ func TestRatelimitGlobal(t *testing.T) { // Reset for approx 1 seconds from now headers.Set("Retry-After", "1") - err := bucket.Release(headers, id) + err := bucket.Release(headers) if err != nil { t.Errorf("Release returned error: %v", err) } @@ -102,7 +102,7 @@ func BenchmarkRatelimitParallelMultiEndpoints(b *testing.B) { // Does not actually send requests, but locks the bucket and releases it with made-up headers func sendBenchReq(endpoint string, rl *RateLimiter) { - bucket, id := rl.LockBucket(endpoint) + bucket := rl.LockBucket(endpoint) headers := http.Header(make(map[string][]string)) @@ -112,5 +112,5 @@ func sendBenchReq(endpoint string, rl *RateLimiter) { time.Sleep(time.Millisecond * 100) - bucket.Release(headers, id) + bucket.Release(headers) } diff --git a/lib/discordgo/restapi.go b/lib/discordgo/restapi.go index 699df04b5b..da526a401d 100644 --- a/lib/discordgo/restapi.go +++ b/lib/discordgo/restapi.go @@ -208,13 +208,8 @@ func (s *Session) doRequest(method, urlStr, contentType string, b []byte, header } func (s *Session) innerDoRequest(method, urlStr, contentType string, b []byte, headers map[string]string, bucket *Bucket) (*http.Request, *http.Response, error) { - bucketLockID := s.Ratelimiter.LockBucketObject(bucket) - defer func() { - err := bucket.Release(nil, bucketLockID) - if err != nil { - s.log(LogError, "failed unlocking ratelimit bucket: %v", err) - } - }() + // Wait for any rate limits before proceeding (does not hold lock after returning) + s.Ratelimiter.LockBucketObject(bucket) if s.Debug { log.Printf("API REQUEST %8s :: %s\n", method, urlStr) @@ -260,12 +255,24 @@ func (s *Session) innerDoRequest(method, urlStr, contentType string, b []byte, h } } + // Make the HTTP request (no lock held - prevents deadlock if request hangs) resp, err := s.Client.Do(req) - if err == nil { - err = bucket.Release(resp.Header, bucketLockID) + if err != nil { + // Log timeout errors specifically for debugging + if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { + s.log(LogWarning, "HTTP request timeout (30s) for %s %s: %v", method, urlStr, err) + } + // Still need to update rate limit state even on error (for concurrent request tracking) + bucket.Release(nil) + return req, nil, err + } + + // Update rate limit state from response headers + if releaseErr := bucket.Release(resp.Header); releaseErr != nil { + s.log(LogError, "failed updating ratelimit bucket: %v", releaseErr) } - return req, resp, err + return req, resp, nil } func unmarshal(data []byte, v interface{}) error { From cfcf44c36e9cac117f5808d8de242f6aa166cab4 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:29:44 +0530 Subject: [PATCH 84/92] Revert "fix ratelimiter deadlock issues" This reverts commit a498b8921ae78e2fe3d74d410efe458cf8eab3f6. --- lib/discordgo/discord.go | 10 +---- lib/discordgo/ratelimit.go | 65 ++++++++++++--------------------- lib/discordgo/ratelimit_test.go | 12 +++--- lib/discordgo/restapi.go | 27 +++++--------- 4 files changed, 41 insertions(+), 73 deletions(-) diff --git a/lib/discordgo/discord.go b/lib/discordgo/discord.go index f2bb7c430c..78b3b910e6 100644 --- a/lib/discordgo/discord.go +++ b/lib/discordgo/discord.go @@ -62,7 +62,7 @@ func New(args ...interface{}) (s *Session, err error) { ShardID: 0, ShardCount: 1, MaxRestRetries: 10, - Client: createHTTPClient(), + Client: cleanhttp.DefaultPooledClient(), LastHeartbeatAck: time.Now().UTC(), tokenInvalid: new(int32), } @@ -143,14 +143,6 @@ func New(args ...interface{}) (s *Session, err error) { return } -// createHTTPClient creates an HTTP client with a reasonable timeout -// to prevent requests from hanging indefinitely. -func createHTTPClient() *http.Client { - client := cleanhttp.DefaultPooledClient() - client.Timeout = 30 * time.Second - return client -} - func CheckRetry(_ context.Context, resp *http.Response, err error) (bool, error) { if err != nil { return true, err diff --git a/lib/discordgo/ratelimit.go b/lib/discordgo/ratelimit.go index 9c495e7af2..b21bcaa73f 100644 --- a/lib/discordgo/ratelimit.go +++ b/lib/discordgo/ratelimit.go @@ -1,15 +1,12 @@ package discordgo import ( - "context" "net/http" "strconv" "strings" "sync" "sync/atomic" "time" - - "github.com/sirupsen/logrus" ) // customRateLimit holds information for defining a custom rate limit @@ -63,10 +60,10 @@ func (r *RateLimiter) GetBucket(key string) *Bucket { } b := &Bucket{ - Remaining: 1, - Key: key, - global: r.global, - sem: make(chan struct{}, 1), // Semaphore to serialize requests per bucket + Remaining: 1, + Key: key, + global: r.global, + lockCounter: new(int64), } if r.MaxConcurrentRequests > 0 { @@ -117,30 +114,14 @@ func (r *RateLimiter) GetWaitTime(b *Bucket, minRemaining int) time.Duration { } // LockBucket Locks until a request can be made -func (r *RateLimiter) LockBucket(bucketID string) *Bucket { +func (r *RateLimiter) LockBucket(bucketID string) (b *Bucket, lockID int64) { bucket := r.GetBucket(bucketID) - r.LockBucketObject(bucket) - return bucket + id := r.LockBucketObject(bucket) + return bucket, id } -// LockBucketObject waits for any rate limits on the bucket, acquires the per-bucket -// semaphore, decrements remaining, and returns. The semaphore serializes requests -// to prevent racing 429s, while a timeout prevents deadlock if a request hangs. -func (r *RateLimiter) LockBucketObject(b *Bucket) { - // First, acquire the per-bucket semaphore to serialize requests. - // Use a timeout to prevent deadlock if a request never completes. - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - select { - case b.sem <- struct{}{}: - // Acquired semaphore, proceed - case <-ctx.Done(): - // Timeout waiting for semaphore - proceed anyway to prevent deadlock - // This indicates a previous request is hanging - logrus.Warnf("WARNING: Semaphore timeout (30s) for bucket %s - previous request may be hanging", b.Key) - } - +// LockBucketObject Locks an already resolved bucket until a request can be made +func (r *RateLimiter) LockBucketObject(b *Bucket) (lockID int64) { b.Lock() if wait := r.GetWaitTime(b, 1); wait > 0 { @@ -170,7 +151,9 @@ func (r *RateLimiter) LockBucketObject(b *Bucket) { } b.Remaining-- - b.Unlock() // Release mutex before HTTP request (semaphore still held) + + counter := atomic.AddInt64(b.lockCounter, 1) + return counter } func (r *RateLimiter) SetGlobalTriggered(to time.Time) { @@ -185,26 +168,26 @@ type Bucket struct { reset time.Time global *int64 numConcurrentLocks *int32 - sem chan struct{} // Semaphore to serialize requests per bucket lastReset time.Time customRateLimit *customRateLimit Userdata interface{} + + lockCounter *int64 } -// Release updates the bucket's ratelimit info from response headers. -// This acquires the lock, updates state, then releases the lock and semaphore. -func (b *Bucket) Release(headers http.Header) error { - b.Lock() +// Release unlocks the bucket and reads the headers to update the buckets ratelimit info +// and locks up the whole thing in case if there's a global ratelimit. +func (b *Bucket) Release(headers http.Header, lockCounter int64) error { + if atomic.LoadInt64(b.lockCounter) != lockCounter { + // attempted double unlock + return nil + } + defer b.Unlock() - // Release the per-bucket semaphore to allow next request - select { - case <-b.sem: - // Released semaphore - default: - // Semaphore wasn't held (timeout case in LockBucketObject) - } + // make sure that we can no longer unlock with the same ID + atomic.AddInt64(b.lockCounter, 1) if b.numConcurrentLocks != nil { atomic.AddInt32(b.numConcurrentLocks, -1) diff --git a/lib/discordgo/ratelimit_test.go b/lib/discordgo/ratelimit_test.go index a69362e4b7..2f597a0a5c 100644 --- a/lib/discordgo/ratelimit_test.go +++ b/lib/discordgo/ratelimit_test.go @@ -12,7 +12,7 @@ func TestRatelimitReset(t *testing.T) { rl := NewRatelimiter() sendReq := func(endpoint string) { - bucket := rl.LockBucket(endpoint) + bucket, id := rl.LockBucket(endpoint) headers := http.Header(make(map[string][]string)) @@ -21,7 +21,7 @@ func TestRatelimitReset(t *testing.T) { headers.Set("X-RateLimit-Reset-After", "2") headers.Set("Date", time.Now().Format(time.RFC850)) - err := bucket.Release(headers) + err := bucket.Release(headers, id) if err != nil { t.Errorf("Release returned error: %v", err) } @@ -50,7 +50,7 @@ func TestRatelimitGlobal(t *testing.T) { rl := NewRatelimiter() sendReq := func(endpoint string) { - bucket := rl.LockBucket(endpoint) + bucket, id := rl.LockBucket(endpoint) headers := http.Header(make(map[string][]string)) @@ -58,7 +58,7 @@ func TestRatelimitGlobal(t *testing.T) { // Reset for approx 1 seconds from now headers.Set("Retry-After", "1") - err := bucket.Release(headers) + err := bucket.Release(headers, id) if err != nil { t.Errorf("Release returned error: %v", err) } @@ -102,7 +102,7 @@ func BenchmarkRatelimitParallelMultiEndpoints(b *testing.B) { // Does not actually send requests, but locks the bucket and releases it with made-up headers func sendBenchReq(endpoint string, rl *RateLimiter) { - bucket := rl.LockBucket(endpoint) + bucket, id := rl.LockBucket(endpoint) headers := http.Header(make(map[string][]string)) @@ -112,5 +112,5 @@ func sendBenchReq(endpoint string, rl *RateLimiter) { time.Sleep(time.Millisecond * 100) - bucket.Release(headers) + bucket.Release(headers, id) } diff --git a/lib/discordgo/restapi.go b/lib/discordgo/restapi.go index da526a401d..699df04b5b 100644 --- a/lib/discordgo/restapi.go +++ b/lib/discordgo/restapi.go @@ -208,8 +208,13 @@ func (s *Session) doRequest(method, urlStr, contentType string, b []byte, header } func (s *Session) innerDoRequest(method, urlStr, contentType string, b []byte, headers map[string]string, bucket *Bucket) (*http.Request, *http.Response, error) { - // Wait for any rate limits before proceeding (does not hold lock after returning) - s.Ratelimiter.LockBucketObject(bucket) + bucketLockID := s.Ratelimiter.LockBucketObject(bucket) + defer func() { + err := bucket.Release(nil, bucketLockID) + if err != nil { + s.log(LogError, "failed unlocking ratelimit bucket: %v", err) + } + }() if s.Debug { log.Printf("API REQUEST %8s :: %s\n", method, urlStr) @@ -255,24 +260,12 @@ func (s *Session) innerDoRequest(method, urlStr, contentType string, b []byte, h } } - // Make the HTTP request (no lock held - prevents deadlock if request hangs) resp, err := s.Client.Do(req) - if err != nil { - // Log timeout errors specifically for debugging - if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { - s.log(LogWarning, "HTTP request timeout (30s) for %s %s: %v", method, urlStr, err) - } - // Still need to update rate limit state even on error (for concurrent request tracking) - bucket.Release(nil) - return req, nil, err - } - - // Update rate limit state from response headers - if releaseErr := bucket.Release(resp.Header); releaseErr != nil { - s.log(LogError, "failed updating ratelimit bucket: %v", releaseErr) + if err == nil { + err = bucket.Release(resp.Header, bucketLockID) } - return req, resp, nil + return req, resp, err } func unmarshal(data []byte, v interface{}) error { From 63334a23a494a993177ac9fe09476bf6a6c83f61 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:34:45 +0530 Subject: [PATCH 85/92] add a guildscope cooldown on clean for 120 seconds --- moderation/commands.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/moderation/commands.go b/moderation/commands.go index 864b342dd8..e7f3d0a5be 100644 --- a/moderation/commands.go +++ b/moderation/commands.go @@ -656,13 +656,14 @@ var ModerationCommands = []*commands.YAGCommand{ }, }, { - CustomEnabled: true, - CmdCategory: commands.CategoryModeration, - Name: "Clean", - Description: "Delete the last number of messages from chat, optionally filtering by user, max age and regex or ignoring pinned messages.", - LongDescription: "Specify a regex with \"-r regex_here\" and max age with \"-ma 1h10m\"\nYou can invert the regex match (i.e. only clear messages that do not match the given regex) by supplying the `-im` flag\nNote: Will only look in the last 1k messages, and none > 2 weeks old.", - Aliases: []string{"clear", "cl"}, - RequiredArgs: 1, + CustomEnabled: true, + CmdCategory: commands.CategoryModeration, + Name: "Clean", + GuildScopeCooldown: 120, + Description: "Delete the last number of messages from chat, optionally filtering by user, max age and regex or ignoring pinned messages.", + LongDescription: "Specify a regex with \"-r regex_here\" and max age with \"-ma 1h10m\"\nYou can invert the regex match (i.e. only clear messages that do not match the given regex) by supplying the `-im` flag\nNote: Will only look in the last 1k messages, and none > 2 weeks old.", + Aliases: []string{"clear", "cl"}, + RequiredArgs: 1, Arguments: []*dcmd.ArgDef{ {Name: "Num", Type: &dcmd.IntArg{Min: 1, Max: 100}}, {Name: "User", Type: dcmd.UserID, Default: 0}, From 91a925d6ad2376f0178066d61b2c26ce6b99cce8 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Thu, 29 Jan 2026 01:25:05 +0530 Subject: [PATCH 86/92] disable pin related features --- common/templates/context_funcs.go | 80 ++++++++++++++++--------------- moderation/commands.go | 12 +++-- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/common/templates/context_funcs.go b/common/templates/context_funcs.go index 681bfcebdb..67666690b8 100644 --- a/common/templates/context_funcs.go +++ b/common/templates/context_funcs.go @@ -22,9 +22,10 @@ import ( ) var ( - ErrTooManyCalls = errors.New("too many calls to this function") - ErrTooManyAPICalls = errors.New("too many potential Discord API calls") - ErrRegexCacheLimit = errors.New("too many unique regular expressions (regex)") + ErrTooManyCalls = errors.New("too many calls to this function") + ErrTooManyAPICalls = errors.New("too many potential Discord API calls") + ErrFuncRemovedTemporarily = errors.New("this function is removed temporarily") + ErrRegexCacheLimit = errors.New("too many unique regular expressions (regex)") ) func (c *Context) tmplSendDM(s ...interface{}) string { @@ -598,22 +599,23 @@ func (c *Context) tmplEditComponentsMessage(filterSpecialMentions bool) func(cha func (c *Context) tmplPinMessage(unpin bool) func(channel, msgID interface{}) (string, error) { return func(channel, msgID interface{}) (string, error) { - if c.IncreaseCheckCallCounter("message_pins", 5) { - return "", ErrTooManyCalls - } - - cID := c.ChannelArgNoDM(channel) - if cID == 0 { - return "", errors.New("unknown channel") - } - mID := ToInt64(msgID) - var err error - if unpin { - err = common.BotSession.ChannelMessageUnpin(cID, mID) - } else { - err = common.BotSession.ChannelMessagePin(cID, mID) - } - return "", err + return "", ErrFuncRemovedTemporarily + // if c.IncreaseCheckCallCounter("message_pins", 5) { + // return "", ErrTooManyCalls + // } + + // cID := c.ChannelArgNoDM(channel) + // if cID == 0 { + // return "", errors.New("unknown channel") + // } + // mID := ToInt64(msgID) + // var err error + // if unpin { + // err = common.BotSession.ChannelMessageUnpin(cID, mID) + // } else { + // err = common.BotSession.ChannelMessagePin(cID, mID) + // } + // return "", err } } @@ -1703,30 +1705,32 @@ func (c *Context) tmplGetChannelOrThread(channel interface{}) (*CtxChannel, erro func (c *Context) tmplGetChannelPins(pinCount bool) func(channel interface{}) (interface{}, error) { return func(channel interface{}) (interface{}, error) { - if c.IncreaseCheckCallCounterPremium("channel_pins", 2, 4) { - return 0, ErrTooManyCalls - } + return 0, ErrFuncRemovedTemporarily - cID := c.ChannelArgNoDM(channel) - if cID == 0 { - return 0, errors.New("unknown channel") - } + // if c.IncreaseCheckCallCounterPremium("channel_pins", 2, 4) { + // return 0, ErrTooManyCalls + // } - msg, err := common.BotSession.ChannelMessagesPinned(cID) - if err != nil { - return 0, err - } + // cID := c.ChannelArgNoDM(channel) + // if cID == 0 { + // return 0, errors.New("unknown channel") + // } - if pinCount { - return len(msg), nil - } + // msg, err := common.BotSession.ChannelMessagesPinned(cID) + // if err != nil { + // return 0, err + // } - pinnedMessages := make([]discordgo.Message, 0, len(msg)) - for _, m := range msg { - pinnedMessages = append(pinnedMessages, *m) - } + // if pinCount { + // return len(msg), nil + // } + + // pinnedMessages := make([]discordgo.Message, 0, len(msg)) + // for _, m := range msg { + // pinnedMessages = append(pinnedMessages, *m) + // } - return pinnedMessages, nil + // return pinnedMessages, nil } } diff --git a/moderation/commands.go b/moderation/commands.go index e7f3d0a5be..6591131656 100644 --- a/moderation/commands.go +++ b/moderation/commands.go @@ -733,11 +733,13 @@ var ModerationCommands = []*commands.YAGCommand{ } if parsed.Switches["nopin"].Bool() { - pinned, err := common.BotSession.ChannelMessagesPinned(parsed.ChannelID) - if err != nil { - return "Failed fetching pinned messages", err - } - filters = append(filters, NewIgnorePinnedMessagesFilter(pinned)) + return "nopin flag has been disabled due to discord api changes.", nil + + // pinned, err := common.BotSession.ChannelMessagesPinned(parsed.ChannelID) + // if err != nil { + // return "Failed fetching pinned messages", err + // } + // filters = append(filters, NewIgnorePinnedMessagesFilter(pinned)) } if onlyDeleteWithAttachments := parsed.Switches["a"].Bool(); onlyDeleteWithAttachments { From 5395ef2505246dfcdfe5c664502b6dec16b5c73c Mon Sep 17 00:00:00 2001 From: Galen CC Date: Fri, 30 Jan 2026 14:53:30 -0500 Subject: [PATCH 87/92] Restore clean's -nopin flag (#2020) * moderation: restore clean command's -nopin flag Add the `"pinned"` message field to dstate.MessageState rather than fetching the channel's pinned messages for filtering Also clean up some cruft while we're here Signed-off-by: Galen CC * dstate tracker: update all message fields in handleMessageUpdate MESSAGE_UPDATE events send the same fields as MESSAGE_CREATE, so we can reuse dstate.MessageStateFromDgo rather than duplicating logic in handleMessageUpdate Signed-off-by: Galen CC * dstate: return earlier in handleMessageUpdate Signed-off-by: Galen CC --------- Signed-off-by: Galen CC --- bot/history.go | 20 ------------- lib/dstate/helpers.go | 1 + lib/dstate/inmemorytracker/tracker.go | 41 ++------------------------- lib/dstate/interface.go | 1 + moderation/commands.go | 27 +++--------------- 5 files changed, 8 insertions(+), 82 deletions(-) diff --git a/bot/history.go b/bot/history.go index c1d93bf0ae..3ca43ccb13 100644 --- a/bot/history.go +++ b/bot/history.go @@ -49,9 +49,7 @@ func GetMessages(guildID int64, channelID int64, limit int, deleted bool) ([]*ds // Copy over to buffer for _, m := range msgs { ms := dstate.MessageStateFromDgo(m) - // ms := dstate.MessageStateFromMessage(m) msgBuf = append(msgBuf, ms) - // msgBuf[nRemaining-k] = ms } // Oldest message is last @@ -65,21 +63,3 @@ func GetMessages(guildID int64, channelID int64, limit int, deleted bool) ([]*ds return msgBuf, nil } - -// type DiscordMessages []*dstate.MessageState - -// // Len is the number of elements in the collection. -// func (d DiscordMessages) Len() int { return len(d) } - -// // Less reports whether the element with -// // index i should sort before the element with index j. -// func (d DiscordMessages) Less(i, j int) bool { -// return d[i].ParsedCreated.Before(d[j].ParsedCreated) -// } - -// Swap swaps the elements with indexes i and j. -// func (d DiscordMessages) Swap(i, j int) { -// temp := d[i] -// d[i] = d[j] -// d[j] = temp -// } diff --git a/lib/dstate/helpers.go b/lib/dstate/helpers.go index d0ddc16511..de1e2db074 100644 --- a/lib/dstate/helpers.go +++ b/lib/dstate/helpers.go @@ -93,6 +93,7 @@ func MessageStateFromDgo(m *discordgo.Message) *MessageState { MentionRoles: m.MentionRoles, ParsedCreatedAt: parsedC, ParsedEditedAt: parsedE, + Pinned: m.Pinned, RoleSubscriptionData: m.RoleSubscriptionData, } if m.Reference() != nil { diff --git a/lib/dstate/inmemorytracker/tracker.go b/lib/dstate/inmemorytracker/tracker.go index 4daf2e828b..ca17af4b3a 100644 --- a/lib/dstate/inmemorytracker/tracker.go +++ b/lib/dstate/inmemorytracker/tracker.go @@ -291,7 +291,7 @@ func (shard *ShardTracker) handleGuildCreate(gc *discordgo.GuildCreate) { emojis[i] = *gc.Emojis[i] } - stickers := make([]discordgo.Sticker,len(gc.Stickers)) + stickers := make([]discordgo.Sticker, len(gc.Stickers)) for i := range gc.Stickers { stickers[i] = *gc.Stickers[i] } @@ -675,47 +675,10 @@ func (shard *ShardTracker) handleMessageUpdate(m *discordgo.MessageUpdate) { if cl, ok := shard.channelMessages[m.ChannelID]; ok { for e := cl.Back(); e != nil; e = e.Prev() { - // do something with e.Value cast := e.Value.(*dstate.MessageState) if cast.ID == m.ID { - // Update the message - cop := *cast - - if m.Content != "" { - cop.Content = m.Content - } - - if m.Mentions != nil { - cop.Mentions = make([]discordgo.User, len(m.Mentions)) - for i, v := range m.Mentions { - cop.Mentions[i] = *v - } - } - if m.Embeds != nil { - cop.Embeds = make([]discordgo.MessageEmbed, len(m.Embeds)) - for i, v := range m.Embeds { - cop.Embeds[i] = *v - } - } - - if m.Attachments != nil { - cop.Attachments = make([]discordgo.MessageAttachment, len(m.Attachments)) - for i, v := range m.Attachments { - cop.Attachments[i] = *v - } - } - - if m.Author != nil { - cop.Author = *m.Author - } - - if m.MentionRoles != nil { - cop.MentionRoles = m.MentionRoles - } - - e.Value = &cop + e.Value = dstate.MessageStateFromDgo(m.Message) return - // m.parseTimes(msg.Timestamp, msg.EditedTimestamp) } } } diff --git a/lib/dstate/interface.go b/lib/dstate/interface.go index 0d9985e21f..ccc0e25e8f 100644 --- a/lib/dstate/interface.go +++ b/lib/dstate/interface.go @@ -422,6 +422,7 @@ type MessageState struct { ParsedEditedAt time.Time Deleted bool + Pinned bool RoleSubscriptionData *discordgo.RoleSubscriptionData } diff --git a/moderation/commands.go b/moderation/commands.go index 6591131656..1925e8021c 100644 --- a/moderation/commands.go +++ b/moderation/commands.go @@ -733,16 +733,10 @@ var ModerationCommands = []*commands.YAGCommand{ } if parsed.Switches["nopin"].Bool() { - return "nopin flag has been disabled due to discord api changes.", nil - - // pinned, err := common.BotSession.ChannelMessagesPinned(parsed.ChannelID) - // if err != nil { - // return "Failed fetching pinned messages", err - // } - // filters = append(filters, NewIgnorePinnedMessagesFilter(pinned)) + filters = append(filters, &IgnorePinnedMessagesFilter{}) } - if onlyDeleteWithAttachments := parsed.Switches["a"].Bool(); onlyDeleteWithAttachments { + if parsed.Switches["a"].Bool() { filters = append(filters, &MessagesWithAttachmentsFilter{}) } @@ -1387,23 +1381,10 @@ func (f *MessageAgeFilter) Matches(msg *dstate.MessageState) (delete bool) { } // Do not delete pinned messages. -type IgnorePinnedMessagesFilter struct { - PinnedMsgIDs map[int64]struct{} -} - -func NewIgnorePinnedMessagesFilter(pinned []*discordgo.Message) *IgnorePinnedMessagesFilter { - ids := make(map[int64]struct{}) - for _, msg := range pinned { - ids[msg.ID] = struct{}{} - } - return &IgnorePinnedMessagesFilter{ids} -} +type IgnorePinnedMessagesFilter struct{} func (f *IgnorePinnedMessagesFilter) Matches(msg *dstate.MessageState) (delete bool) { - if _, pinned := f.PinnedMsgIDs[msg.ID]; pinned { - return false - } - return true + return !msg.Pinned } // Only delete messages with attachments. From d483d08f29a3f1dba06c37e131fd5ea988ad519f Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Sat, 31 Jan 2026 12:50:39 +0530 Subject: [PATCH 88/92] migrated to new pin endpoints (#2021) * migrated to new pin endpoints * reduce pin list/count usage limit. --- common/templates/context_funcs.go | 80 ++++++++++++++++--------------- lib/discordgo/endpoints.go | 8 ++-- lib/discordgo/message.go | 11 +++++ lib/discordgo/permission.go | 6 +++ lib/discordgo/permission_name.go | 6 +++ lib/discordgo/restapi.go | 18 ++++++- 6 files changed, 84 insertions(+), 45 deletions(-) diff --git a/common/templates/context_funcs.go b/common/templates/context_funcs.go index 67666690b8..1b0f2ccc1b 100644 --- a/common/templates/context_funcs.go +++ b/common/templates/context_funcs.go @@ -599,23 +599,22 @@ func (c *Context) tmplEditComponentsMessage(filterSpecialMentions bool) func(cha func (c *Context) tmplPinMessage(unpin bool) func(channel, msgID interface{}) (string, error) { return func(channel, msgID interface{}) (string, error) { - return "", ErrFuncRemovedTemporarily - // if c.IncreaseCheckCallCounter("message_pins", 5) { - // return "", ErrTooManyCalls - // } - - // cID := c.ChannelArgNoDM(channel) - // if cID == 0 { - // return "", errors.New("unknown channel") - // } - // mID := ToInt64(msgID) - // var err error - // if unpin { - // err = common.BotSession.ChannelMessageUnpin(cID, mID) - // } else { - // err = common.BotSession.ChannelMessagePin(cID, mID) - // } - // return "", err + if c.IncreaseCheckCallCounter("message_pins", 2) { + return "", ErrTooManyCalls + } + + cID := c.ChannelArgNoDM(channel) + if cID == 0 { + return "", errors.New("unknown channel") + } + mID := ToInt64(msgID) + var err error + if unpin { + err = common.BotSession.ChannelMessageUnpin(cID, mID) + } else { + err = common.BotSession.ChannelMessagePin(cID, mID) + } + return "", err } } @@ -1705,32 +1704,35 @@ func (c *Context) tmplGetChannelOrThread(channel interface{}) (*CtxChannel, erro func (c *Context) tmplGetChannelPins(pinCount bool) func(channel interface{}) (interface{}, error) { return func(channel interface{}) (interface{}, error) { - return 0, ErrFuncRemovedTemporarily - - // if c.IncreaseCheckCallCounterPremium("channel_pins", 2, 4) { - // return 0, ErrTooManyCalls - // } - - // cID := c.ChannelArgNoDM(channel) - // if cID == 0 { - // return 0, errors.New("unknown channel") - // } + if c.IncreaseCheckCallCounterPremium("channel_pins", 1, 2) { + return 0, ErrTooManyCalls + } - // msg, err := common.BotSession.ChannelMessagesPinned(cID) - // if err != nil { - // return 0, err - // } + cID := c.ChannelArgNoDM(channel) + if cID == 0 { + return 0, errors.New("unknown channel") + } - // if pinCount { - // return len(msg), nil - // } + hasMore := true + var before *time.Time + msgs := make([]discordgo.Message, 0) + for hasMore { + pinned, err := common.BotSession.ChannelMessagesPinned(cID, 50, before) + if err != nil { + return 0, err + } + hasMore = pinned.HasMore + for _, item := range pinned.Items { + msgs = append(msgs, *item.Message) + } + before = &pinned.Items[len(pinned.Items)-1].PinnedAt + } - // pinnedMessages := make([]discordgo.Message, 0, len(msg)) - // for _, m := range msg { - // pinnedMessages = append(pinnedMessages, *m) - // } + if pinCount { + return len(msgs), nil + } - // return pinnedMessages, nil + return msgs, nil } } diff --git a/lib/discordgo/endpoints.go b/lib/discordgo/endpoints.go index bd50a37ccf..ae15a37bc1 100644 --- a/lib/discordgo/endpoints.go +++ b/lib/discordgo/endpoints.go @@ -349,10 +349,10 @@ func CreateEndpoints(base string) { EndpointChannelMessages = func(cID int64) string { return EndpointChannels + StrID(cID) + "/messages" } EndpointChannelMessage = func(cID, mID int64) string { return EndpointChannels + StrID(cID) + "/messages/" + StrID(mID) } EndpointChannelMessageAck = func(cID, mID int64) string { return EndpointChannels + StrID(cID) + "/messages/" + StrID(mID) + "/ack" } - EndpointChannelMessagesBulkDelete = func(cID int64) string { return EndpointChannel(cID) + "/messages/bulk-delete" } - EndpointChannelMessagesPins = func(cID int64) string { return EndpointChannel(cID) + "/pins" } - EndpointChannelMessagePin = func(cID, mID int64) string { return EndpointChannel(cID) + "/pins/" + StrID(mID) } - EndpointChannelMessageCrosspost = func(cID, mID int64) string { return EndpointChannel(cID) + "/messages/" + StrID(mID) + "/crosspost" } + EndpointChannelMessagesBulkDelete = func(cID int64) string { return EndpointChannelMessages(cID) + "/bulk-delete" } + EndpointChannelMessagesPins = func(cID int64) string { return EndpointChannelMessages(cID) + "/pins" } + EndpointChannelMessagePin = func(cID, mID int64) string { return EndpointChannelMessagesPins(cID) + "/" + StrID(mID) } + EndpointChannelMessageCrosspost = func(cID, mID int64) string { return EndpointChannelMessages(cID) + StrID(mID) + "/crosspost" } EndpointChannelMessageThread = func(cID, mID int64) string { return EndpointChannelMessage(cID, mID) + "/threads" } EndpointThreadMembers = func(tID int64) string { return EndpointChannel(tID) + "/thread-members" } EndpointThreadMember = func(tID int64, mID string) string { return EndpointThreadMembers(tID) + "/" + mID } diff --git a/lib/discordgo/message.go b/lib/discordgo/message.go index f4012be5ce..b5f98bf95a 100644 --- a/lib/discordgo/message.go +++ b/lib/discordgo/message.go @@ -17,6 +17,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/sirupsen/logrus" ) @@ -319,6 +320,16 @@ type File struct { Reader io.Reader } +type PinnedMessage struct { + Message *Message `json:"message"` + PinnedAt time.Time `json:"pinned_at"` +} + +type PinnedItems struct { + HasMore bool `json:"has_more"` + Items []*PinnedMessage `json:"items"` +} + // MessageSend stores all parameters you can send with ChannelMessageSendComplex. type MessageSend struct { Content string `json:"content,omitempty"` diff --git a/lib/discordgo/permission.go b/lib/discordgo/permission.go index a863e33f90..a0b652cc4b 100644 --- a/lib/discordgo/permission.go +++ b/lib/discordgo/permission.go @@ -49,6 +49,9 @@ const ( PermissionUseExternalSounds int64 = 1 << 45 // Allows the usage of custom soundboard sounds from other servers V PermissionSendVoiceMessages int64 = 1 << 46 // Allows sending voice messages T, V, S PermissionSendPolls int64 = 1 << 49 // Allows sending polls T, V, S + PermissionUseExternalApps int64 = 1 << 50 // Allows user-installed apps to send public responses. When disabled, users will still be allowed to use their apps but the responses will be ephemeral. This only applies to apps not also installed to the server. T, V, S + PermissionPinMessages int64 = 1 << 51 // Allows for pinning/unpinning messages in a channel T + PermissionBypassSlowmode int64 = 1 << 52 // Allows bypassing slowmode restrictions T,V,S ) // all bits set except the leftmost to avoid using negative numbers in case discord doesn't handle it @@ -102,6 +105,9 @@ var AllPermissions = []int64{ PermissionManageEvents, PermissionCreateGuildExpressions, PermissionCreateEvents, + PermissionPinMessages, + PermissionBypassSlowmode, + PermissionUseExternalApps, PermissionManageThreads, PermissionUsePublicThreads, diff --git a/lib/discordgo/permission_name.go b/lib/discordgo/permission_name.go index 0a006f2d5a..b8e054637e 100644 --- a/lib/discordgo/permission_name.go +++ b/lib/discordgo/permission_name.go @@ -89,6 +89,12 @@ func PermissionName(p int64) string { return "CreateGuildExpressions" case PermissionCreateEvents: return "CreateEvents" + case PermissionPinMessages: + return "PinMessages" + case PermissionBypassSlowmode: + return "BypassSlowmode" + case PermissionUseExternalApps: + return "UseExternalApps" case PermissionManageThreads: return "ManageThreads" case PermissionUsePublicThreads: diff --git a/lib/discordgo/restapi.go b/lib/discordgo/restapi.go index 699df04b5b..3189780552 100644 --- a/lib/discordgo/restapi.go +++ b/lib/discordgo/restapi.go @@ -1918,9 +1918,23 @@ func (s *Session) ChannelMessageUnpin(channelID, messageID int64) (err error) { // ChannelMessagesPinned returns an array of Message structures for pinned messages // within a given channel // channelID : The ID of a Channel. -func (s *Session) ChannelMessagesPinned(channelID int64) (st []*Message, err error) { +func (s *Session) ChannelMessagesPinned(channelID int64, limit int, before *time.Time) (st *PinnedItems, err error) { - body, err := s.RequestWithBucketID("GET", EndpointChannelMessagesPins(channelID), nil, nil, EndpointChannelMessagesPins(channelID)) + uri := EndpointChannelMessagesPins(channelID) + queryParams := url.Values{} + if before != nil && before.Unix() > 0 { + queryParams.Set("before", before.Format(time.RFC3339)) + } + if limit > 50 { + limit = 50 + } + if limit > 0 { + queryParams.Set("limit", strconv.Itoa(limit)) + } + + uri += "?" + queryParams.Encode() + + body, err := s.RequestWithBucketID("GET", uri, nil, nil, EndpointChannelMessagesPins(channelID)) if err != nil { return From e5c26bf3e959bfa599783ef35d350352eb4ebc42 Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Sat, 31 Jan 2026 13:10:29 +0530 Subject: [PATCH 89/92] reduce cooldown on clean command, and make cooldown error readable --- commands/tmplexec.go | 2 +- commands/yagcommmand.go | 3 ++- moderation/commands.go | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/commands/tmplexec.go b/commands/tmplexec.go index a12dd4f025..3efd955b5d 100644 --- a/commands/tmplexec.go +++ b/commands/tmplexec.go @@ -189,7 +189,7 @@ func execCmd(tmplCtx *templates.Context, dryRun bool, m *discordgo.MessageCreate } if cd > 0 { - return "", errors.NewPlain("this command is on guild scope cooldown") + return "", errors.NewPlain("This command is on cooldown, try again in " + strconv.Itoa(cd) + "seconds") } resp, err := runFunc(data) diff --git a/commands/yagcommmand.go b/commands/yagcommmand.go index 715d46ab85..9221e789b0 100644 --- a/commands/yagcommmand.go +++ b/commands/yagcommmand.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "strconv" "strings" "sync" "sync/atomic" @@ -496,7 +497,7 @@ func (yc *YAGCommand) checkCanExecuteCommand(data *dcmd.Data) (canExecute bool, if cdLeft > 0 { resp = &CanExecuteError{ Type: ReasonCooldown, - Message: "Command is on cooldown", + Message: "Command is on cooldown, try again in " + strconv.Itoa(cdLeft) + " seconds", } return false, resp, settings, nil } diff --git a/moderation/commands.go b/moderation/commands.go index 1925e8021c..69d25ba474 100644 --- a/moderation/commands.go +++ b/moderation/commands.go @@ -659,7 +659,7 @@ var ModerationCommands = []*commands.YAGCommand{ CustomEnabled: true, CmdCategory: commands.CategoryModeration, Name: "Clean", - GuildScopeCooldown: 120, + GuildScopeCooldown: 10, Description: "Delete the last number of messages from chat, optionally filtering by user, max age and regex or ignoring pinned messages.", LongDescription: "Specify a regex with \"-r regex_here\" and max age with \"-ma 1h10m\"\nYou can invert the regex match (i.e. only clear messages that do not match the given regex) by supplying the `-im` flag\nNote: Will only look in the last 1k messages, and none > 2 weeks old.", Aliases: []string{"clear", "cl"}, From 57bdfa73701084cedb23366275fad8065014ca90 Mon Sep 17 00:00:00 2001 From: Galen CC Date: Mon, 2 Feb 2026 11:03:55 -0500 Subject: [PATCH 90/92] discordgo: fix EndpointChannelMessageCrosspost (#2023) * discordgo: fix EndpointChannelMessageCrosspost Signed-off-by: Galen CC * discordgo: use EndpointChannelMessage in EndpointChannelMessageCrosspost Signed-off-by: Galen CC --------- Signed-off-by: Galen CC --- lib/discordgo/endpoints.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/discordgo/endpoints.go b/lib/discordgo/endpoints.go index ae15a37bc1..59d816f48a 100644 --- a/lib/discordgo/endpoints.go +++ b/lib/discordgo/endpoints.go @@ -352,7 +352,7 @@ func CreateEndpoints(base string) { EndpointChannelMessagesBulkDelete = func(cID int64) string { return EndpointChannelMessages(cID) + "/bulk-delete" } EndpointChannelMessagesPins = func(cID int64) string { return EndpointChannelMessages(cID) + "/pins" } EndpointChannelMessagePin = func(cID, mID int64) string { return EndpointChannelMessagesPins(cID) + "/" + StrID(mID) } - EndpointChannelMessageCrosspost = func(cID, mID int64) string { return EndpointChannelMessages(cID) + StrID(mID) + "/crosspost" } + EndpointChannelMessageCrosspost = func(cID, mID int64) string { return EndpointChannelMessage(cID, mID) + "/crosspost" } EndpointChannelMessageThread = func(cID, mID int64) string { return EndpointChannelMessage(cID, mID) + "/threads" } EndpointThreadMembers = func(tID int64) string { return EndpointChannel(tID) + "/thread-members" } EndpointThreadMember = func(tID int64, mID string) string { return EndpointThreadMembers(tID) + "/" + mID } From 028772adcb2255d1c0471ed3780e257255cc4787 Mon Sep 17 00:00:00 2001 From: Ashish <51633862+ashishjh-bst@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:34:15 +0530 Subject: [PATCH 91/92] Fixed error response in case of ticket being closed with an interaction (#2022) --- tickets/tickets_bot.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tickets/tickets_bot.go b/tickets/tickets_bot.go index 8a8a24dd86..58d419897f 100644 --- a/tickets/tickets_bot.go +++ b/tickets/tickets_bot.go @@ -506,14 +506,15 @@ func (p *Plugin) handleInteractionCreate(evt *eventsystem.EventData) (retry bool case discordgo.InteractionModalSubmit: response, err = handleModal(evt, ic, ic.Member, conf, currentChannel) } - if response != nil { - if response.Data.Content == "" && len(response.Data.Components) == 0 { - response = errorResponse - } - } else { + + if err != nil { response = errorResponse } + if response.Data.Content == "" && len(response.Data.Components) == 0 { + return false, nil + } + respErr := common.BotSession.CreateInteractionResponse(ic.ID, ic.Token, response) if respErr != nil { // try again as a followup, if that still fails, return the original error @@ -525,7 +526,8 @@ func (p *Plugin) handleInteractionCreate(evt *eventsystem.EventData) (retry bool return bot.CheckDiscordErrRetry(respErr), respErr } } - return false, err + + return false, nil } func (p *Plugin) OnRemovedPremiumGuild(guildID int64) error { From 25cf2235affe80e5e870774ef27cfa7e19af359d Mon Sep 17 00:00:00 2001 From: ashishjh-bst <51633862+ashishjh-bst@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:27:46 +0530 Subject: [PATCH 92/92] fix error for tmplGetChannelPins when channel has no pins --- common/templates/context_funcs.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/templates/context_funcs.go b/common/templates/context_funcs.go index 1b0f2ccc1b..8800c2067f 100644 --- a/common/templates/context_funcs.go +++ b/common/templates/context_funcs.go @@ -1722,10 +1722,12 @@ func (c *Context) tmplGetChannelPins(pinCount bool) func(channel interface{}) (i return 0, err } hasMore = pinned.HasMore + if hasMore && len(pinned.Items) > 0 { + before = &pinned.Items[len(pinned.Items)-1].PinnedAt + } for _, item := range pinned.Items { msgs = append(msgs, *item.Message) } - before = &pinned.Items[len(pinned.Items)-1].PinnedAt } if pinCount {