Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ plugins:
# Public GitHub lookups; token is optional and should be supplied out-of-band.
github: {enabled: true, timeout_seconds: 8, max_length: 360, token: ""}
reddit: {enabled: true, timeout_seconds: 8, max_length: 360}
# Save and search memorable channel lines; only explicit grabs are persisted.
grab: {enabled: true, max_length: 320, max_quotes_per_user: 20}
# Read-only current kernel release lookup from kernel.org.
linux: {enabled: true, timeout_seconds: 8, max_length: 260}
# Local food and drink suggestions; add one item per line under data/foods.
foods: {enabled: true, data_dir: "data/foods", max_length: 240}
# Local sports suggestions; add one sport per line to data/sports.txt.
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ plugins:
weapons: {enabled: true, data_file: "data/weapons.txt", max_length: 240}
github: {enabled: true, timeout_seconds: 8, max_length: 360, token: ""}
reddit: {enabled: true, timeout_seconds: 8, max_length: 360}
grab: {enabled: true, max_length: 320, max_quotes_per_user: 20}
linux: {enabled: true, timeout_seconds: 8, max_length: 260}
foods: {enabled: true, data_dir: "data/foods", max_length: 240}
sports: {enabled: true, data_file: "data/sports.txt", max_length: 200}
car: {enabled: true, data_file: "data/cars.txt", max_length: 240}
Expand Down
58 changes: 58 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Plugins are enabled or disabled under plugins.<name>.enabled in config.yaml.
- define: short English dictionary definitions
- calc: safe local arithmetic and unit conversion
- github: compact public GitHub repository, issue, release, user, commit, and search lookups
- grab: save, replay, search, and randomly show memorable channel lines
- linux: current Linux kernel release lines from kernel.org
- weapons: high-level local firearm and weapons-name catalog
- status: local connection, uptime, and counter status
- xkcd: latest or numbered XKCD comics
Expand Down Expand Up @@ -335,6 +337,62 @@ BOT_GITHUB_TOKEN=your-optional-read-only-token
The token is sent only to `api.github.com` over HTTPS. Leave it empty when the
anonymous public API limit is sufficient.

## Grabbed channel lines

The `grab` plugin lets users save memorable lines without keeping a complete
transcript of the channel in memory. GoBot remembers only the latest message
from each nickname in the current channel; a line is written to BoltDB only
when someone explicitly grabs it:

~~~text
!grab Alice
!lgrab Alice
!grabr
!grabr Alice
!grabs deployment
~~~

`!grab <nick>` saves that nickname's latest message. `!lgrab` repeats their
most recently saved line, `!grabr` shows a random saved line, and `!grabs`
searches saved nicknames and text. Search results are limited to three compact
matches so the plugin cannot flood a channel. Duplicate lines are ignored and
each nickname is limited to the configured number of saved lines. The plugin
supports ordinary messages and `/me` actions, strips IRC control characters,
and adds a zero-width separator after the first nickname character to avoid
unwanted highlights.

~~~yaml
plugins:
grab:
enabled: true
max_length: 320
max_quotes_per_user: 20
~~~

The saved lines are scoped by network and channel and survive restarts. The
plugin does not expose a command to dump the entire database.

## Linux kernel versions

`!linux` and `!kernel` fetch the current release-line summary from
`kernel.org` and return it as one bounded IRC message:

~~~text
!linux
!kernel
~~~

The lookup is read-only, has a short timeout, limits the response body, and
does not accept arbitrary URLs. It requires no API key:

~~~yaml
plugins:
linux:
enabled: true
timeout_seconds: 8
max_length: 260
~~~

## Foods and drinks

Foods is a local, data-driven suggestion plugin. It does not call a food API
Expand Down
291 changes: 291 additions & 0 deletions plugins/grab.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
package plugins

import (
"fmt"
"math/rand"
"strings"
"sync"
"time"

"github.com/variablenix/GoBot/bot"
"github.com/variablenix/GoBot/storage"
)

const grabBucket = "grabs"

type grabbedLine struct {
Nick string `json:"nick"`
Text string `json:"text"`
At time.Time `json:"at"`
}

// Grab remembers the latest channel message from each nickname in memory and
// persists only explicitly saved grabs. This keeps normal chat lightweight
// while allowing saved quotes to survive restarts.
type Grab struct {
db *storage.DB
mu sync.RWMutex
latest map[string]map[string]grabbedLine
maxLength int
maxQuotesPerUser int
}

func (p *Grab) Name() string { return "grab" }

func (p *Grab) Commands() []string {
return []string{"grab", "lastgrab", "lgrab", "grabrandom", "grabr", "grabsearch", "grabs"}
}

func (p *Grab) Help() string {
return "!grab <nick> — save their latest message; !lgrab <nick> — repeat the last saved line; !grabr [nick] — show a random saved line; !grabs <text> — search saved lines"
}

func (p *Grab) Init(c bot.PluginConfig, db *storage.DB) error {
p.db = db
p.maxLength = c.Int("max_length", 320)
if p.maxLength < 120 || p.maxLength > 500 {
p.maxLength = 320
}
p.maxQuotesPerUser = c.Int("max_quotes_per_user", 20)
if p.maxQuotesPerUser < 1 || p.maxQuotesPerUser > 100 {
p.maxQuotesPerUser = 20
}
p.latest = make(map[string]map[string]grabbedLine)
return nil
}

func (p *Grab) Handle(b *bot.Bot, m bot.Message) bool {
if m.Command == "PRIVMSG" && m.IsChannel && strings.TrimSpace(m.Nick) != "" {
p.remember(m)
}

cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix)
if !ok || !m.IsChannel {
return false
}

switch strings.ToLower(cmd) {
case "grab":
p.handleGrab(b, m, arg)
case "lastgrab", "lgrab":
p.handleLastGrab(b, m, arg)
case "grabrandom", "grabr":
p.handleRandomGrab(b, m, arg)
case "grabsearch", "grabs":
p.handleSearch(b, m, arg)
default:
return false
}
return true
}

func (p *Grab) remember(m bot.Message) {
text := normalizeGrabText(m.Text)
if text == "" {
return
}
channel := strings.ToLower(strings.TrimSpace(m.Target))
nick := strings.ToLower(strings.TrimSpace(m.Nick))
p.mu.Lock()
defer p.mu.Unlock()
if p.latest[channel] == nil {
p.latest[channel] = make(map[string]grabbedLine)
}
p.latest[channel][nick] = grabbedLine{Nick: m.Nick, Text: text, At: m.Timestamp}
}

func (p *Grab) handleGrab(b *bot.Bot, m bot.Message, arg string) {
target := strings.TrimSpace(arg)
if len(strings.Fields(target)) != 1 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !grab <nick>"))
return
}
if strings.EqualFold(target, m.Nick) {
b.Send(m.ReplyTarget(), ircColor(ircCyan, "please grab someone else; I already heard that one"))
return
}

channel := strings.ToLower(strings.TrimSpace(m.Target))
nick := strings.ToLower(target)
p.mu.RLock()
line, found := p.latest[channel][nick]
p.mu.RUnlock()
if !found || line.Text == "" {
b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("I couldn't find a recent message from %s", target)))
return
}

if p.db == nil {
b.Send(m.ReplyTarget(), ircColor(ircRed, "saved quotes are temporarily unavailable"))
return
}
key := grabKey(b.Config.NetworkName, m.Target, nick)
p.mu.Lock()
quotes := p.load(key)
for _, quote := range quotes {
if quote.Text == line.Text {
p.mu.Unlock()
b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("I already saved that line from %s", line.Nick)))
return
}
}
quotes = append(quotes, line)
if len(quotes) > p.maxQuotesPerUser {
quotes = quotes[len(quotes)-p.maxQuotesPerUser:]
}
err := p.db.Set(grabBucket, key, quotes)
p.mu.Unlock()
if err != nil {
b.Send(m.ReplyTarget(), ircColor(ircRed, "I couldn't save that line"))
return
}
b.Send(m.ReplyTarget(), ircColor(ircGreen, fmt.Sprintf("saved %s's latest line", line.Nick)))
}

func (p *Grab) handleLastGrab(b *bot.Bot, m bot.Message, arg string) {
target := strings.TrimSpace(arg)
if len(strings.Fields(target)) != 1 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !lgrab <nick>"))
return
}
quotes := p.quotesFor(b.Config.NetworkName, m.Target, target)
if len(quotes) == 0 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s has never been grabbed here", target)))
return
}
line := quotes[len(quotes)-1]
b.Send(m.ReplyTarget(), ircColor(ircCyan, formatGrab(line.Nick, line.Text)))
}

func (p *Grab) handleRandomGrab(b *bot.Bot, m bot.Message, arg string) {
arg = strings.TrimSpace(arg)
if len(strings.Fields(arg)) > 1 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !grabr [nick]"))
return
}
all := p.channelQuotes(b.Config.NetworkName, m.Target)
if arg != "" {
wanted := strings.ToLower(arg)
filtered := all[:0]
for _, line := range all {
if strings.EqualFold(line.Nick, wanted) {
filtered = append(filtered, line)
}
}
all = filtered
}
if len(all) == 0 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "there are no saved grabs here"))
return
}
line := all[rand.Intn(len(all))]
b.Send(m.ReplyTarget(), ircColor(ircCyan, formatGrab(line.Nick, line.Text)))
}

func (p *Grab) handleSearch(b *bot.Bot, m bot.Message, arg string) {
term := strings.TrimSpace(arg)
if term == "" || len([]rune(term)) > 80 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "usage: !grabs <text>"))
return
}
term = strings.ToLower(term)
matches := make([]grabbedLine, 0, 3)
for _, line := range p.channelQuotes(b.Config.NetworkName, m.Target) {
if strings.Contains(strings.ToLower(line.Nick), term) || strings.Contains(strings.ToLower(line.Text), term) {
matches = append(matches, line)
if len(matches) == 3 {
break
}
}
}
if len(matches) == 0 {
b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("no saved grabs matched %q", strings.TrimSpace(arg))))
return
}
parts := make([]string, len(matches))
for i, line := range matches {
parts[i] = formatGrab(line.Nick, line.Text)
}
result := strings.Join(parts, " | ")
if len(matches) == 3 {
result += " | showing up to 3"
}
b.Send(m.ReplyTarget(), ircColor(ircCyan, truncateRunes(result, p.maxLength)))
}

func (p *Grab) quotesFor(network, channel, nick string) []grabbedLine {
if p.db == nil {
return nil
}
p.mu.RLock()
defer p.mu.RUnlock()
return p.load(grabKey(network, channel, strings.ToLower(strings.TrimSpace(nick))))
}

func (p *Grab) channelQuotes(network, channel string) []grabbedLine {
if p.db == nil {
return nil
}
keys, err := p.db.List(grabBucket)
if err != nil {
return nil
}
prefix := grabChannelPrefix(network, channel)
result := make([]grabbedLine, 0)
p.mu.RLock()
defer p.mu.RUnlock()
for _, key := range keys {
if !strings.HasPrefix(key, prefix) {
continue
}
result = append(result, p.load(key)...)
}
return result
}

func (p *Grab) load(key string) []grabbedLine {
if p.db == nil {
return nil
}
raw, err := p.db.Get(grabBucket, key)
if err != nil {
return nil
}
var quotes []grabbedLine
if storage.Decode(raw, &quotes) != nil {
return nil
}
return quotes
}

func grabChannelPrefix(network, channel string) string {
return strings.ToLower(strings.TrimSpace(network)) + "\x00" + strings.ToLower(strings.TrimSpace(channel)) + "\x00"
}

func grabKey(network, channel, nick string) string {
return grabChannelPrefix(network, channel) + strings.ToLower(strings.TrimSpace(nick))
}

func normalizeGrabText(text string) string {
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "\x01ACTION ") && strings.HasSuffix(text, "\x01") {
text = "* " + strings.TrimSuffix(strings.TrimPrefix(text, "\x01ACTION "), "\x01")
}
return truncateRunes(cleanExternalText(text), 360)
}

func formatGrab(nick, text string) string {
nick = strings.TrimSpace(nick)
if nick == "" {
nick = "someone"
}
runes := []rune(nick)
display := nick
if len(runes) > 1 {
display = string(runes[0]) + "\u200b" + string(runes[1:])
}
if strings.HasPrefix(text, "* ") {
return "* " + display + " " + strings.TrimSpace(strings.TrimPrefix(text, "* "))
}
return fmt.Sprintf("<%s> %s", display, text)
}
Loading