Initial commit: hugot lysenko
This commit is contained in:
59
handlers/ignore/ignore.go
Normal file
59
handlers/ignore/ignore.go
Normal 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)
|
||||
}
|
46
handlers/ignore/ignore_test.go
Normal file
46
handlers/ignore/ignore_test.go
Normal 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
86
handlers/quotedb/dao.go
Normal 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(
|
||||
"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
|
||||
}
|
211
handlers/quotedb/handlers.go
Normal file
211
handlers/quotedb/handlers.go
Normal 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))
|
||||
}
|
32
handlers/quotedb/null_time.go
Normal file
32
handlers/quotedb/null_time.go
Normal 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
118
handlers/quotedb/quotedb.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user