catbase/bot/bot.go

418 lines
11 KiB
Go
Raw Normal View History

2016-01-17 18:00:44 +00:00
// © 2013 the CatBase Authors under the WTFPL. See AUTHORS for the list of authors.
package bot
import (
"fmt"
"math/rand"
2013-06-01 17:10:15 +00:00
"net/http"
"reflect"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
2016-03-19 18:02:46 +00:00
"github.com/jmoiron/sqlx"
2019-03-07 16:35:42 +00:00
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot/msg"
"github.com/velour/catbase/bot/msglog"
"github.com/velour/catbase/bot/user"
2016-01-17 18:00:44 +00:00
"github.com/velour/catbase/config"
2021-07-21 13:54:29 +00:00
"golang.org/x/crypto/bcrypt"
)
// 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
2020-10-09 16:00:10 +00:00
// 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
2016-03-10 18:37:07 +00:00
logIn chan msg.Message
logOut chan msg.Messages
version string
2013-06-01 17:10:15 +00:00
// The entries to the bot's HTTP interface
httpEndPoints []EndPoint
2017-09-29 04:58:21 +00:00
// filters registered by plugins
filters map[string]func(string) string
2019-02-05 17:25:31 +00:00
callbacks CallbackMap
password string
passwordCreated time.Time
2020-04-29 21:45:53 +00:00
quiet bool
router *chi.Mux
}
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{
2019-05-27 23:21:53 +00:00
{
2019-01-22 00:16:57 +00:00
Name: config.Get("Nick", "bot"),
},
}
bot := &bot{
config: config,
plugins: make(map[string]Plugin),
pluginOrdering: make([]string, 0),
pluginBlacklist: make(map[string]bool),
2020-10-09 16:00:10 +00:00
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),
router: chi.NewRouter(),
}
2013-06-01 17:10:15 +00:00
bot.migrateDB()
bot.RefreshPluginBlacklist()
2020-10-09 16:00:10 +00:00
bot.RefreshPluginWhitelist()
log.Debug().Msgf("created web router")
bot.router.Use(middleware.Logger)
bot.router.Use(middleware.StripSlashes)
bot.router.HandleFunc("/", bot.serveRoot)
bot.router.HandleFunc("/nav", bot.serveNav)
2013-06-01 17:10:15 +00:00
connector.RegisterEvent(bot.Receive)
2016-03-10 18:37:07 +00:00
2013-06-01 17:10:15 +00:00
return bot
}
func (b *bot) ListenAndServe() {
addr := b.config.Get("HttpAddr", "127.0.0.1:1337")
log.Debug().Msgf("starting web service at %s", addr)
log.Fatal().Err(http.ListenAndServe(addr, b.router)).Msg("bot killed")
}
func (b *bot) RegisterWeb(r http.Handler, root string) {
b.router.Mount(root, r)
}
func (b *bot) RegisterWebName(r http.Handler, root, name string) {
b.httpEndPoints = append(b.httpEndPoints, EndPoint{name, root})
b.router.Mount(root, r)
}
2020-06-17 18:24:34 +00:00
// 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.
2019-05-27 23:21:53 +00:00
func (b *bot) DefaultConnector() Connector {
return b.conn
}
2020-06-17 18:24:34 +00:00
// WhoAmI returns the bot's current registered name
2019-05-27 23:21:53 +00:00
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")
2020-10-09 16:00:10 +00:00
}
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)
}
2020-06-17 18:24:34 +00:00
// 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 {
2016-04-21 15:19:38 +00:00
names := b.conn.Who(channel)
users := []user.User{}
for _, n := range names {
users = append(users, user.New(n))
}
2016-04-21 15:19:38 +00:00
return users
}
var suffixRegex *regexp.Regexp
// IsCmd checks if message is a command and returns its curtailed version
2016-03-11 02:11:52 +00:00
func IsCmd(c *config.Config, message string) (bool, string) {
2019-01-22 00:16:57 +00:00
cmdcs := c.GetArray("CommandChar", []string{"!"})
botnick := strings.ToLower(c.Get("Nick", "bot"))
if botnick == "" {
2019-03-07 16:35:42 +00:00
log.Fatal().
Msgf(`You must run catbase -set nick -val <your bot nick>`)
}
2016-03-11 02:11:52 +00:00
iscmd := false
lowerMessage := strings.ToLower(message)
if strings.HasPrefix(lowerMessage, botnick) &&
2016-03-11 02:11:52 +00:00
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
}
}
2016-03-11 02:11:52 +00:00
}
// trim off any whitespace left on the message
message = strings.TrimSpace(message)
return iscmd, message
}
func (b *bot) CheckAdmin(nick string) bool {
2019-01-22 00:16:57 +00:00
for _, u := range b.Config().GetArray("Admins", []string{}) {
if nick == u {
return true
}
}
return false
}
2017-09-29 04:58:21 +00:00
// 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
}
2021-02-01 15:45:41 +00:00
// RegisterTable registers multiple regex handlers at a time
func (b *bot) RegisterTable(p Plugin, handlers HandlerTable) {
for _, h := range handlers {
if h.IsCmd {
b.RegisterRegexCmd(p, h.Kind, h.Regex, h.Handler)
} else {
b.RegisterRegex(p, h.Kind, h.Regex, h.Handler)
}
}
}
// RegisterRegex does what register does, but with a matcher
func (b *bot) RegisterRegex(p Plugin, kind Kind, r *regexp.Regexp, resp ResponseHandler) {
2021-04-27 16:36:34 +00:00
t := PluginName(p)
if _, ok := b.callbacks[t]; !ok {
2021-02-02 02:25:19 +00:00
b.callbacks[t] = make(map[Kind][]HandlerSpec)
}
if _, ok := b.callbacks[t][kind]; !ok {
2021-02-02 02:25:19 +00:00
b.callbacks[t][kind] = []HandlerSpec{}
}
2021-02-02 02:25:19 +00:00
spec := HandlerSpec{
Kind: kind,
Regex: r,
Handler: resp,
}
2021-02-02 02:25:19 +00:00
b.callbacks[t][kind] = append(b.callbacks[t][kind], spec)
}
2021-01-31 23:08:25 +00:00
// 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)
2019-02-05 17:25:31 +00:00
}
2019-02-07 16:30:42 +00:00
2020-06-17 18:24:34 +00:00
// 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
}
2020-04-29 21:45:53 +00:00
2020-06-17 18:24:34 +00:00
// SetQuiet is called to silence the bot from sending channel messages
2020-04-29 21:45:53 +00:00
func (b *bot) SetQuiet(status bool) {
b.quiet = status
}
2020-06-17 18:24:34 +00:00
// 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
}
2020-10-09 16:00:10 +00:00
// 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
}
2020-06-17 18:24:34 +00:00
// 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
}
2020-10-09 16:00:10 +00:00
func (b *bot) GetWhitelist() []string {
list := []string{}
for k := range b.pluginWhitelist {
list = append(list, k)
}
return list
}
2021-04-27 16:36:34 +00:00
func (b *bot) OnBlacklist(channel, plugin string) bool {
return b.pluginBlacklist[channel+plugin]
}
2020-10-09 16:00:10 +00:00
func (b *bot) onWhitelist(plugin string) bool {
return b.pluginWhitelist[plugin]
}
func pluginNameStem(name string) string {
return strings.Split(strings.TrimPrefix(name, "*"), ".")[0]
}
2021-04-27 16:36:34 +00:00
func PluginName(p Plugin) string {
t := reflect.TypeOf(p).String()
return t
}
2021-07-21 13:54:29 +00:00
func (b *bot) CheckPassword(secret, password string) bool {
if password == "" {
return false
}
2021-07-21 13:54:29 +00:00
if b.password == password {
return true
}
parts := strings.SplitN(password, ":", 2)
if len(parts) == 2 {
secret = parts[0]
password = parts[1]
}
q := `select encoded_pass from apppass where secret = ?`
encodedPasswords := [][]byte{}
b.DB().Select(&encodedPasswords, q, secret)
for _, p := range encodedPasswords {
if err := bcrypt.CompareHashAndPassword(p, []byte(password)); err == nil {
return true
}
}
return false
}