From 308129bdf0a323ca085362b75478c1a47a69b2f7 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Thu, 28 Jun 2018 12:11:12 +0200 Subject: [PATCH] Added input handling and m_roleplay client-side handlers (...) - Added Client.EmitInput() - Added ircutil.ParseArgAndText for common /msg type inputs - Added target getters for Event - Added default handling for input events that haven't been killed - Removed handler_debug - Changed timeout for interaction to 2s - Fixed comment typo in event_packet.go --- client.go | 42 +++++++++++- client_test.go | 46 ++++++++++--- event.go | 56 ++++++++++++++++ event_input.go | 27 ++++++++ event_packet.go | 2 +- handler_debug.go | 46 ------------- handlers/input.go | 111 ++++++++++++++++++++++++++++++++ handlers/mroleplay.go | 87 +++++++++++++++++++++++++ handlers/package.go | 4 ++ internal/irctest/interaction.go | 2 +- ircutil/parse-arg-and-text.go | 17 +++++ 11 files changed, 383 insertions(+), 57 deletions(-) create mode 100644 event_input.go delete mode 100644 handler_debug.go create mode 100644 handlers/input.go create mode 100644 handlers/mroleplay.go create mode 100644 handlers/package.go create mode 100644 ircutil/parse-arg-and-text.go diff --git a/client.go b/client.go index 42e6778..11d49ca 100644 --- a/client.go +++ b/client.go @@ -293,6 +293,22 @@ func (client *Client) SendQueuedf(format string, a ...interface{}) { client.SendQueued(fmt.Sprintf(format, a...)) } +// SendCTCP sends a queued message with the following CTCP verb and text. If reply is true, +// it will use a NOTICE instead of PRIVMSG. +func (client *Client) SendCTCP(verb, targetName string, reply bool, text string) { + ircVerb := "PRIVMSG" + if reply { + ircVerb = "NOTICE" + } + + client.SendQueuedf("%s %s :\x01%s %s\x01", ircVerb, targetName, verb, text) +} + +// SendCTCPf is SendCTCP with a fmt.Sprintf +func (client *Client) SendCTCPf(verb, targetName string, reply bool, format string, a ...interface{}) { + client.SendCTCP(verb, targetName, reply, fmt.Sprintf(format, a...)) +} + // Emit sends an event through the client's event, and it will return immediately // unless the internal channel is filled up. The returned context can be used to // wait for the event, or the client's destruction. @@ -340,6 +356,25 @@ func (client *Client) EmitSync(ctx context.Context, event Event) (err error) { } } +// EmitInput emits an input event parsed from the line. +func (client *Client) EmitInput(line string, target Target) context.Context { + event := ParseInput(line) + + if target != nil { + client.mutex.RLock() + event.targets = append(event.targets, target) + event.targetIds[target] = client.targteIds[target] + client.mutex.RUnlock() + } else { + client.mutex.RLock() + event.targets = append(event.targets, client.status) + event.targetIds[client.status] = client.targteIds[client.status] + client.mutex.RUnlock() + } + + return client.Emit(event) +} + // Value gets a client value. func (client *Client) Value(key string) (v interface{}, ok bool) { client.mutex.RLock() @@ -512,6 +547,11 @@ func (client *Client) handleEventLoop() { client.handleEvent(event) emit(event, client) + // Turn an unhandled input into a raw command. + if event.kind == "input" && !event.Killed() { + client.SendQueued(strings.ToUpper(event.verb) + " " + event.Text) + } + event.cancel() } case <-ticker.C: @@ -560,7 +600,7 @@ func (client *Client) handleSendLoop() { time.Sleep(time.Second - deltaTime) lastRefresh = now - queue = 0 + queue = 1 } } else { lastRefresh = now diff --git a/client_test.go b/client_test.go index 99ecfda..4a127ff 100644 --- a/client_test.go +++ b/client_test.go @@ -6,10 +6,14 @@ import ( "testing" "git.aiterp.net/gisle/irc" + "git.aiterp.net/gisle/irc/handlers" "git.aiterp.net/gisle/irc/internal/irctest" ) func TestClient(t *testing.T) { + irc.Handle(handlers.Input) + irc.Handle(handlers.MRoleplay) + client := irc.New(context.Background(), irc.Config{ Nick: "Test", User: "Tester", @@ -71,8 +75,8 @@ func TestClient(t *testing.T) { {Kind: 'S', Data: ":Gisle!~irce@10.32.0.1 MODE #Test +N-s "}, {Kind: 'S', Data: ":Test1234!~test2@172.17.37.1 JOIN #Test Test1234"}, {Kind: 'S', Data: ":Gisle!~irce@10.32.0.1 MODE #Test +v Test1234"}, - {Kind: 'S', Data: "PING :archgisle.lan"}, // Ping/Pong to sync. - {Kind: 'C', Data: "PONG :archgisle.lan"}, + {Kind: 'S', Data: "PING :testserver.example.com"}, // Ping/Pong to sync. + {Kind: 'C', Data: "PONG :testserver.example.com"}, {Callback: func() error { channel := client.Channel("#Test") if channel == nil { @@ -98,8 +102,8 @@ func TestClient(t *testing.T) { {Kind: 'S', Data: ":Hunter2!~test2@172.17.37.1 AWAY :Doing stuff"}, {Kind: 'S', Data: ":Gisle!~irce@10.32.0.1 AWAY"}, {Kind: 'S', Data: ":Gisle!~irce@10.32.0.1 PART #Test :Leaving the channel"}, - {Kind: 'S', Data: "PING :archgisle.lan"}, // Ping/Pong to sync. - {Kind: 'C', Data: "PONG :archgisle.lan"}, + {Kind: 'S', Data: "PING :testserver.example.com"}, // Ping/Pong to sync. + {Kind: 'C', Data: "PONG :testserver.example.com"}, {Callback: func() error { channel := client.Channel("#Test") if channel == nil { @@ -133,8 +137,8 @@ func TestClient(t *testing.T) { return nil }}, {Kind: 'S', Data: ":Hunter2!~test2@172.17.37.1 PRIVMSG Test768 :Hello, World"}, - {Kind: 'S', Data: "PING :archgisle.lan"}, // Ping/Pong to sync. - {Kind: 'C', Data: "PONG :archgisle.lan"}, + {Kind: 'S', Data: "PING :testserver.example.com"}, // Ping/Pong to sync. + {Kind: 'C', Data: "PONG :testserver.example.com"}, {Callback: func() error { query := client.Query("Hunter2") if query == nil { @@ -144,8 +148,8 @@ func TestClient(t *testing.T) { return nil }}, {Kind: 'S', Data: ":Hunter2!~test2@172.17.37.1 NICK SevenAsterisks"}, - {Kind: 'S', Data: "PING :archgisle.lan"}, // Ping/Pong to sync. - {Kind: 'C', Data: "PONG :archgisle.lan"}, + {Kind: 'S', Data: "PING :testserver.example.com"}, // Ping/Pong to sync. + {Kind: 'C', Data: "PONG :testserver.example.com"}, {Callback: func() error { oldQuerry := client.Query("Hunter2") if oldQuerry != nil { @@ -159,6 +163,32 @@ func TestClient(t *testing.T) { return nil }}, + {Callback: func() error { + client.EmitInput("/invalidcommand stuff and things", nil) + return nil + }}, + {Kind: 'C', Data: "INVALIDCOMMAND stuff and things"}, + {Kind: 'S', Data: ":testserver.example.com 421 Test768 INVALIDCOMMAND :Unknown command"}, + {Callback: func() error { + channel := client.Channel("#Test") + if channel == nil { + return errors.New("Channel #Test not found") + } + + client.EmitInput("/me does stuff", channel) + client.EmitInput("/describe #Test describes stuff", channel) + client.EmitInput("/text Hello, World", channel) + client.EmitInput("Hello again", channel) + return nil + }}, + {Kind: 'C', Data: "PRIVMSG #Test :\x01ACTION does stuff\x01"}, + {Kind: 'C', Data: "PRIVMSG #Test :\x01ACTION describes stuff\x01"}, + {Kind: 'C', Data: "PRIVMSG #Test :Hello, World"}, + {Kind: 'C', Data: "PRIVMSG #Test :Hello again"}, + {Kind: 'S', Data: ":Test768!~test@127.0.0.1 PRIVMSG #Test :\x01ACTION does stuff\x01"}, + {Kind: 'S', Data: ":Test768!~test@127.0.0.1 PRIVMSG #Test :\x01ACTION describes stuff\x01"}, + {Kind: 'S', Data: ":Test768!~test@127.0.0.1 PRIVMSG #Test :Hello, World"}, + {Kind: 'S', Data: ":Test768!~test@127.0.0.1 PRIVMSG #Test :Hello again"}, }, } diff --git a/event.go b/event.go index 9495d6c..0c5244e 100644 --- a/event.go +++ b/event.go @@ -91,6 +91,9 @@ func (event *Event) Context() context.Context { // Kill stops propagation of the event. The context will be killed once // the current event handler returns. +// +// A use case for this is to prevent the default input handler from firing +// on an already prcoessed input event. func (event *Event) Kill() { event.killed = true } @@ -125,6 +128,59 @@ func (event *Event) Arg(index int) string { return event.Args[index] } +// Target finds the first target with one of the kinds specified. If none +// are specified, the first target will be returned. If more than one +// is provided, the first kinds are prioritized. +func (event *Event) Target(kinds ...string) Target { + if len(event.targets) == 0 { + return nil + } + + if len(kinds) == 0 || kinds[0] == "" { + return event.targets[0] + } + + for _, kind := range kinds { + for _, target := range event.targets { + if target.Kind() == kind { + return target + } + } + } + + return nil +} + +// ChannelTarget gets the first channel target. +func (event *Event) ChannelTarget() *Channel { + target := event.Target("channel") + if target == nil { + return nil + } + + return target.(*Channel) +} + +// QueryTarget gets the first query target. +func (event *Event) QueryTarget() *Query { + target := event.Target("query") + if target == nil { + return nil + } + + return target.(*Query) +} + +// StatusTarget gets the first status target. +func (event *Event) StatusTarget() *Status { + target := event.Target("status") + if target == nil { + return nil + } + + return target.(*Status) +} + // MarshalJSON makes a JSON object from the event. func (event *Event) MarshalJSON() ([]byte, error) { data := eventJSONData{ diff --git a/event_input.go b/event_input.go new file mode 100644 index 0000000..9b6c988 --- /dev/null +++ b/event_input.go @@ -0,0 +1,27 @@ +package irc + +import ( + "strings" + "time" +) + +// ParseInput parses an input command into an event. +func ParseInput(line string) Event { + event := NewEvent("input", "") + event.Time = time.Now() + + if strings.HasPrefix(line, "/") { + split := strings.SplitN(line[1:], " ", 2) + event.verb = split[0] + if len(split) == 2 { + event.Text = split[1] + } + } else { + event.Text = line + event.verb = "text" + } + + event.name = event.kind + "." + event.verb + + return event +} diff --git a/event_packet.go b/event_packet.go index 723b077..3202a88 100644 --- a/event_packet.go +++ b/event_packet.go @@ -8,7 +8,7 @@ import ( var unescapeTags = strings.NewReplacer("\\\\", "\\", "\\:", ";", "\\s", " ", "\\r", "\r", "\\n", "\n") -// ParsePacket parses and irc line and returns an event that's either of kind `packet`, `ctcp` or `ctcpreply` +// ParsePacket parses an irc line and returns an event that's either of kind `packet`, `ctcp` or `ctcpreply` func ParsePacket(line string) (Event, error) { event := NewEvent("packet", "") event.Time = time.Now() diff --git a/handler_debug.go b/handler_debug.go deleted file mode 100644 index c8657e9..0000000 --- a/handler_debug.go +++ /dev/null @@ -1,46 +0,0 @@ -package irc - -import ( - "encoding/json" - "log" -) - -// DebugLogger is for -type DebugLogger interface { - Println(v ...interface{}) -} - -type defaultDebugLogger struct{} - -func (logger *defaultDebugLogger) Println(v ...interface{}) { - log.Println(v...) -} - -// EnableDebug logs all events that passes through it, ignoring killed -// events. It will always include the standard handlers, but any custom -// handlers defined after EnableDebug will not have their effects shown. -// You may pass `nil` as a logger to use the standard log package's Println. -func EnableDebug(logger DebugLogger, indented bool) { - if logger != nil { - logger = &defaultDebugLogger{} - } - - Handle(func(event *Event, client *Client) { - var data []byte - var err error - - if indented { - data, err = json.MarshalIndent(event, "", " ") - if err != nil { - return - } - } else { - data, err = json.Marshal(event) - if err != nil { - return - } - } - - logger.Println(string(data)) - }) -} diff --git a/handlers/input.go b/handlers/input.go new file mode 100644 index 0000000..205f76c --- /dev/null +++ b/handlers/input.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "git.aiterp.net/gisle/irc" + "git.aiterp.net/gisle/irc/ircutil" +) + +// Input handles the default input. +func Input(event *irc.Event, client *irc.Client) { + switch event.Name() { + + // /msg sends an action to a target specified before the message. + case "input.msg": + { + targetName, text := ircutil.ParseArgAndText(event.Text) + if targetName == "" || text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /msg ")) + break + } + + overhead := client.PrivmsgOverhead(targetName, true) + cuts := ircutil.CutMessage(text, overhead) + for _, cut := range cuts { + client.Sendf("PRIVMSG %s :%s", targetName, cut) + } + + event.Kill() + } + + // /text (or text without a command) sends a message to the target. + case "input.text": + { + if event.Text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /text ")) + break + } + + target := event.Target("query", "channel") + if target == nil { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Target is not a channel or query")) + break + } + + overhead := client.PrivmsgOverhead(target.Name(), false) + cuts := ircutil.CutMessage(event.Text, overhead) + for _, cut := range cuts { + client.SendQueuedf("PRIVMSG %s :%s", target.Name(), cut) + } + + event.Kill() + } + + // /me and /action sends a CTCP ACTION. + case "input.me", "input.action": + { + if event.Text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /me ")) + break + } + + target := event.Target("query", "channel") + if target == nil { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Target is not a channel or query")) + break + } + + overhead := client.PrivmsgOverhead(target.Name(), true) + cuts := ircutil.CutMessage(event.Text, overhead) + for _, cut := range cuts { + client.SendCTCP("ACTION", target.Name(), false, cut) + } + + event.Kill() + } + + // /describe sends an action to a target specified before the message, like /msg. + case "input.describe": + { + targetName, text := ircutil.ParseArgAndText(event.Text) + if targetName == "" || text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /describe ")) + break + } + + overhead := client.PrivmsgOverhead(targetName, true) + cuts := ircutil.CutMessage(text, overhead) + for _, cut := range cuts { + client.SendCTCP("ACTION", targetName, false, cut) + } + + event.Kill() + } + + // /m is a shorthand for /mode that targets the current channel + case "input.m": + { + if event.Text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /m ")) + break + } + + channel := event.ChannelTarget() + if channel != nil { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Target is not a channel")) + break + } + + client.SendQueuedf("MODE %s %s", channel.Name(), event.Text) + } + } +} diff --git a/handlers/mroleplay.go b/handlers/mroleplay.go new file mode 100644 index 0000000..40e37eb --- /dev/null +++ b/handlers/mroleplay.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "strings" + + "git.aiterp.net/gisle/irc" + "git.aiterp.net/gisle/irc/ircutil" +) + +// MRoleplay is a handler that adds commands for cutting NPC commands, as well as cleaning up +// the input from the server. It's named after Charybdis IRCd's m_roleplay module. +func MRoleplay(event *irc.Event, client *irc.Client) { + switch event.Name() { + case "packet.privmsg", "ctcp.action": + { + // Detect m_roleplay + if strings.HasPrefix(event.Nick, "\x1F") { + event.Nick = event.Nick[1 : len(event.Nick)-2] + if event.Verb() == "PRIVMSG" { + event.RenderTags["mRoleplay"] = "npc" + } else { + event.RenderTags["mRoleplay"] = "npca" + } + } else if strings.HasPrefix(event.Nick, "=") { + event.RenderTags["mRoleplay"] = "scene" + } else { + break + } + + lastSpace := strings.LastIndex(event.Text, " ") + lastParanthesis := strings.LastIndex(event.Text, "(") + if lastParanthesis != -1 && lastSpace != -1 && lastParanthesis == lastSpace+1 { + event.Text = event.Text[:lastSpace] + } + } + case "input.npcc", "input.npcac": + { + isAction := event.Verb() == "npcac" + nick, text := ircutil.ParseArgAndText(event.Text) + if nick == "" || text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /"+event.Verb()+" ")) + break + } + + channel := event.ChannelTarget() + if channel == nil { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Target is not a channel")) + break + } + + overhead := ircutil.MessageOverhead("\x1f"+nick+"\x1f", client.Nick(), "npc.fakeuser.invalid", channel.Name(), isAction) + cuts := ircutil.CutMessage(event.Text, overhead) + + for _, cut := range cuts { + npcCommand := "NPCA" + if event.Verb() == "npcc" { + npcCommand = "NPC" + } + + client.SendQueuedf("%s %s :%s", npcCommand, channel.Name(), cut) + } + + event.Kill() + } + case "input.scenec": + { + if event.Text == "" { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Usage: /scenec ")) + break + } + + channel := event.ChannelTarget() + if channel == nil { + client.EmitNonBlocking(irc.NewErrorEvent("input", "Target is not a channel")) + break + } + + overhead := ircutil.MessageOverhead("=Scene=", client.Nick(), "npc.fakeuser.invalid", channel.Name(), false) + cuts := ircutil.CutMessage(event.Text, overhead) + for _, cut := range cuts { + client.SendQueuedf("SCENE %s :%s", channel.Name(), cut) + } + + event.Kill() + } + } +} diff --git a/handlers/package.go b/handlers/package.go new file mode 100644 index 0000000..d66ac23 --- /dev/null +++ b/handlers/package.go @@ -0,0 +1,4 @@ +// Package handlers contain extra handlers that add features to an IRC client that are not +// always necessary. This includes most input handlers and features. If something adds +// a IRCv3 cap, it should not be here, however. +package handlers diff --git a/internal/irctest/interaction.go b/internal/irctest/interaction.go index 75f1f3b..d3fee94 100644 --- a/internal/irctest/interaction.go +++ b/internal/irctest/interaction.go @@ -63,7 +63,7 @@ func (interaction *Interaction) Listen() (addr string, err error) { } case 'C': { - conn.SetReadDeadline(time.Now().Add(time.Second)) + conn.SetReadDeadline(time.Now().Add(time.Second * 2)) input, err := reader.ReadString('\n') if err != nil { interaction.Failure = &InteractionFailure{ diff --git a/ircutil/parse-arg-and-text.go b/ircutil/parse-arg-and-text.go new file mode 100644 index 0000000..5e821f7 --- /dev/null +++ b/ircutil/parse-arg-and-text.go @@ -0,0 +1,17 @@ +package ircutil + +import ( + "strings" +) + +// ParseArgAndText parses a text like "#Channel stuff and things" into "#Channel" +// and "stuff and things". This is commonly used for input commands which has +// no standard +func ParseArgAndText(s string) (arg, text string) { + spaceIndex := strings.Index(s, " ") + if spaceIndex == -1 { + return s, "" + } + + return s[:spaceIndex], s[spaceIndex+1:] +}