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
1 change: 1 addition & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ plugins:
calc: {enabled: true}
# Local, operator-editable text catalogs; one line per entry under data/fun.
fun: {enabled: true, data_dir: "data/fun", max_length: 240}
attack: {enabled: true}
# High-level local firearm/weapons catalog; identification only, no instructions.
weapons: {enabled: true, data_file: "data/weapons.txt", max_length: 240}
# Public GitHub lookups; token is optional and should be supplied out-of-band.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ plugins:
define: {enabled: true, timeout_seconds: 8, max_length: 240}
calc: {enabled: true}
fun: {enabled: true, data_dir: "data/fun", max_length: 240}
attack: {enabled: true}
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}
Expand Down
24 changes: 24 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Plugins are enabled or disabled under plugins.<name>.enabled in config.yaml.
- cats: short cat facts
- eightball: customizable Magic 8-Ball answers
- fun: local yo-momma jokes, one-liners, puns, and wisdom
- attack: playful target-based actions and messages
- cheer: family-friendly cheers
- seen, tell, karma, and dice: channel utilities
- quote, choose, and time: lightweight utilities
Expand Down Expand Up @@ -602,6 +603,29 @@ plugins:
The catalogs are local and editable, so operators can remove a line or adapt
the tone of a channel without changing Go code.

## Attack actions

The `attack` plugin provides short, playful target-based actions and messages:

~~~text
!attack slap Alice
!slap Alice
!hug Alice
!flirt Alice
!compliment Alice
!high5 Alice
!gift Alice
~~~

The canonical form is `!attack <style> <nick>`. Aliases include `!bite`,
`!fight`, `!glomp`, `!insult`, `!kill`, `!lart`, `!present`, `!spank`, and
`!stab`. Action styles use standard IRC CTCP ACTION formatting, so clients
usually render them like `/me` messages; compliment, flirt, and insult use
ordinary messages. Targets must be a single IRC-style nickname. Targeting
GoBot or `self` makes GoBot perform the playful action toward the sender.
Templates are built in, capped to short safe text, and contain no real-world
instructions.

## Firearm and weapons catalog

The `weapons` plugin is a lightweight reference/randomizer for firearm and
Expand Down
239 changes: 239 additions & 0 deletions plugins/attack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package plugins

import (
"fmt"
"math/rand"
"strings"
"unicode"

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

type attackResponse int

const (
attackAction attackResponse = iota
attackMessage
)

type attackDefinition struct {
name string
response attackResponse
templates []string
}

var attackDefinitions = map[string]attackDefinition{
"bite": {
name: "bite", response: attackAction,
templates: []string{"gives {target} a tiny cartoon bite.", "nibbles {target} with maximum theatricality."},
},
"compliment": {
name: "compliment", response: attackMessage,
templates: []string{"{target}, your charisma stat is dangerously high.", "{target}, you are doing an excellent job being you."},
},
"fight": {
name: "fight", response: attackAction,
templates: []string{"challenges {target} to a duel of rock-paper-scissors.", "squares up against {target} with a pool noodle."},
},
"flirt": {
name: "flirt", response: attackMessage,
templates: []string{"{target}, are you always this charming, or is today special?", "{target}, your smile just caused a minor system outage."},
},
"glomp": {
name: "glomp", response: attackAction,
templates: []string{"glomps {target} with surprising enthusiasm.", "launches a soft, friendly glomp at {target}."},
},
"highfive": {
name: "highfive", response: attackAction,
templates: []string{"high-fives {target}.", "offers {target} an exceptionally crisp high five."},
},
"hug": {
name: "hug", response: attackAction,
templates: []string{"hugs {target} warmly.", "gives {target} a reassuring internet hug."},
},
"insult": {
name: "insult", response: attackMessage,
templates: []string{"{target}, your Wi-Fi password probably has a typo in it.", "{target}, I have seen loading bars with more forward momentum."},
},
"kill": {
name: "kill", response: attackAction,
templates: []string{"dramatically defeats {target} in a completely fictional cartoon duel.", "declares {target} defeated by the ancient art of exaggerated stage combat."},
},
"lart": {
name: "lart", response: attackAction,
templates: []string{"larts {target} with a giant foam mallet.", "delivers a highly theatrical lart to {target}."},
},
"present": {
name: "present", response: attackAction,
templates: []string{"gives {target} a suspiciously well-wrapped present.", "presents {target} with a gift containing exactly one surprise."},
},
"slap": {
name: "slap", response: attackAction,
templates: []string{"slaps {target} with a comically oversized foam hand.", "gives {target} a playful cartoon slap."},
},
"spank": {
name: "spank", response: attackAction,
templates: []string{"gives {target} a playful, completely theatrical pat.", "attempts a cartoon spank and immediately loses the prop."},
},
"stab": {
name: "stab", response: attackAction,
templates: []string{"stabs {target} with a harmless foam sword.", "attempts a dramatic foam-sword stab at {target}."},
},
}

var attackCommandOrder = []string{
"attack", "bite", "compliment", "fight", "flirt", "glomp", "highfive", "hug", "insult", "kill", "lart", "present", "slap", "spank", "stab",
}

var attackAliases = map[string]string{
"attack": "attack",
"bite": "bite",
"compliment": "compliment",
"fight": "fight",
"fite": "fight",
"flirt": "flirt",
"glomp": "glomp",
"high5": "highfive",
"highfive": "highfive",
"hi5": "highfive",
"hug": "hug",
"insult": "insult",
"kill": "kill",
"lart": "lart",
"present": "present",
"gift": "present",
"slap": "slap",
"spank": "spank",
"stab": "stab",
}

// Attack provides short, playful target-based actions inspired by classic IRC
// fun bots. Templates are built in and user input is restricted to IRC-style
// nicknames so control characters cannot reach an outbound message.
type Attack struct{}

func (p *Attack) Name() string { return "attack" }

func (p *Attack) Commands() []string {
commands := make([]string, len(attackCommandOrder))
copy(commands, attackCommandOrder)
commands = append(commands, "fite", "gift", "high5", "hi5")
return commands
}

func (p *Attack) Help() string {
return "!attack <style> <nick> — playful action or message; aliases include !slap, !hug, !flirt, !compliment, !gift, and !high5"
}

func (p *Attack) Init(_ bot.PluginConfig, _ *storage.DB) error { return nil }

func (p *Attack) Handle(b *bot.Bot, m bot.Message) bool {
command, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix)
if !ok {
return false
}
canonical, ok := attackAliases[strings.ToLower(command)]
if !ok {
return false
}

style, target, valid := parseAttackArguments(canonical, arg)
if !valid {
b.Send(m.ReplyTarget(), attackUsage(canonical))
return true
}
if !validAttackTarget(target) {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "attack targets must be a single valid nickname"))
return true
}

definition := attackDefinitions[style]
actor := cleanExternalText(m.Nick)
if actor == "" {
actor = "someone"
}
if isAttackSelfTarget(target, b.Config.Identity.Nick) {
target = actor
actor = cleanExternalText(b.Config.Identity.Nick)
if actor == "" {
actor = "GoBot"
}
}

template := definition.templates[rand.Intn(len(definition.templates))]
text := renderAttackTemplate(template, actor, target)
if definition.response == attackAction {
b.Send(m.ReplyTarget(), formatAttackAction(text))
} else {
b.Send(m.ReplyTarget(), text)
}
return true
}

func parseAttackArguments(command, arg string) (string, string, bool) {
fields := strings.Fields(arg)
if command == "attack" {
if len(fields) != 2 {
return "", "", false
}
style, ok := attackAliases[strings.ToLower(fields[0])]
if !ok || style == "attack" {
return "", "", false
}
return style, fields[1], true
}
if len(fields) != 1 {
return "", "", false
}
style, ok := attackAliases[command]
if !ok || style == "attack" {
return "", "", false
}
return style, fields[0], true
}

func attackUsage(command string) string {
if command == "attack" {
return ircColor(ircYellow, "usage: !attack <style> <nick> (styles: bite, compliment, fight, flirt, glomp, highfive, hug, insult, kill, lart, present, slap, spank, stab)")
}
return ircColor(ircYellow, fmt.Sprintf("usage: !%s <nick>", command))
}

func renderAttackTemplate(template, actor, target string) string {
return cleanExternalText(strings.NewReplacer("{actor}", actor, "{target}", target).Replace(template))
}

func formatAttackAction(text string) string {
return "\x01ACTION " + cleanExternalText(text) + "\x01"
}

func isAttackSelfTarget(target, botNick string) bool {
return strings.EqualFold(target, "self") || (botNick != "" && strings.EqualFold(target, botNick))
}

func validAttackTarget(target string) bool {
if target == "" || len([]rune(target)) > 30 {
return false
}
for i, r := range target {
if i == 0 {
if !isAttackNickStart(r) {
return false
}
continue
}
if !isAttackNickPart(r) {
return false
}
}
return true
}

func isAttackNickStart(r rune) bool {
return unicode.IsLetter(r) || strings.ContainsRune("[]\\`^{}|_", r)
}

func isAttackNickPart(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("-[]\\`^{}|_", r)
}
78 changes: 78 additions & 0 deletions plugins/attack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package plugins

import (
"strings"
"testing"
)

func TestAttackCommandParsing(t *testing.T) {
style, target, ok := parseAttackArguments("attack", "slap Alice")
if !ok || style != "slap" || target != "Alice" {
t.Fatalf("canonical attack parse = (%q, %q, %v)", style, target, ok)
}
style, target, ok = parseAttackArguments("gift", "Alice")
if !ok || style != "present" || target != "Alice" {
t.Fatalf("alias attack parse = (%q, %q, %v)", style, target, ok)
}
if _, _, ok := parseAttackArguments("attack", "Alice"); ok {
t.Fatal("attack without a style unexpectedly parsed")
}
if _, _, ok := parseAttackArguments("attack", "unknown Alice"); ok {
t.Fatal("unknown attack style unexpectedly parsed")
}
}

func TestAttackTargetsRejectUnsafeText(t *testing.T) {
for _, target := range []string{"", "Alice Smith", "Alice\x01", "Alice\n", strings.Repeat("A", 31), "@Alice"} {
if validAttackTarget(target) {
t.Errorf("validAttackTarget(%q) = true, want false", target)
}
}
for _, target := range []string{"Alice", "ak[Relay]", "self", "bot_2"} {
if !validAttackTarget(target) {
t.Errorf("validAttackTarget(%q) = false, want true", target)
}
}
}

func TestAttackSelfTargetDetection(t *testing.T) {
if !isAttackSelfTarget("self", "GoBot") || !isAttackSelfTarget("gObOt", "GoBot") {
t.Fatal("expected self and bot nickname targets to be detected")
}
if isAttackSelfTarget("Alice", "GoBot") {
t.Fatal("ordinary target was incorrectly treated as self")
}
}

func TestAttackTemplateAndActionFormatting(t *testing.T) {
text := renderAttackTemplate("slaps {target} while {actor} tries not to laugh.", "Echo", "Alice")
if text != "slaps Alice while Echo tries not to laugh." {
t.Fatalf("rendered attack = %q", text)
}
action := formatAttackAction(text)
if action != "\x01ACTION slaps Alice while Echo tries not to laugh.\x01" {
t.Fatalf("formatted action = %q", action)
}
if strings.ContainsAny(action, "\r\n") {
t.Fatalf("action contains a line break: %q", action)
}
}

func TestAttackDefinitionsHaveSafeTemplates(t *testing.T) {
plugin := &Attack{}
commands := plugin.Commands()
if len(commands) < len(attackDefinitions) {
t.Fatalf("commands = %d, definitions = %d", len(commands), len(attackDefinitions))
}
for name, definition := range attackDefinitions {
if definition.name != name || len(definition.templates) == 0 {
t.Fatalf("invalid definition %q: %+v", name, definition)
}
for _, template := range definition.templates {
output := renderAttackTemplate(template, "Echo", "Alice")
if output == "" || strings.ContainsAny(output, "\r\n\x00") {
t.Fatalf("unsafe or empty template for %q: %q", name, output)
}
}
}
}
1 change: 1 addition & 0 deletions plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func All() []bot.Plugin {
&Help{},
&Alias{},
&Fun{},
&Attack{},
&Weapons{},
}
}