initial draft of quote plugin. (#10) * initial draft of quote plugin. * add random quote fetching. * better error reporting
Anthony DeDominic adedomin@gmail.com
Sat, 11 Dec 2021 10:35:56 -0500
3 files changed,
391 insertions(+),
0 deletions(-)
M
database/db.go
→
database/db.go
@@ -1,7 +1,13 @@
package database import ( + "bytes" + "errors" + "fmt" + "math" "path" + "strconv" + "time" "git.icyphox.sh/paprika/config" "github.com/dgraph-io/badger/v3"@@ -14,6 +20,20 @@ type database struct {
*badger.DB } +// see: https://dgraph.io/docs/badger/get-started/#garbage-collection +func gc() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + again: + err := DB.DB.RunValueLogGC(0.7) + if err == nil { + goto again + } + } + panic("Unreachable!") +} + func Open() (*badger.DB, error) { db, err := badger.Open( badger.DefaultOptions(path.Join(config.C.DbPath, "badger")),@@ -21,6 +41,7 @@ )
if err != nil { return nil, err } + go gc() return db, nil }@@ -55,3 +76,57 @@ return nil, err
} return val, nil } + +var NumTooBig = errors.New("Number Too Big") +var InvalidNumber = errors.New("Invalid Number") + +// encode number so it sorts lexicographically, while being semi readable. +func EncodeNumber(n int) ([]byte, error) { + neg := false + num := n + if n < 0 { + neg = true + num = -num + } + + digits := int(math.Trunc(math.Log10(float64(num))))+1 + if digits > 93 { + return []byte{}, NumTooBig + } + + if !neg { + lenCode := 33 + digits + return []byte(fmt.Sprintf("%c %d", lenCode, n)), nil + } else { + lenCode := 127 - digits + return []byte(fmt.Sprintf("!%c %d", lenCode, n)), nil + } +} + +// encode number so it sorts lexicographically, while being semi readable. +func DecodeNumber(n []byte) (int, error) { + if len(n) < 3 { + return 0, InvalidNumber + } + + // No digit padding + if n[0] < 33 || n[0] > 126 { + return 0, InvalidNumber + } + + num := bytes.SplitN(n, []byte{' '}, 2) + if len(num) != 2 { + return 0, InvalidNumber + } + + number, err := strconv.Atoi(string(num[1])) + if err != nil { + return number, err + } + + if n[0] == '!' { + return -number, nil + } else { + return number, nil + } +}
M
plugins/plugins.go
→
plugins/plugins.go
@@ -49,6 +49,10 @@ panic("Conformity Error, IRCd sent us a PRIVMSG with an empty target and message.")
} sym := target[0] // we only care about the byte (ASCII) + return likelyInvalidNickChr(sym) +} + +func likelyInvalidNickChr(sym byte) bool { // Is one of: !"#$%&'()*+,_./ // or one of: ;<=>?@ // If your IRCd uses symbols outside of this range,
A
plugins/quotes.go
@@ -0,0 +1,312 @@
+package plugins + +import ( + "bytes" + "errors" + "fmt" + "log" + "math/rand" + "strconv" + "strings" + + "git.icyphox.sh/paprika/database" + "github.com/dgraph-io/badger/v3" + "gopkg.in/irc.v3" +) + +type Quotes struct{} + +func init() { + Register(Quotes{}) +} + +func (Quotes) Triggers() []string { + return []string{".q", ".quote"} +} + +func result(quoteNum int, total int, nick string, quote string) string { + return fmt.Sprintf("[%d/%d] <%s> %s", quoteNum, total, nick, quote) +} + +var found = errors.New("Found") +var KeyEncodingError = errors.New("Unexpected key encoding") +var TooManyQuotes = errors.New("Too many quotes") + +func getQuoteTotal(txn *badger.Txn, keyPrefix []byte) (int, error) { + item, err := txn.Get([]byte(keyPrefix)) + if err != nil { + return 0, err + } + it, err := item.ValueCopy(nil) + if err != nil { + return 0, err + } + res, err := strconv.Atoi(string(it)) + if _, ok := err.(*strconv.NumError); ok { + log.Printf("quotes.go: Warning: Something is wrong with the value in key: %s", keyPrefix) + return 0, nil // return 0 in hopes of it being overwritten + } else if err != nil { + return 0, err + } + return res, nil +} + +func getAndIncrementQuoteTotal(txn *badger.Txn, keyPrefix []byte) (int, error) { +total, err := getQuoteTotal(txn, keyPrefix) +if err == badger.ErrKeyNotFound { + total = 0 +} else if err != nil { + return 0, err +} +total++ +return total, txn.Set(keyPrefix, []byte(strconv.Itoa(total))) +} + +func findQuotes(nick string, keyPrefix, search []byte) (string, error) { +var ( + num int + total int + quote string + ) + + err := database.DB.DB.View(func(txn *badger.Txn) error { + iter := txn.NewIterator(badger.DefaultIteratorOptions) + defer iter.Close() + + var err error + prefix := append(keyPrefix, ' ') + for iter.Seek(prefix); iter.ValidForPrefix(prefix); iter.Next() { + item := iter.Item() + key := item.Key() + err = item.Value(func(val []byte) error { + if bytes.Contains(val, search) { + quote = string(val) + keys := bytes.SplitN(key, []byte{' '}, 3) + if len(keys) != 3 { + log.Printf("quotes.go: Key Error: %s is not in expected format", key) + return KeyEncodingError + } + num, err = database.DecodeNumber(keys[2]) + if err != nil { + return err + } else { + return found + } + } else { + return nil + } + }) + + if err != nil { + break + } + } + + if err == found { + total, err = getQuoteTotal(txn, []byte(keyPrefix)) + if err != nil { + return err + } else { + return found + } + } else { + return err + } + }) + + if err == nil { + return "No quote found.", nil + } else if err == found { + return result(num, total, nick, quote), nil + } else { + return "", err + } +} + +func addQuote(keyPrefix, quote []byte) (string, error) { + var id int + err := database.DB.DB.Update(func(txn *badger.Txn) error { + var err error + id, err = getAndIncrementQuoteTotal(txn, keyPrefix) + if err != nil { + return err + } else if id > 5_000 { + return TooManyQuotes + } + + encodedId, err := database.EncodeNumber(id) + if err != nil { + return err + } + + key := append(keyPrefix, ' ') + key = append(key, encodedId...) + + err = txn.Set(key, quote) + if err != nil { + return err + } + return nil + }) + if err == nil { + return fmt.Sprintf("Quote %d added.", id), err + } else { + return "", err + } +} + +func getQuote(nick string, qnum int, keyPrefix []byte) (string, error) { + var ( + num int + total int + quote string + negative bool + ) + + if qnum < 0 { + qnum += 1 + negative = true + } else { + negative = false + } + + err := database.DB.DB.View(func(txn *badger.Txn) error { + var err error + total, err = getQuoteTotal(txn, keyPrefix) + if err != nil { + return err + } + + num = qnum + if negative { + num = total + qnum + if num < 1 { + return badger.ErrKeyNotFound + } + } else if num > total { + return badger.ErrKeyNotFound + } else if num == randomQuote { + // [1, total+1) + num = rand.Intn(total) + 1 + } + + encodeQnum, err := database.EncodeNumber(num) + if err != nil { + return err + } + encodedKey := append(keyPrefix, ' ') + encodedKey = append(encodedKey, encodeQnum...) + + qItem, err := txn.Get(encodedKey) + if err != nil { + return err + } + quoteT, err := qItem.ValueCopy(nil) + if err != nil { + return err + } + quote = string(quoteT) + return nil + }) + + if err == badger.ErrKeyNotFound { + return "No such quote for " + nick, nil + } else if err != nil { + return "", err + } else { + return result(num, total, nick, quote), nil + } +} + +const randomQuote = 0 +const ( + addQ int = iota + getQ + start + parseNick + parseGetParam +) + +func (Quotes) Execute(m *irc.Message) (string, error) { + params := strings.Split(m.Trailing(), " ") + if len(params) == 1 { + return ".q [ add ] nickname [ quote | search | number ]", nil + } + + pState := start + cState := getQ + + var nick string + keyPrefix := []byte(m.Params[0] + " ") + for i := 1 ;i < len(params); i++ { + word := params[i] + back: + if len(word) == 0 { + continue + } + switch pState { + case start: + pState = parseNick + if word == "add" { + cState = addQ + } else { + goto back + } + case parseNick: + if word == "<" || len(word) == 0 { + break + } + + // <xyz> -> xyz + word = strings.TrimPrefix(word, "<") + word = strings.TrimSuffix(word, ">") + if len(word) == 0 { + return "Invalid nickname given", nil + } + // This is used elsewhere to check the prefix of a "target" + // if it's true, then this word still has a prefix we can + // remove. + if likelyInvalidNickChr(word[0]) { + word = word[1:] + } + if len(word) == 0 { + return "Invalid nickname given", nil + } + // we only allow "< " prefix, not "<" + 2*sym + for j := 0; j < len(word); j++ { + if likelyInvalidNickChr(word[j]) { + return fmt.Sprintf("Invalid nickname: %s", word), nil + } + } + nick = word + keyPrefix = append(keyPrefix, nick...) + if cState == addQ { + quote := strings.Join(params[i+1:], " ") + if len(quote) == 0 { + return "Empty quote not allowed.", nil + } + return addQuote(keyPrefix, []byte(quote)) + } else { + pState = parseGetParam + } + case parseGetParam: + if i + 1 == len(params) { + qnum, err := strconv.Atoi(word) + if err != nil { + return findQuotes(nick, keyPrefix, []byte(word)) + } else { + return getQuote(nick, qnum, keyPrefix) + } + } else { + quote := strings.Join(params[i+1:], " ") + return findQuotes(nick, keyPrefix, []byte(quote)) + } + } + } + // If no number given, use 0 to indicate random quote. + if pState == parseGetParam { + return getQuote(nick, randomQuote, keyPrefix) + } else { + return "Invalid number of parameters.", nil + } +}