├── LICENSE ├── README.md ├── emojis.go ├── paginator.go ├── util.go └── widget.go /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Necroforger 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dg-widgets 2 | Make widgets with embeds and reactions 3 | 4 | ![img](https://i.imgur.com/viJc9Cm.gif) 5 | 6 | # Example usage 7 | ```go 8 | func (s *discordgo.Session, m *discordgo.Message) { 9 | p := dgwidgets.NewPaginator(s, m.ChannelID) 10 | 11 | // Add embed pages to paginator 12 | p.Add(&discordgo.MessageEmbed{Description: "Page one"}, 13 | &discordgo.MessageEmbed{Description: "Page two"}, 14 | &discordgo.MessageEmbed{Description: "Page three"}) 15 | 16 | // Sets the footers of all added pages to their page numbers. 17 | p.SetPageFooters() 18 | 19 | // When the paginator is done listening set the colour to yellow 20 | p.ColourWhenDone = 0xffff 21 | 22 | // Stop listening for reaction events after five minutes 23 | p.Widget.Timeout = time.Minute * 5 24 | 25 | // Add a custom handler for the gun reaction. 26 | p.Widget.Handle("🔫", func(w *dgwidgets.Widget, r *discordgo.MessageReaction) { 27 | s.ChannelMessageSend(m.ChannelID, "Bang!") 28 | }) 29 | 30 | p.Spawn() 31 | } 32 | ``` -------------------------------------------------------------------------------- /emojis.go: -------------------------------------------------------------------------------- 1 | package dgwidgets 2 | 3 | // emoji constants 4 | const ( 5 | NavPlus = "➕" 6 | NavPlay = "▶" 7 | NavPause = "⏸" 8 | NavStop = "⏹" 9 | NavRight = "➡" 10 | NavLeft = "⬅" 11 | NavUp = "⬆" 12 | NavDown = "⬇" 13 | NavEnd = "⏩" 14 | NavBeginning = "⏪" 15 | NavNumbers = "🔢" 16 | NavInformation = "ℹ" 17 | NavSave = "💾" 18 | ) 19 | -------------------------------------------------------------------------------- /paginator.go: -------------------------------------------------------------------------------- 1 | package dgwidgets 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "sync" 7 | "time" 8 | 9 | "github.com/bwmarrin/discordgo" 10 | ) 11 | 12 | // Paginator provides a method for creating a navigatable embed 13 | type Paginator struct { 14 | sync.Mutex 15 | Pages []*discordgo.MessageEmbed 16 | Index int 17 | 18 | // Loop back to the beginning or end when on the first or last page. 19 | Loop bool 20 | Widget *Widget 21 | 22 | Ses *discordgo.Session 23 | 24 | DeleteMessageWhenDone bool 25 | DeleteReactionsWhenDone bool 26 | ColourWhenDone int 27 | 28 | lockToUser bool 29 | 30 | running bool 31 | } 32 | 33 | // NewPaginator returns a new Paginator 34 | // ses : discordgo session 35 | // channelID: channelID to spawn the paginator on 36 | func NewPaginator(ses *discordgo.Session, channelID string) *Paginator { 37 | p := &Paginator{ 38 | Ses: ses, 39 | Pages: []*discordgo.MessageEmbed{}, 40 | Index: 0, 41 | Loop: false, 42 | DeleteMessageWhenDone: false, 43 | DeleteReactionsWhenDone: false, 44 | ColourWhenDone: -1, 45 | Widget: NewWidget(ses, channelID, nil), 46 | } 47 | p.addHandlers() 48 | 49 | return p 50 | } 51 | 52 | func (p *Paginator) addHandlers() { 53 | p.Widget.Handle(NavBeginning, func(w *Widget, r *discordgo.MessageReaction) { 54 | if err := p.Goto(0); err == nil { 55 | p.Update() 56 | } 57 | }) 58 | p.Widget.Handle(NavLeft, func(w *Widget, r *discordgo.MessageReaction) { 59 | if err := p.PreviousPage(); err == nil { 60 | p.Update() 61 | } 62 | }) 63 | p.Widget.Handle(NavRight, func(w *Widget, r *discordgo.MessageReaction) { 64 | if err := p.NextPage(); err == nil { 65 | p.Update() 66 | } 67 | }) 68 | p.Widget.Handle(NavEnd, func(w *Widget, r *discordgo.MessageReaction) { 69 | if err := p.Goto(len(p.Pages) - 1); err == nil { 70 | p.Update() 71 | } 72 | }) 73 | p.Widget.Handle(NavNumbers, func(w *Widget, r *discordgo.MessageReaction) { 74 | if msg, err := w.QueryInput("enter the page number you would like to open", r.UserID, 10*time.Second); err == nil { 75 | if n, err := strconv.Atoi(msg.Content); err == nil { 76 | p.Goto(n - 1) 77 | p.Update() 78 | } 79 | } 80 | }) 81 | } 82 | 83 | // Spawn spawns the paginator in channel p.ChannelID 84 | func (p *Paginator) Spawn() error { 85 | if p.Running() { 86 | return ErrAlreadyRunning 87 | } 88 | p.Lock() 89 | p.running = true 90 | p.Unlock() 91 | 92 | defer func() { 93 | p.Lock() 94 | p.running = false 95 | p.Unlock() 96 | 97 | // Delete Message when done 98 | if p.DeleteMessageWhenDone && p.Widget.Message != nil { 99 | p.Ses.ChannelMessageDelete(p.Widget.Message.ChannelID, p.Widget.Message.ID) 100 | } else if p.ColourWhenDone >= 0 { 101 | if page, err := p.Page(); err == nil { 102 | page.Color = p.ColourWhenDone 103 | p.Update() 104 | } 105 | } 106 | 107 | // Delete reactions when done 108 | if p.DeleteReactionsWhenDone && p.Widget.Message != nil { 109 | p.Ses.MessageReactionsRemoveAll(p.Widget.ChannelID, p.Widget.Message.ID) 110 | } 111 | }() 112 | 113 | page, err := p.Page() 114 | if err != nil { 115 | return err 116 | } 117 | p.Widget.Embed = page 118 | 119 | return p.Widget.Spawn() 120 | } 121 | 122 | // Add a page to the paginator 123 | // embed: embed page to add. 124 | func (p *Paginator) Add(embeds ...*discordgo.MessageEmbed) { 125 | p.Pages = append(p.Pages, embeds...) 126 | } 127 | 128 | // Page returns the page of the current index 129 | func (p *Paginator) Page() (*discordgo.MessageEmbed, error) { 130 | p.Lock() 131 | defer p.Unlock() 132 | 133 | if p.Index < 0 || p.Index >= len(p.Pages) { 134 | return nil, ErrIndexOutOfBounds 135 | } 136 | 137 | return p.Pages[p.Index], nil 138 | } 139 | 140 | // NextPage sets the page index to the next page 141 | func (p *Paginator) NextPage() error { 142 | p.Lock() 143 | defer p.Unlock() 144 | 145 | if p.Index+1 >= 0 && p.Index+1 < len(p.Pages) { 146 | p.Index++ 147 | return nil 148 | } 149 | 150 | // Set the queue back to the beginning if Loop is enabled. 151 | if p.Loop { 152 | p.Index = 0 153 | return nil 154 | } 155 | 156 | return ErrIndexOutOfBounds 157 | } 158 | 159 | // PreviousPage sets the current page index to the previous page. 160 | func (p *Paginator) PreviousPage() error { 161 | p.Lock() 162 | defer p.Unlock() 163 | 164 | if p.Index-1 >= 0 && p.Index-1 < len(p.Pages) { 165 | p.Index-- 166 | return nil 167 | } 168 | 169 | // Set the queue back to the beginning if Loop is enabled. 170 | if p.Loop { 171 | p.Index = len(p.Pages) - 1 172 | return nil 173 | } 174 | 175 | return ErrIndexOutOfBounds 176 | } 177 | 178 | // Goto jumps to the requested page index 179 | // index: The index of the page to go to 180 | func (p *Paginator) Goto(index int) error { 181 | p.Lock() 182 | defer p.Unlock() 183 | if index < 0 || index >= len(p.Pages) { 184 | return ErrIndexOutOfBounds 185 | } 186 | p.Index = index 187 | return nil 188 | } 189 | 190 | // Update updates the message with the current state of the paginator 191 | func (p *Paginator) Update() error { 192 | if p.Widget.Message == nil { 193 | return ErrNilMessage 194 | } 195 | 196 | page, err := p.Page() 197 | if err != nil { 198 | return err 199 | } 200 | 201 | _, err = p.Widget.UpdateEmbed(page) 202 | return err 203 | } 204 | 205 | // Running returns the running status of the paginator 206 | func (p *Paginator) Running() bool { 207 | p.Lock() 208 | running := p.running 209 | p.Unlock() 210 | return running 211 | } 212 | 213 | // SetPageFooters sets the footer of each embed to 214 | // Be its page number out of the total length of the embeds. 215 | func (p *Paginator) SetPageFooters() { 216 | for index, embed := range p.Pages { 217 | embed.Footer = &discordgo.MessageEmbedFooter{ 218 | Text: fmt.Sprintf("#[%d / %d]", index+1, len(p.Pages)), 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package dgwidgets 2 | 3 | import ( 4 | "github.com/bwmarrin/discordgo" 5 | ) 6 | 7 | // NextMessageCreateC returns a channel for the next MessageCreate event 8 | func nextMessageCreateC(s *discordgo.Session) chan *discordgo.MessageCreate { 9 | out := make(chan *discordgo.MessageCreate) 10 | s.AddHandlerOnce(func(_ *discordgo.Session, e *discordgo.MessageCreate) { 11 | out <- e 12 | }) 13 | return out 14 | } 15 | 16 | // NextMessageReactionAddC returns a channel for the next MessageReactionAdd event 17 | func nextMessageReactionAddC(s *discordgo.Session) chan *discordgo.MessageReactionAdd { 18 | out := make(chan *discordgo.MessageReactionAdd) 19 | s.AddHandlerOnce(func(_ *discordgo.Session, e *discordgo.MessageReactionAdd) { 20 | out <- e 21 | }) 22 | return out 23 | } 24 | 25 | // EmbedsFromString splits a string into a slice of MessageEmbeds. 26 | // txt : text to split 27 | // chunklen: How long the text in each embed should be 28 | // (if set to 0 or less, it defaults to 2048) 29 | func EmbedsFromString(txt string, chunklen int) []*discordgo.MessageEmbed { 30 | if chunklen <= 0 { 31 | chunklen = 2048 32 | } 33 | 34 | embeds := []*discordgo.MessageEmbed{} 35 | for i := 0; i < int((float64(len(txt))/float64(chunklen))+0.5); i++ { 36 | start := i * chunklen 37 | end := start + chunklen 38 | if end > len(txt) { 39 | end = len(txt) 40 | } 41 | embeds = append(embeds, &discordgo.MessageEmbed{ 42 | Description: txt[start:end], 43 | }) 44 | } 45 | return embeds 46 | } 47 | -------------------------------------------------------------------------------- /widget.go: -------------------------------------------------------------------------------- 1 | package dgwidgets 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | 8 | "github.com/bwmarrin/discordgo" 9 | ) 10 | 11 | // error vars 12 | var ( 13 | ErrAlreadyRunning = errors.New("err: Widget already running") 14 | ErrIndexOutOfBounds = errors.New("err: Index is out of bounds") 15 | ErrNilMessage = errors.New("err: Message is nil") 16 | ErrNilEmbed = errors.New("err: embed is nil") 17 | ErrNotRunning = errors.New("err: not running") 18 | ) 19 | 20 | // WidgetHandler ... 21 | type WidgetHandler func(*Widget, *discordgo.MessageReaction) 22 | 23 | // Widget is a message embed with reactions for buttons. 24 | // Accepts custom handlers for reactions. 25 | type Widget struct { 26 | sync.Mutex 27 | Embed *discordgo.MessageEmbed 28 | Message *discordgo.Message 29 | Ses *discordgo.Session 30 | ChannelID string 31 | Timeout time.Duration 32 | Close chan bool 33 | 34 | // Handlers binds emoji names to functions 35 | Handlers map[string]WidgetHandler 36 | // keys stores the handlers keys in the order they were added 37 | Keys []string 38 | 39 | // Delete reactions after they are added 40 | DeleteReactions bool 41 | // Only allow listed users to use reactions. 42 | UserWhitelist []string 43 | 44 | running bool 45 | } 46 | 47 | // NewWidget returns a pointer to a Widget object 48 | // ses : discordgo session 49 | // channelID: channelID to spawn the widget on 50 | func NewWidget(ses *discordgo.Session, channelID string, embed *discordgo.MessageEmbed) *Widget { 51 | return &Widget{ 52 | ChannelID: channelID, 53 | Ses: ses, 54 | Keys: []string{}, 55 | Handlers: map[string]WidgetHandler{}, 56 | Close: make(chan bool), 57 | DeleteReactions: true, 58 | Embed: embed, 59 | } 60 | } 61 | 62 | // isUserAllowed returns true if the user is allowed 63 | // to use this widget. 64 | func (w *Widget) isUserAllowed(userID string) bool { 65 | if w.UserWhitelist == nil || len(w.UserWhitelist) == 0 { 66 | return true 67 | } 68 | for _, user := range w.UserWhitelist { 69 | if user == userID { 70 | return true 71 | } 72 | } 73 | return false 74 | } 75 | 76 | // Spawn spawns the widget in channel w.ChannelID 77 | func (w *Widget) Spawn() error { 78 | if w.Running() { 79 | return ErrAlreadyRunning 80 | } 81 | w.running = true 82 | defer func() { 83 | w.running = false 84 | }() 85 | 86 | if w.Embed == nil { 87 | return ErrNilEmbed 88 | } 89 | 90 | startTime := time.Now() 91 | 92 | // Create initial message. 93 | msg, err := w.Ses.ChannelMessageSendEmbed(w.ChannelID, w.Embed) 94 | if err != nil { 95 | return err 96 | } 97 | w.Message = msg 98 | 99 | // Add reaction buttons 100 | for _, v := range w.Keys { 101 | w.Ses.MessageReactionAdd(w.Message.ChannelID, w.Message.ID, v) 102 | } 103 | 104 | var reaction *discordgo.MessageReaction 105 | for { 106 | // Navigation timeout enabled 107 | if w.Timeout != 0 { 108 | select { 109 | case k := <-nextMessageReactionAddC(w.Ses): 110 | reaction = k.MessageReaction 111 | case <-time.After(startTime.Add(w.Timeout).Sub(time.Now())): 112 | return nil 113 | case <-w.Close: 114 | return nil 115 | } 116 | } else /*Navigation timeout not enabled*/ { 117 | select { 118 | case k := <-nextMessageReactionAddC(w.Ses): 119 | reaction = k.MessageReaction 120 | case <-w.Close: 121 | return nil 122 | } 123 | } 124 | 125 | // Ignore reactions sent by bot 126 | if reaction.MessageID != w.Message.ID || w.Ses.State.User.ID == reaction.UserID { 127 | continue 128 | } 129 | 130 | if v, ok := w.Handlers[reaction.Emoji.Name]; ok { 131 | if w.isUserAllowed(reaction.UserID) { 132 | go v(w, reaction) 133 | } 134 | } 135 | 136 | if w.DeleteReactions { 137 | go func() { 138 | if w.isUserAllowed(reaction.UserID) { 139 | time.Sleep(time.Millisecond * 250) 140 | w.Ses.MessageReactionRemove(reaction.ChannelID, reaction.MessageID, reaction.Emoji.Name, reaction.UserID) 141 | } 142 | }() 143 | } 144 | } 145 | } 146 | 147 | // Handle adds a handler for the given emoji name 148 | // emojiName: The unicode value of the emoji 149 | // handler : handler function to call when the emoji is clicked 150 | // func(*Widget, *discordgo.MessageReaction) 151 | func (w *Widget) Handle(emojiName string, handler WidgetHandler) error { 152 | if _, ok := w.Handlers[emojiName]; !ok { 153 | w.Keys = append(w.Keys, emojiName) 154 | w.Handlers[emojiName] = handler 155 | } 156 | // if the widget is running, append the added emoji to the message. 157 | if w.Running() && w.Message != nil { 158 | return w.Ses.MessageReactionAdd(w.Message.ChannelID, w.Message.ID, emojiName) 159 | } 160 | return nil 161 | } 162 | 163 | // QueryInput querys the user with ID `id` for input 164 | // prompt : Question prompt 165 | // userID : UserID to get message from 166 | // timeout: How long to wait for the user's response 167 | func (w *Widget) QueryInput(prompt string, userID string, timeout time.Duration) (*discordgo.Message, error) { 168 | msg, err := w.Ses.ChannelMessageSend(w.ChannelID, "<@"+userID+">, "+prompt) 169 | if err != nil { 170 | return nil, err 171 | } 172 | defer func() { 173 | w.Ses.ChannelMessageDelete(msg.ChannelID, msg.ID) 174 | }() 175 | 176 | timeoutChan := make(chan int) 177 | go func() { 178 | time.Sleep(timeout) 179 | timeoutChan <- 0 180 | }() 181 | 182 | for { 183 | select { 184 | case usermsg := <-nextMessageCreateC(w.Ses): 185 | if usermsg.Author.ID != userID { 186 | continue 187 | } 188 | w.Ses.ChannelMessageDelete(usermsg.ChannelID, usermsg.ID) 189 | return usermsg.Message, nil 190 | case <-timeoutChan: 191 | return nil, errors.New("Timed out") 192 | } 193 | } 194 | } 195 | 196 | // Running returns w.running 197 | func (w *Widget) Running() bool { 198 | w.Lock() 199 | running := w.running 200 | w.Unlock() 201 | return running 202 | } 203 | 204 | // UpdateEmbed updates the embed object and edits the original message 205 | // embed: New embed object to replace w.Embed 206 | func (w *Widget) UpdateEmbed(embed *discordgo.MessageEmbed) (*discordgo.Message, error) { 207 | if w.Message == nil { 208 | return nil, ErrNilMessage 209 | } 210 | return w.Ses.ChannelMessageEditEmbed(w.ChannelID, w.Message.ID, embed) 211 | } 212 | --------------------------------------------------------------------------------