diff --git a/config.yaml b/config.yaml index 3815500..6e687bf 100644 --- a/config.yaml +++ b/config.yaml @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 6bbae08..6fbcfe0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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} diff --git a/docs/plugins.md b/docs/plugins.md index 0a44b1c..3d08cbc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -22,6 +22,8 @@ Plugins are enabled or disabled under plugins..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 @@ -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 ` 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 diff --git a/plugins/grab.go b/plugins/grab.go new file mode 100644 index 0000000..e798363 --- /dev/null +++ b/plugins/grab.go @@ -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 — save their latest message; !lgrab — repeat the last saved line; !grabr [nick] — show a random saved line; !grabs — 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 ")) + 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 ")) + 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 ")) + 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, "es) != 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) +} diff --git a/plugins/grab_test.go b/plugins/grab_test.go new file mode 100644 index 0000000..0c1ebb4 --- /dev/null +++ b/plugins/grab_test.go @@ -0,0 +1,67 @@ +package plugins + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/variablenix/GoBot/bot" + "github.com/variablenix/GoBot/storage" +) + +func TestGrabCommands(t *testing.T) { + p := &Grab{} + if got := p.Name(); got != "grab" { + t.Fatalf("Name() = %q", got) + } + commands := strings.Join(p.Commands(), " ") + for _, want := range []string{"grab", "lgrab", "grabr", "grabs"} { + if !strings.Contains(commands, want) { + t.Fatalf("command %q missing from %q", want, commands) + } + } +} + +func TestGrabPersistsAndLimitsQuotes(t *testing.T) { + db, err := storage.Open(filepath.Join(t.TempDir(), "bot.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + p := &Grab{} + if err := p.Init(bot.PluginConfig{"max_quotes_per_user": 2}, db); err != nil { + t.Fatal(err) + } + key := grabKey("network", "#channel", "alice") + for i := 1; i <= 3; i++ { + p.mu.Lock() + quotes := append(p.load(key), grabbedLine{Nick: "Alice", Text: string(rune('0' + i)), At: time.Now()}) + if len(quotes) > p.maxQuotesPerUser { + quotes = quotes[len(quotes)-p.maxQuotesPerUser:] + } + if err := p.db.Set(grabBucket, key, quotes); err != nil { + p.mu.Unlock() + t.Fatal(err) + } + p.mu.Unlock() + } + quotes := p.quotesFor("network", "#channel", "alice") + if len(quotes) != 2 || quotes[0].Text != "2" || quotes[1].Text != "3" { + t.Fatalf("unexpected persisted quotes: %#v", quotes) + } +} + +func TestNormalizeAndFormatGrab(t *testing.T) { + if got := normalizeGrabText("hello\r\nworld"); got != "helloworld" { + t.Fatalf("normalized text = %q", got) + } + if got := normalizeGrabText("\x01ACTION waves\x01"); got != "* waves" { + t.Fatalf("normalized action = %q", got) + } + formatted := formatGrab("Alice", "hello") + if formatted != " hello" { + t.Fatalf("formatted grab = %q", formatted) + } +} diff --git a/plugins/linux.go b/plugins/linux.go new file mode 100644 index 0000000..90edc30 --- /dev/null +++ b/plugins/linux.go @@ -0,0 +1,105 @@ +package plugins + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/variablenix/GoBot/bot" + "github.com/variablenix/GoBot/storage" +) + +const linuxKernelBannerURL = "https://www.kernel.org/finger_banner" + +// Linux provides a compact read-only lookup of current Linux kernel release +// lines. It mirrors CloudBot's kernel command without exposing arbitrary URL +// fetching or allowing an unbounded response into IRC. +type Linux struct{ cfg bot.PluginConfig } + +func (p *Linux) Name() string { return "linux" } +func (p *Linux) Commands() []string { return []string{"linux", "kernel"} } +func (p *Linux) Help() string { + return "!linux or !kernel — show current Linux kernel versions from kernel.org" +} +func (p *Linux) Init(c bot.PluginConfig, _ *storage.DB) error { p.cfg = c; return nil } + +func (p *Linux) Handle(b *bot.Bot, m bot.Message) bool { + cmd, _, ok := bot.IsCommand(m, b.Config.CommandPrefix) + if !ok || (cmd != "linux" && cmd != "kernel") { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), linuxTimeout(p.cfg)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, linuxKernelBannerURL, nil) + if err != nil { + b.Send(m.ReplyTarget(), ircColor(ircRed, "Linux kernel information is temporarily unavailable")) + return true + } + req.Header.Set("Accept", "text/plain") + req.Header.Set("User-Agent", "GoBot/1.0 (IRC bot; Linux kernel lookup)") + res, err := apiHTTPClient.Do(req) + if err != nil || res.StatusCode != http.StatusOK { + if res != nil { + res.Body.Close() + } + b.Send(m.ReplyTarget(), ircColor(ircRed, "Linux kernel information is temporarily unavailable")) + return true + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 64*1024)) + if err != nil { + b.Send(m.ReplyTarget(), ircColor(ircRed, "Linux kernel information could not be read")) + return true + } + result := formatKernelBanner(string(body)) + if result == "" { + b.Send(m.ReplyTarget(), ircColor(ircRed, "Linux kernel information could not be parsed")) + return true + } + maxLength := p.cfg.Int("max_length", 260) + if maxLength < 100 || maxLength > 400 { + maxLength = 260 + } + b.Send(m.ReplyTarget(), ircColor(ircCyan, truncateRunes(result, maxLength))) + return true +} + +func linuxTimeout(c bot.PluginConfig) time.Duration { + seconds := c.Int("timeout_seconds", 8) + if seconds < 1 || seconds > 30 { + seconds = 8 + } + return time.Duration(seconds) * time.Second +} + +func formatKernelBanner(contents string) string { + lines := strings.Split(contents, "\n") + versions := make([]string, 0, len(lines)) + for _, raw := range lines { + line := strings.TrimSpace(cleanExternalText(raw)) + if line == "" { + continue + } + lower := strings.ToLower(line) + const prefix = "the latest " + if strings.HasPrefix(lower, prefix) { + if index := strings.Index(line, ":"); index >= 0 && index+1 < len(line) { + kind := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line[len(prefix):index]), "version of the Linux kernel is")) + value := strings.TrimSpace(line[index+1:]) + if kind != "" && value != "" { + versions = append(versions, kind+" "+value) + continue + } + } + } + versions = append(versions, line) + } + if len(versions) == 0 { + return "" + } + return fmt.Sprintf("Linux kernel versions: %s", strings.Join(versions, ", ")) +} diff --git a/plugins/linux_test.go b/plugins/linux_test.go new file mode 100644 index 0000000..71bf095 --- /dev/null +++ b/plugins/linux_test.go @@ -0,0 +1,33 @@ +package plugins + +import ( + "strings" + "testing" + + "github.com/variablenix/GoBot/bot" +) + +func TestLinuxCommandsAndBounds(t *testing.T) { + p := &Linux{} + if got := p.Commands(); len(got) != 2 || got[0] != "linux" || got[1] != "kernel" { + t.Fatalf("unexpected commands: %v", got) + } + if err := p.Init(bot.PluginConfig{"timeout_seconds": 60, "max_length": 20}, nil); err != nil { + t.Fatal(err) + } + if got := linuxTimeout(p.cfg); got.String() != "8s" { + t.Fatalf("timeout = %s, want 8s", got) + } +} + +func TestFormatKernelBanner(t *testing.T) { + input := "The latest stable version of the Linux kernel is: 6.16.9\nThe latest longterm version of the Linux kernel is: 6.12.45\n" + got := formatKernelBanner(input) + want := "Linux kernel versions: stable 6.16.9, longterm 6.12.45" + if got != want { + t.Fatalf("formatKernelBanner() = %q, want %q", got, want) + } + if strings.ContainsAny(got, "\r\n") { + t.Fatal("kernel output contains a line break") + } +} diff --git a/plugins/plugins.go b/plugins/plugins.go index 1a48ae5..a52248a 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -36,6 +36,8 @@ func All() []bot.Plugin { &Calculator{}, &GitHub{}, &Reddit{}, + &Grab{}, + &Linux{}, &Foods{}, &Sports{}, &Car{},