// © 2013 the CatBase Authors under the WTFPL. See AUTHORS for the list of authors. package bot import ( "fmt" "math/rand" "net/http" "reflect" "regexp" "strings" "time" "github.com/jmoiron/sqlx" "github.com/rs/zerolog/log" "github.com/velour/catbase/bot/msg" "github.com/velour/catbase/bot/msglog" "github.com/velour/catbase/bot/user" "github.com/velour/catbase/config" ) // bot type provides storage for bot-wide information, configs, and database connections type bot struct { // Each plugin must be registered in our plugins handler. To come: a map so that this // will allow plugins to respond to specific kinds of events plugins map[string]Plugin pluginOrdering []string // channel -> plugin pluginBlacklist map[string]bool // plugin, this is bot-wide pluginWhitelist map[string]bool // Users holds information about all of our friends users []user.User // Represents the bot me user.User config *config.Config conn Connector logIn chan msg.Message logOut chan msg.Messages version string // The entries to the bot's HTTP interface httpEndPoints []EndPoint // filters registered by plugins filters map[string]func(string) string callbacks CallbackMap password string passwordCreated time.Time quiet bool } type EndPoint struct { Name string `json:"name"` URL string `json:"url"` } // Variable represents a $var replacement type Variable struct { Variable, Value string } // New creates a bot for a given connection and set of handlers. func New(config *config.Config, connector Connector) Bot { logIn := make(chan msg.Message) logOut := make(chan msg.Messages) msglog.RunNew(logIn, logOut) users := []user.User{ { Name: config.Get("Nick", "bot"), }, } bot := &bot{ config: config, plugins: make(map[string]Plugin), pluginOrdering: make([]string, 0), pluginBlacklist: make(map[string]bool), pluginWhitelist: make(map[string]bool), conn: connector, users: users, me: users[0], logIn: logIn, logOut: logOut, httpEndPoints: make([]EndPoint, 0), filters: make(map[string]func(string) string), callbacks: make(CallbackMap), } bot.migrateDB() bot.RefreshPluginBlacklist() bot.RefreshPluginWhitelist() http.HandleFunc("/", bot.serveRoot) http.HandleFunc("/nav", bot.serveNav) connector.RegisterEvent(bot.Receive) return bot } // DefaultConnector is the main connector used for the bot // If more than one connector is on, some users may not see all messages if this is used. // Usage should be limited to out-of-band communications such as timed messages. func (b *bot) DefaultConnector() Connector { return b.conn } // WhoAmI returns the bot's current registered name func (b *bot) WhoAmI() string { return b.me.Name } // Config gets the configuration that the bot is using func (b *bot) Config() *config.Config { return b.config } func (b *bot) DB() *sqlx.DB { return b.config.DB } // Create any tables if necessary based on version of DB // Plugins should create their own tables, these are only for official bot stuff // Note: This does not return an error. Database issues are all fatal at this stage. func (b *bot) migrateDB() { if _, err := b.DB().Exec(`create table if not exists variables ( id integer primary key, name string, value string );`); err != nil { log.Fatal().Err(err).Msgf("Initial DB migration create variables table") } if _, err := b.DB().Exec(`create table if not exists pluginBlacklist ( channel string, name string, primary key (channel, name) );`); err != nil { log.Fatal().Err(err).Msgf("Initial DB migration create blacklist table") } if _, err := b.DB().Exec(`create table if not exists pluginWhitelist ( name string primary key );`); err != nil { log.Fatal().Err(err).Msgf("Initial DB migration create whitelist table") } } // Adds a constructed handler to the bots handlers list func (b *bot) AddPlugin(h Plugin) { name := reflect.TypeOf(h).String() b.plugins[name] = h b.pluginOrdering = append(b.pluginOrdering, name) } // Who returns users for a channel the bot is in // Check the particular connector for channel values func (b *bot) Who(channel string) []user.User { names := b.conn.Who(channel) users := []user.User{} for _, n := range names { users = append(users, user.New(n)) } return users } var suffixRegex *regexp.Regexp // IsCmd checks if message is a command and returns its curtailed version func IsCmd(c *config.Config, message string) (bool, string) { cmdcs := c.GetArray("CommandChar", []string{"!"}) botnick := strings.ToLower(c.Get("Nick", "bot")) if botnick == "" { log.Fatal(). Msgf(`You must run catbase -set nick -val `) } iscmd := false lowerMessage := strings.ToLower(message) if strings.HasPrefix(lowerMessage, botnick) && len(lowerMessage) > len(botnick) && (lowerMessage[len(botnick)] == ',' || lowerMessage[len(botnick)] == ':') { iscmd = true message = message[len(botnick):] // trim off the customary addressing punctuation if message[0] == ':' || message[0] == ',' { message = message[1:] } } else { for _, cmdc := range cmdcs { if strings.HasPrefix(lowerMessage, cmdc) && len(cmdc) > 0 { iscmd = true message = message[len(cmdc):] break } } } // trim off any whitespace left on the message message = strings.TrimSpace(message) return iscmd, message } func (b *bot) CheckAdmin(nick string) bool { for _, u := range b.Config().GetArray("Admins", []string{}) { if nick == u { return true } } return false } // Register a text filter which every outgoing message is passed through func (b *bot) RegisterFilter(name string, f func(string) string) { b.filters[name] = f } // RegisterRegex does what register does, but with a matcher func (b *bot) RegisterRegex(p Plugin, kind Kind, r *regexp.Regexp, resp ResponseHandler) { t := reflect.TypeOf(p).String() if _, ok := b.callbacks[t]; !ok { b.callbacks[t] = make(map[Kind]map[*regexp.Regexp][]ResponseHandler) } if _, ok := b.callbacks[t][kind]; !ok { b.callbacks[t][kind] = map[*regexp.Regexp][]ResponseHandler{} } if _, ok := b.callbacks[t][kind][r]; !ok { b.callbacks[t][kind][r] = []ResponseHandler{} } b.callbacks[t][kind][r] = append(b.callbacks[t][kind][r], resp) } // RegisterRegexCmd is a shortcut to filter non-command messages from a registration func (b *bot) RegisterRegexCmd(p Plugin, kind Kind, r *regexp.Regexp, resp ResponseHandler) { newResp := func(req Request) bool { if !req.Msg.Command { return false } return resp(req) } b.RegisterRegex(p, kind, r, newResp) } // Register a callback // This function should be considered deprecated. func (b *bot) Register(p Plugin, kind Kind, cb Callback) { r := regexp.MustCompile(`.*`) resp := func(r Request) bool { return cb(r.Conn, r.Kind, r.Msg, r.Args...) } b.RegisterRegex(p, kind, r, resp) } func (b *bot) RegisterWeb(root, name string) { b.httpEndPoints = append(b.httpEndPoints, EndPoint{name, root}) } // GetPassword returns a random password generated for the bot // Passwords expire in 24h and are used for the web interface func (b *bot) GetPassword() string { if b.passwordCreated.Before(time.Now().Add(-24 * time.Hour)) { adjs := b.config.GetArray("bot.passwordAdjectives", []string{"very"}) nouns := b.config.GetArray("bot.passwordNouns", []string{"noun"}) verbs := b.config.GetArray("bot.passwordVerbs", []string{"do"}) a, n, v := adjs[rand.Intn(len(adjs))], nouns[rand.Intn(len(nouns))], verbs[rand.Intn(len(verbs))] b.passwordCreated = time.Now() b.password = fmt.Sprintf("%s-%s-%s", a, n, v) } return b.password } // SetQuiet is called to silence the bot from sending channel messages func (b *bot) SetQuiet(status bool) { b.quiet = status } // RefreshPluginBlacklist loads data for which plugins are disabled for particular channels func (b *bot) RefreshPluginBlacklist() error { blacklistItems := []struct { Channel string Name string }{} if err := b.DB().Select(&blacklistItems, `select channel, name from pluginBlacklist`); err != nil { return fmt.Errorf("%w", err) } b.pluginBlacklist = make(map[string]bool) for _, i := range blacklistItems { b.pluginBlacklist[i.Channel+i.Name] = true } log.Debug().Interface("blacklist", b.pluginBlacklist).Msgf("Refreshed plugin blacklist") return nil } // RefreshPluginWhitelist loads data for which plugins are enabled func (b *bot) RefreshPluginWhitelist() error { whitelistItems := []struct { Name string }{ {Name: "admin"}, // we must always ensure admin is on! } if err := b.DB().Select(&whitelistItems, `select name from pluginWhitelist`); err != nil { return fmt.Errorf("%w", err) } b.pluginWhitelist = make(map[string]bool) for _, i := range whitelistItems { b.pluginWhitelist[i.Name] = true } log.Debug().Interface("whitelist", b.pluginWhitelist).Msgf("Refreshed plugin whitelist") return nil } // GetPluginNames returns an ordered list of plugins loaded (used for blacklisting) func (b *bot) GetPluginNames() []string { names := []string{} for _, name := range b.pluginOrdering { names = append(names, pluginNameStem(name)) } return names } func (b *bot) GetWhitelist() []string { list := []string{} for k := range b.pluginWhitelist { list = append(list, k) } return list } func (b *bot) onBlacklist(channel, plugin string) bool { return b.pluginBlacklist[channel+plugin] } func (b *bot) onWhitelist(plugin string) bool { return b.pluginWhitelist[plugin] } func pluginNameStem(name string) string { return strings.Split(strings.TrimPrefix(name, "*"), ".")[0] }