├── LICENSE ├── README.md ├── arguments.go ├── copied_from_d.go ├── ctx.go ├── ctx_test.go ├── error.go ├── example ├── README.md ├── commands.go ├── debug │ └── debug.go └── main.go ├── extras └── arguments │ ├── emoji.go │ ├── flag.go │ └── mention.go ├── go.mod ├── go.sum ├── nameflag.go ├── nameflag_test.go ├── routes.go.bak ├── subcommand.go └── subcommand_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 diamondburned (Forefront) 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [rfrouter](https://godoc.org/git.sr.ht/~diamondburned/rfrouter) 2 | 3 | Proof-of-concept 4 | 5 | ## Usage 6 | 7 | ```go 8 | var token = "Bot 123456abcxyz" 9 | var cmds = Commands{} 10 | 11 | c, err := rfrouter.StartBot(token, &cmds, 12 | func(ctx *rfrouter.Context) error { 13 | // Set the prefix 14 | ctx.Prefix = "~" 15 | 16 | // Set the descriptions 17 | ctx.Name = "bot example" 18 | ctx.Description = "https://git.sr.ht/~diamondburned/rfrouter" 19 | 20 | return err 21 | }, 22 | ) 23 | 24 | if err != nil { 25 | log.Fatalln("Failed to start the bot:", err) 26 | } 27 | 28 | defer c() 29 | ``` 30 | 31 | ```go 32 | type Commands struct { 33 | // Field name can be anything, but this field must be provided 34 | Ctx *Context 35 | } 36 | 37 | func (c *Commands) Send(msg *discordgo.MessageCreate, arg string) error { 38 | _, err := c.Ctx.Send(msg.ChannelID, "You sent: " + arg) 39 | return err 40 | } 41 | ``` 42 | 43 | ## Features 44 | 45 | - Automatic command routing from Go methods 46 | - Implicit name conversion between strings and method names 47 | - Mapping Go arguments from strings 48 | - Pluggable parsers and arguments 49 | - Subcommands allow for plug-ins 50 | - Help page generation 51 | 52 | ## Non-features 53 | 54 | - Descriptions for commands: impossible (or otherwise impractical) without any 55 | form of code parsing. 56 | 57 | ## Some extra features nobody cares about 58 | 59 | ### Interfaces 60 | 61 | #### Parseable 62 | 63 | ```go 64 | // Parseable implements a Parse(string) method for data structures that can be 65 | // used as arguments. 66 | type Parseable interface { 67 | Parse(string) error 68 | } 69 | ``` 70 | 71 | ###### Example (refer to `extras/arguments/emoji.go`) 72 | 73 | #### ManualParseable 74 | 75 | ```go 76 | // ManualParseable implements a ParseContent(string) method. If the library sees 77 | // this for an argument, it will send all of the arguments (including the 78 | // command) into the method. If used, this should be the only argument followed 79 | // after the Message Create event. Any more and the router will ignore. 80 | type ManualParseable interface { 81 | // $0 will have its prefix trimmed. 82 | ParseContent([]string) error 83 | } 84 | ``` 85 | 86 | ###### Example (refer to `extras/arguments/flag.go`) 87 | 88 | #### Usager 89 | 90 | ```go 91 | // Usager is optionally used to override the generated usage for either an 92 | // argument, or multiple (using ManualParseable). 93 | type Usager interface { 94 | Usage() string 95 | } 96 | ``` 97 | 98 | #### Example (refer to `extras/arguments/mention.go`) 99 | -------------------------------------------------------------------------------- /arguments.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "strconv" 7 | ) 8 | 9 | type argumentValueFn func(string) (reflect.Value, error) 10 | 11 | // Parseable implements a Parse(string) method for data structures that can be 12 | // used as arguments. 13 | type Parseable interface { 14 | Parse(string) error 15 | } 16 | 17 | // ManaulParseable implements a ParseContent(string) method. If the library sees 18 | // this for an argument, it will send all of the arguments (including the 19 | // command) into the method. If used, this should be the only argument followed 20 | // after the Message Create event. Any more and the router will ignore. 21 | type ManualParseable interface { 22 | // $0 will have its prefix trimmed. 23 | ParseContent([]string) error 24 | } 25 | 26 | type RawArguments struct { 27 | Arguments []string 28 | } 29 | 30 | func (r *RawArguments) ParseContent(args []string) error { 31 | r.Arguments = args 32 | return nil 33 | } 34 | 35 | // nilV, only used to return an error 36 | var nilV = reflect.Value{} 37 | 38 | func getArgumentValueFn(t reflect.Type) (argumentValueFn, error) { 39 | if t.Implements(typeIParser) { 40 | mt, ok := t.MethodByName("Parse") 41 | if !ok { 42 | panic("BUG: type IParser does not implement Parse") 43 | } 44 | 45 | return func(input string) (reflect.Value, error) { 46 | v := reflect.New(t.Elem()) 47 | 48 | ret := mt.Func.Call([]reflect.Value{ 49 | v, reflect.ValueOf(input), 50 | }) 51 | 52 | if err := errorReturns(ret); err != nil { 53 | return nilV, err 54 | } 55 | 56 | return v, nil 57 | }, nil 58 | } 59 | 60 | var fn argumentValueFn 61 | 62 | switch t.Kind() { 63 | case reflect.String: 64 | fn = func(s string) (reflect.Value, error) { 65 | return reflect.ValueOf(s), nil 66 | } 67 | 68 | case reflect.Int, reflect.Int8, 69 | reflect.Int16, reflect.Int32, reflect.Int64: 70 | 71 | fn = func(s string) (reflect.Value, error) { 72 | i, err := strconv.ParseInt(s, 10, 64) 73 | return quickRet(i, err, t) 74 | } 75 | 76 | case reflect.Uint, reflect.Uint8, 77 | reflect.Uint16, reflect.Uint32, reflect.Uint64: 78 | 79 | fn = func(s string) (reflect.Value, error) { 80 | u, err := strconv.ParseUint(s, 10, 64) 81 | return quickRet(u, err, t) 82 | } 83 | 84 | case reflect.Float32, reflect.Float64: 85 | fn = func(s string) (reflect.Value, error) { 86 | f, err := strconv.ParseFloat(s, 64) 87 | return quickRet(f, err, t) 88 | } 89 | 90 | case reflect.Bool: 91 | fn = func(s string) (reflect.Value, error) { 92 | switch s { 93 | case "true", "yes", "y", "Y", "1": 94 | return reflect.ValueOf(true), nil 95 | case "false", "no", "n", "N", "0": 96 | return reflect.ValueOf(false), nil 97 | default: 98 | return nilV, errors.New("invalid bool [true/false]") 99 | } 100 | } 101 | } 102 | 103 | if fn == nil { 104 | return nil, errors.New("invalid type: " + t.String()) 105 | } 106 | 107 | return fn, nil 108 | } 109 | 110 | func quickRet(v interface{}, err error, t reflect.Type) (reflect.Value, error) { 111 | if err != nil { 112 | return nilV, err 113 | } 114 | 115 | rv := reflect.ValueOf(v) 116 | 117 | if t == nil { 118 | return rv, nil 119 | } 120 | 121 | return rv.Convert(t), nil 122 | } 123 | -------------------------------------------------------------------------------- /copied_from_d.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import "github.com/bwmarrin/discordgo" 4 | 5 | // UserPermissions but userID is after channelID. 6 | func (ctx *Context) UserPermissions(channelID, userID string, 7 | ) (apermissions int, err error) { 8 | 9 | // Try to just get permissions from state. 10 | apermissions, err = ctx.Session.State.UserChannelPermissions( 11 | userID, channelID) 12 | if err == nil { 13 | return 14 | } 15 | 16 | // Otherwise try get as much data from state as possible, falling back to the network. 17 | channel, err := ctx.Channel(channelID) 18 | if err != nil { 19 | return 20 | } 21 | 22 | guild, err := ctx.Guild(channel.GuildID) 23 | if err != nil { 24 | return 25 | } 26 | 27 | if userID == guild.OwnerID { 28 | apermissions = discordgo.PermissionAll 29 | return 30 | } 31 | 32 | member, err := ctx.Member(guild.ID, userID) 33 | if err != nil { 34 | return 35 | } 36 | 37 | return MemberPermissions(guild, channel, member), nil 38 | } 39 | 40 | // Why this isn't exported, I have no idea. 41 | func MemberPermissions(guild *discordgo.Guild, channel *discordgo.Channel, 42 | member *discordgo.Member) (apermissions int) { 43 | 44 | userID := member.User.ID 45 | 46 | if userID == guild.OwnerID { 47 | apermissions = discordgo.PermissionAll 48 | return 49 | } 50 | 51 | for _, role := range guild.Roles { 52 | if role.ID == guild.ID { 53 | apermissions |= role.Permissions 54 | break 55 | } 56 | } 57 | 58 | for _, role := range guild.Roles { 59 | for _, roleID := range member.Roles { 60 | if role.ID == roleID { 61 | apermissions |= role.Permissions 62 | break 63 | } 64 | } 65 | } 66 | 67 | if apermissions&discordgo.PermissionAdministrator == 68 | discordgo.PermissionAdministrator { 69 | 70 | apermissions |= discordgo.PermissionAll 71 | } 72 | 73 | // Apply @everyone overrides from the channel. 74 | for _, overwrite := range channel.PermissionOverwrites { 75 | if guild.ID == overwrite.ID { 76 | apermissions &= ^overwrite.Deny 77 | apermissions |= overwrite.Allow 78 | break 79 | } 80 | } 81 | 82 | denies := 0 83 | allows := 0 84 | 85 | // Member overwrites can override role overrides, so do two passes 86 | for _, overwrite := range channel.PermissionOverwrites { 87 | for _, roleID := range member.Roles { 88 | if overwrite.Type == "role" && roleID == overwrite.ID { 89 | denies |= overwrite.Deny 90 | allows |= overwrite.Allow 91 | break 92 | } 93 | } 94 | } 95 | 96 | apermissions &= ^denies 97 | apermissions |= allows 98 | 99 | for _, overwrite := range channel.PermissionOverwrites { 100 | if overwrite.Type == "member" && overwrite.ID == userID { 101 | apermissions &= ^overwrite.Deny 102 | apermissions |= overwrite.Allow 103 | break 104 | } 105 | } 106 | 107 | if apermissions&discordgo.PermissionAdministrator == 108 | discordgo.PermissionAdministrator { 109 | 110 | apermissions |= discordgo.PermissionAllChannel 111 | } 112 | 113 | return apermissions 114 | } 115 | -------------------------------------------------------------------------------- /ctx.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import ( 4 | "encoding/csv" 5 | "log" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/bwmarrin/discordgo" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type Context struct { 14 | *Subcommand 15 | *discordgo.Session 16 | 17 | // Descriptive (but optional) bot name 18 | Name string 19 | 20 | // Descriptive help body 21 | Description string 22 | 23 | // The prefix for commands 24 | Prefix string 25 | 26 | // FormatError formats any errors returned by anything, including the method 27 | // commands or the reflect functions. This also includes invalid usage 28 | // errors or unknown command errors. Returning an empty string means 29 | // ignoring the error. 30 | FormatError func(error) string 31 | 32 | // ErrorLogger logs any error that anything makes and the library can't 33 | // reply to the client. This includes any event callback errors that aren't 34 | // Message Create. 35 | ErrorLogger func(error) 36 | 37 | // ReplyError when true replies to the user the error. 38 | ReplyError bool 39 | 40 | // Subcommands contains all the registered subcommands. 41 | Subcommands []*Subcommand 42 | } 43 | 44 | // StartBot quickly starts a bot with the given command. It will prepend "Bot" 45 | // into the token automatically. Refer to example/ for usage. 46 | func StartBot(token string, cmd interface{}, 47 | opts func(*Context) error) (stop func() error, err error) { 48 | 49 | s, err := discordgo.New("Bot " + token) 50 | if err != nil { 51 | return nil, errors.Wrap(err, "Failed to create a dgo session") 52 | } 53 | 54 | c, err := New(s, cmd) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "Failed to create rfrouter") 57 | } 58 | 59 | if opts != nil { 60 | if err := opts(c); err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | cancel := c.Start() 66 | 67 | if err := s.Open(); err != nil { 68 | return nil, errors.Wrap(err, "Failed to connect to Discord") 69 | } 70 | 71 | return func() error { 72 | cancel() 73 | return s.Close() 74 | }, nil 75 | } 76 | 77 | // New makes a new context with a "~" as the prefix. cmds must be a pointer to a 78 | // struct with a *Context field. Example: 79 | // 80 | // type Commands struct { 81 | // Ctx *Context 82 | // } 83 | // 84 | // cmds := &Commands{} 85 | // c, err := rfrouter.New(session, cmds) 86 | // 87 | // Commands' exported methods will all be used as commands. Messages are parsed 88 | // with its first argument (the command) mapped accordingly to c.MapName, which 89 | // capitalizes the first letter automatically to reflect the exported method 90 | // name. 91 | // 92 | // The default prefix is "~", which means commands must start with "~" followed 93 | // by the command name in the first argument, else it will be ignored. 94 | // 95 | // c.Start() should be called afterwards to actually handle incoming events. 96 | func New(s *discordgo.Session, cmd interface{}) (*Context, error) { 97 | c, err := NewSubcommand(cmd) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | ctx := &Context{ 103 | Subcommand: c, 104 | Session: s, 105 | Prefix: "~", 106 | FormatError: func(err error) string { 107 | return err.Error() 108 | }, 109 | ErrorLogger: func(err error) { 110 | log.Println("Bot error:", err) 111 | }, 112 | ReplyError: true, 113 | } 114 | 115 | if err := ctx.InitCommands(ctx); err != nil { 116 | return nil, errors.Wrap(err, "Failed to initialize with given cmds") 117 | } 118 | 119 | return ctx, nil 120 | } 121 | 122 | func (ctx *Context) RegisterSubcommand(cmd interface{}) (*Subcommand, error) { 123 | s, err := NewSubcommand(cmd) 124 | if err != nil { 125 | return nil, errors.Wrap(err, "Failed to add subcommand") 126 | } 127 | 128 | // Register the subcommand's name. 129 | s.NeedsName() 130 | 131 | if err := s.InitCommands(ctx); err != nil { 132 | return nil, errors.Wrap(err, "Failed to initialize subcommand") 133 | } 134 | 135 | // Do a collision check 136 | for _, sub := range ctx.Subcommands { 137 | if sub.name == s.name { 138 | return nil, errors.New( 139 | "New subcommand has duplicate name: " + s.name) 140 | } 141 | } 142 | 143 | ctx.Subcommands = append(ctx.Subcommands, s) 144 | return s, nil 145 | } 146 | 147 | // Start adds itself into the discordgo Session handlers. This needs to be run. 148 | // The returned function is a delete function, which removes itself from the 149 | // Session handlers. 150 | func (ctx *Context) Start() func() { 151 | return ctx.Session.AddHandler(func(_ *discordgo.Session, v interface{}) { 152 | if err := ctx.callCmd(v); err != nil { 153 | if str := ctx.FormatError(err); str != "" { 154 | // Log the main error first 155 | ctx.ErrorLogger(errors.Wrap(err, str)) 156 | 157 | mc, ok := v.(*discordgo.MessageCreate) 158 | if !ok { 159 | return 160 | } 161 | 162 | if ctx.ReplyError { 163 | _, Merr := ctx.Session.ChannelMessageSend(mc.ChannelID, str) 164 | if Merr != nil { 165 | // Then the message error 166 | ctx.ErrorLogger(Merr) 167 | // TODO: there ought to be a better way lol 168 | } 169 | } 170 | } 171 | } 172 | }) 173 | } 174 | 175 | // Call should only be used if you know what you're doing. 176 | func (ctx *Context) Call(event interface{}) error { 177 | return ctx.callCmd(event) 178 | } 179 | 180 | // Send sends a string, an embed pointer or a MessageSend pointer. Any other 181 | // type given will panic. 182 | func (ctx *Context) Send(channelID string, content interface{}) (err error) { 183 | switch content := content.(type) { 184 | case string: 185 | _, err = ctx.Session.ChannelMessageSend(channelID, content) 186 | case *discordgo.MessageEmbed: 187 | _, err = ctx.Session.ChannelMessageSendEmbed(channelID, content) 188 | case *discordgo.MessageSend: 189 | _, err = ctx.Session.ChannelMessageSendComplex(channelID, content) 190 | default: 191 | // BUG 192 | panic("Send received an unknown content type") 193 | } 194 | 195 | return 196 | } 197 | 198 | // Reply mentions the user when sending the message. 199 | func (ctx *Context) Reply(m *discordgo.Message, reply string) error { 200 | return ctx.Send(m.ChannelID, m.Author.Mention()+", "+reply) 201 | } 202 | 203 | // Help generates one. This function is used more for reference than an actual 204 | // help message. As such, it only uses exported fields or methods. 205 | func (ctx *Context) Help() string { 206 | var help strings.Builder 207 | 208 | // Generate the headers and descriptions 209 | help.WriteString("__Help__") 210 | 211 | if ctx.Name != "" { 212 | help.WriteString(": " + ctx.Name) 213 | } 214 | 215 | if ctx.Description != "" { 216 | help.WriteString("\n " + ctx.Description) 217 | } 218 | 219 | if ctx.Flag.Is(AdminOnly) { 220 | // That's it. 221 | return help.String() 222 | } 223 | 224 | // Separators 225 | help.WriteString("\n---\n") 226 | 227 | // Generate all commands 228 | help.WriteString("__Commands__\n") 229 | 230 | for _, cmd := range ctx.Commands { 231 | if cmd.Flag.Is(AdminOnly) { 232 | // Hidden 233 | continue 234 | } 235 | 236 | help.WriteString(" " + ctx.Prefix + cmd.Name()) 237 | 238 | switch { 239 | case len(cmd.Usage()) > 0: 240 | help.WriteString(" " + strings.Join(cmd.Usage(), " ")) 241 | case cmd.Description != "": 242 | help.WriteString(": " + cmd.Description) 243 | } 244 | 245 | help.WriteByte('\n') 246 | } 247 | 248 | var subHelp = strings.Builder{} 249 | 250 | for _, sub := range ctx.Subcommands { 251 | if sub.Flag.Is(AdminOnly) { 252 | // Hidden 253 | continue 254 | } 255 | 256 | subHelp.WriteString(" " + sub.Name()) 257 | 258 | if sub.Description != "" { 259 | subHelp.WriteString(": " + sub.Description) 260 | } 261 | 262 | subHelp.WriteByte('\n') 263 | 264 | for _, cmd := range sub.Commands { 265 | if cmd.Flag.Is(AdminOnly) { 266 | continue 267 | } 268 | 269 | subHelp.WriteString(" " + 270 | ctx.Prefix + sub.Name() + " " + cmd.Name()) 271 | 272 | switch { 273 | case len(cmd.Usage()) > 0: 274 | subHelp.WriteString(" " + strings.Join(cmd.Usage(), " ")) 275 | case cmd.Description != "": 276 | subHelp.WriteString(": " + cmd.Description) 277 | } 278 | 279 | subHelp.WriteByte('\n') 280 | } 281 | } 282 | 283 | if sub := subHelp.String(); sub != "" { 284 | help.WriteString("---\n") 285 | help.WriteString("__Subcommands__\n") 286 | help.WriteString(sub) 287 | } 288 | 289 | return help.String() 290 | } 291 | 292 | // Member returns the member, adding it to the State. 293 | func (ctx *Context) Member(guildID, memberID string) (*discordgo.Member, error) { 294 | m, err := ctx.Session.State.Member(guildID, memberID) 295 | if err != nil { 296 | m, err = ctx.Session.GuildMember(guildID, memberID) 297 | if err != nil { 298 | return nil, err 299 | } 300 | 301 | ctx.Session.State.MemberAdd(m) 302 | } 303 | 304 | return m, nil 305 | } 306 | 307 | // Role returns the role, adding it to the State. 308 | func (ctx *Context) Role(guildID, roleID string) (*discordgo.Role, error) { 309 | r, err := ctx.Session.State.Role(guildID, roleID) 310 | if err != nil { 311 | roles, err := ctx.Session.GuildRoles(guildID) 312 | if err != nil { 313 | return nil, err 314 | } 315 | 316 | for _, role := range roles { 317 | ctx.Session.State.RoleAdd(guildID, role) 318 | 319 | if role.ID == roleID { 320 | r = role 321 | } 322 | } 323 | } 324 | 325 | if r == nil { 326 | return nil, errors.New("role not found") 327 | } 328 | 329 | return r, nil 330 | } 331 | 332 | // Channel returns the channel, adding it to the State. 333 | func (ctx *Context) Channel(channelID string) (*discordgo.Channel, error) { 334 | c, err := ctx.Session.State.Channel(channelID) 335 | if err != nil { 336 | c, err = ctx.Session.Channel(channelID) 337 | if err != nil { 338 | return nil, err 339 | } 340 | 341 | ctx.Session.State.ChannelAdd(c) 342 | } 343 | 344 | return c, nil 345 | } 346 | 347 | func (ctx *Context) callCmd(ev interface{}) error { 348 | evT := reflect.TypeOf(ev) 349 | 350 | if evT != typeMessageCreate { 351 | var callers []reflect.Value 352 | var isAdmin *bool // i want to die 353 | 354 | for _, cmd := range ctx.Commands { 355 | if cmd.event == evT { 356 | if cmd.Flag.Is(AdminOnly) && 357 | !ctx.eventIsAdmin(ev, &isAdmin) { 358 | 359 | continue 360 | } 361 | 362 | callers = append(callers, cmd.value) 363 | } 364 | } 365 | 366 | for _, sub := range ctx.Subcommands { 367 | if sub.Flag.Is(AdminOnly) && 368 | !ctx.eventIsAdmin(ev, &isAdmin) { 369 | 370 | continue 371 | } 372 | 373 | for _, cmd := range sub.Commands { 374 | if cmd.event == evT { 375 | if cmd.Flag.Is(AdminOnly) && 376 | !ctx.eventIsAdmin(ev, &isAdmin) { 377 | 378 | continue 379 | } 380 | 381 | callers = append(callers, cmd.value) 382 | } 383 | } 384 | } 385 | 386 | for _, c := range callers { 387 | if err := callWith(c, ev); err != nil { 388 | ctx.ErrorLogger(err) 389 | } 390 | } 391 | 392 | return nil 393 | } 394 | 395 | // safe assertion always 396 | mc := ev.(*discordgo.MessageCreate) 397 | 398 | // check if prefix 399 | if !strings.HasPrefix(mc.Content, ctx.Prefix) { 400 | // not a command, ignore 401 | return nil 402 | } 403 | 404 | // trim the prefix before splitting, this way multi-words prefices work 405 | content := mc.Content[len(ctx.Prefix):] 406 | 407 | // parse arguments 408 | args, err := ParseArgs(content) 409 | if err != nil { 410 | return err 411 | } 412 | 413 | if len(args) < 1 { 414 | return nil // ??? 415 | } 416 | 417 | var cmd *CommandContext 418 | var start int // arg starts from $start 419 | 420 | // Search for the command 421 | for _, c := range ctx.Commands { 422 | if c.name == args[0] { 423 | cmd = c 424 | start = 1 425 | break 426 | } 427 | } 428 | 429 | // Can't find command, look for subcommands of len(args) has a 2nd 430 | // entry. 431 | if cmd == nil && len(args) > 1 { 432 | for _, s := range ctx.Subcommands { 433 | if s.name != args[0] { 434 | continue 435 | } 436 | 437 | for _, c := range s.Commands { 438 | if c.name == args[1] { 439 | cmd = c 440 | start = 2 441 | break 442 | } 443 | } 444 | 445 | if cmd == nil { 446 | return &ErrUnknownCommand{ 447 | Command: args[1], 448 | Parent: args[0], 449 | Prefix: ctx.Prefix, 450 | ctx: s.Commands, 451 | } 452 | } 453 | } 454 | } 455 | 456 | if cmd == nil || start == 0 { 457 | return &ErrUnknownCommand{ 458 | Command: args[0], 459 | Prefix: ctx.Prefix, 460 | ctx: ctx.Commands, 461 | } 462 | } 463 | 464 | // Start converting 465 | var argv []reflect.Value 466 | 467 | // Check manual parser 468 | if cmd.parseType != nil { 469 | // Create a zero value instance of this 470 | v := reflect.New(cmd.parseType) 471 | 472 | // Call the manual parse method 473 | ret := cmd.parseMethod.Func.Call([]reflect.Value{ 474 | v, reflect.ValueOf(args), 475 | }) 476 | 477 | // Check the method returns for error 478 | if err := errorReturns(ret); err != nil { 479 | // TODO: maybe wrap this? 480 | return err 481 | } 482 | 483 | // Add the pointer to the argument into argv 484 | argv = append(argv, v) 485 | goto Call 486 | } 487 | 488 | // Here's an edge case: when the handler takes no arguments, we allow that 489 | // anyway, as they might've used the raw content. 490 | if len(cmd.arguments) == 0 { 491 | goto Call 492 | } 493 | 494 | // Not enough arguments given 495 | if len(args[start:]) != len(cmd.arguments) { 496 | return &ErrInvalidUsage{ 497 | Args: args, 498 | Prefix: ctx.Prefix, 499 | Index: len(cmd.arguments) - start, 500 | Err: "Not enough arguments given", 501 | ctx: cmd, 502 | } 503 | } 504 | 505 | argv = make([]reflect.Value, len(cmd.arguments)) 506 | 507 | for i := start; i < len(args); i++ { 508 | v, err := cmd.arguments[i-start](args[i]) 509 | if err != nil { 510 | return &ErrInvalidUsage{ 511 | Args: args, 512 | Prefix: ctx.Prefix, 513 | Index: i, 514 | Err: err.Error(), 515 | ctx: cmd, 516 | } 517 | } 518 | 519 | argv[i-start] = v 520 | } 521 | 522 | Call: 523 | // call the function and parse the error return value 524 | return callWith(cmd.value, ev, argv...) 525 | } 526 | 527 | func (ctx *Context) eventIsAdmin(ev interface{}, is **bool) bool { 528 | if *is != nil { 529 | return **is 530 | } 531 | 532 | var channelID = reflectChannelID(ev) 533 | if channelID == "" { 534 | return false 535 | } 536 | 537 | var userID = reflectUserID(ev) 538 | if userID == "" { 539 | return false 540 | } 541 | 542 | var res bool 543 | 544 | p, err := ctx.UserPermissions(channelID, userID) 545 | if err == nil && p&discordgo.PermissionAdministrator != 0 { 546 | res = true 547 | } 548 | 549 | *is = &res 550 | return res 551 | } 552 | 553 | func callWith(caller reflect.Value, ev interface{}, values ...reflect.Value) error { 554 | return errorReturns(caller.Call(append( 555 | []reflect.Value{reflect.ValueOf(ev)}, 556 | values..., 557 | ))) 558 | } 559 | 560 | var ParseArgs = func(args string) ([]string, error) { 561 | // fuck me 562 | // TODO: make modular 563 | // TODO: actual tokenizer+parser 564 | r := csv.NewReader(strings.NewReader(args)) 565 | r.Comma = ' ' 566 | 567 | return r.Read() 568 | } 569 | 570 | func errorReturns(returns []reflect.Value) error { 571 | // assume first is always error, since we checked for this in parseCommands 572 | v := returns[0].Interface() 573 | 574 | if v == nil { 575 | return nil 576 | } 577 | 578 | return v.(error) 579 | } 580 | 581 | func reflectChannelID(_struct interface{}) string { 582 | return _reflectID(reflect.ValueOf(_struct), "Channel") 583 | } 584 | 585 | func reflectGuildID(_struct interface{}) string { 586 | return _reflectID(reflect.ValueOf(_struct), "Guild") 587 | } 588 | 589 | func reflectUserID(_struct interface{}) string { 590 | return _reflectID(reflect.ValueOf(_struct), "User") 591 | } 592 | 593 | func _reflectID(v reflect.Value, thing string) string { 594 | if !v.IsValid() { 595 | return "" 596 | } 597 | 598 | t := v.Type() 599 | 600 | if t.Kind() == reflect.Ptr { 601 | v = v.Elem() 602 | 603 | // Recheck after dereferring 604 | if !v.IsValid() { 605 | return "" 606 | } 607 | 608 | t = v.Type() 609 | } 610 | 611 | if t.Kind() != reflect.Struct { 612 | return "" 613 | } 614 | 615 | numFields := t.NumField() 616 | 617 | for i := 0; i < numFields; i++ { 618 | field := t.Field(i) 619 | fType := field.Type 620 | 621 | if fType.Kind() == reflect.Ptr { 622 | fType = fType.Elem() 623 | } 624 | 625 | switch fType.Kind() { 626 | case reflect.Struct: 627 | if chID := _reflectID(v.Field(i), thing); chID != "" { 628 | return chID 629 | } 630 | case reflect.String: 631 | if field.Name == thing+"ID" { 632 | // grab value real quick 633 | return v.Field(i).String() 634 | } 635 | 636 | // Special case where the struct name has Channel in it 637 | if field.Name == "ID" && strings.Contains(t.Name(), thing) { 638 | return v.Field(i).String() 639 | } 640 | } 641 | } 642 | 643 | return "" 644 | } 645 | -------------------------------------------------------------------------------- /ctx_test.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/bwmarrin/discordgo" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type testCommands struct { 13 | Ctx *Context 14 | Return chan interface{} 15 | } 16 | 17 | func (t *testCommands) Send(_ *discordgo.MessageCreate, arg string) error { 18 | t.Return <- arg 19 | return errors.New("oh no") 20 | } 21 | 22 | func (t *testCommands) Custom(_ *discordgo.MessageCreate, c *CustomParseable) error { 23 | t.Return <- c.args 24 | return nil 25 | } 26 | 27 | func (t *testCommands) NoArgs(_ *discordgo.MessageCreate) error { 28 | return errors.New("passed") 29 | } 30 | 31 | func (t *testCommands) Noop(_ *discordgo.MessageCreate) error { 32 | return nil 33 | } 34 | 35 | type CustomParseable struct { 36 | args []string 37 | } 38 | 39 | func (c *CustomParseable) ParseContent(args []string) error { 40 | c.args = args 41 | return nil 42 | } 43 | 44 | func TestNewContext(t *testing.T) { 45 | var session = &discordgo.Session{ 46 | Token: "dumb token", 47 | } 48 | 49 | _, err := New(session, &testCommands{}) 50 | if err != nil { 51 | t.Fatal("Failed to create new context:", err) 52 | } 53 | } 54 | 55 | func TestContext(t *testing.T) { 56 | var given = &testCommands{} 57 | var session = &discordgo.Session{ 58 | Token: "dumb token", 59 | } 60 | 61 | s, err := NewSubcommand(given) 62 | if err != nil { 63 | t.Fatal("Failed to create subcommand:", err) 64 | } 65 | 66 | var ctx = &Context{ 67 | Subcommand: s, 68 | Session: session, 69 | } 70 | 71 | t.Run("init commands", func(t *testing.T) { 72 | if err := ctx.Subcommand.InitCommands(ctx); err != nil { 73 | t.Fatal("Failed to init commands:", err) 74 | } 75 | 76 | if given.Ctx == nil { 77 | t.Fatal("given's Context field is nil") 78 | } 79 | 80 | if given.Ctx.Session.Token != "dumb token" { 81 | t.Fatal("given's Session token is wrong") 82 | } 83 | }) 84 | 85 | testReturn := func(expects interface{}, content string) (call error) { 86 | // Return channel for testing 87 | ret := make(chan interface{}) 88 | given.Return = ret 89 | 90 | // Mock a messageCreate event 91 | m := &discordgo.MessageCreate{ 92 | Message: &discordgo.Message{ 93 | Content: content, 94 | }, 95 | } 96 | 97 | var ( 98 | callCh = make(chan error) 99 | ) 100 | 101 | go func() { 102 | callCh <- ctx.callCmd(m) 103 | }() 104 | 105 | select { 106 | case arg := <-ret: 107 | if !reflect.DeepEqual(arg, expects) { 108 | t.Fatal("returned argument is invalid:", arg) 109 | } 110 | call = <-callCh 111 | 112 | case call = <-callCh: 113 | t.Fatal("expected return before error:", call) 114 | } 115 | 116 | return 117 | } 118 | 119 | t.Run("call command", func(t *testing.T) { 120 | // Set a custom prefix 121 | ctx.Prefix = "~" 122 | 123 | if err := testReturn("test", "~send test"); err.Error() != "oh no" { 124 | t.Fatal("unexpected error:", err) 125 | } 126 | }) 127 | 128 | t.Run("call command custom parser", func(t *testing.T) { 129 | ctx.Prefix = "!" 130 | expects := []string{"custom", "arg1", ":)"} 131 | 132 | if err := testReturn(expects, "!custom arg1 :)"); err != nil { 133 | t.Fatal("Unexpected call error:", err) 134 | } 135 | }) 136 | 137 | testMessage := func(content string) error { 138 | // Mock a messageCreate event 139 | m := &discordgo.MessageCreate{ 140 | Message: &discordgo.Message{ 141 | Content: content, 142 | }, 143 | } 144 | 145 | return ctx.callCmd(m) 146 | } 147 | 148 | t.Run("call command without args", func(t *testing.T) { 149 | ctx.Prefix = "" 150 | 151 | if err := testMessage("noargs"); err.Error() != "passed" { 152 | t.Fatal("unexpected error:", err) 153 | } 154 | }) 155 | 156 | // Test error cases 157 | 158 | t.Run("call unknown command", func(t *testing.T) { 159 | ctx.Prefix = "joe pls " 160 | 161 | err := testMessage("joe pls no") 162 | 163 | if err == nil || !strings.HasPrefix(err.Error(), "Unknown command:") { 164 | t.Fatal("unexpected error:", err) 165 | } 166 | }) 167 | 168 | // Test subcommands 169 | 170 | t.Run("register subcommand", func(t *testing.T) { 171 | ctx.Prefix = "run " 172 | 173 | _, err := ctx.RegisterSubcommand(&testCommands{}) 174 | if err != nil { 175 | t.Fatal("Failed to register subcommand:", err) 176 | } 177 | 178 | if err := testMessage("run testcommands noop"); err != nil { 179 | t.Fatal("unexpected error:", err) 180 | } 181 | }) 182 | } 183 | 184 | func BenchmarkConstructor(b *testing.B) { 185 | var session = &discordgo.Session{ 186 | Token: "dumb token", 187 | } 188 | 189 | for i := 0; i < b.N; i++ { 190 | _, _ = New(session, &testCommands{}) 191 | } 192 | } 193 | 194 | func BenchmarkCall(b *testing.B) { 195 | var given = &testCommands{} 196 | var session = &discordgo.Session{ 197 | Token: "dumb token", 198 | } 199 | 200 | s, _ := NewSubcommand(given) 201 | 202 | var ctx = &Context{ 203 | Subcommand: s, 204 | Session: session, 205 | Prefix: "~", 206 | } 207 | 208 | m := &discordgo.MessageCreate{ 209 | Message: &discordgo.Message{ 210 | Content: "~noop", 211 | }, 212 | } 213 | 214 | b.ResetTimer() 215 | 216 | for i := 0; i < b.N; i++ { 217 | ctx.callCmd(m) 218 | } 219 | } 220 | 221 | func BenchmarkHelp(b *testing.B) { 222 | var given = &testCommands{} 223 | var session = &discordgo.Session{ 224 | Token: "dumb token", 225 | } 226 | 227 | s, _ := NewSubcommand(given) 228 | 229 | var ctx = &Context{ 230 | Subcommand: s, 231 | Session: session, 232 | Prefix: "~", 233 | } 234 | 235 | b.ResetTimer() 236 | 237 | for i := 0; i < b.N; i++ { 238 | _ = ctx.Help() 239 | } 240 | } 241 | 242 | type hasID struct { 243 | ChannelID string 244 | } 245 | 246 | type embedsID struct { 247 | *hasID 248 | *embedsID 249 | } 250 | 251 | type hasChannelInName struct { 252 | ID string 253 | } 254 | 255 | func TestReflectChannelID(t *testing.T) { 256 | var s = &hasID{ 257 | ChannelID: "channelID", 258 | } 259 | 260 | t.Run("hasID", func(t *testing.T) { 261 | if id := reflectChannelID(s); id != "channelID" { 262 | t.Fatal("unexpected channelID:", id) 263 | } 264 | }) 265 | 266 | t.Run("embedsID", func(t *testing.T) { 267 | var e = &embedsID{ 268 | hasID: s, 269 | } 270 | 271 | if id := reflectChannelID(e); id != "channelID" { 272 | t.Fatal("unexpected channelID:", id) 273 | } 274 | }) 275 | 276 | t.Run("hasChannelInName", func(t *testing.T) { 277 | var s = &hasChannelInName{ 278 | ID: "channelID", 279 | } 280 | 281 | if id := reflectChannelID(s); id != "channelID" { 282 | t.Fatal("unexpected channelID:", id) 283 | } 284 | }) 285 | } 286 | 287 | func BenchmarkReflectChannelID_1Level(b *testing.B) { 288 | var s = &hasID{ 289 | ChannelID: "channelID", 290 | } 291 | 292 | for i := 0; i < b.N; i++ { 293 | _ = reflectChannelID(s) 294 | } 295 | } 296 | 297 | func BenchmarkReflectChannelID_5Level(b *testing.B) { 298 | var s = &embedsID{ 299 | nil, 300 | &embedsID{ 301 | nil, 302 | &embedsID{ 303 | nil, 304 | &embedsID{ 305 | hasID: &hasID{ 306 | ChannelID: "channelID", 307 | }, 308 | }, 309 | }, 310 | }, 311 | } 312 | 313 | for i := 0; i < b.N; i++ { 314 | _ = reflectChannelID(s) 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type ErrUnknownCommand struct { 8 | Command string 9 | Parent string 10 | 11 | Prefix string 12 | 13 | // TODO: list available commands? 14 | // Here, as a reminder 15 | ctx []*CommandContext 16 | } 17 | 18 | func (err *ErrUnknownCommand) Error() string { 19 | var header = "Unknown command: " + err.Prefix 20 | if err.Parent != "" { 21 | header += err.Parent + " " + err.Command 22 | } else { 23 | header += err.Command 24 | } 25 | 26 | return header 27 | } 28 | 29 | type ErrInvalidUsage struct { 30 | Args []string 31 | Prefix string 32 | 33 | Index int 34 | Err string 35 | 36 | // TODO: usage generator? 37 | // Here, as a reminder 38 | ctx *CommandContext 39 | } 40 | 41 | func (err *ErrInvalidUsage) Error() string { 42 | if err.Index == 0 { 43 | return "Invalid usage" 44 | } 45 | 46 | if len(err.Args) == 0 { 47 | return "Missing arguments. Refer to help." 48 | } 49 | 50 | body := "Invalid usage at " + err.Prefix 51 | 52 | // Write the first part 53 | body += strings.Join(err.Args[:err.Index], " ") 54 | 55 | // Write the wrong part 56 | body += " __" + err.Args[err.Index] + "__ " 57 | 58 | // Write the last part 59 | body += strings.Join(err.Args[err.Index+1:], " ") 60 | 61 | if err.Err != "" { 62 | body += "\nError: " + err.Err 63 | } 64 | 65 | return body 66 | } 67 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/rfrouter/b54cad694e47d80bfa311b77cad714d4195f0cfd/example/README.md -------------------------------------------------------------------------------- /example/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "git.sr.ht/~diamondburned/rfrouter" 7 | "git.sr.ht/~diamondburned/rfrouter/extras/arguments" 8 | "github.com/bwmarrin/discordgo" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type Commands struct { 13 | Context *rfrouter.Context 14 | HelloCalled int 15 | } 16 | 17 | // ~hello 18 | func (c *Commands) Hello(m *discordgo.MessageCreate) error { 19 | c.HelloCalled++ 20 | 21 | return c.Context.Send(m.ChannelID, fmt.Sprintf( 22 | "Hello, %s: %d", m.Author.Mention(), c.HelloCalled)) 23 | } 24 | 25 | // ~flagdemo -opt -str "test string" ayy lmao 26 | func (c *Commands) FlagDemo(m *discordgo.MessageCreate, f *arguments.Flag) error { 27 | var fs = arguments.NewFlagSet() 28 | 29 | opt := fs.Bool("opt", false, "") 30 | str := fs.String("str", "", "") 31 | 32 | if err := f.With(fs); err != nil { 33 | return errors.Wrap(err, "Invalid flags") 34 | } 35 | 36 | args := fs.Args() 37 | 38 | return c.Context.Send(m.ChannelID, fmt.Sprintf( 39 | `opt: %v, str: "%s", args: %v`, 40 | *opt, *str, args), 41 | ) 42 | } 43 | 44 | func (c *Commands) Channel(m *discordgo.MessageCreate, ch *arguments.ChannelMention) error { 45 | channel, err := c.Context.Channel(string(*ch)) 46 | if err != nil { 47 | return errors.Wrap(err, "Failed to get channel") 48 | } 49 | 50 | return c.Context.Send(m.ChannelID, fmt.Sprintf( 51 | "Channel \"%s\" ID %s NSFW %v Topic \"%s\"", 52 | channel.Name, channel.ID, channel.NSFW, channel.Topic, 53 | )) 54 | } 55 | 56 | // ~echo - admin only 57 | func (c *Commands) AーEcho(m *discordgo.MessageCreate) error { 58 | return c.Context.Send(m.ChannelID, m.Content) 59 | } 60 | 61 | func (c *Commands) AーEditMessage(m *discordgo.MessageUpdate) error { 62 | for _, user := range m.Mentions { 63 | if user.ID == c.Context.State.User.ID { 64 | return c.Context.Reply(m.Message, "you edited.") 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // ~help 72 | func (c *Commands) Help(m *discordgo.MessageCreate) error { 73 | return c.Context.Send(m.ChannelID, c.Context.Help()) 74 | } 75 | -------------------------------------------------------------------------------- /example/debug/debug.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "git.sr.ht/~diamondburned/rfrouter" 8 | "github.com/bwmarrin/discordgo" 9 | ) 10 | 11 | // Admin only 12 | type AーDebug struct { 13 | Context *rfrouter.Context 14 | } 15 | 16 | func (d *AーDebug) Name() string { 17 | return "d" 18 | } 19 | 20 | func (d *AーDebug) Description() string { 21 | return "debugging commands" 22 | } 23 | 24 | // ~debug goroutines 25 | func (d *AーDebug) Goroutines(m *discordgo.MessageCreate) error { 26 | return d.Context.Send(m.ChannelID, fmt.Sprintf("goroutines: %d", 27 | runtime.NumGoroutine())) 28 | } 29 | 30 | // ~debug GOOS 31 | func (d *AーDebug) RーGOOS(m *discordgo.MessageCreate) error { 32 | return d.Context.Send(m.ChannelID, runtime.GOOS) 33 | } 34 | 35 | // ~debug GC 36 | func (d *AーDebug) RーGC(m *discordgo.MessageCreate) error { 37 | runtime.GC() 38 | return nil 39 | } 40 | 41 | // ~debug die 42 | func (d *AーDebug) AーDie(m *discordgo.MessageCreate) error { 43 | panic("Death requested from " + m.Author.Username) 44 | } 45 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | 8 | "git.sr.ht/~diamondburned/rfrouter" 9 | "git.sr.ht/~diamondburned/rfrouter/example/debug" 10 | ) 11 | 12 | func main() { 13 | var token = os.Getenv("BOT_TOKEN") 14 | if token == "" { 15 | log.Fatalln("$BOT_TOKEN not given") 16 | } 17 | 18 | var commands = Commands{ 19 | HelloCalled: 69, 20 | } 21 | 22 | c, err := rfrouter.StartBot(token, &commands, 23 | func(ctx *rfrouter.Context) error { 24 | // Set the prefix 25 | ctx.Prefix = "~" 26 | 27 | // Set the descriptions 28 | ctx.Name = "rfrouter example" 29 | ctx.Description = "https://git.sr.ht/~diamondburned/rfrouter" 30 | 31 | // Add the subcommand 32 | _, err := ctx.RegisterSubcommand(&debug.AーDebug{}) 33 | return err 34 | }, 35 | ) 36 | 37 | if err != nil { 38 | log.Fatalln("Failed to start the bot:", err) 39 | } 40 | 41 | // Stop bot on exit 42 | defer c() 43 | 44 | log.Println("Started bot...") 45 | 46 | sig := make(chan os.Signal) 47 | signal.Notify(sig, os.Interrupt) 48 | <-sig 49 | } 50 | -------------------------------------------------------------------------------- /extras/arguments/emoji.go: -------------------------------------------------------------------------------- 1 | package arguments 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | ) 7 | 8 | var ( 9 | EmojiRegex = regexp.MustCompile(`<(a?):(.+?):(\d+)>`) 10 | 11 | ErrInvalidEmoji = errors.New("Invalid emoji") 12 | ) 13 | 14 | type Emoji struct { 15 | ID string 16 | 17 | Custom bool 18 | Name string 19 | Animated bool 20 | } 21 | 22 | func (e *Emoji) Parse(arg string) error { 23 | // Check if Unicode 24 | var unicode string 25 | 26 | for _, r := range arg { 27 | if r < '\U0001F600' && r > '\U0001F64F' { 28 | unicode += string(r) 29 | } 30 | } 31 | 32 | if unicode != "" { 33 | e.ID = unicode 34 | e.Custom = false 35 | 36 | return nil 37 | } 38 | 39 | var matches = EmojiRegex.FindStringSubmatch(arg) 40 | 41 | if len(matches) != 4 { 42 | return ErrInvalidEmoji 43 | } 44 | 45 | e.Custom = true 46 | e.Animated = matches[1] == "a" 47 | e.Name = matches[2] 48 | e.ID = matches[3] 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /extras/arguments/flag.go: -------------------------------------------------------------------------------- 1 | package arguments 2 | 3 | import "flag" 4 | 5 | var FlagName = "command" 6 | 7 | func NewFlagSet() *flag.FlagSet { 8 | return flag.NewFlagSet(FlagName, flag.ContinueOnError) 9 | } 10 | 11 | type Flag struct { 12 | arguments []string 13 | } 14 | 15 | func (f *Flag) ParseContent(arguments []string) error { 16 | // trim the command out 17 | f.arguments = arguments[1:] 18 | return nil 19 | } 20 | 21 | func (f *Flag) Usage() string { 22 | return "flags..." 23 | } 24 | 25 | func (f *Flag) With(fs *flag.FlagSet) error { 26 | return fs.Parse(f.arguments) 27 | } 28 | -------------------------------------------------------------------------------- /extras/arguments/mention.go: -------------------------------------------------------------------------------- 1 | package arguments 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | ) 7 | 8 | var ( 9 | ChannelRegex = regexp.MustCompile(`<#(\d+)>`) 10 | UserRegex = regexp.MustCompile(`<@!?(\d+)>`) 11 | RoleRegex = regexp.MustCompile(`<@&(\d+)>`) 12 | ) 13 | 14 | type ChannelMention string 15 | 16 | func (m *ChannelMention) Parse(arg string) error { 17 | return grabFirst(ChannelRegex, "channel mention", arg, (*string)(m)) 18 | } 19 | 20 | func (m *ChannelMention) Usage() string { 21 | return "#channel" 22 | } 23 | 24 | type UserMention string 25 | 26 | func (m *UserMention) Parse(arg string) error { 27 | return grabFirst(UserRegex, "user mention", arg, (*string)(m)) 28 | } 29 | 30 | func (m *UserMention) Usage() string { 31 | return "@user" 32 | } 33 | 34 | type RoleMention string 35 | 36 | func (m *RoleMention) Parse(arg string) error { 37 | return grabFirst(RoleRegex, "role mention", arg, (*string)(m)) 38 | } 39 | 40 | func (m *RoleMention) Usage() string { 41 | return "@role" 42 | } 43 | 44 | func grabFirst(reg *regexp.Regexp, item, input string, output *string) error { 45 | matches := reg.FindStringSubmatch(input) 46 | if len(matches) < 2 { 47 | return errors.New("Invalid " + item) 48 | } 49 | 50 | *output = matches[1] 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module git.sr.ht/~diamondburned/rfrouter 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/bwmarrin/discordgo v0.20.2 7 | github.com/k0kubun/pp v3.0.1+incompatible 8 | github.com/mattn/go-colorable v0.1.4 // indirect 9 | github.com/pkg/errors v0.8.1 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bwmarrin/discordgo v0.20.2 h1:nA7jiTtqUA9lT93WL2jPjUp8ZTEInRujBdx1C9gkr20= 2 | github.com/bwmarrin/discordgo v0.20.2/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q= 3 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 4 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 5 | github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40= 6 | github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= 7 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 8 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 9 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= 10 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 11 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 12 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA= 14 | golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 15 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= 16 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 17 | -------------------------------------------------------------------------------- /nameflag.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import "strings" 4 | 5 | type NameFlag uint64 6 | 7 | const FlagSeparator = 'ー' 8 | 9 | const ( 10 | None NameFlag = 1 << iota 11 | 12 | // These flags only apply to messageCreate events. 13 | 14 | Raw // R 15 | AdminOnly // A 16 | ) 17 | 18 | func ParseFlag(name string) (NameFlag, string) { 19 | parts := strings.SplitN(name, string(FlagSeparator), 2) 20 | if len(parts) != 2 { 21 | return 0, name 22 | } 23 | 24 | var f NameFlag 25 | 26 | for _, r := range parts[0] { 27 | switch r { 28 | case 'R': 29 | f |= Raw 30 | case 'A': 31 | f |= AdminOnly 32 | } 33 | } 34 | 35 | return f, parts[1] 36 | } 37 | 38 | func (f NameFlag) Is(flag NameFlag) bool { 39 | return f&flag != 0 40 | } 41 | -------------------------------------------------------------------------------- /nameflag_test.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import "testing" 4 | 5 | func TestNameFlag(t *testing.T) { 6 | type entry struct { 7 | Name string 8 | Expect NameFlag 9 | String string 10 | } 11 | 12 | var entries = []entry{{ 13 | Name: "AーEcho", 14 | Expect: AdminOnly, 15 | }, { 16 | Name: "RAーGC", 17 | Expect: Raw | AdminOnly, 18 | }} 19 | 20 | for _, entry := range entries { 21 | var f, _ = ParseFlag(entry.Name) 22 | if !f.Is(entry.Expect) { 23 | t.Fatalf("unexpected expectation for %s: %v", entry.Name, f) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /routes.go.bak: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/bwmarrin/discordgo" 8 | ) 9 | 10 | type Commands struct { 11 | Context *Context 12 | } 13 | 14 | // A command must have its first argument the event. 15 | // Arguments follow it afterwards. Variadic arguments should be supported. 16 | // The return arguments must be (error). 17 | func (c *Commands) Info(m *discordgo.MessageCreate, number int, variadic ...string) error { 18 | embed := &discordgo.MessageEmbed{ 19 | Fields: []*discordgo.MessageEmbedField{{ 20 | Name: "Content", 21 | Value: m.Content, 22 | }, { 23 | Name: "`number`", 24 | Value: strconv.Itoa(number), 25 | }, { 26 | Name: "variadic", 27 | Value: strings.Join(variadic, ", "), 28 | }}, 29 | } 30 | 31 | _, err := c.Context.Send(m.ChannelID, embed) 32 | return err 33 | } 34 | -------------------------------------------------------------------------------- /subcommand.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/bwmarrin/discordgo" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | var ( 12 | typeMessageCreate = reflect.TypeOf((*discordgo.MessageCreate)(nil)) 13 | // typeof.Implements(typeI*) 14 | typeIError = reflect.TypeOf((*error)(nil)).Elem() 15 | typeIManP = reflect.TypeOf((*ManualParseable)(nil)).Elem() 16 | typeIParser = reflect.TypeOf((*Parseable)(nil)).Elem() 17 | typeIUsager = reflect.TypeOf((*Usager)(nil)).Elem() 18 | ) 19 | 20 | type Subcommand struct { 21 | Description string 22 | 23 | // Commands contains all the registered command contexts. 24 | Commands []*CommandContext 25 | 26 | // struct name 27 | name string 28 | 29 | // struct flags 30 | Flag NameFlag 31 | 32 | // Directly to struct 33 | cmdValue reflect.Value 34 | cmdType reflect.Type 35 | 36 | // Pointer value 37 | ptrValue reflect.Value 38 | ptrType reflect.Type 39 | 40 | // command interface as reference 41 | command interface{} 42 | } 43 | 44 | // CommandContext is an internal struct containing fields to make this library 45 | // work. As such, they're all unexported. Description, however, is exported for 46 | // editing, and may be used to generate more informative help messages. 47 | type CommandContext struct { 48 | Description string 49 | Flag NameFlag 50 | 51 | name string // all lower-case 52 | value reflect.Value // Func 53 | event reflect.Type // discordgo.* 54 | method reflect.Method 55 | 56 | // equal slices 57 | argStrings []string 58 | arguments []argumentValueFn 59 | 60 | parseMethod reflect.Method 61 | parseType reflect.Type 62 | parseUsage string 63 | } 64 | 65 | // Descriptor is optionally used to set the Description of a command context. 66 | type Descriptor interface { 67 | Description() string 68 | } 69 | 70 | // Namer is optionally used to override the command context's name. 71 | type Namer interface { 72 | Name() string 73 | } 74 | 75 | // Usager is optionally used to override the generated usage for either an 76 | // argument, or multiple (using ManualParseable). 77 | type Usager interface { 78 | Usage() string 79 | } 80 | 81 | func (cctx *CommandContext) Name() string { 82 | return cctx.name 83 | } 84 | 85 | func (cctx *CommandContext) Usage() []string { 86 | if cctx.parseType != nil { 87 | return []string{cctx.parseUsage} 88 | } 89 | 90 | if len(cctx.arguments) == 0 { 91 | return nil 92 | } 93 | 94 | return cctx.argStrings 95 | } 96 | 97 | func NewSubcommand(cmd interface{}) (*Subcommand, error) { 98 | var sub = Subcommand{ 99 | command: cmd, 100 | } 101 | 102 | // Set description 103 | if d, ok := cmd.(Descriptor); ok { 104 | sub.Description = d.Description() 105 | } 106 | 107 | if err := sub.reflectCommands(); err != nil { 108 | return nil, errors.Wrap(err, "Failed to reflect commands") 109 | } 110 | 111 | if err := sub.parseCommands(); err != nil { 112 | return nil, errors.Wrap(err, "Failed to parse commands") 113 | } 114 | 115 | return &sub, nil 116 | } 117 | 118 | // Name returns the command name in lower case. This only returns non-zero for 119 | // subcommands. 120 | func (sub *Subcommand) Name() string { 121 | return sub.name 122 | } 123 | 124 | // NeedsName sets the name for this subcommand. Like InitCommands, this 125 | // shouldn't be called at all, rather you should use RegisterSubcommand. 126 | func (sub *Subcommand) NeedsName() { 127 | flag, name := ParseFlag(sub.cmdType.Name()) 128 | 129 | // Check for interface 130 | if n, ok := sub.command.(Namer); ok { 131 | name = n.Name() 132 | } 133 | 134 | if !flag.Is(Raw) { 135 | name = strings.ToLower(name) 136 | } 137 | 138 | sub.name = name 139 | sub.Flag = flag 140 | } 141 | 142 | func (sub *Subcommand) reflectCommands() error { 143 | t := reflect.TypeOf(sub.command) 144 | v := reflect.ValueOf(sub.command) 145 | 146 | if t.Kind() != reflect.Ptr { 147 | return errors.New("sub is not a pointer") 148 | } 149 | 150 | // Set the pointer fields 151 | sub.ptrValue = v 152 | sub.ptrType = t 153 | 154 | ts := t.Elem() 155 | vs := v.Elem() 156 | 157 | if ts.Kind() != reflect.Struct { 158 | return errors.New("sub is not pointer to struct") 159 | } 160 | 161 | // Set the struct fields 162 | sub.cmdValue = vs 163 | sub.cmdType = ts 164 | 165 | return nil 166 | } 167 | 168 | // InitCommands fills a Subcommand with a context. This shouldn't be called at 169 | // all, rather you should use the RegisterSubcommand method of a Context. 170 | func (sub *Subcommand) InitCommands(ctx *Context) error { 171 | // Start filling up a *Context field 172 | for i := 0; i < sub.cmdValue.NumField(); i++ { 173 | field := sub.cmdValue.Field(i) 174 | 175 | if !field.CanSet() || !field.CanInterface() { 176 | continue 177 | } 178 | 179 | if _, ok := field.Interface().(*Context); !ok { 180 | continue 181 | } 182 | 183 | field.Set(reflect.ValueOf(ctx)) 184 | return nil 185 | } 186 | 187 | return errors.New("No fields with *Command found") 188 | } 189 | 190 | func (sub *Subcommand) parseCommands() error { 191 | var numMethods = sub.ptrValue.NumMethod() 192 | var commands = make([]*CommandContext, 0, numMethods) 193 | 194 | for i := 0; i < numMethods; i++ { 195 | method := sub.ptrValue.Method(i) 196 | 197 | if !method.CanInterface() { 198 | continue 199 | } 200 | 201 | methodT := method.Type() 202 | numArgs := methodT.NumIn() 203 | 204 | // Doesn't meet requirement for an event 205 | if numArgs == 0 { 206 | continue 207 | } 208 | 209 | // Check return type 210 | if err := methodT.Out(0); err == nil || !err.Implements(typeIError) { 211 | // Invalid, skip 212 | continue 213 | } 214 | 215 | var command = CommandContext{ 216 | method: sub.ptrType.Method(i), 217 | value: method, 218 | event: methodT.In(0), // parse event 219 | } 220 | 221 | // Parse the method name 222 | flag, name := ParseFlag(command.method.Name) 223 | 224 | if !flag.Is(Raw) { 225 | name = strings.ToLower(name) 226 | } 227 | 228 | // Set the method name and flag 229 | command.name = name 230 | command.Flag = flag 231 | 232 | // TODO: allow more flexibility 233 | if command.event != typeMessageCreate { 234 | goto Done 235 | } 236 | 237 | if numArgs == 1 { 238 | // done 239 | goto Done 240 | } 241 | 242 | if t := methodT.In(1); t.Implements(typeIManP) { 243 | mt, _ := t.MethodByName("ParseContent") 244 | 245 | command.parseMethod = mt 246 | command.parseType = t.Elem() 247 | 248 | command.parseUsage = usager(t) 249 | if command.parseUsage == "" { 250 | command.parseUsage = t.String() 251 | } 252 | 253 | goto Done 254 | } 255 | 256 | command.arguments = make([]argumentValueFn, 0, numArgs) 257 | 258 | // Fill up arguments 259 | for i := 1; i < numArgs; i++ { 260 | t := methodT.In(i) 261 | 262 | avfs, err := getArgumentValueFn(t) 263 | if err != nil { 264 | return errors.Wrap(err, "Error parsing argument "+t.String()) 265 | } 266 | 267 | command.arguments = append(command.arguments, avfs) 268 | 269 | var usage = usager(t) 270 | if usage == "" { 271 | usage = t.String() 272 | } 273 | 274 | command.argStrings = append(command.argStrings, usage) 275 | } 276 | 277 | Done: 278 | // Append 279 | commands = append(commands, &command) 280 | } 281 | 282 | sub.Commands = commands 283 | return nil 284 | } 285 | 286 | func usager(t reflect.Type) string { 287 | if !t.Implements(typeIUsager) { 288 | return "" 289 | } 290 | 291 | usageFn, _ := t.MethodByName("Usage") 292 | v := usageFn.Func.Call([]reflect.Value{ 293 | reflect.New(t.Elem()), 294 | }) 295 | return v[0].String() 296 | } 297 | -------------------------------------------------------------------------------- /subcommand_test.go: -------------------------------------------------------------------------------- 1 | package rfrouter 2 | 3 | import "testing" 4 | 5 | func TestNewSubcommand(t *testing.T) { 6 | _, err := NewSubcommand(&testCommands{}) 7 | if err != nil { 8 | t.Fatal("Failed to create new subcommand:", err) 9 | } 10 | } 11 | 12 | func TestSubcommand(t *testing.T) { 13 | var given = &testCommands{} 14 | var sub = &Subcommand{ 15 | command: given, 16 | } 17 | 18 | t.Run("reflect commands", func(t *testing.T) { 19 | if err := sub.reflectCommands(); err != nil { 20 | t.Fatal("Failed to reflect commands:", err) 21 | } 22 | }) 23 | 24 | t.Run("parse commands", func(t *testing.T) { 25 | if err := sub.parseCommands(); err != nil { 26 | t.Fatal("Failed to parse commands:", err) 27 | } 28 | 29 | // !!! CHANGE ME 30 | if len(sub.Commands) != 4 { 31 | t.Fatal("invalid ctx.commands len", len(sub.Commands)) 32 | } 33 | 34 | var ( 35 | foundSend bool 36 | foundCustom bool 37 | foundNoArgs bool 38 | ) 39 | 40 | for _, this := range sub.Commands { 41 | switch this.name { 42 | case "send": 43 | foundSend = true 44 | if len(this.arguments) != 1 { 45 | t.Fatal("invalid arguments len", len(this.arguments)) 46 | } 47 | 48 | case "custom": 49 | foundCustom = true 50 | if len(this.arguments) > 0 { 51 | t.Fatal("arguments should be 0 for custom") 52 | } 53 | if this.parseType == nil { 54 | t.Fatal("custom has nil manualParse") 55 | } 56 | 57 | case "noargs": 58 | foundNoArgs = true 59 | if len(this.arguments) != 0 { 60 | t.Fatal("expected 0 arguments, got non-zero") 61 | } 62 | if this.parseType != nil { 63 | t.Fatal("unexpected parseType") 64 | } 65 | 66 | case "noop": 67 | // Found, but whatever 68 | 69 | default: 70 | t.Fatal("Unexpected command:", this.name) 71 | } 72 | 73 | if this.event != typeMessageCreate { 74 | t.Fatal("invalid event type:", this.event.String()) 75 | } 76 | } 77 | 78 | if !foundSend { 79 | t.Fatal("missing send") 80 | } 81 | 82 | if !foundCustom { 83 | t.Fatal("missing custom") 84 | } 85 | 86 | if !foundNoArgs { 87 | t.Fatal("missing noargs") 88 | } 89 | }) 90 | } 91 | 92 | func BenchmarkSubcommandConstructor(b *testing.B) { 93 | for i := 0; i < b.N; i++ { 94 | NewSubcommand(&testCommands{}) 95 | } 96 | } 97 | --------------------------------------------------------------------------------