From 41e45e8f1369b647e12336014417d6370155031d Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 5 Jul 2016 02:35:33 +0100 Subject: [PATCH] Initial commit: hugot lysenko --- .gitignore | 1 + handlers/ignore/ignore.go | 59 +++++++++ handlers/ignore/ignore_test.go | 46 +++++++ handlers/quotedb/dao.go | 86 ++++++++++++++ handlers/quotedb/handlers.go | 211 +++++++++++++++++++++++++++++++++ handlers/quotedb/null_time.go | 32 +++++ handlers/quotedb/quotedb.go | 118 ++++++++++++++++++ lysenko.go | 84 +++++++++++++ 8 files changed, 637 insertions(+) create mode 100644 .gitignore create mode 100644 handlers/ignore/ignore.go create mode 100644 handlers/ignore/ignore_test.go create mode 100644 handlers/quotedb/dao.go create mode 100644 handlers/quotedb/handlers.go create mode 100644 handlers/quotedb/null_time.go create mode 100644 handlers/quotedb/quotedb.go create mode 100644 lysenko.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36a7ba9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/lysenko diff --git a/handlers/ignore/ignore.go b/handlers/ignore/ignore.go new file mode 100644 index 0000000..1fba0c7 --- /dev/null +++ b/handlers/ignore/ignore.go @@ -0,0 +1,59 @@ +// package ignore is a Hugot handler that will force any messages received from +// a set of matching idents to be ignored +package ignore + +import ( + "github.com/tcolgate/hugot" + "golang.org/x/net/context" +) + +type IgnoreHearsHandler struct { + hugot.HearsHandler + + Ignores map[string]struct{} +} + +type IgnoreCommandHandler struct { + hugot.CommandHandler + + Ignores map[string]struct{} +} + +func NewCommand(list []string, next hugot.CommandHandler) hugot.CommandHandler { + return &IgnoreCommandHandler{ + CommandHandler: next, + Ignores: buildIgnores(list), + } +} + +func NewHears(list []string, next hugot.HearsHandler) hugot.HearsHandler { + return &IgnoreHearsHandler{ + HearsHandler: next, + Ignores: buildIgnores(list), + } +} + +func buildIgnores(ignores []string) map[string]struct{} { + out := make(map[string]struct{}, len(ignores)) + for _, nick := range ignores { + out[nick] = struct{}{} + } + + return out +} + +func (i *IgnoreHearsHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, matches [][]string) { + if _, present := i.Ignores[m.From]; present { + return + } + + i.HearsHandler.Heard(ctx, w, m, matches) +} + +func (i *IgnoreCommandHandler) Command(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message) error { + if _, present := i.Ignores[m.From]; present { + return nil + } + + return i.CommandHandler.Command(ctx, w, m) +} diff --git a/handlers/ignore/ignore_test.go b/handlers/ignore/ignore_test.go new file mode 100644 index 0000000..eb449b7 --- /dev/null +++ b/handlers/ignore/ignore_test.go @@ -0,0 +1,46 @@ +package ignore_test + +import ( + "regexp" + "testing" + + "github.com/tcolgate/hugot" + "golang.org/x/net/context" + "ur.gs/lysenko/handlers/ignore" +) + +type DummyHearsHandler struct { + Called bool +} + +func (h *DummyHearsHandler) Describe() (string, string) { + return "", "" +} + +func (h *DummyHearsHandler) Hears() *regexp.Regexp { + return regexp.MustCompile(``) +} + +func (h *DummyHearsHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, submatches [][]string) { + h.Called = true +} + +func TestIgnoreHearsPassesOKNick(t *testing.T) { + msg := hugot.Message{} + dummy := &DummyHearsHandler{} + ignorer := ignore.NewHears([]string{"foo"}, dummy) + ignorer.Heard(context.TODO(), hugot.NewNullResponseWriter(msg), &msg, [][]string{}) + if !dummy.Called { + t.Fatal("Dummy not called when it should have been") + } +} + +func TestIgnoreHearsBlocksBadNick(t *testing.T) { + msg := hugot.Message{From: "foo"} + dummy := &DummyHearsHandler{} + ignorer := ignore.NewHears([]string{"foo"}, dummy) + ignorer.Heard(context.TODO(), hugot.NewNullResponseWriter(msg), &msg, [][]string{}) + if dummy.Called { + t.Fatal("Dummy called when it shouldn't have been") + } +} diff --git a/handlers/quotedb/dao.go b/handlers/quotedb/dao.go new file mode 100644 index 0000000..41c898a --- /dev/null +++ b/handlers/quotedb/dao.go @@ -0,0 +1,86 @@ +package quotedb + +import ( + "database/sql" + "errors" +) + +// TODO(lupine): Much of the contents here can be replaced with sqlx, in time + +type Quote struct { + Id int // Unique among all quotes + QuoteId int // Unique within a particular channel + Channel string + Data string + Author string + CreatedAt NullTime + DeletedAt NullTime + DeletedBy sql.NullString +} + +var ( + SCHEMA = ` + create table if not exists quotes( + id integer not null primary key, + quote_id integer not null, + channel string not null, + data text not null, + author text not null, + created_at timestamp, + deleted_at timestamp, + deleted_by string + ); + create index if not exists quotes_by_channel on quotes(channel); + create index if not exists quotes_by_deleted_at on quotes(deleted_at); + create index if not exists quotes_by_data on quotes(data); + create unique index if not exists unique_quotes_by_channel_and_quote_id on quotes(channel, quote_id); + ` + + quoteCols = `id,quote_id,channel,data,author,created_at,deleted_at,deleted_by` + + findQuoteById = `SELECT ` + quoteCols + ` FROM quotes WHERE id = ? LIMIT 1` + findQuoteByQuoteId = `SELECT ` + quoteCols + ` FROM quotes WHERE quote_id = ? AND channel = ? LIMIT 1` + findLastQuote = `SELECT ` + quoteCols + ` FROM quotes WHERE channel = ? ORDER BY quote_id DESC LIMIT 1` + findQuotesByTerm = `SELECT ` + quoteCols + ` FROM quotes WHERE channel = ? AND data LIKE ? ORDER BY quote_id DESC` + findRandomQuote = `SELECT ` + quoteCols + ` FROM quotes WHERE deleted_at IS NULL AND channel = ? ORDER BY RANDOM() LIMIT 1` + + insertQuote = ` + INSERT INTO quotes ( + channel, + quote_id, + data, + author, + created_at + ) SELECT + $1, + ifnull(max(quote_id),0)+1, + $2, + $3, + $4 + FROM quotes WHERE channel = $1; + ` + + errInsertIdUnknown = errors.New("SQL: Couldn't determine insert ID") +) + +// urgh. Code reuse. Not. +func scanQuotesRows(rows *sql.Rows) (*Quote, error) { + var quote Quote + + err := rows.Scan( + "e.Id, "e.QuoteId, "e.Channel, "e.Data, "e.Author, + "e.CreatedAt, "e.DeletedAt, "e.DeletedBy, + ) + + return "e, err +} + +func scanQuotesRow(row *sql.Row) (*Quote, error) { + var quote Quote + + err := row.Scan( + "e.Id, "e.QuoteId, "e.Channel, "e.Data, "e.Author, + "e.CreatedAt, "e.DeletedAt, "e.DeletedBy) + + return "e, err +} diff --git a/handlers/quotedb/handlers.go b/handlers/quotedb/handlers.go new file mode 100644 index 0000000..9c09b6a --- /dev/null +++ b/handlers/quotedb/handlers.go @@ -0,0 +1,211 @@ +// Package quotedb implements a plugin to store quotes for channels. We pop them +// into a database to aid indexing / etc. Recommend SQLite. +package quotedb + +import ( + "fmt" + "log" + "math/rand" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/tcolgate/hugot" + "golang.org/x/net/context" +) + +var ( + hearAddQuote = regexp.MustCompile(`^\!addquote (.*)$`) + hearLastQuote = regexp.MustCompile(`^\!lastquote$`) + hearFindQuote = regexp.MustCompile(`^\!findquote (.*)$`) + hearRandQuote = regexp.MustCompile(`^\!randquote$`) + hearQuote = regexp.MustCompile(`^\!quote (\d+)$`) +) + +type AddQuoteHandler struct { + QuoteDB *QuoteDB +} + +type LastQuoteHandler struct { + QuoteDB *QuoteDB +} + +type FindQuoteHandler struct { + QuoteDB *QuoteDB +} + +type RandQuoteHandler struct { + QuoteDB *QuoteDB +} + +type QuoteHandler struct { + QuoteDB *QuoteDB +} + +func (h *AddQuoteHandler) Describe() (string, string) { + return "addquote", "Add a quote to the database" +} + +func (h *AddQuoteHandler) Hears() *regexp.Regexp { + return hearAddQuote +} + +func (h *AddQuoteHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, submatches [][]string) { + if len(submatches) != 1 || len(submatches[0]) != 2 { + log.Printf("Submatches weird! %+v", submatches) + return + } + + var rsp string + quote, err := h.QuoteDB.AddQuote(m.Channel, submatches[0][1], m.From) + if err != nil { + log.Printf("%s: AddQuote error: %v", m.Channel, err) + rsp = "Failed to add quote, sorry!" + } else { + rsp = fmt.Sprintf("Quote %d added", quote.QuoteId) + } + + w.Send(ctx, m.Reply(rsp)) +} + +func (h *LastQuoteHandler) Describe() (string, string) { + return "lastquote", "Show the last-added quote" +} + +func (h *LastQuoteHandler) Hears() *regexp.Regexp { + return hearLastQuote +} + +func (h *LastQuoteHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, submatches [][]string) { + quote, err := h.QuoteDB.FindLastQuote(m.Channel) + if err != nil { + log.Printf("%s: Error in LastQuote: %v", m.Channel, err) + return + } + + rsp := fmt.Sprintf("Quote %d by %s: %s", quote.QuoteId, quote.Author, quote.Data) + w.Send(ctx, m.Reply(rsp)) +} + +func (h *FindQuoteHandler) Describe() (string, string) { + return "findquote", "Search for a quote by substring" +} + +func (h *FindQuoteHandler) Hears() *regexp.Regexp { + return hearFindQuote +} + +func (h *FindQuoteHandler) find(channel, term string) string { + quotes, err := h.QuoteDB.FindQuotesByString(term, channel) + if err != nil { + return "Couldn't find any quotes matching search term" + } + + switch len(quotes) { + case 0: + return "No results" + case 1: + return fmt.Sprintf("1 match. ID %d: %s: %s", quotes[0].QuoteId, quotes[0].Author, quotes[0].Data) + } + + quote := quotes[0] + quoteCount := strconv.Itoa(len(quotes)) + if len(quotes) > 5 { + quoteCount = "More than 5" + } + + quoteIds := make([]int, 0) + max := 5 + if len(quotes) < max { + max = len(quotes) + } + + indices := rand.Perm(len(quotes))[0:max] + for _, i := range indices { + if quotes[i] == quote || quotes[i] == nil { + continue + } + quoteIds = append(quoteIds, quotes[i].QuoteId) + } + quoteIds = append(quoteIds, quote.QuoteId) + + sort.Ints(quoteIds) + quoteIdStrs := make([]string, len(quoteIds)) + for i, d := range quoteIds { + quoteIdStrs[i] = strconv.Itoa(d) + } + idStr := strings.Join(quoteIdStrs, ", ") + + return fmt.Sprintf("%s matches. IDs: %s. Most recent: %s: %s", quoteCount, idStr, quote.Author, quote.Data) +} + +func (h *FindQuoteHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, submatches [][]string) { + if len(submatches) != 1 || len(submatches[0]) != 2 { + log.Printf("Submatches weird! %+v", submatches) + return + } + + rsp := h.find(m.Channel, submatches[0][1]) + w.Send(ctx, m.Reply(rsp)) +} + +func (h *RandQuoteHandler) Describe() (string, string) { + return "randquote", "Show a random quote" +} + +func (h *RandQuoteHandler) Hears() *regexp.Regexp { + return hearRandQuote +} + +func (h *RandQuoteHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, submatches [][]string) { + quote, err := h.QuoteDB.FindRandomQuote(m.Channel) + if err != nil { + log.Printf("%s: Error in RandQuote: %v", m.Channel, err) + return + } + + rsp := fmt.Sprintf("Quote %d by %s: %s", quote.QuoteId, quote.Author, quote.Data) + w.Send(ctx, m.Reply(rsp)) +} + +func (h *QuoteHandler) Describe() (string, string) { + return "quote", "show a specific quote, identified by ID" +} + +func (h *QuoteHandler) Hears() *regexp.Regexp { + return hearQuote +} + +func (h *QuoteHandler) show(channel, qid string) string { + id, err := strconv.Atoi(qid) + if err != nil { + return "invalid ID" + } + + quote, err := h.QuoteDB.FindQuoteByQuoteId(id, channel) + if err != nil { + return fmt.Sprintf("Couldn't find quote %d", id) + } + + if quote.DeletedAt.Valid { + delName := "(unknown)" + if quote.DeletedBy.Valid { + delName = quote.DeletedBy.String + } + + return fmt.Sprintf("Quote %d was deleted by %s at %s", quote.Id, delName, quote.DeletedAt.Time.String()) + } + + return fmt.Sprintf("%s: %s", quote.Author, quote.Data) +} + +func (h *QuoteHandler) Heard(ctx context.Context, w hugot.ResponseWriter, m *hugot.Message, submatches [][]string) { + if len(submatches) != 1 || len(submatches[0]) != 2 { + log.Printf("Submatches weird! %+v", submatches) + return + } + + rsp := h.show(m.Channel, submatches[0][1]) + w.Send(ctx, m.Reply(rsp)) +} diff --git a/handlers/quotedb/null_time.go b/handlers/quotedb/null_time.go new file mode 100644 index 0000000..677a021 --- /dev/null +++ b/handlers/quotedb/null_time.go @@ -0,0 +1,32 @@ +package quotedb + +import ( + "database/sql/driver" + "time" +) + +// This is more or less exactly pq.NullTime +type NullTime struct { + Time time.Time + Valid bool +} + +func (nt *NullTime) Scan(value interface{}) error { + if value == nil { + nt.Time, nt.Valid = time.Unix(0, 0).UTC(), false + return nil + } + + nt.Valid = true + nt.Time = value.(time.Time) + + return nil +} + +func (nt *NullTime) Value() (driver.Value, error) { + if !nt.Valid { + return nil, nil + } + + return nt.Time, nil +} diff --git a/handlers/quotedb/quotedb.go b/handlers/quotedb/quotedb.go new file mode 100644 index 0000000..0d57e5b --- /dev/null +++ b/handlers/quotedb/quotedb.go @@ -0,0 +1,118 @@ +package quotedb + +import ( + "database/sql" + "fmt" + _ "github.com/mattn/go-sqlite3" + "log" + "time" +) + +type QuoteDB struct { + DB *sql.DB +} + +func New(filename string) (*QuoteDB, error) { + dbh, err := sql.Open("sqlite3", filename) + if err != nil { + return nil, err + } + + if _, err := dbh.Exec(SCHEMA); err != nil { + dbh.Close() + return nil, err + } + + return &QuoteDB{DB: dbh}, nil +} + +func (q *QuoteDB) AddQuote(channel string, data string, author string) (*Quote, error) { + now := time.Now().UTC() + + result, insertErr := q.DB.Exec(insertQuote, channel, data, author, now) + if insertErr != nil { + log.Printf("Error inserting a quote:", insertErr) + return nil, insertErr + } + + id, idErr := result.LastInsertId() + if idErr != nil { + log.Printf("InsertIdUnknown while adding a quote") + return nil, errInsertIdUnknown + } + + return q.findQuoteById(id) +} + +func (q *QuoteDB) findQuoteById(id int64) (*Quote, error) { + quote, err := scanQuotesRow(q.DB.QueryRow(findQuoteById, id)) + + if err != nil && err != sql.ErrNoRows { + log.Println("Encountered", err, "looking up quote with rowid", id) + } + + return quote, err +} + +func (q *QuoteDB) FindQuoteByQuoteId(id int, channel string) (*Quote, error) { + quote, err := scanQuotesRow(q.DB.QueryRow(findQuoteByQuoteId, id, channel)) + + if err != nil && err != sql.ErrNoRows { + log.Println("Encountered", err, "looking up quote", id, "on channel", channel) + } + + return quote, err +} + +func (q *QuoteDB) FindRandomQuote(channel string) (*Quote, error) { + quote, err := scanQuotesRow(q.DB.QueryRow(findRandomQuote, channel)) + + if err != nil && err != sql.ErrNoRows { + log.Println("Encountered", err, "looking up random quote on channel", channel) + } + + return quote, err + +} + +func (q *QuoteDB) FindLastQuote(channel string) (*Quote, error) { + quote, err := scanQuotesRow(q.DB.QueryRow(findLastQuote, channel)) + + if err != nil && err != sql.ErrNoRows { + log.Println("Encountered", err, "looking up last quote on channel", channel) + } + + return quote, err +} + +func (q QuoteDB) FindQuotesByString(search string, channel string) ([]*Quote, error) { + term := fmt.Sprintf("%%%s%%", search) + + rows, err := q.DB.Query(findQuotesByTerm, channel, term) + + if err != nil { + log.Println("Encountered", err, "searching for quotes on channel", channel, "with term", term) + return nil, err + } + + quotes := make([]*Quote, 0) + + defer rows.Close() + for rows.Next() { + quote, err := scanQuotesRows(rows) + if err != nil { + log.Println("Encountered", err, "searching for quotes on channel", channel, "with term", term) + return nil, err + } + + quotes = append(quotes, quote) + } + + err = rows.Err() + if err != nil { + log.Println("Encountered", err, "searching for quotes on channel", channel, "with term", term) + return nil, err + } + + return quotes, nil +} diff --git a/lysenko.go b/lysenko.go new file mode 100644 index 0000000..a88ac9f --- /dev/null +++ b/lysenko.go @@ -0,0 +1,84 @@ +package main + +import ( + "flag" + "log" + "math/rand" + "strings" + "time" + + "github.com/tcolgate/hugot" + "github.com/tcolgate/hugot/adapters/irc" + irce "github.com/thoj/go-ircevent" + "golang.org/x/net/context" + + "ur.gs/lysenko/handlers/ignore" + "ur.gs/lysenko/handlers/quotedb" +) + +var ( + channels = flag.String("channels", "##testing,##test", "Channels to join (separated by comma)") + host = flag.String("host", "chat.freenode.net", "Server host[:port]") + ident = flag.String("ident", "lysenko", "Lysenko Bot") + ignores = flag.String("ignore", "", "nicks to ignore, comma-separated") + nick = flag.String("nick", "lysenko", "Lysenko Bot") + nickserv = flag.String("nickserv", "", "NickServ password") + quotes = flag.String("quotedb", ":memory:", "sqlite3 quote database") + ssl = flag.Bool("ssl", true, "Enable SSL") +) + +func init() { + // TODO: better rand.Seed + rand.Seed(time.Now().Unix()) +} + +func buildAdder(mux *hugot.Mux, ignoreList []string) func(next hugot.HearsHandler) { + return func(next hugot.HearsHandler) { + mux.AddHearsHandler(ignore.NewHears(ignoreList, next)) + } + +} + +func main() { + flag.Parse() + ignoreList := strings.Split(*ignores, ",") + channelList := strings.Split(*channels, ",") + + _ = ignoreList // TODO: use this + + conn := irce.IRC(*nick, *ident) + conn.UseTLS = *ssl + conn.Password = *nickserv + + if err := conn.Connect(*host); err != nil { + log.Fatal(err) + } + defer conn.Quit() + + for _, channel := range channelList { + conn.Join(channel) + } + + ctx := context.Background() + adapter, err := irc.New(conn) + if err != nil { + log.Fatal(err) + } + + db, err := quotedb.New(*quotes) + if err != nil { + log.Fatal(err) + } + defer db.DB.Close() + + add := buildAdder(hugot.DefaultMux, ignoreList) + add("edb.AddQuoteHandler{QuoteDB: db}) + add("edb.LastQuoteHandler{QuoteDB: db}) + add("edb.FindQuoteHandler{QuoteDB: db}) + add("edb.RandQuoteHandler{QuoteDB: db}) + add("edb.QuoteHandler{QuoteDB: db}) + + go hugot.ListenAndServe(ctx, adapter, hugot.DefaultMux) + + conn.Loop() +}