Initial commit: hugot lysenko

This commit is contained in:
2016-07-05 02:35:33 +01:00
commit 41e45e8f13
8 changed files with 637 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/lysenko

59
handlers/ignore/ignore.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}
}

86
handlers/quotedb/dao.go Normal file
View File

@@ -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(
&quote.Id, &quote.QuoteId, &quote.Channel, &quote.Data, &quote.Author,
&quote.CreatedAt, &quote.DeletedAt, &quote.DeletedBy,
)
return &quote, err
}
func scanQuotesRow(row *sql.Row) (*Quote, error) {
var quote Quote
err := row.Scan(
&quote.Id, &quote.QuoteId, &quote.Channel, &quote.Data, &quote.Author,
&quote.CreatedAt, &quote.DeletedAt, &quote.DeletedBy)
return &quote, err
}

View File

@@ -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))
}

View File

@@ -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
}

118
handlers/quotedb/quotedb.go Normal file
View File

@@ -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
}

84
lysenko.go Normal file
View File

@@ -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(&quotedb.AddQuoteHandler{QuoteDB: db})
add(&quotedb.LastQuoteHandler{QuoteDB: db})
add(&quotedb.FindQuoteHandler{QuoteDB: db})
add(&quotedb.RandQuoteHandler{QuoteDB: db})
add(&quotedb.QuoteHandler{QuoteDB: db})
go hugot.ListenAndServe(ctx, adapter, hugot.DefaultMux)
conn.Loop()
}