Commands
Registering
func (m *Weather) Commands() map[string]goroku.CommandHandler {
return map[string]goroku.CommandHandler{
"weather": m.WeatherCmd,
}
}The map key is the command word. With the default . prefix that registers .weather. Keys are matched case-insensitively.
Command and alias names are unique across all loaded modules. If another module already owns weather, registration fails with a collision error naming the conflict rather than silently overriding it.
Aliases
Add CommandMetas — an optional interface, so declaring it is enough:
func (m *Weather) CommandMetas() map[string]goroku.CommandMeta {
return map[string]goroku.CommandMeta{
"weather": {
Aliases: []string{"w", "wx"},
},
}
}.weather, .w and .wx now all work.
Restricting where a command runs
CommandMeta fields are filters. The dispatcher checks them before your handler runs, so a command that does not apply simply does not fire.
func (m *Weather) CommandMetas() map[string]goroku.CommandMeta {
return map[string]goroku.CommandMeta{
"weather": {
Aliases: []string{"w"},
OnlyOwner: true, // only you
OnlyGroups: true, // only in groups
NoForwarded: true, // ignore forwarded messages
},
}
}Common ones:
| Field | Effect |
|---|---|
OnlyOwner | Only the account owner |
OnlyPM / NoPM | Only / never in private chats |
OnlyGroups, OnlyChannels, OnlyChats | Restrict by chat type |
OnlyReply / NoReply | Require / forbid a replied-to message |
OnlyMedia, OnlyPhotos, OnlyVideos, OnlyDocs | Require an attachment |
NoMedia, NoStickers, NoAudio, NoDoc | Reject attachments |
Ratelimit | Subject the command to per-user and per-chat rate limits |
Filter | func(*Message) bool for anything else |
The full list is in CommandMeta.
Matching on content
"weather": {
StartsWith: "город ",
Regex: `^\d{5}$`,
},Regex is compiled once at registration. An invalid pattern fails registration rather than silently never matching.
Errors
Return an error and the user sees it:
func (m *Weather) WeatherCmd(msg *goroku.Message) error {
city := msg.Args()
if city == "" {
return msg.Answer(m.T("no_city", "❌ <b>Which city?</b>"))
}
data, err := m.fetch(city)
if err != nil {
return fmt.Errorf("fetch weather for %q: %w", city, err)
}
return msg.Answer(data)
}Two different outcomes:
msg.Answer(...)with a friendly message — an expected situation, like missing arguments. Returnnil.return err— something actually failed. Goroku logs it and shows the user❌ Command execution error: <your message>.
Wrap errors with context (fmt.Errorf("...: %w", err)); that text is what you will read in the log at 3am.
Rate limits
Commands are not rate-limited by default. Opt in per command:
"weather": { Ratelimit: true },The dispatcher then applies the configured per-user and per-chat windows, and tells the user when they are over the limit.
Concurrency
Handlers run concurrently, each in its own goroutine with a bounded pool. Two consequences:
- Guard shared state on your module struct with a mutex. Your module is a single instance shared across every chat.
- Long work is fine — it will not block other commands — but respect cancellation via
msg.Context()so shutdown is not delayed.
type Weather struct {
goroku.Base
mu sync.Mutex
cache map[string]string
}
func (m *Weather) WeatherCmd(msg *goroku.Message) error {
m.mu.Lock()
cached, ok := m.cache[msg.Args()]
m.mu.Unlock()
if ok {
return msg.Answer(cached)
}
return m.fetchAndAnswer(msg)
}