├── .gitignore ├── README.md ├── dgrouter.go ├── dgrouter_test.go ├── disgordrouter ├── args.go ├── context.go ├── readme.md └── router_wrapper.go ├── examples ├── middleware │ └── middleware.go ├── pingpong │ ├── README.md │ └── pingpong.go ├── soundboard │ ├── README.md │ ├── soundboard.go │ └── sounds │ │ ├── interlude.mp3 │ │ ├── songs.json │ │ └── songs.txt ├── subrouters │ └── subrouters.go └── temporary-roles │ └── main.go ├── exmiddleware ├── README.md ├── middleware.go ├── retrievers.go └── util.go ├── exrouter ├── args.go ├── context.go ├── router_wrapper.go └── router_wrapper_test.go ├── matchers.go └── route.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.test 2 | *.exe 3 | debug 4 | *.db 5 | *.db.lock -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dgrouter 2 | 3 | Router to simplify command routing in discord bots. 4 | [exrouter](https://github.com/Necroforger/dgrouter/tree/master/exrouter) provides some extra features like wrapping the router type to add command handlers which typecast to its own Context type. 5 | 6 | If you make any interesting changes feel free to submit a Pull Request 7 | 8 | - [exrouter godoc reference](https://godoc.org/github.com/Necroforger/dgrouter/exrouter) 9 | - [exmiddleware godoc reference](https://godoc.org/github.com/Necroforger/dgrouter/exmiddleware) 10 | - [dgrouter godoc reference](https://godoc.org/github.com/Necroforger/dgrouter) 11 | 12 | ## Features 13 | 14 | - [Subroutes](https://github.com/Necroforger/dgrouter/blob/master/examples/subrouters/subrouters.go#L28) 15 | - [Route grouping](https://github.com/Necroforger/dgrouter/blob/master/examples/middleware/middleware.go#L69) 16 | - [Route aliases](https://github.com/Necroforger/dgrouter/blob/master/examples/soundboard/soundboard.go#L97) 17 | - [Middleware](https://github.com/Necroforger/dgrouter/blob/master/examples/middleware/middleware.go#L38) 18 | - [Regex matching](https://github.com/Necroforger/dgrouter/blob/master/examples/pingpong/pingpong.go#L39) 19 | 20 | ## example 21 | ```go 22 | router.On("ping", func(ctx *exrouter.Context) { ctx.Reply("pong")}).Desc("responds with pong") 23 | 24 | router.On("avatar", func(ctx *exrouter.Context) { 25 | ctx.Reply(ctx.Msg.Author.AvatarURL("2048")) 26 | }).Desc("returns the user's avatar") 27 | 28 | router.Default = router.On("help", func(ctx *exrouter.Context) { 29 | var text = "" 30 | for _, v := range router.Routes { 31 | text += v.Name + " : \t" + v.Description + "\n" 32 | } 33 | ctx.Reply("```" + text + "```") 34 | }).Desc("prints this help menu") 35 | ``` 36 | -------------------------------------------------------------------------------- /dgrouter.go: -------------------------------------------------------------------------------- 1 | package dgrouter 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // Error variables 8 | var ( 9 | ErrCouldNotFindRoute = errors.New("Could not find route") 10 | ErrRouteAlreadyExists = errors.New("route already exists") 11 | ) 12 | 13 | // HandlerFunc is a command handler 14 | type HandlerFunc func(interface{}) 15 | 16 | // MiddlewareFunc is a middleware 17 | type MiddlewareFunc func(HandlerFunc) HandlerFunc 18 | 19 | // Group allows you to do things like more easily manage categories 20 | // For example, setting the routes category in the callback will cause 21 | // All future added routes to inherit the category. 22 | // example: 23 | // Group(func (r *Route) { 24 | // r.Cat("stuff") 25 | // r.On("thing", nil).Desc("the category of this function will be stuff") 26 | // }) 27 | func (r *Route) Group(fn func(r *Route)) *Route { 28 | rt := New() 29 | fn(rt) 30 | for _, v := range rt.Routes { 31 | r.AddRoute(v) 32 | } 33 | return r 34 | } 35 | 36 | // Use adds the given middleware func to this route's middleware chain 37 | func (r *Route) Use(fn ...MiddlewareFunc) *Route { 38 | r.Middleware = append(r.Middleware, fn...) 39 | return r 40 | } 41 | 42 | // On registers a route with the name you supply 43 | // name : name of the route to create 44 | // handler : handler function 45 | func (r *Route) On(name string, handler HandlerFunc) *Route { 46 | rt := r.OnMatch(name, nil, handler) 47 | rt.Matcher = NewNameMatcher(rt) 48 | return rt 49 | } 50 | 51 | // OnMatch adds a handler for the given route 52 | // name : name of the route to add 53 | // matcher : matcher function used to match the route 54 | // handler : handler function for the route 55 | func (r *Route) OnMatch(name string, matcher func(string) bool, handler HandlerFunc) *Route { 56 | if rt := r.Find(name); rt != nil { 57 | return rt 58 | } 59 | 60 | nhandler := handler 61 | 62 | // Add middleware to the handler 63 | for _, v := range r.Middleware { 64 | nhandler = v(nhandler) 65 | } 66 | 67 | rt := &Route{ 68 | Name: name, 69 | Category: r.Category, 70 | Handler: nhandler, 71 | Matcher: matcher, 72 | } 73 | 74 | r.AddRoute(rt) 75 | return rt 76 | } 77 | 78 | // AddRoute adds a route to the router 79 | // Will return RouteAlreadyExists error on failure 80 | // route : route to add 81 | func (r *Route) AddRoute(route *Route) error { 82 | // Check if the route already exists 83 | if rt := r.Find(route.Name); rt != nil { 84 | return ErrRouteAlreadyExists 85 | } 86 | 87 | route.Parent = r 88 | r.Routes = append(r.Routes, route) 89 | return nil 90 | } 91 | 92 | // RemoveRoute removes a route from the router 93 | // route : route to remove 94 | func (r *Route) RemoveRoute(route *Route) error { 95 | for i, v := range r.Routes { 96 | if v == route { 97 | r.Routes = append(r.Routes[:i], r.Routes[i+1:]...) 98 | return nil 99 | } 100 | } 101 | return ErrCouldNotFindRoute 102 | } 103 | 104 | // Find finds a route with the given name 105 | // It will return nil if nothing is found 106 | // name : name of route to find 107 | func (r *Route) Find(name string) *Route { 108 | for _, v := range r.Routes { 109 | if v.Matcher(name) { 110 | return v 111 | } 112 | } 113 | return nil 114 | } 115 | 116 | // FindFull a full path of routes by searching through their subroutes 117 | // Until the deepest match is found. 118 | // It will return the route matched and the depth it was found at 119 | // args : path of route you wish to find 120 | // ex. FindFull(command, subroute1, subroute2, nonexistent) 121 | // will return the deepest found match, which will be subroute2 122 | func (r *Route) FindFull(args ...string) (*Route, int) { 123 | nr := r 124 | i := 0 125 | for _, v := range args { 126 | if rt := nr.Find(v); rt != nil { 127 | nr = rt 128 | i++ 129 | } else { 130 | break 131 | } 132 | } 133 | return nr, i 134 | } 135 | 136 | // New returns a new route 137 | func New() *Route { 138 | return &Route{ 139 | Routes: []*Route{}, 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /dgrouter_test.go: -------------------------------------------------------------------------------- 1 | package dgrouter_test 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/Necroforger/dgrouter" 8 | ) 9 | 10 | func TestRouter(t *testing.T) { 11 | r := dgrouter.New() 12 | 13 | r.On("ping", func(i interface{}) { log.Println("hello") }).Desc("Responds with pong").Cat("general") 14 | r.OnMatch("hello", dgrouter.NewRegexMatcher("h.llo"), nil).Desc("tests regular expressions").Cat("regex") 15 | 16 | if rt := r.Find("ping"); rt != nil { 17 | rt.Handler(nil) 18 | } else { 19 | t.Fail() 20 | } 21 | 22 | if rt := r.Find("route that doesn't exist"); rt != nil { 23 | t.Fail() 24 | } 25 | 26 | if rt := r.Find("hallo"); rt != nil { 27 | log.Println("found route") 28 | } else { 29 | t.Fail() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /disgordrouter/args.go: -------------------------------------------------------------------------------- 1 | package disgordrouter 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "strings" 7 | ) 8 | 9 | // separator is the separator character for splitting arguments 10 | const separator = ' ' 11 | 12 | // Args is a helper type for dealing with command arguments 13 | type Args []string 14 | 15 | // Get returns the argument at index n 16 | func (a Args) Get(n int) string { 17 | if n >= 0 && n < len(a) { 18 | return a[n] 19 | } 20 | return "" 21 | } 22 | 23 | // After returns all arguments after index n 24 | func (a Args) After(n int) string { 25 | if n >= 0 && n < len(a) { 26 | return strings.Join(a[n:], string(separator)) 27 | } 28 | return "" 29 | } 30 | 31 | // ParseArgs parses command arguments 32 | func ParseArgs(content string) Args { 33 | cv := csv.NewReader(bytes.NewBufferString(content)) 34 | cv.Comma = separator 35 | fields, err := cv.Read() 36 | if err != nil { 37 | return strings.Split(content, string(separator)) 38 | } 39 | return fields 40 | } 41 | -------------------------------------------------------------------------------- /disgordrouter/context.go: -------------------------------------------------------------------------------- 1 | package disgordrouter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/andersfylling/disgord" 9 | 10 | "github.com/Necroforger/dgrouter" 11 | ) 12 | 13 | // Context represents a command context 14 | type Context struct { 15 | // Route is the route that this command came from 16 | Route *dgrouter.Route 17 | Msg *disgord.Message 18 | Ses disgord.Session 19 | 20 | // List of arguments supplied with the command 21 | Args Args 22 | 23 | // Vars that can be optionally set using the Set and Get functions 24 | vmu sync.RWMutex 25 | Vars map[string]interface{} 26 | } 27 | 28 | // Set sets a variable on the context 29 | func (c *Context) Set(key string, d interface{}) { 30 | c.vmu.Lock() 31 | c.Vars[key] = d 32 | c.vmu.Unlock() 33 | } 34 | 35 | // Get retrieves a variable from the context 36 | func (c *Context) Get(key string) interface{} { 37 | if c, ok := c.Vars[key]; ok { 38 | return c 39 | } 40 | return nil 41 | } 42 | 43 | // Reply replies to the sender with the given message 44 | func (c *Context) Reply(args ...interface{}) (*disgord.Message, error) { 45 | return c.Ses.SendMsg(context.Background(), c.Msg.ChannelID, fmt.Sprint(args...)) 46 | } 47 | 48 | // ReplyEmbed replies to the sender with an embed 49 | func (c *Context) ReplyEmbed(args ...interface{}) (*disgord.Message, error) { 50 | return c.Ses.CreateMessage(context.Background(), c.Msg.ChannelID, &disgord.CreateMessageParams{ 51 | Embed: &disgord.Embed{ 52 | Description: fmt.Sprint(args...), 53 | }, 54 | }) 55 | } 56 | 57 | // Guild retrieves a guild from the state or restapi 58 | func (c *Context) Guild(guildID string) (*disgord.Guild, error) { 59 | return c.Ses.GetGuild(context.Background(), disgord.ParseSnowflakeString(guildID)) 60 | } 61 | 62 | // Channel retrieves a channel from the state or restapi 63 | func (c *Context) Channel(channelID string) (*disgord.Channel, error) { 64 | return c.Ses.GetChannel(context.Background(), disgord.ParseSnowflakeString(channelID)) 65 | } 66 | 67 | // Member retrieves a member from the state or restapi 68 | func (c *Context) Member(guildID, userID string) (*disgord.Member, error) { 69 | return c.Ses.GetMember(context.Background(), disgord.ParseSnowflakeString(guildID), disgord.ParseSnowflakeString(userID)) 70 | } 71 | 72 | // NewContext returns a new context from a message 73 | func NewContext(s disgord.Session, m *disgord.Message, args Args, route *dgrouter.Route) *Context { 74 | return &Context{ 75 | Route: route, 76 | Msg: m, 77 | Ses: s, 78 | Args: args, 79 | Vars: map[string]interface{}{}, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /disgordrouter/readme.md: -------------------------------------------------------------------------------- 1 | # Disgordrouter 2 | 3 | This is a command router using a context for the 4 | [disgord](https://github.com/andersfylling/disgord) library. 5 | It functions the same as the exrouter package. -------------------------------------------------------------------------------- /disgordrouter/router_wrapper.go: -------------------------------------------------------------------------------- 1 | package disgordrouter 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/Necroforger/dgrouter" 7 | "github.com/andersfylling/disgord" 8 | ) 9 | 10 | // HandlerFunc ... 11 | type HandlerFunc func(*Context) 12 | 13 | // MiddlewareFunc is middleware 14 | type MiddlewareFunc func(HandlerFunc) HandlerFunc 15 | 16 | // Route wraps dgrouter.Router to use a Context 17 | type Route struct { 18 | *dgrouter.Route 19 | } 20 | 21 | // New returns a new router wrapper 22 | func New() *Route { 23 | return &Route{ 24 | Route: dgrouter.New(), 25 | } 26 | } 27 | 28 | // On registers a handler function 29 | func (r *Route) On(name string, handler HandlerFunc) *Route { 30 | return &Route{r.Route.On(name, WrapHandler(handler))} 31 | } 32 | 33 | // Group ... 34 | func (r *Route) Group(fn func(rt *Route)) *Route { 35 | return &Route{r.Route.Group(func(r *dgrouter.Route) { 36 | fn(&Route{r}) 37 | })} 38 | } 39 | 40 | // Use ... 41 | func (r *Route) Use(fn ...MiddlewareFunc) *Route { 42 | var wrapped []dgrouter.MiddlewareFunc 43 | for _, v := range fn { 44 | wrapped = append(wrapped, WrapMiddleware(v)) 45 | } 46 | return &Route{ 47 | r.Route.Use(wrapped...), 48 | } 49 | } 50 | 51 | // WrapMiddleware ... 52 | func WrapMiddleware(mware MiddlewareFunc) dgrouter.MiddlewareFunc { 53 | return func(next dgrouter.HandlerFunc) dgrouter.HandlerFunc { 54 | return func(i interface{}) { 55 | WrapHandler(mware(UnwrapHandler(next)))(i) 56 | } 57 | } 58 | } 59 | 60 | // OnMatch registers a route with the given matcher 61 | func (r *Route) OnMatch(name string, matcher func(string) bool, handler HandlerFunc) *Route { 62 | return &Route{r.Route.OnMatch(name, matcher, WrapHandler(handler))} 63 | } 64 | 65 | func mention(id string) string { 66 | return "<@" + id + ">" 67 | } 68 | 69 | func nickMention(id string) string { 70 | return "<@!" + id + ">" 71 | } 72 | 73 | // FindAndExecute is a helper method for calling routes 74 | // it creates a context from a message, finds its route, and executes the handler 75 | // it looks for a message prefix which is either the prefix specified or the message is prefixed 76 | // with a bot mention 77 | // s : discordgo session to pass to context 78 | // prefix : prefix you want the bot to respond to 79 | // botID : user ID of the bot to allow you to substitute the bot ID for a prefix 80 | // m : discord message to pass to context 81 | func (r *Route) FindAndExecute(s disgord.Session, prefix string, botID disgord.Snowflake, m *disgord.Message) error { 82 | var pf string 83 | botIDStr := botID.String() 84 | 85 | // If the message content is only a bot mention and the mention route is not nil, send the mention route 86 | if r.Default != nil && m.Content == mention(botIDStr) || r.Default != nil && m.Content == nickMention(botIDStr) { 87 | r.Default.Handler(NewContext(s, m, []string{""}, r.Default)) 88 | return nil 89 | } 90 | 91 | // Append a space to the mentions 92 | bmention := mention(botIDStr) + " " 93 | nmention := nickMention(botIDStr) + " " 94 | 95 | p := func(t string) bool { 96 | return strings.HasPrefix(m.Content, t) 97 | } 98 | 99 | switch { 100 | case prefix != "" && p(prefix): 101 | pf = prefix 102 | case p(bmention): 103 | pf = bmention 104 | case p(nmention): 105 | pf = nmention 106 | default: 107 | return dgrouter.ErrCouldNotFindRoute 108 | } 109 | 110 | command := strings.TrimPrefix(m.Content, pf) 111 | args := ParseArgs(command) 112 | 113 | if rt, depth := r.FindFull(args...); depth > 0 { 114 | args = append([]string{strings.Join(args[:depth], string(separator))}, args[depth:]...) 115 | rt.Handler(NewContext(s, m, args, rt)) 116 | } else { 117 | return dgrouter.ErrCouldNotFindRoute 118 | } 119 | 120 | return nil 121 | } 122 | 123 | // WrapHandler wraps a dgrouter.HandlerFunc 124 | func WrapHandler(fn HandlerFunc) dgrouter.HandlerFunc { 125 | if fn == nil { 126 | return nil 127 | } 128 | return func(i interface{}) { 129 | fn(i.(*Context)) 130 | } 131 | } 132 | 133 | // UnwrapHandler unwraps a handler 134 | func UnwrapHandler(fn dgrouter.HandlerFunc) HandlerFunc { 135 | return func(ctx *Context) { 136 | fn(ctx) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /examples/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "time" 7 | 8 | "github.com/Necroforger/dgrouter/exmiddleware" 9 | "github.com/Necroforger/dgrouter/exrouter" 10 | "github.com/bwmarrin/discordgo" 11 | ) 12 | 13 | // Command line flags 14 | var ( 15 | fToken = flag.String("t", "", "bot token") 16 | fPrefix = flag.String("p", "!", "bot prefix") 17 | ) 18 | 19 | // AllowedNames are names allowed to use the auth commands 20 | var AllowedNames = []string{ 21 | "necroforger", 22 | "necro", 23 | 24 | "foxtail-grass-studios", 25 | 26 | "wriggle", 27 | "reimu", 28 | "marisa", 29 | "remilia", 30 | "flandre", 31 | "satori", 32 | "koishi", 33 | "parsee", 34 | "cirno", 35 | } 36 | 37 | // Auth is an authentication middleware 38 | // Only allowing people with certain names to use these 39 | // Routes 40 | func Auth(fn exrouter.HandlerFunc) exrouter.HandlerFunc { 41 | return func(ctx *exrouter.Context) { 42 | member, err := ctx.Member(ctx.Msg.GuildID, ctx.Msg.Author.ID) 43 | if err != nil { 44 | ctx.Reply("Could not fetch member: ", err) 45 | } 46 | 47 | ctx.Reply("Authenticating...") 48 | 49 | for _, v := range AllowedNames { 50 | if member.Nick == v { 51 | ctx.Set("member", member) 52 | fn(ctx) 53 | return 54 | } 55 | } 56 | 57 | ctx.Reply("You don't have permission to use this command") 58 | } 59 | } 60 | 61 | func main() { 62 | flag.Parse() 63 | 64 | s, err := discordgo.New(*fToken) 65 | if err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | router := exrouter.New() 70 | 71 | // NSFW Only commands 72 | router.Group(func(r *exrouter.Route) { 73 | // Default callback function to use when a middleware has an error 74 | // It will reply to the sender with the error that occured 75 | reply := exmiddleware.CatchDefault 76 | 77 | // Create a specific reply for when a middleware errors 78 | replyNSFW := exmiddleware.CatchReply("You need to be in an NSFW channel to use this command") 79 | 80 | r.Use( 81 | // Inserts the calling member into ctx.Data 82 | exmiddleware.GetMember(reply), 83 | 84 | // Inserts the Guild in which the message came from into ctx.Data 85 | exmiddleware.GetGuild(reply), 86 | 87 | // Require that the message originates from an nsfw channel 88 | // If there is an error, run the function replyNSFW 89 | exmiddleware.RequireNSFW(replyNSFW), 90 | 91 | // Enforce that the commands in this group can only be used once per 10 seconds per user 92 | exmiddleware.UserCooldown(time.Second*10, exmiddleware.CatchReply("This command is on cooldown...")), 93 | ) 94 | 95 | r.On("nsfw", func(ctx *exrouter.Context) { 96 | ctx.Reply("This command was used in a NSFW channel\n" + 97 | "Your guild is: " + exmiddleware.Guild(ctx).Name + "\n" + 98 | "Your nickanme is: " + exmiddleware.Member(ctx).Nick, 99 | ) 100 | }) 101 | }) 102 | 103 | router.Group(func(r *exrouter.Route) { 104 | // Added routes inherit their parent category. 105 | // I set the parent category here and it won't affect the 106 | // Actual router, just this group 107 | r.Cat("main") 108 | 109 | // This authentication middleware applies only to this group 110 | r.Use(Auth) 111 | log.Printf("len(middleware) = %d\n", len(r.Middleware)) 112 | 113 | r.On("testauth", func(ctx *exrouter.Context) { 114 | ctx.Reply("Hello " + ctx.Get("member").(*discordgo.Member).Nick + ", you have permission to use this command") 115 | }) 116 | }) 117 | 118 | router.Group(func(r *exrouter.Route) { 119 | r.Cat("other") 120 | r.On("ping", func(ctx *exrouter.Context) { ctx.Reply("pong") }).Desc("Responds with pong").Cat("other") 121 | }) 122 | 123 | router.Default = router.On("help", func(ctx *exrouter.Context) { 124 | var text = "" 125 | for _, v := range router.Routes { 126 | text += v.Name + " : \t" + v.Description + ":\t" + v.Category + "\n" 127 | } 128 | ctx.Reply("```" + text + "```") 129 | }).Desc("prints this help menu") 130 | 131 | // Add message handler 132 | s.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) { 133 | router.FindAndExecute(s, *fPrefix, s.State.User.ID, m.Message) 134 | }) 135 | 136 | err = s.Open() 137 | if err != nil { 138 | log.Fatal(err) 139 | } 140 | 141 | log.Println("bot is running...") 142 | // Prevent the bot from exiting 143 | <-make(chan struct{}) 144 | } 145 | -------------------------------------------------------------------------------- /examples/pingpong/README.md: -------------------------------------------------------------------------------- 1 | # pingpong 2 | Simple ping bot, to get up to speed quickly with dgrouter 3 | ## How to use 4 | ``` 5 | ./pingpong -t -p ! 6 | ``` 7 | ## Flags 8 | 9 | | Flag | description | 10 | |------|-------------| 11 | | t | bot token | 12 | | p | bot prefix | -------------------------------------------------------------------------------- /examples/pingpong/pingpong.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/Necroforger/dgrouter" 8 | 9 | "github.com/Necroforger/dgrouter/exrouter" 10 | "github.com/bwmarrin/discordgo" 11 | ) 12 | 13 | // Command line flags 14 | var ( 15 | fToken = flag.String("t", "", "bot token") 16 | fPrefix = flag.String("p", "!", "bot prefix") 17 | ) 18 | 19 | func main() { 20 | flag.Parse() 21 | 22 | s, err := discordgo.New("Bot " + *fToken) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | router := exrouter.New() 28 | 29 | // Add some commands 30 | router.On("ping", func(ctx *exrouter.Context) { 31 | ctx.Reply("pong") 32 | }).Desc("responds with pong") 33 | 34 | router.On("avatar", func(ctx *exrouter.Context) { 35 | ctx.Reply(ctx.Msg.Author.AvatarURL("2048")) 36 | }).Desc("returns the user's avatar") 37 | 38 | // Match the regular expression user(name)? 39 | router.OnMatch("username", dgrouter.NewRegexMatcher("user(name)?"), func(ctx *exrouter.Context) { 40 | ctx.Reply("Your username is " + ctx.Msg.Author.Username) 41 | }) 42 | 43 | router.Default = router.On("help", func(ctx *exrouter.Context) { 44 | var text = "" 45 | for _, v := range router.Routes { 46 | text += v.Name + " : \t" + v.Description + "\n" 47 | } 48 | ctx.Reply("```" + text + "```") 49 | }).Desc("prints this help menu") 50 | 51 | // Add message handler 52 | s.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) { 53 | router.FindAndExecute(s, *fPrefix, s.State.User.ID, m.Message) 54 | }) 55 | 56 | err = s.Open() 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | log.Println("bot is running...") 62 | // Prevent the bot from exiting 63 | <-make(chan struct{}) 64 | } 65 | -------------------------------------------------------------------------------- /examples/soundboard/README.md: -------------------------------------------------------------------------------- 1 | # soundboard 2 | Example music bot using dca to stream and transcode music 3 | - Responds to mentions 4 | - Plays music from youtube 5 | 6 | See the sounds directory for an example of how to include some media files. 7 | If the file extension ends in .json, the bot will create a command that will use ytdl play the supplied youtube links. The json should be in the form of 8 | ```json 9 | [ 10 | ["name", "youtube-url"], 11 | ["name2", "youtube-url"] 12 | ] 13 | ``` 14 | 15 | ## Dependencies 16 | This bot depends on [ffmpeg](https://ffmpeg.org) to transcode the audio to opus. 17 | It should be installed to your path. 18 | 19 | [Here is a guide for installing on windows](https://github.com/adaptlearning/adapt_authoring/wiki/Installing-FFmpeg) 20 | 21 | ## Flags 22 | 23 | Remember to prefix your bot token with "Bot " 24 | 25 | | Flag | description | 26 | |-------|----------------------------| 27 | | t | bot token | 28 | | p | bot prefix | 29 | | d | sound directory | 30 | | watch | hot reload on file changes | 31 | -------------------------------------------------------------------------------- /examples/soundboard/soundboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | 17 | "github.com/fsnotify/fsnotify" 18 | "github.com/rylio/ytdl" 19 | 20 | "github.com/jonas747/dca" 21 | 22 | "sync" 23 | 24 | "github.com/Necroforger/dgrouter/exrouter" 25 | "github.com/bwmarrin/discordgo" 26 | ) 27 | 28 | // Command line flags 29 | var ( 30 | fToken = flag.String("t", "", "bot token") 31 | fPrefix = flag.String("p", "!", "bot prefix") 32 | fSoundDir = flag.String("d", "sounds", "directory of sound files") 33 | fWatch = flag.Bool("watch", false, "watch the sound directory for changes") 34 | ) 35 | 36 | func reply(ctx *exrouter.Context, args ...interface{}) { 37 | _, err := ctx.Reply(args...) 38 | if err != nil { 39 | log.Println("error sending message: ", err) 40 | } 41 | } 42 | 43 | func decodeFromFile(path string, v *[][]string) error { 44 | f, err := os.Open(path) 45 | if err != nil { 46 | return err 47 | } 48 | defer f.Close() 49 | 50 | if strings.HasSuffix(path, ".txt") { 51 | t, err := ioutil.ReadFile(path) 52 | if err != nil { 53 | return err 54 | } 55 | decodeFromString(string(t), v) 56 | } 57 | 58 | return json.NewDecoder(f).Decode(v) 59 | } 60 | 61 | func decodeFromString(txt string, v *[][]string) { 62 | lines := strings.Split(txt, "\n") 63 | for _, ln := range lines { 64 | // Skip comments and empty lines 65 | if strings.HasPrefix(ln, "#") || strings.TrimSpace(ln) == "" { 66 | continue 67 | } 68 | 69 | // Split into space separated pairs 70 | pair := strings.SplitN(ln, " ", 2) 71 | 72 | // Trim the extra spaces 73 | for i := 0; i < len(pair); i++ { 74 | pair[i] = strings.TrimSpace(pair[i]) 75 | } 76 | 77 | *v = append(*v, pair) 78 | } 79 | } 80 | 81 | func trimExtension(p string) string { 82 | return strings.TrimSuffix(p, filepath.Ext(p)) 83 | } 84 | 85 | func createRouter(s *discordgo.Session) *exrouter.Route { 86 | router := exrouter.New() 87 | 88 | // Create playback functions 89 | files, err := ioutil.ReadDir(*fSoundDir) 90 | if err != nil { 91 | log.Fatal("no sound directory: ", err) 92 | } 93 | for _, v := range files { 94 | if v.IsDir() { 95 | continue 96 | } 97 | if filepath.Ext(v.Name()) == ".json" || filepath.Ext(v.Name()) == ".txt" { 98 | var data = [][]string{} 99 | decodeFromFile(path.Join(*fSoundDir, v.Name()), &data) 100 | for _, d := range data { 101 | router.On(trimExtension(d[0]), createYoutubeFunction(d[1])).Desc("Plays " + d[1]) 102 | } 103 | continue 104 | } 105 | router.On( 106 | trimExtension(v.Name()), 107 | createMusicFunction(filepath.Join(*fSoundDir, v.Name())), 108 | ).Desc("plays " + v.Name()) 109 | } 110 | 111 | router.On("stop", func(ctx *exrouter.Context) { 112 | stopStreaming(ctx.Msg.GuildID) 113 | }).Desc("Stops the currently running stream") 114 | 115 | router.On("leave", func(ctx *exrouter.Context) { 116 | s.Lock() 117 | if vc, ok := s.VoiceConnections[ctx.Msg.GuildID]; ok { 118 | err := vc.Disconnect() 119 | if err != nil { 120 | reply(ctx, "error disconnecting from voice channel: ", err) 121 | } 122 | } 123 | s.Unlock() 124 | }).Desc("Leaves the current voice channel") 125 | 126 | router.On("yt", func(ctx *exrouter.Context) { 127 | createYoutubeFunction(ctx.Args.Get(1))(ctx) 128 | }).Desc("plays a youtube link").Alias("youtube") 129 | 130 | // Create help route and set it to the default route for bot mentions 131 | router.Default = router.On("help", func(ctx *exrouter.Context) { 132 | var text = "" 133 | var maxlen int 134 | for _, v := range router.Routes { 135 | if len(v.Name) > maxlen { 136 | maxlen = len(v.Name) 137 | } 138 | } 139 | for _, v := range router.Routes { 140 | text += fmt.Sprintf("%-"+strconv.Itoa(maxlen+5)+"s: %s\n", v.Name, v.Description) 141 | } 142 | reply(ctx, "```"+text+"```") 143 | }).Desc("prints this help menu") 144 | 145 | return router 146 | } 147 | 148 | func watchDirectory(dir string, fn func(fsnotify.Event)) *fsnotify.Watcher { 149 | watcher, err := fsnotify.NewWatcher() 150 | if err != nil { 151 | log.Fatal(err) 152 | } 153 | 154 | go func() { 155 | for { 156 | select { 157 | case event, ok := <-watcher.Events: 158 | if !ok { 159 | return 160 | } 161 | log.Println("event:", event) 162 | if event.Op&fsnotify.Write == fsnotify.Write { 163 | log.Println("modified file:", event.Name) 164 | } 165 | fn(event) 166 | case err, ok := <-watcher.Errors: 167 | if !ok { 168 | return 169 | } 170 | log.Println("error:", err) 171 | } 172 | } 173 | }() 174 | 175 | err = watcher.Add(dir) 176 | if err != nil { 177 | log.Fatal(err) 178 | } 179 | 180 | log.Println("Listening for changes in directory: " + dir) 181 | 182 | return watcher 183 | } 184 | 185 | func main() { 186 | flag.Parse() 187 | 188 | s, err := discordgo.New(*fToken) 189 | if err != nil { 190 | log.Fatal(err) 191 | } 192 | 193 | var rmu sync.RWMutex 194 | router := createRouter(s) 195 | 196 | // Rebuild the router when the sound directory is modified 197 | if *fWatch { 198 | watcher := watchDirectory(*fSoundDir, func(evt fsnotify.Event) { 199 | rmu.Lock() 200 | defer rmu.Unlock() 201 | router = createRouter(s) 202 | }) 203 | defer watcher.Close() 204 | } 205 | 206 | // Add message handler 207 | s.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) { 208 | rmu.RLock() 209 | defer rmu.RUnlock() 210 | // TODO : allow handlers to be called in goroutines to fix race condition when hot-reloading 211 | go router.FindAndExecute(s, *fPrefix, s.State.User.ID, m.Message) 212 | }) 213 | 214 | err = s.Open() 215 | if err != nil { 216 | log.Fatal(err) 217 | } 218 | 219 | log.Println("bot is running...") 220 | // Prevent the bot from exiting 221 | <-make(chan struct{}) 222 | } 223 | 224 | // thread safe map 225 | type syncmap struct { 226 | sync.RWMutex 227 | Data map[string]interface{} 228 | } 229 | 230 | func newSyncmap() *syncmap { 231 | return &syncmap{ 232 | Data: map[string]interface{}{}, 233 | } 234 | } 235 | 236 | // Set ... 237 | func (s *syncmap) Set(key string, value interface{}) { 238 | s.Lock() 239 | s.Data[key] = value 240 | s.Unlock() 241 | } 242 | 243 | // Get ... 244 | func (s *syncmap) Get(key string) (interface{}, bool) { 245 | s.RLock() 246 | defer s.RUnlock() 247 | if v, ok := s.Data[key]; ok { 248 | return v, true 249 | } 250 | return nil, false 251 | } 252 | 253 | // links guild ids to their dca encoder session 254 | var streams = newSyncmap() 255 | 256 | type streamSession struct { 257 | EncodeSession *dca.EncodeSession 258 | StreamSession *dca.StreamingSession 259 | } 260 | 261 | func createMusicFunction(fpath string) func(ctx *exrouter.Context) { 262 | return func(ctx *exrouter.Context) { 263 | vc, err := getVoiceConnection(ctx.Ses, ctx.Msg.Author.ID, ctx.Msg.GuildID) 264 | if err != nil { 265 | reply(ctx, "error obtaining voice connection") 266 | log.Println("error getting voice connection: ", err) 267 | return 268 | } 269 | 270 | log.Println("Creating encoding session") 271 | 272 | opts := dca.StdEncodeOptions 273 | opts.RawOutput = true 274 | opts.Bitrate = 120 275 | 276 | encodeSession, err := dca.EncodeFile(fpath, opts) 277 | if err != nil { 278 | reply(ctx, "error creating encode session") 279 | log.Println("error creating encode session: ", err) 280 | return 281 | } 282 | 283 | // set speaking to true 284 | if err := vc.Speaking(true); err != nil { 285 | reply(ctx, "could not set speaking to true") 286 | return 287 | } 288 | defer vc.Speaking(false) 289 | 290 | done := make(chan error) 291 | streamer := dca.NewStream(encodeSession, vc, done) 292 | 293 | // Stop any currently running stream 294 | stopStreaming(vc.GuildID) 295 | 296 | log.Println("Adding streaming to map") 297 | streams.Set(ctx.Msg.GuildID, &streamSession{ 298 | EncodeSession: encodeSession, 299 | StreamSession: streamer, 300 | }) 301 | 302 | if err := <-done; err != nil { 303 | log.Println(err) 304 | // Clean up incase something happened and ffmpeg is still running 305 | encodeSession.Truncate() 306 | } 307 | } 308 | } 309 | 310 | // createYoutubeFunction creates a function for streaming youtube videos 311 | func createYoutubeFunction(yurl string) func(ctx *exrouter.Context) { 312 | return func(ctx *exrouter.Context) { 313 | vc, err := getVoiceConnection(ctx.Ses, ctx.Msg.Author.ID, ctx.Msg.GuildID) 314 | if err != nil { 315 | reply(ctx, "error obtaining voice connection") 316 | log.Println("error getting voice connection: ", err) 317 | return 318 | } 319 | 320 | err = playYoutubeVideo(vc, yurl) 321 | if err != nil { 322 | reply(ctx, "error playing youtube video: ", err) 323 | } 324 | } 325 | } 326 | 327 | func playYoutubeVideo(vc *discordgo.VoiceConnection, yurl string) error { 328 | info, err := ytdl.GetVideoInfo(yurl) 329 | if err != nil { 330 | return err 331 | } 332 | 333 | rd, wr := io.Pipe() 334 | go func() { 335 | if key := info.Formats.Best(ytdl.FormatAudioEncodingKey); len(key) != 0 { 336 | err := info.Download(key[0], wr) 337 | if err != nil { 338 | log.Println(err) 339 | } 340 | } 341 | wr.Close() 342 | }() 343 | 344 | opts := dca.StdEncodeOptions 345 | opts.RawOutput = true 346 | opts.Bitrate = 120 347 | 348 | encodeSession, err := dca.EncodeMem(rd, opts) 349 | if err != nil { 350 | return err 351 | } 352 | 353 | // set speaking to true 354 | if err := vc.Speaking(true); err != nil { 355 | return err 356 | } 357 | defer vc.Speaking(false) 358 | 359 | done := make(chan error) 360 | streamer := dca.NewStream(encodeSession, vc, done) 361 | 362 | // Stop any currently running stream 363 | stopStreaming(vc.GuildID) 364 | 365 | log.Println("Adding streaming to map") 366 | streams.Set(vc.GuildID, &streamSession{ 367 | EncodeSession: encodeSession, 368 | StreamSession: streamer, 369 | }) 370 | 371 | if err := <-done; err != nil { 372 | log.Println(err) 373 | // Clean up incase something happened and ffmpeg is still running 374 | encodeSession.Cleanup() 375 | } 376 | 377 | return nil 378 | } 379 | 380 | func stopStreaming(guildID string) { 381 | if v, ok := streams.Get(guildID); ok { 382 | // v.(*streamSession).EncodeSession.Cleanup() 383 | err := v.(*streamSession).EncodeSession.Stop() 384 | if err != nil { 385 | log.Println("error stopping streaming session: ", err) 386 | } 387 | v.(*streamSession).EncodeSession.Cleanup() 388 | } 389 | } 390 | 391 | // getVoiceConnection gets a bot's voice connection 392 | func getVoiceConnection(s *discordgo.Session, userID string, guildID string) (*discordgo.VoiceConnection, error) { 393 | guild, err := s.State.Guild(guildID) 394 | if err != nil { 395 | guild, err = s.Guild(guildID) 396 | if err != nil { 397 | return nil, err 398 | } 399 | } 400 | 401 | for _, v := range guild.VoiceStates { 402 | if v.UserID == userID { 403 | return s.ChannelVoiceJoin(guildID, v.ChannelID, false, false) 404 | } 405 | } 406 | 407 | return nil, errors.New("Voice connection not found") 408 | } 409 | -------------------------------------------------------------------------------- /examples/soundboard/sounds/interlude.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Necroforger/dgrouter/e66453b957c1bcce881b9dabe3d1fe2627aec394/examples/soundboard/sounds/interlude.mp3 -------------------------------------------------------------------------------- /examples/soundboard/sounds/songs.json: -------------------------------------------------------------------------------- 1 | [ 2 | ["kosmodrom", "https://www.youtube.com/watch?v=LfNHbOtcbNg"], 3 | ["root_to_root", "https://youtu.be/-aM799BIMMA"], 4 | ["scarlet", "https://youtu.be/x7fMgT0FfHs"], 5 | ["bad_apple", "https://youtu.be/-CQ0I7S19jw"] 6 | ] -------------------------------------------------------------------------------- /examples/soundboard/sounds/songs.txt: -------------------------------------------------------------------------------- 1 | # This is another method for adding commands that link to youtube videos 2 | # use space separated pairs each on a new line. file extension must be .txt 3 | 4 | # You don't have to put them in columns, but I do because it looks better. 5 | 6 | ghost_ship https://youtu.be/LBI3caejkGE 7 | scarlet2 https://youtu.be/ijXTen8q4W4 -------------------------------------------------------------------------------- /examples/subrouters/subrouters.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "strings" 7 | 8 | "github.com/Necroforger/dgrouter/exrouter" 9 | "github.com/bwmarrin/discordgo" 10 | ) 11 | 12 | // Command line flags 13 | var ( 14 | fToken = flag.String("t", "", "bot token") 15 | fPrefix = flag.String("p", "!", "bot prefix") 16 | ) 17 | 18 | func main() { 19 | flag.Parse() 20 | 21 | s, err := discordgo.New(*fToken) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | router := exrouter.New() 27 | 28 | router.On("sub", nil). 29 | On("sub2", func(ctx *exrouter.Context) { 30 | ctx.Reply("sub2 called with arguments:\n", strings.Join(ctx.Args, ";")) 31 | }). 32 | On("sub3", func(ctx *exrouter.Context) { 33 | ctx.Reply("sub3 called with arguments:\n", strings.Join(ctx.Args, ";")) 34 | }) 35 | 36 | router.Default = router.On("help", func(ctx *exrouter.Context) { 37 | var f func(depth int, r *exrouter.Route) string 38 | f = func(depth int, r *exrouter.Route) string { 39 | text := "" 40 | for _, v := range r.Routes { 41 | text += strings.Repeat(" ", depth) + v.Name + " : " + v.Description + "\n" 42 | text += f(depth+1, &exrouter.Route{Route: v}) 43 | } 44 | return text 45 | } 46 | ctx.Reply("```" + f(0, router) + "```") 47 | }).Desc("prints this help menu") 48 | 49 | // Add message handler 50 | s.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) { 51 | router.FindAndExecute(s, *fPrefix, s.State.User.ID, m.Message) 52 | }) 53 | 54 | err = s.Open() 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | 59 | log.Println("bot is running...") 60 | // Prevent the bot from exiting 61 | <-make(chan struct{}) 62 | } 63 | -------------------------------------------------------------------------------- /examples/temporary-roles/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/boltdb/bolt" 14 | 15 | "github.com/Necroforger/dgrouter/exrouter" 16 | "github.com/bwmarrin/discordgo" 17 | ) 18 | 19 | var ( 20 | fToken = flag.String("t", "", "token to log into bot with") 21 | fDB = flag.String("db", "roles.db", "location of boltdb database file to store data in") 22 | fPrefix = flag.String("p", "!", "bot prefix") 23 | ) 24 | 25 | var database *bolt.DB 26 | 27 | const bucket = "roles" 28 | 29 | func monitorDatabase(s *discordgo.Session, done chan int) { 30 | t := time.NewTicker(time.Second * 1) 31 | for { 32 | select { 33 | case <-t.C: 34 | database.Update(func(tx *bolt.Tx) error { 35 | b, err := tx.CreateBucketIfNotExists([]byte(bucket)) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | b.ForEach(func(k, v []byte) error { 41 | var r RoleExpiration 42 | err := json.Unmarshal(v, &r) 43 | if err != nil { 44 | log.Println("Error unmarshaling JSON: ", err) 45 | return err 46 | } 47 | if time.Now().After(r.Expires) { 48 | err := s.GuildMemberRoleRemove(r.GuildID, r.UserID, r.RoleID) 49 | if err != nil { 50 | log.Println("error removing role") 51 | return err 52 | } 53 | err = b.Delete(k) 54 | if err != nil { 55 | log.Println("error deleting: ", err) 56 | return err 57 | } 58 | } 59 | return nil 60 | }) 61 | 62 | return nil 63 | }) 64 | case <-done: 65 | return 66 | } 67 | } 68 | } 69 | 70 | func main() { 71 | flag.Parse() 72 | 73 | s, err := discordgo.New(*fToken) 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | 78 | database, err = bolt.Open(*fDB, 0666, nil) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | 83 | done := make(chan int) // close this channel when you are done monitoring 84 | go monitorDatabase(s, done) 85 | 86 | err = s.Open() 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | 91 | r := exrouter.New() 92 | r.On("setrole", cmdRole).Desc("usage: setrole [userid] [role_name] [duration in seconds]") 93 | 94 | // Create help route and set it to the default route for bot mentions 95 | r.Default = r.On("help", func(ctx *exrouter.Context) { 96 | var text = "" 97 | var maxlen int 98 | for _, v := range r.Routes { 99 | if len(v.Name) > maxlen { 100 | maxlen = len(v.Name) 101 | } 102 | } 103 | for _, v := range r.Routes { 104 | text += fmt.Sprintf("%-"+strconv.Itoa(maxlen+5)+"s: %s\n", v.Name, v.Description) 105 | } 106 | ctx.Reply("```" + text + "```") 107 | }).Desc("prints this help menu") 108 | 109 | s.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { 110 | r.FindAndExecute(s, *fPrefix, s.State.User.ID, m.Message) 111 | }) 112 | 113 | log.Println("bot is running...") 114 | // Prevent the bot from exiting 115 | <-make(chan struct{}) 116 | } 117 | 118 | // RoleExpiration ... 119 | type RoleExpiration struct { 120 | RoleID string 121 | UserID string 122 | GuildID string 123 | Expires time.Time 124 | } 125 | 126 | func saveRoleExpiration(data RoleExpiration) error { 127 | return database.Update(func(tx *bolt.Tx) error { 128 | b, err := tx.CreateBucketIfNotExists([]byte(bucket)) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | id, err := b.NextSequence() 134 | if err != nil { 135 | return err 136 | } 137 | 138 | key := make([]byte, 8) 139 | binary.BigEndian.PutUint64(key, uint64(id)) 140 | 141 | s, err := json.Marshal(data) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | return b.Put(key, s) 147 | }) 148 | } 149 | 150 | func cmdRole(ctx *exrouter.Context) { 151 | if ctx.Args.Get(1) == "" || ctx.Args.Get(2) == "" { 152 | ctx.Reply(ctx.Route.Description) 153 | return 154 | } 155 | 156 | guild, err := ctx.Guild(ctx.Msg.GuildID) 157 | if err != nil { 158 | ctx.Reply("Guild not found") 159 | return 160 | } 161 | 162 | role := findRoleByName(guild, ctx.Args.Get(2)) 163 | if role == nil { 164 | ctx.Reply("Role not found") 165 | return 166 | } 167 | 168 | err = ctx.Ses.GuildMemberRoleAdd(ctx.Msg.GuildID, ctx.Args.Get(1), role.ID) 169 | if err != nil { 170 | ctx.Reply("Could not add role to member: ", err) 171 | return 172 | } 173 | 174 | var duration int 175 | if n, err := strconv.Atoi(ctx.Args.Get(3)); err == nil { 176 | duration = n 177 | } else { 178 | duration = 10 179 | } 180 | 181 | expires := time.Now().Add(time.Second * time.Duration(duration)) 182 | 183 | saveRoleExpiration(RoleExpiration{ 184 | Expires: expires, 185 | GuildID: ctx.Msg.GuildID, 186 | UserID: ctx.Args.Get(1), 187 | RoleID: role.ID, 188 | }) 189 | 190 | ctx.Reply("Set temporary role for user\nIt will expire on " + expires.String()) 191 | } 192 | 193 | func findRoleByName(guild *discordgo.Guild, name string) *discordgo.Role { 194 | for _, v := range guild.Roles { 195 | if strings.ToLower(v.Name) == name { 196 | return v 197 | } 198 | } 199 | return nil 200 | } 201 | -------------------------------------------------------------------------------- /exmiddleware/README.md: -------------------------------------------------------------------------------- 1 | # Exmiddleware 2 | Collection of middleware for common things 3 | 4 | ## Example 5 | ```go 6 | router.Group(func(r *exrouter.Route) { 7 | // Default callback function to use when a middleware has an error 8 | // It will reply to the sender with the error that occured 9 | reply := exmiddleware.CatchDefault 10 | 11 | // Create a specific reply for when a middleware errors 12 | replyNSFW := exmiddleware.CatchReply("You need to be in an NSFW channel to use this command") 13 | 14 | r.Use( 15 | // Inserts the calling member into ctx.Data 16 | exmiddleware.GetMember(reply), 17 | 18 | // Inserts the Guild in which the message came from into ctx.Data 19 | exmiddleware.GetGuild(reply), 20 | 21 | // Require that the message originates from an nsfw channel 22 | // If there is an error, run the function replyNSFW 23 | exmiddleware.RequireNSFW(replyNSFW), 24 | 25 | // Enforce that the commands in this group can only be used once per 10 seconds per user 26 | exmiddleware.UserCooldown(time.Second*10, exmiddleware.CatchReply("This command is on cooldown...")), 27 | ) 28 | 29 | r.On("nsfw", func(ctx *exrouter.Context) { 30 | ctx.Reply("This command was used in a NSFW channel\n" + 31 | "Your guild is: " + exmiddleware.Guild(ctx).Name + "\n" + 32 | "Your nickanme is: " + exmiddleware.Member(ctx).Nick, 33 | ) 34 | }) 35 | }) 36 | ``` -------------------------------------------------------------------------------- /exmiddleware/middleware.go: -------------------------------------------------------------------------------- 1 | package exmiddleware 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/Necroforger/dgrouter" 8 | "github.com/Necroforger/dgrouter/exrouter" 9 | ) 10 | 11 | const ( 12 | ctxPrefix = "middleware." 13 | ctxError = ctxPrefix + "err" 14 | ctxGuild = ctxPrefix + "guild" 15 | ctxChannel = ctxPrefix + "channel" 16 | ctxMember = ctxPrefix + "member" 17 | ) 18 | 19 | // Errors 20 | var ( 21 | ErrOnCooldown = errors.New("command is on cooldown") 22 | ErrChannelNotNSFW = errors.New("this command can only be used in an NSFW channel") 23 | ) 24 | 25 | // CatchFunc function called if one of the middleware experiences an error 26 | // Can be left as nil 27 | type CatchFunc func(ctx *exrouter.Context) 28 | 29 | // CatchDefault is the default catch function 30 | func CatchDefault(ctx *exrouter.Context) { 31 | if e := Err(ctx); e != nil { 32 | ctx.Reply("error: ", e) 33 | } 34 | } 35 | 36 | // CatchReply returns a function that prints the message you pass it 37 | func CatchReply(message string) func(ctx *exrouter.Context) { 38 | return func(ctx *exrouter.Context) { 39 | ctx.Reply(message) 40 | } 41 | } 42 | 43 | // UserCooldown creates a user specific cooldown timer for a route or collection of routes 44 | func UserCooldown(cooldown time.Duration, catch CatchFunc) exrouter.MiddlewareFunc { 45 | // Table is a map of userIDs to a map of routes which store the last time they were called 46 | // By a given user. 47 | table := map[string]map[*dgrouter.Route]time.Time{} 48 | 49 | return func(fn exrouter.HandlerFunc) exrouter.HandlerFunc { 50 | return func(ctx *exrouter.Context) { 51 | user, ok := table[ctx.Msg.Author.ID] 52 | if !ok { 53 | table[ctx.Msg.Author.ID] = map[*dgrouter.Route]time.Time{} 54 | user = table[ctx.Msg.Author.ID] 55 | } 56 | 57 | // Retrieve the last time this command was used 58 | last, ok := user[ctx.Route] 59 | if !ok { 60 | // Set the last time the command was used and return 61 | // If nothing was found 62 | user[ctx.Route] = time.Now() 63 | fn(ctx) 64 | return 65 | } 66 | 67 | if !time.Now().After(last.Add(cooldown)) { 68 | callCatch(ctx, catch, ErrOnCooldown) 69 | return 70 | } 71 | 72 | // Update the last time command was used 73 | user[ctx.Route] = time.Now() 74 | fn(ctx) 75 | } 76 | } 77 | } 78 | 79 | // RequireNSFW requires a message to be sent from an NSFW channel 80 | func RequireNSFW(catch CatchFunc) exrouter.MiddlewareFunc { 81 | return func(fn exrouter.HandlerFunc) exrouter.HandlerFunc { 82 | return func(ctx *exrouter.Context) { 83 | channel, err := getChannel(ctx.Ses, ctx.Msg.ChannelID) 84 | if err != nil { 85 | callCatch(ctx, catch, err) 86 | return 87 | } 88 | if !channel.NSFW { 89 | callCatch(ctx, catch, ErrChannelNotNSFW) 90 | return 91 | } 92 | ctx.Set(ctxChannel, channel) 93 | fn(ctx) 94 | } 95 | } 96 | } 97 | 98 | // GetGuild retrieves the guild in which the message was sent from 99 | func GetGuild(catch CatchFunc) exrouter.MiddlewareFunc { 100 | return func(fn exrouter.HandlerFunc) exrouter.HandlerFunc { 101 | return func(ctx *exrouter.Context) { 102 | guild, err := getGuild(ctx.Ses, ctx.Msg.GuildID) 103 | if err != nil { 104 | callCatch(ctx, catch, err) 105 | return 106 | } 107 | 108 | ctx.Set(ctxGuild, guild) 109 | fn(ctx) 110 | } 111 | } 112 | } 113 | 114 | // GetChannel retrieves the channel in which the message was sent from 115 | func GetChannel(catch CatchFunc) exrouter.MiddlewareFunc { 116 | return func(fn exrouter.HandlerFunc) exrouter.HandlerFunc { 117 | return func(ctx *exrouter.Context) { 118 | channel, err := getChannel(ctx.Ses, ctx.Msg.GuildID) 119 | if err != nil { 120 | callCatch(ctx, catch, err) 121 | return 122 | } 123 | 124 | ctx.Set(ctxChannel, channel) 125 | fn(ctx) 126 | } 127 | } 128 | } 129 | 130 | // GetMember retrieves the member of the message sender 131 | func GetMember(catch CatchFunc) exrouter.MiddlewareFunc { 132 | return func(fn exrouter.HandlerFunc) exrouter.HandlerFunc { 133 | return func(ctx *exrouter.Context) { 134 | member, err := getMember(ctx.Ses, ctx.Msg.GuildID, ctx.Msg.Author.ID) 135 | if err != nil { 136 | callCatch(ctx, catch, err) 137 | } 138 | ctx.Set(ctxMember, member) 139 | fn(ctx) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /exmiddleware/retrievers.go: -------------------------------------------------------------------------------- 1 | package exmiddleware 2 | 3 | import ( 4 | "github.com/Necroforger/dgrouter/exrouter" 5 | "github.com/bwmarrin/discordgo" 6 | ) 7 | 8 | // Err retrieves the error variable from the context 9 | func Err(ctx *exrouter.Context) error { 10 | if v := ctx.Get(ctxError); v != nil { 11 | return v.(error) 12 | } 13 | return nil 14 | } 15 | 16 | // Guild retrieves the guild variable from a context 17 | func Guild(ctx *exrouter.Context) *discordgo.Guild { 18 | if v := ctx.Get(ctxGuild); v != nil { 19 | return v.(*discordgo.Guild) 20 | } 21 | return nil 22 | } 23 | 24 | // Channel retrieves the channel variable from a context 25 | func Channel(ctx *exrouter.Context) *discordgo.Channel { 26 | if v := ctx.Get(ctxChannel); v != nil { 27 | return v.(*discordgo.Channel) 28 | } 29 | return nil 30 | } 31 | 32 | // Member fetches the member from the context 33 | func Member(ctx *exrouter.Context) *discordgo.Member { 34 | if v := ctx.Get(ctxMember); v != nil { 35 | return v.(*discordgo.Member) 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /exmiddleware/util.go: -------------------------------------------------------------------------------- 1 | package exmiddleware 2 | 3 | import ( 4 | "github.com/Necroforger/dgrouter/exrouter" 5 | "github.com/bwmarrin/discordgo" 6 | ) 7 | 8 | func getChannel(s *discordgo.Session, channelID string) (*discordgo.Channel, error) { 9 | channel, err := s.State.Channel(channelID) 10 | if err != nil { 11 | return s.Channel(channelID) 12 | } 13 | return channel, err 14 | } 15 | 16 | func getGuild(s *discordgo.Session, guildID string) (*discordgo.Guild, error) { 17 | guild, err := s.State.Guild(guildID) 18 | if err != nil { 19 | return s.Guild(guildID) 20 | } 21 | return guild, err 22 | } 23 | 24 | func getMember(s *discordgo.Session, guildID, userID string) (*discordgo.Member, error) { 25 | member, err := s.State.Member(guildID, userID) 26 | if err != nil { 27 | return s.GuildMember(guildID, userID) 28 | } 29 | return member, err 30 | } 31 | 32 | // callCatch calls a catch function with an error 33 | func callCatch(ctx *exrouter.Context, fn CatchFunc, err error) { 34 | if fn == nil { 35 | return 36 | } 37 | ctx.Set(ctxError, err) 38 | fn(ctx) 39 | } 40 | -------------------------------------------------------------------------------- /exrouter/args.go: -------------------------------------------------------------------------------- 1 | package exrouter 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "strings" 7 | ) 8 | 9 | // separator is the separator character for splitting arguments 10 | const separator = ' ' 11 | 12 | // Args is a helper type for dealing with command arguments 13 | type Args []string 14 | 15 | // Get returns the argument at index n 16 | func (a Args) Get(n int) string { 17 | if n >= 0 && n < len(a) { 18 | return a[n] 19 | } 20 | return "" 21 | } 22 | 23 | // After returns all arguments after index n 24 | func (a Args) After(n int) string { 25 | if n >= 0 && n < len(a) { 26 | return strings.Join(a[n:], string(separator)) 27 | } 28 | return "" 29 | } 30 | 31 | // ParseArgs parses command arguments 32 | func ParseArgs(content string) Args { 33 | cv := csv.NewReader(bytes.NewBufferString(content)) 34 | cv.Comma = separator 35 | fields, err := cv.Read() 36 | if err != nil { 37 | return strings.Split(content, string(separator)) 38 | } 39 | return fields 40 | } 41 | -------------------------------------------------------------------------------- /exrouter/context.go: -------------------------------------------------------------------------------- 1 | package exrouter 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/Necroforger/dgrouter" 8 | 9 | "github.com/bwmarrin/discordgo" 10 | ) 11 | 12 | // Context represents a command context 13 | type Context struct { 14 | // Route is the route that this command came from 15 | Route *dgrouter.Route 16 | Msg *discordgo.Message 17 | Ses *discordgo.Session 18 | 19 | // List of arguments supplied with the command 20 | Args Args 21 | 22 | // Vars that can be optionally set using the Set and Get functions 23 | vmu sync.RWMutex 24 | Vars map[string]interface{} 25 | } 26 | 27 | // Set sets a variable on the context 28 | func (c *Context) Set(key string, d interface{}) { 29 | c.vmu.Lock() 30 | c.Vars[key] = d 31 | c.vmu.Unlock() 32 | } 33 | 34 | // Get retrieves a variable from the context 35 | func (c *Context) Get(key string) interface{} { 36 | if c, ok := c.Vars[key]; ok { 37 | return c 38 | } 39 | return nil 40 | } 41 | 42 | // Reply replies to the sender with the given message 43 | func (c *Context) Reply(args ...interface{}) (*discordgo.Message, error) { 44 | return c.Ses.ChannelMessageSend(c.Msg.ChannelID, fmt.Sprint(args...)) 45 | } 46 | 47 | // ReplyEmbed replies to the sender with an embed 48 | func (c *Context) ReplyEmbed(args ...interface{}) (*discordgo.Message, error) { 49 | return c.Ses.ChannelMessageSendEmbed(c.Msg.ChannelID, &discordgo.MessageEmbed{ 50 | Description: fmt.Sprint(args...), 51 | }) 52 | } 53 | 54 | // Guild retrieves a guild from the state or restapi 55 | func (c *Context) Guild(guildID string) (*discordgo.Guild, error) { 56 | g, err := c.Ses.State.Guild(guildID) 57 | if err != nil { 58 | g, err = c.Ses.Guild(guildID) 59 | } 60 | return g, err 61 | } 62 | 63 | // Channel retrieves a channel from the state or restapi 64 | func (c *Context) Channel(channelID string) (*discordgo.Channel, error) { 65 | ch, err := c.Ses.State.Channel(channelID) 66 | if err != nil { 67 | ch, err = c.Ses.Channel(channelID) 68 | } 69 | return ch, err 70 | } 71 | 72 | // Member retrieves a member from the state or restapi 73 | func (c *Context) Member(guildID, userID string) (*discordgo.Member, error) { 74 | m, err := c.Ses.State.Member(guildID, userID) 75 | if err != nil { 76 | m, err = c.Ses.GuildMember(guildID, userID) 77 | } 78 | return m, err 79 | } 80 | 81 | // NewContext returns a new context from a message 82 | func NewContext(s *discordgo.Session, m *discordgo.Message, args Args, route *dgrouter.Route) *Context { 83 | return &Context{ 84 | Route: route, 85 | Msg: m, 86 | Ses: s, 87 | Args: args, 88 | Vars: map[string]interface{}{}, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /exrouter/router_wrapper.go: -------------------------------------------------------------------------------- 1 | package exrouter 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/Necroforger/dgrouter" 7 | "github.com/bwmarrin/discordgo" 8 | ) 9 | 10 | // HandlerFunc ... 11 | type HandlerFunc func(*Context) 12 | 13 | // MiddlewareFunc is middleware 14 | type MiddlewareFunc func(HandlerFunc) HandlerFunc 15 | 16 | // Route wraps dgrouter.Router to use a Context 17 | type Route struct { 18 | *dgrouter.Route 19 | } 20 | 21 | // New returns a new router wrapper 22 | func New() *Route { 23 | return &Route{ 24 | Route: dgrouter.New(), 25 | } 26 | } 27 | 28 | // On registers a handler function 29 | func (r *Route) On(name string, handler HandlerFunc) *Route { 30 | return &Route{r.Route.On(name, WrapHandler(handler))} 31 | } 32 | 33 | // Group ... 34 | func (r *Route) Group(fn func(rt *Route)) *Route { 35 | return &Route{r.Route.Group(func(r *dgrouter.Route) { 36 | fn(&Route{r}) 37 | })} 38 | } 39 | 40 | // Use ... 41 | func (r *Route) Use(fn ...MiddlewareFunc) *Route { 42 | wrapped := []dgrouter.MiddlewareFunc{} 43 | for _, v := range fn { 44 | wrapped = append(wrapped, WrapMiddleware(v)) 45 | } 46 | return &Route{ 47 | r.Route.Use(wrapped...), 48 | } 49 | } 50 | 51 | // WrapMiddleware ... 52 | func WrapMiddleware(mware MiddlewareFunc) dgrouter.MiddlewareFunc { 53 | return func(next dgrouter.HandlerFunc) dgrouter.HandlerFunc { 54 | return func(i interface{}) { 55 | WrapHandler(mware(UnwrapHandler(next)))(i) 56 | } 57 | } 58 | } 59 | 60 | // OnMatch registers a route with the given matcher 61 | func (r *Route) OnMatch(name string, matcher func(string) bool, handler HandlerFunc) *Route { 62 | return &Route{r.Route.OnMatch(name, matcher, WrapHandler(handler))} 63 | } 64 | 65 | func mention(id string) string { 66 | return "<@" + id + ">" 67 | } 68 | 69 | func nickMention(id string) string { 70 | return "<@!" + id + ">" 71 | } 72 | 73 | // FindAndExecute is a helper method for calling routes 74 | // it creates a context from a message, finds its route, and executes the handler 75 | // it looks for a message prefix which is either the prefix specified or the message is prefixed 76 | // with a bot mention 77 | // s : discordgo session to pass to context 78 | // prefix : prefix you want the bot to respond to 79 | // botID : user ID of the bot to allow you to substitute the bot ID for a prefix 80 | // m : discord message to pass to context 81 | func (r *Route) FindAndExecute(s *discordgo.Session, prefix string, botID string, m *discordgo.Message) error { 82 | var pf string 83 | 84 | // If the message content is only a bot mention and the mention route is not nil, send the mention route 85 | if r.Default != nil && m.Content == mention(botID) || r.Default != nil && m.Content == nickMention(botID) { 86 | r.Default.Handler(NewContext(s, m, []string{""}, r.Default)) 87 | return nil 88 | } 89 | 90 | // Append a space to the mentions 91 | bmention := mention(botID) + " " 92 | nmention := nickMention(botID) + " " 93 | 94 | p := func(t string) bool { 95 | return strings.HasPrefix(m.Content, t) 96 | } 97 | 98 | switch { 99 | case prefix != "" && p(prefix): 100 | pf = prefix 101 | case p(bmention): 102 | pf = bmention 103 | case p(nmention): 104 | pf = nmention 105 | default: 106 | return dgrouter.ErrCouldNotFindRoute 107 | } 108 | 109 | command := strings.TrimPrefix(m.Content, pf) 110 | args := ParseArgs(command) 111 | 112 | if rt, depth := r.FindFull(args...); depth > 0 { 113 | args = append([]string{strings.Join(args[:depth], string(separator))}, args[depth:]...) 114 | rt.Handler(NewContext(s, m, args, rt)) 115 | } else { 116 | return dgrouter.ErrCouldNotFindRoute 117 | } 118 | 119 | return nil 120 | } 121 | 122 | // WrapHandler wraps a dgrouter.HandlerFunc 123 | func WrapHandler(fn HandlerFunc) dgrouter.HandlerFunc { 124 | if fn == nil { 125 | return nil 126 | } 127 | return func(i interface{}) { 128 | fn(i.(*Context)) 129 | } 130 | } 131 | 132 | // UnwrapHandler unwraps a handler 133 | func UnwrapHandler(fn dgrouter.HandlerFunc) HandlerFunc { 134 | return func(ctx *Context) { 135 | fn(ctx) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /exrouter/router_wrapper_test.go: -------------------------------------------------------------------------------- 1 | package exrouter_test 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/Necroforger/dgrouter" 8 | "github.com/Necroforger/dgrouter/exrouter" 9 | "github.com/bwmarrin/discordgo" 10 | ) 11 | 12 | func TestRouter(t *testing.T) { 13 | messages := []string{ 14 | "!ping", 15 | "!say hello", 16 | "!test args one two three", 17 | "<@botid> say hello", 18 | "<@!botid> say hello", 19 | "<@!botid>", 20 | } 21 | 22 | r := exrouter.Route{ 23 | Route: dgrouter.New(), 24 | } 25 | 26 | r.On("ping", func(ctx *exrouter.Context) {}) 27 | 28 | r.On("say", func(ctx *exrouter.Context) { 29 | if ctx.Args.Get(1) != "hello" { 30 | t.Fail() 31 | } 32 | }) 33 | 34 | r.On("test", func(ctx *exrouter.Context) { 35 | ctx.Set("hello", "hi") 36 | if r := ctx.Get("hello"); r.(string) != "hi" { 37 | t.Fail() 38 | } 39 | expected := []string{"args", "one", "two", "three"} 40 | for i, v := range expected { 41 | if ctx.Args.Get(i+1) != v { 42 | t.Fail() 43 | } 44 | } 45 | }) 46 | 47 | mentionRoute := r.On("help", func(ctx *exrouter.Context) { 48 | log.Println("Bot was mentioned") 49 | }) 50 | 51 | // Set the default route for this router 52 | // Will be triggered on bot mentions 53 | r.Handler = mentionRoute.Handler 54 | 55 | for _, v := range messages { 56 | // Construct mock message 57 | msg := &discordgo.Message{ 58 | Author: &discordgo.User{ 59 | Username: "necroforger", 60 | Bot: false, 61 | }, 62 | Content: v, 63 | } 64 | 65 | // Attempt to find and execute the route for this message 66 | err := r.FindAndExecute(nil, "!", "botid", msg) 67 | if err != nil { 68 | t.Fail() 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /matchers.go: -------------------------------------------------------------------------------- 1 | package dgrouter 2 | 3 | import "regexp" 4 | 5 | // NewRegexMatcher returns a new regex matcher 6 | func NewRegexMatcher(regex string) func(string) bool { 7 | r := regexp.MustCompile(regex) 8 | return func(command string) bool { 9 | return r.MatchString(command) 10 | } 11 | } 12 | 13 | // NewNameMatcher returns a matcher that matches a route's name and aliases 14 | func NewNameMatcher(r *Route) func(string) bool { 15 | return func(command string) bool { 16 | for _, v := range r.Aliases { 17 | if command == v { 18 | return true 19 | } 20 | } 21 | return command == r.Name 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /route.go: -------------------------------------------------------------------------------- 1 | package dgrouter 2 | 3 | // Route is a command route 4 | type Route struct { 5 | // Routes is a slice of subroutes 6 | Routes []*Route 7 | 8 | Name string 9 | Aliases []string 10 | Description string 11 | Category string 12 | 13 | // Matcher is a function that determines 14 | // If this route will be matched 15 | Matcher func(string) bool 16 | 17 | // Handler is the Handler for this route 18 | Handler HandlerFunc 19 | 20 | // Default route for responding to bot mentions 21 | Default *Route 22 | 23 | // The parent for this route 24 | Parent *Route 25 | 26 | // Middleware to be applied when adding subroutes 27 | Middleware []MiddlewareFunc 28 | } 29 | 30 | // Desc sets this routes description 31 | func (r *Route) Desc(description string) *Route { 32 | r.Description = description 33 | return r 34 | } 35 | 36 | // Cat sets this route's category 37 | func (r *Route) Cat(category string) *Route { 38 | r.Category = category 39 | return r 40 | } 41 | 42 | // Alias appends aliases to this route's alias list 43 | func (r *Route) Alias(aliases ...string) *Route { 44 | r.Aliases = append(r.Aliases, aliases...) 45 | return r 46 | } 47 | --------------------------------------------------------------------------------