diff --git a/EsefexApi/api/api.go b/EsefexApi/api/api.go index a0d1cff..c766e9c 100644 --- a/EsefexApi/api/api.go +++ b/EsefexApi/api/api.go @@ -4,6 +4,7 @@ import ( "esefexapi/api/middleware" "esefexapi/api/routes" "esefexapi/audioplayer" + "esefexapi/clientnotifiy" "esefexapi/db" "esefexapi/service" @@ -22,19 +23,21 @@ type HttpApi struct { handlers *routes.RouteHandlers mw *middleware.Middleware a audioplayer.IAudioPlayer - apiPort int + port int cProto string + domain string stop chan struct{} ready chan struct{} } -func NewHttpApi(dbs *db.Databases, plr audioplayer.IAudioPlayer, ds *discordgo.Session, apiPort int, cProto string) *HttpApi { +func NewHttpApi(dbs *db.Databases, plr audioplayer.IAudioPlayer, ds *discordgo.Session, apiPort int, cProto string, wsCN *clientnotifiy.WsClientNotifier, domain string) *HttpApi { return &HttpApi{ - handlers: routes.NewRouteHandlers(dbs, plr, ds, cProto), + handlers: routes.NewRouteHandlers(dbs, plr, ds, cProto, wsCN), mw: middleware.NewMiddleware(dbs, ds), a: plr, - apiPort: apiPort, + port: apiPort, cProto: cProto, + domain: domain, stop: make(chan struct{}, 1), ready: make(chan struct{}), } @@ -67,10 +70,12 @@ func (api *HttpApi) run() { router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./api/public/")))) - log.Printf("Webserver started on port %d (http://localhost:%d)\n", api.apiPort, api.apiPort) + router.Handle("/api/ws", cors(auth(h.GetWs()))).Methods("GET") + + log.Printf("Webserver started on port %d (%s)\n", api.port, api.domain) // nolint:errcheck - go http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", api.apiPort), router) + go http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", api.port), router) close(api.ready) <-api.stop diff --git a/EsefexApi/api/middleware/auth.go b/EsefexApi/api/middleware/auth.go index 78302c4..fca4570 100644 --- a/EsefexApi/api/middleware/auth.go +++ b/EsefexApi/api/middleware/auth.go @@ -29,8 +29,10 @@ func (m *Middleware) Auth(next http.Handler) http.Handler { return } + userID := Ouser.Unwrap().ID + // Inject the user into the request context - ctx := context.WithValue(r.Context(), "user", Ouser) + ctx := context.WithValue(r.Context(), "user", userID) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/EsefexApi/api/public/simpleui/index.html b/EsefexApi/api/public/simpleui/index.html index f05ac27..2e23b7e 100644 --- a/EsefexApi/api/public/simpleui/index.html +++ b/EsefexApi/api/public/simpleui/index.html @@ -4,7 +4,7 @@ Esefex Simple UI - + diff --git a/EsefexApi/api/public/simpleui/index.js b/EsefexApi/api/public/simpleui/index.js index f205124..ca853ce 100644 --- a/EsefexApi/api/public/simpleui/index.js +++ b/EsefexApi/api/public/simpleui/index.js @@ -1,4 +1,18 @@ async function init() { + // create a websocket connection to the server + let socket = new WebSocket(`ws://${window.location.host}/api/ws`); + socket.onopen = () => { + console.log('websocket connection established'); + }; + socket.addEventListener('message', async (event) => { + if (event.data != 'update') { + return; + } + + // reload the page + window.location.reload(); + }); + const soundsDiv = document.getElementById('sounds'); let guildRequest = await fetch('/api/guild', { diff --git a/EsefexApi/api/routes/getws.go b/EsefexApi/api/routes/getws.go new file mode 100644 index 0000000..20fb3d6 --- /dev/null +++ b/EsefexApi/api/routes/getws.go @@ -0,0 +1,29 @@ +package routes + +import ( + "esefexapi/types" + "log" + "net/http" + + "github.com/gorilla/websocket" +) + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// api/ws +func (h *RouteHandlers) GetWs() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := r.Context().Value("user").(types.UserID) + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Error upgrading websocket: %v", err) + return + } + + h.wsCN.AddConnection(userID, conn) + }) +} diff --git a/EsefexApi/api/routes/routes.go b/EsefexApi/api/routes/routes.go index a3dafdd..8ab9627 100644 --- a/EsefexApi/api/routes/routes.go +++ b/EsefexApi/api/routes/routes.go @@ -2,6 +2,7 @@ package routes import ( "esefexapi/audioplayer" + "esefexapi/clientnotifiy" "esefexapi/db" "github.com/bwmarrin/discordgo" @@ -11,14 +12,16 @@ type RouteHandlers struct { dbs *db.Databases a audioplayer.IAudioPlayer ds *discordgo.Session + wsCN *clientnotifiy.WsClientNotifier cProto string } -func NewRouteHandlers(dbs *db.Databases, a audioplayer.IAudioPlayer, ds *discordgo.Session, cProto string) *RouteHandlers { +func NewRouteHandlers(dbs *db.Databases, a audioplayer.IAudioPlayer, ds *discordgo.Session, cProto string, wsCN *clientnotifiy.WsClientNotifier) *RouteHandlers { return &RouteHandlers{ a: a, dbs: dbs, ds: ds, cProto: cProto, + wsCN: wsCN, } } diff --git a/EsefexApi/bot/bot.go b/EsefexApi/bot/bot.go index 5dbef1a..b8b158b 100644 --- a/EsefexApi/bot/bot.go +++ b/EsefexApi/bot/bot.go @@ -2,6 +2,7 @@ package bot import ( "esefexapi/bot/commands" + "esefexapi/clientnotifiy" "esefexapi/db" "esefexapi/service" @@ -16,14 +17,16 @@ var _ service.IService = &DiscordBot{} type DiscordBot struct { ds *discordgo.Session cmdh *commands.CommandHandlers + cn clientnotifiy.IClientNotifier stop chan struct{} ready chan struct{} } -func NewDiscordBot(ds *discordgo.Session, dbs *db.Databases, domain string) *DiscordBot { +func NewDiscordBot(ds *discordgo.Session, dbs *db.Databases, domain string, cn clientnotifiy.IClientNotifier) *DiscordBot { return &DiscordBot{ ds: ds, - cmdh: commands.NewCommandHandlers(ds, dbs, domain), + cmdh: commands.NewCommandHandlers(ds, dbs, domain, cn), + cn: cn, stop: make(chan struct{}, 1), ready: make(chan struct{}), } @@ -40,6 +43,7 @@ func (b *DiscordBot) run() { ready := b.WaitReady() b.cmdh.RegisterComandHandlers() + b.RegisterClientUpdateHandlers() err := ds.Open() if err != nil { diff --git a/EsefexApi/bot/clientupdate.go b/EsefexApi/bot/clientupdate.go new file mode 100644 index 0000000..29c0516 --- /dev/null +++ b/EsefexApi/bot/clientupdate.go @@ -0,0 +1,27 @@ +package bot + +import ( + "esefexapi/types" + "log" + + "github.com/bwmarrin/discordgo" +) + +func (b *DiscordBot) RegisterClientUpdateHandlers() { + b.ds.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + err := b.cn.UpdateNotificationUsers() + if err != nil { + log.Printf("Error notifying clients: %+v", err) + } + }) + + b.ds.AddHandler(func(s *discordgo.Session, r *discordgo.VoiceStateUpdate) { + userID := types.UserID(r.UserID) + + err := b.cn.UpdateNotificationUsers(userID) + if err != nil { + log.Printf("Error notifying clients: %+v", err) + } + }) + +} diff --git a/EsefexApi/bot/commands/cmdhashstore/cmdhashstore.go b/EsefexApi/bot/commands/cmdhashstore/cmdhashstore.go index 5afeb1b..ea9fd2a 100644 --- a/EsefexApi/bot/commands/cmdhashstore/cmdhashstore.go +++ b/EsefexApi/bot/commands/cmdhashstore/cmdhashstore.go @@ -4,9 +4,11 @@ import ( "esefexapi/util" "io" "os" + + "github.com/pkg/errors" ) -type CommandHashStore interface { +type ICommandHashStore interface { GetCommandHash() (string, error) SetCommandHash(hash string) error } @@ -39,13 +41,13 @@ func (f *FileCmdHashStore) GetCommandHash() (string, error) { func (f *FileCmdHashStore) SetCommandHash(hash string) error { file, err := os.Create(f.FilePath) if err != nil { - return err + return errors.Wrap(err, "error creating file") } defer file.Close() _, err = file.WriteString(hash) if err != nil { - return err + return errors.Wrap(err, "error writing to file") } return nil diff --git a/EsefexApi/bot/commands/commands.go b/EsefexApi/bot/commands/commands.go index 630c845..286ed66 100644 --- a/EsefexApi/bot/commands/commands.go +++ b/EsefexApi/bot/commands/commands.go @@ -5,6 +5,7 @@ import ( "encoding/json" "esefexapi/bot/commands/cmdhandler" "esefexapi/bot/commands/middleware" + "esefexapi/clientnotifiy" "esefexapi/db" "fmt" "log" @@ -29,16 +30,18 @@ type CommandHandlers struct { dbs *db.Databases domain string mw *middleware.CommandMiddleware + cn clientnotifiy.IClientNotifier Commands map[string]*discordgo.ApplicationCommand Handlers map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate) } -func NewCommandHandlers(ds *discordgo.Session, dbs *db.Databases, domain string) *CommandHandlers { +func NewCommandHandlers(ds *discordgo.Session, dbs *db.Databases, domain string, cn clientnotifiy.IClientNotifier) *CommandHandlers { c := &CommandHandlers{ ds: ds, dbs: dbs, domain: domain, mw: middleware.NewCommandMiddleware(dbs), + cn: cn, Commands: map[string]*discordgo.ApplicationCommand{}, Handlers: map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){}, } diff --git a/EsefexApi/bot/commands/sound.go b/EsefexApi/bot/commands/sound.go index 475463a..cd0c900 100644 --- a/EsefexApi/bot/commands/sound.go +++ b/EsefexApi/bot/commands/sound.go @@ -103,6 +103,9 @@ func (c *CommandHandlers) SoundUpload(s *discordgo.Session, i *discordgo.Interac return nil, errors.Wrap(err, "Error adding sound") } + guildID := types.GuildID(i.GuildID) + c.cn.UpdateNotificationGuilds(guildID) + log.Printf("Uploaded sound effect %v to guild %v", uid.SoundID, i.GuildID) return &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, diff --git a/EsefexApi/clientnotifiy/clientnotifiy.go b/EsefexApi/clientnotifiy/clientnotifiy.go new file mode 100644 index 0000000..4d71e49 --- /dev/null +++ b/EsefexApi/clientnotifiy/clientnotifiy.go @@ -0,0 +1,124 @@ +package clientnotifiy + +import ( + "esefexapi/types" + "esefexapi/util/dcgoutil" + + "github.com/bwmarrin/discordgo" + "github.com/gorilla/websocket" + "github.com/pkg/errors" +) + +type IClientNotifier interface { + // UpdateNotificationUsers notifies the clients that some data has been updated + // This should cause the client to refetch the data + // if users is empty, then all clients should be notified + // this function will handle the case where a user does not have any connections + UpdateNotificationUsers(users ...types.UserID) error + UpdateNotificationGuilds(guilds ...types.GuildID) error + UpdateNotificationChannels(channels ...types.ChannelID) error +} + +var _ IClientNotifier = &WsClientNotifier{} + +// implements ClientNotifier +type WsClientNotifier struct { + userConnections map[types.UserID][]*websocket.Conn + ds *discordgo.Session + stop chan struct{} + ready chan struct{} +} + +func NewWsClientNotifier(ds *discordgo.Session) *WsClientNotifier { + return &WsClientNotifier{ + userConnections: make(map[types.UserID][]*websocket.Conn), + ds: ds, + stop: make(chan struct{}), + ready: make(chan struct{}), + } +} + +// UpdateNotificationChannels implements IClientNotifier. +func (w *WsClientNotifier) UpdateNotificationChannels(channels ...types.ChannelID) error { + for _, channel := range channels { + users, err := dcgoutil.ChannelUserIDs(w.ds, channel) + if err != nil { + return errors.Wrap(err, "error getting channel user ids") + } + + err = w.UpdateNotificationUsers(users...) + if err != nil { + return errors.Wrap(err, "error updating notification") + } + } + + return nil +} + +// UpdateNotificationGuilds implements IClientNotifier. +func (w *WsClientNotifier) UpdateNotificationGuilds(guilds ...types.GuildID) error { + for _, guild := range guilds { + users, err := dcgoutil.GuildUserIDs(w.ds, guild) + if err != nil { + return errors.Wrap(err, "error getting channel user ids") + } + + err = w.UpdateNotificationUsers(users...) + if err != nil { + return errors.Wrap(err, "error updating notification") + } + } + + return nil +} + +func (w *WsClientNotifier) UpdateNotificationUsers(users ...types.UserID) error { + if len(users) == 0 { + for k := range w.userConnections { + err := w.writeUpdate(k) + if err != nil { + return errors.Wrap(err, "error writing update") + } + } + return nil + } + + for _, user := range users { + if _, ok := w.userConnections[user]; !ok { + continue + } + + err := w.writeUpdate(user) + if err != nil { + return errors.Wrap(err, "error writing update") + } + } + return nil +} + +func (w *WsClientNotifier) writeUpdate(user types.UserID) error { + var causedError error = nil + + for _, conn := range w.userConnections[user] { + err := conn.WriteMessage(websocket.TextMessage, []byte("update")) + if err != nil { + conn.Close() + w.RemoveConnection(user, conn) + causedError = errors.Wrap(err, "error writing message to websocket, removing connection") + } + } + return causedError +} + +func (w *WsClientNotifier) AddConnection(user types.UserID, conn *websocket.Conn) { + w.userConnections[user] = append(w.userConnections[user], conn) +} + +func (w *WsClientNotifier) RemoveConnection(user types.UserID, conn *websocket.Conn) { + for i, c := range w.userConnections[user] { + if c == conn { + w.userConnections[user] = append(w.userConnections[user][:i], w.userConnections[user][i+1:]...) + break + } + } +} diff --git a/EsefexApi/cmd/testing/websocket/websocket.go b/EsefexApi/cmd/testing/websocket/websocket.go new file mode 100644 index 0000000..964ea02 --- /dev/null +++ b/EsefexApi/cmd/testing/websocket/websocket.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/gorilla/websocket" +) + +func main() { + log.Println("Starting server on port 8080") + + http.HandleFunc("/", index) + http.HandleFunc("/ws", ws) + http.ListenAndServe(":8080", nil) +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +func index(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello World") +} + +func ws(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + panic(err) + } + + for { + _, msg, err := conn.ReadMessage() + if err != nil { + panic(err) + } + conn.WriteMessage(websocket.TextMessage, msg) + } +} diff --git a/EsefexApi/db/db.go b/EsefexApi/db/db.go index ed0b518..fe95c5e 100644 --- a/EsefexApi/db/db.go +++ b/EsefexApi/db/db.go @@ -12,8 +12,8 @@ type Databases struct { SoundDB sounddb.ISoundDB UserDB userdb.IUserDB LinkTokenStore linktokenstore.ILinkTokenStore - PermissionDB permissiondb.PermissionDB - CmdHashStore cmdhashstore.CommandHashStore + PermissionDB permissiondb.IPermissionDB + CmdHashStore cmdhashstore.ICommandHashStore } // func CreateDatabases(cfg *config.Config, ds *discordgo.Session) (*Databases, error) { diff --git a/EsefexApi/go.mod b/EsefexApi/go.mod index 62625c6..c5a779c 100644 --- a/EsefexApi/go.mod +++ b/EsefexApi/go.mod @@ -27,12 +27,13 @@ require ( github.com/rogpeppe/go-internal v1.11.0 // indirect go.mongodb.org/mongo-driver v1.13.1 // indirect golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect + golang.org/x/net v0.17.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/jedib0t/go-pretty v4.3.0+incompatible github.com/jedib0t/go-pretty/v6 v6.5.3 github.com/pkg/errors v0.9.1 diff --git a/EsefexApi/go.sum b/EsefexApi/go.sum index 32564ad..db08e00 100644 --- a/EsefexApi/go.sum +++ b/EsefexApi/go.sum @@ -18,6 +18,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= github.com/jedib0t/go-pretty/v6 v6.5.3 h1:GIXn6Er/anHTkVUoufs7ptEvxdD6KIhR7Axa2wYCPF0= @@ -75,6 +77,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/EsefexApi/main.go b/EsefexApi/main.go index 11ace9c..d15de58 100644 --- a/EsefexApi/main.go +++ b/EsefexApi/main.go @@ -5,6 +5,7 @@ import ( "esefexapi/audioplayer/discordplayer" "esefexapi/bot" "esefexapi/bot/commands/cmdhashstore" + "esefexapi/clientnotifiy" "esefexapi/config" "esefexapi/db" "esefexapi/linktokenstore/memorylinktokenstore" @@ -69,8 +70,10 @@ func main() { botT := time.Duration(cfg.Bot.Timeout * float32(time.Minute)) plr := discordplayer.NewDiscordPlayer(ds, dbs, cfg.Bot.UseTimeouts, botT) - api := api.NewHttpApi(dbs, plr, ds, cfg.HttpApi.Port, cfg.HttpApi.CustomProtocol) - bot := bot.NewDiscordBot(ds, dbs, domain) + wsCN := clientnotifiy.NewWsClientNotifier(ds) + + api := api.NewHttpApi(dbs, plr, ds, cfg.HttpApi.Port, cfg.HttpApi.CustomProtocol, wsCN, domain) + bot := bot.NewDiscordBot(ds, dbs, domain, wsCN) log.Println("Components bootstraped, starting...") diff --git a/EsefexApi/permissiondb/filepermisssiondb/filepermisssiondb.go b/EsefexApi/permissiondb/filepermisssiondb/filepermisssiondb.go index 3d7ca8a..5b781ce 100644 --- a/EsefexApi/permissiondb/filepermisssiondb/filepermisssiondb.go +++ b/EsefexApi/permissiondb/filepermisssiondb/filepermisssiondb.go @@ -14,7 +14,7 @@ import ( "github.com/pkg/errors" ) -var _ permissiondb.PermissionDB = &FilePermissionDB{} +var _ permissiondb.IPermissionDB = &FilePermissionDB{} type FilePermissionDB struct { file *os.File diff --git a/EsefexApi/permissiondb/permissiondb.go b/EsefexApi/permissiondb/permissiondb.go index 2204e5c..902b044 100644 --- a/EsefexApi/permissiondb/permissiondb.go +++ b/EsefexApi/permissiondb/permissiondb.go @@ -5,7 +5,7 @@ import ( "esefexapi/types" ) -type PermissionDB interface { +type IPermissionDB interface { GetUser(guild types.GuildID, userID types.UserID) (permissions.Permissions, error) GetRole(guild types.GuildID, roleID types.RoleID) (permissions.Permissions, error) GetChannel(guild types.GuildID, channelID types.ChannelID) (permissions.Permissions, error) diff --git a/EsefexApi/util/dcgoutil/dcgoutil.go b/EsefexApi/util/dcgoutil/dcgoutil.go index de03b7f..b8a92b2 100644 --- a/EsefexApi/util/dcgoutil/dcgoutil.go +++ b/EsefexApi/util/dcgoutil/dcgoutil.go @@ -167,3 +167,31 @@ func UserIsOwner(ds *discordgo.Session, guildID types.GuildID, userID types.User return guild.OwnerID == userID.String(), nil } + +func ChannelUserIDs(ds *discordgo.Session, channelID types.ChannelID) ([]types.UserID, error) { + channel, err := ds.State.Channel(channelID.String()) + if err != nil { + return nil, errors.Wrap(err, "Error getting channel members") + } + + userIDs := []types.UserID{} + for _, member := range channel.Members { + userIDs = append(userIDs, types.UserID(member.UserID)) + } + + return userIDs, nil +} + +func GuildUserIDs(ds *discordgo.Session, guildID types.GuildID) ([]types.UserID, error) { + guild, err := ds.State.Guild(guildID.String()) + if err != nil { + return nil, errors.Wrap(err, "Error getting guild members") + } + + userIDs := []types.UserID{} + for _, member := range guild.Members { + userIDs = append(userIDs, types.UserID(member.User.ID)) + } + + return userIDs, nil +}