-
Notifications
You must be signed in to change notification settings - Fork 16
/
command.go
369 lines (312 loc) · 12.8 KB
/
command.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
package sarah
import (
"context"
"errors"
"fmt"
"github.com/oklahomer/go-sarah/v3/log"
"reflect"
"regexp"
"strings"
"sync"
)
var (
// ErrCommandInsufficientArgument depicts an error that not enough arguments are set to CommandProps.
// This is returned on CommandProps.Build() inside of runner.Run()
ErrCommandInsufficientArgument = errors.New("BotType, Identifier, InstructionFunc, MatchFunc and (Configurable)Func must be set")
)
// CommandResponse is returned by Command or Task when the execution is finished.
type CommandResponse struct {
Content interface{}
UserContext *UserContext
}
// Command defines interface that all command MUST satisfy.
type Command interface {
// Identifier returns unique id that represents this Command.
Identifier() string
// Execute receives input from user and returns response.
Execute(context.Context, Input) (*CommandResponse, error)
// Instruction returns example of user input. This should be used to provide command usage for end users.
Instruction(input *HelpInput) string
// Match is used to judge if this command corresponds to given user input.
// If this returns true, Bot implementation should proceed to Execute with current user input.
Match(Input) bool
}
type commandConfigWrapper struct {
value CommandConfig
mutex *sync.RWMutex
}
type defaultCommand struct {
identifier string
matchFunc func(Input) bool
instructionFunc func(*HelpInput) string
commandFunc commandFunc
configWrapper *commandConfigWrapper
}
func (command *defaultCommand) Identifier() string {
return command.identifier
}
func (command *defaultCommand) Instruction(input *HelpInput) string {
return command.instructionFunc(input)
}
func (command *defaultCommand) Match(input Input) bool {
return command.matchFunc(input)
}
func (command *defaultCommand) Execute(ctx context.Context, input Input) (*CommandResponse, error) {
wrapper := command.configWrapper
if wrapper == nil {
return command.commandFunc(ctx, input)
}
// If the command has configuration struct, lock before execution.
// Config struct may be updated on configuration file change.
wrapper.mutex.RLock()
defer wrapper.mutex.RUnlock()
return command.commandFunc(ctx, input, wrapper.value)
}
func buildCommand(ctx context.Context, props *CommandProps, watcher ConfigWatcher) (Command, error) {
if props.config == nil {
return &defaultCommand{
identifier: props.identifier,
matchFunc: props.matchFunc,
instructionFunc: props.instructionFunc,
commandFunc: props.commandFunc,
configWrapper: nil,
}, nil
}
// https://github.com/oklahomer/go-sarah/issues/44
locker := configLocker.get(props.botType, props.identifier)
cfg := props.config
err := func() error {
locker.Lock()
defer locker.Unlock()
rv := reflect.ValueOf(cfg)
if rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Map {
return watcher.Read(ctx, props.botType, props.identifier, cfg)
}
// https://groups.google.com/forum/#!topic/Golang-Nuts/KB3_Yj3Ny4c
// Obtain a pointer to the *underlying type* instead of sarah.CommandConfig.
n := reflect.New(reflect.TypeOf(cfg))
// Copy the current field value to newly created instance.
// This includes private field values.
n.Elem().Set(rv)
// Pass the pointer to the newly created instance.
e := watcher.Read(ctx, props.botType, props.identifier, n.Interface())
if e == nil {
// Replace the current value with updated value.
cfg = n.Elem().Interface()
}
return e
}()
var notFoundErr *ConfigNotFoundError
if err != nil && !errors.As(err, ¬FoundErr) {
// Unacceptable error
return nil, fmt.Errorf("failed to read config for %s:%s: %w", props.botType, props.identifier, err)
}
return &defaultCommand{
identifier: props.identifier,
matchFunc: props.matchFunc,
instructionFunc: props.instructionFunc,
commandFunc: props.commandFunc,
configWrapper: &commandConfigWrapper{
value: cfg,
mutex: locker,
},
}, nil
}
// StripMessage is a utility function that strips string from given message based on given regular expression.
// This is to extract usable input value out of entire user message.
// e.g. ".echo Hey!" becomes "Hey!"
func StripMessage(pattern *regexp.Regexp, input string) string {
return strings.TrimSpace(pattern.ReplaceAllString(input, ""))
}
// Commands stashes all registered Command.
type Commands struct {
collection []Command
mutex sync.RWMutex
}
// NewCommands creates and returns new Commands instance.
func NewCommands() *Commands {
return &Commands{
collection: []Command{},
mutex: sync.RWMutex{},
}
}
// Append let developers register new Command to its internal stash.
// If any command is registered with the same ID, the old one is replaced in favor of new one.
func (commands *Commands) Append(command Command) {
commands.mutex.Lock()
defer commands.mutex.Unlock()
// See if command with the same identifier exists.
for i, cmd := range commands.collection {
if cmd.Identifier() == command.Identifier() {
log.Infof("replacing old command in favor of newly appending one: %s.", command.Identifier())
commands.collection[i] = command
return
}
}
// Not stored, then append to the last.
log.Infof("appending new command: %s.", command.Identifier())
commands.collection = append(commands.collection, command)
}
// FindFirstMatched look for first matching command by calling Command's Match method: First Command.Match to return true
// is considered as "first matched" and is returned.
//
// This check is run in the order of Command registration: Earlier the Commands.Append is called, the command is checked
// earlier. So register important Command first.
func (commands *Commands) FindFirstMatched(input Input) Command {
commands.mutex.RLock()
defer commands.mutex.RUnlock()
for _, command := range commands.collection {
if command.Match(input) {
return command
}
}
return nil
}
// ExecuteFirstMatched tries find matching command with the given input, and execute it if one is available.
func (commands *Commands) ExecuteFirstMatched(ctx context.Context, input Input) (*CommandResponse, error) {
command := commands.FindFirstMatched(input)
if command == nil {
return nil, nil
}
return command.Execute(ctx, input)
}
// Helps returns underlying commands help messages in a form of *CommandHelps.
func (commands *Commands) Helps(input *HelpInput) *CommandHelps {
commands.mutex.RLock()
defer commands.mutex.RUnlock()
helps := &CommandHelps{}
for _, command := range commands.collection {
instruction := command.Instruction(input)
if instruction == "" {
continue
}
h := &CommandHelp{
Identifier: command.Identifier(),
Instruction: instruction,
}
*helps = append(*helps, h)
}
return helps
}
// CommandHelps is an alias to slice of CommandHelps' pointers.
type CommandHelps []*CommandHelp
// CommandHelp represents help messages for corresponding Command.
type CommandHelp struct {
Identifier string
Instruction string
}
// CommandConfig provides an interface that every command configuration must satisfy, which actually means empty.
type CommandConfig interface{}
type commandFunc func(context.Context, Input, ...CommandConfig) (*CommandResponse, error)
// NewCommandPropsBuilder returns new CommandPropsBuilder instance.
func NewCommandPropsBuilder() *CommandPropsBuilder {
return &CommandPropsBuilder{
props: &CommandProps{},
}
}
// CommandProps is a designated non-serializable configuration struct to be used in Command construction.
// This holds relatively complex set of Command construction arguments that should be treated as one in logical term.
type CommandProps struct {
botType BotType
identifier string
config CommandConfig
commandFunc commandFunc
matchFunc func(Input) bool
instructionFunc func(*HelpInput) string
}
// CommandPropsBuilder helps to construct CommandProps.
// Developer may set desired property as she goes and call CommandPropsBuilder.Build or CommandPropsBuilder.MustBuild to construct CommandProps at the end.
// A validation logic runs on build, so the returning CommandProps instant is safe to be passed to Runner.
type CommandPropsBuilder struct {
props *CommandProps // This props instance is not fully constructed til Build() is called.
}
// BotType is a setter to provide belonging BotType.
func (builder *CommandPropsBuilder) BotType(botType BotType) *CommandPropsBuilder {
builder.props.botType = botType
return builder
}
// Identifier is a setter for Command identifier.
func (builder *CommandPropsBuilder) Identifier(id string) *CommandPropsBuilder {
builder.props.identifier = id
return builder
}
// MatchPattern is a setter to provide command match pattern.
// This regular expression is used to find matching command with given Input.
//
// Use MatchFunc to set more customizable matching logic.
func (builder *CommandPropsBuilder) MatchPattern(pattern *regexp.Regexp) *CommandPropsBuilder {
builder.props.matchFunc = func(input Input) bool {
return pattern.MatchString(input.Message())
}
return builder
}
// MatchFunc is a setter to provide a function that judges if an incoming input "matches" to this Command.
// When this returns true, this Command is considered as "corresponding to user input" and becomes Command execution candidate.
//
// MatchPattern may be used to specify a regular expression that is checked against user input, Input.Message();
// MatchFunc can specify more customizable matching logic. e.g. only return true on specific sender's specific message on specific time range.
func (builder *CommandPropsBuilder) MatchFunc(matchFunc func(Input) bool) *CommandPropsBuilder {
builder.props.matchFunc = matchFunc
return builder
}
// Func is a setter to provide command function that requires no configuration.
// If ConfigurableFunc and Func are both called, later call overrides the previous one.
func (builder *CommandPropsBuilder) Func(fn func(context.Context, Input) (*CommandResponse, error)) *CommandPropsBuilder {
builder.props.config = nil
builder.props.commandFunc = func(ctx context.Context, input Input, cfg ...CommandConfig) (*CommandResponse, error) {
return fn(ctx, input)
}
return builder
}
// ConfigurableFunc is a setter to provide command function.
// While Func let developers set simple function, this allows them to provide function that requires some sort of configuration struct.
// On Runner.Run configuration is read from YAML/JSON file located at /path/to/config/dir/{commandIdentifier}.(yaml|yml|json) and mapped to given CommandConfig struct.
// If no YAML/JSON file is found, runner considers the given CommandConfig is fully configured and ready to use.
// This configuration struct is passed to command function as its third argument.
func (builder *CommandPropsBuilder) ConfigurableFunc(config CommandConfig, fn func(context.Context, Input, CommandConfig) (*CommandResponse, error)) *CommandPropsBuilder {
builder.props.config = config
builder.props.commandFunc = func(ctx context.Context, input Input, cfg ...CommandConfig) (*CommandResponse, error) {
return fn(ctx, input, cfg[0])
}
return builder
}
// Instruction is a setter to provide an instruction of command execution.
// This should be used to provide command usage for end users.
func (builder *CommandPropsBuilder) Instruction(instruction string) *CommandPropsBuilder {
builder.props.instructionFunc = func(input *HelpInput) string {
return instruction
}
return builder
}
// InstructionFunc is a setter to provide a function that receives user input and returns instruction.
// Use Instruction() when a simple text instruction can always be returned.
// If the instruction has to be customized per user or the instruction has to be hidden in a certain group or from a certain user,
// use InstructionFunc().
// Use receiving *HelpInput and judge if an instruction should be returned.
// e.g. .reboot command is only supported for administrator users in admin group so this command should be hidden in other groups.
//
// Also see MatchFunc() for such authentication mechanism.
func (builder *CommandPropsBuilder) InstructionFunc(fnc func(input *HelpInput) string) *CommandPropsBuilder {
builder.props.instructionFunc = fnc
return builder
}
// Build builds new CommandProps instance with provided values.
func (builder *CommandPropsBuilder) Build() (*CommandProps, error) {
if builder.props.botType == "" ||
builder.props.identifier == "" ||
builder.props.instructionFunc == nil ||
builder.props.matchFunc == nil ||
builder.props.commandFunc == nil {
return nil, ErrCommandInsufficientArgument
}
return builder.props, nil
}
// MustBuild is like Build but panics if any error occurs on Build.
// It simplifies safe initialization of global variables holding built CommandProps instances.
func (builder *CommandPropsBuilder) MustBuild() *CommandProps {
props, err := builder.Build()
if err != nil {
panic(fmt.Errorf("error on building CommandProps: %w", err))
}
return props
}