-
Notifications
You must be signed in to change notification settings - Fork 16
/
command.go
384 lines (325 loc) · 14 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
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
package sarah
import (
"context"
"errors"
"fmt"
"github.com/oklahomer/go-kasumi/logger"
"reflect"
"regexp"
"strings"
"sync"
)
var (
// ErrCommandInsufficientArgument depicts an error that not enough arguments are set to CommandProps.
// This can be returned by CommandPropsBuilder.Build.
ErrCommandInsufficientArgument = errors.New("BotType, Identifier, InstructionFunc, MatchFunc and (Configurable)Func must be set")
)
// CommandResponse is returned by Command or ContextualFunc when the execution is finished.
type CommandResponse struct {
// Content represents a group of data returned to the user.
// Since this is passed to Bot.SendMessage as part of OutputMessage,
// its type may vary depending on the Bot's integrating chat service.
Content interface{}
// UserContext represents a user's contextual state to be stored.
// When this is non-nil and a UserContextStorage is present for the Bot, this value is passed to UserContextStorage.
// The user's next Input is fed to UserContext.Next so the user can continue the interaction until UserContext is no longer returned.
UserContext *UserContext
}
// Command defines an interface that all executable command MUST satisfy.
// This is executed against a user's input.
type Command interface {
// Identifier returns a unique id of this Command.
Identifier() string
// Execute receives an Input sent by a user and returns a response in a form of CommandResponse.
Execute(context.Context, Input) (*CommandResponse, error)
// Instruction returns a help message to show the Command usage to the requesting user.
// A list of instructions may be returned to a user at once, so the message should be simple.
Instruction(*HelpInput) string
// Match is used to judge if this Command corresponds to the given Input.
// If this returns true, the Bot implementation should proceed to Execute this Command with the current 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 a config struct, lock before execution.
// The config struct may be updated by ConfigWatcher at the same time.
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 CommandConfig.
n := reflect.New(reflect.TypeOf(cfg))
// Copy the current field value to the newly created instance.
// This includes private field values.
n.Elem().Set(rv)
// Pass the pointer of the created instance.
e := watcher.Read(ctx, props.botType, props.identifier, n.Interface())
if e == nil {
// Replace the current value with the updated one.
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 applies the given regular expression to the input string and replaces the matching part with the empty string.
// Use this to extract the meaningful input value out of the 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.
// A Bot implementation can refer to this to register a given command on Bot.AppendCommand call, and to find a matching Command on Bot.Respond call.
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 lets developers register a new Command to its internal stash.
// If another command is already registered with the same ID, the existing one is replaced in favor of the new one.
func (commands *Commands) Append(command Command) {
commands.mutex.Lock()
defer commands.mutex.Unlock()
// See if a command with the same identifier exists.
for i, cmd := range commands.collection {
if cmd.Identifier() == command.Identifier() {
logger.Infof("Replace old command in favor of newly appending one: %s.", command.Identifier())
commands.collection[i] = command
return
}
}
// Not stored, then append to the last.
logger.Infof("Append new command: %s.", command.Identifier())
commands.collection = append(commands.collection, command)
}
// FindFirstMatched looks for the first matching command by calling each Command's Command.Match method:
// The first Command to return true is considered as "first matched" and is returned.
//
// The check for each Command is run in the order of registration; The earlier the Commands.Append is called, the earlier the check.
// Be sure to register an 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 finding a matching command with the given Input and executes a Command 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 all belonging 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 a slice of CommandHelp pointers.
type CommandHelps []*CommandHelp
// CommandHelp represents an instruction for the corresponding Command.
type CommandHelp struct {
// Identifier represents the unique id of the corresponding Command.
Identifier string
// Instruction represents a help message to guide the Command usage.
Instruction string
}
// CommandConfig provides an interface that every command configuration value must satisfy, which actually is empty.
// Think of this as a kind of marker interface with a more meaningful name.
type CommandConfig interface{}
type commandFunc func(context.Context, Input, ...CommandConfig) (*CommandResponse, error)
// NewCommandPropsBuilder returns new CommandPropsBuilder instance.
// Use this to set up a CommandProps that can be used to build a Command on the fly.
func NewCommandPropsBuilder() *CommandPropsBuilder {
return &CommandPropsBuilder{
props: &CommandProps{},
}
}
// CommandProps is a designated non-serializable configuration struct to be used for Command construction.
// This holds a relatively complex set of Command construction arguments and properties.
type CommandProps struct {
botType BotType
identifier string
config CommandConfig
commandFunc commandFunc
matchFunc func(Input) bool
instructionFunc func(*HelpInput) string
}
// CommandPropsBuilder helps to construct a CommandProps.
// A developer may set up a Command construction property -- CommandProps -- by calling CommandPropsBuilder.Build or CommandPropsBuilder.MustBuild at the end.
// A validation logic runs on build, so the returning CommandProps instant is safe to be passed to RegisterCommandProps.
type CommandPropsBuilder struct {
props *CommandProps // This instance is not fully constructed til Build() is called.
}
// BotType is a setter to provide the belonging BotType.
func (builder *CommandPropsBuilder) BotType(botType BotType) *CommandPropsBuilder {
builder.props.botType = botType
return builder
}
// Identifier is a setter for a Command identifier.
func (builder *CommandPropsBuilder) Identifier(id string) *CommandPropsBuilder {
builder.props.identifier = id
return builder
}
// MatchPattern is a setter to provide a command match pattern.
// This regular expression is used against the given Input to see if the Command matches the Input.
//
// Use MatchFunc to set a more customizable matcher 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 for a function to judge if an incoming Input "matches" the Command.
// When this returns true, this command is considered to "match the user input" and becomes a 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 a specific sender's specific message on a specific time range.
func (builder *CommandPropsBuilder) MatchFunc(matchFunc func(Input) bool) *CommandPropsBuilder {
builder.props.matchFunc = matchFunc
return builder
}
// Func is a setter to provide a command function that requires no configuration.
// If ConfigurableFunc and Func are both called, the 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 a command function that takes a configuration value as a struct.
// While Func lets developers set a simple function, this allows them to provide a function that requires some sort of configuration struct.
// On Sarah initiation, configuration settings are read by ConfigWatcher and mapped to the given CommandConfig value.
// This configuration value is passed to the command -- fn -- 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 a user input and returns an 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() as it takes HelpInput as its argument.
// Use *HelpInput and judge if an instruction should be returned to the user.
// e.g. .reboot command is only supported for administrator users in the admin group so this command should be hidden in other groups.
//
// Also, see MatchFunc() for such an authentication mechanism.
func (builder *CommandPropsBuilder) InstructionFunc(fnc func(input *HelpInput) string) *CommandPropsBuilder {
builder.props.instructionFunc = fnc
return builder
}
// Build builds a new CommandProps instance with the 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 the initialization of a global variable holding the built CommandProps instance.
func (builder *CommandPropsBuilder) MustBuild() *CommandProps {
props, err := builder.Build()
if err != nil {
panic(fmt.Errorf("error on building CommandProps: %w", err))
}
return props
}