diff --git a/README.md b/README.md index 77a465a..89d1115 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ Synopsis: // Add handlers to do things here! // e.g. join a channel on connect. - c.HandleFunc("connected", + c.HandleFunc(irc.CONNECTED, func(conn *irc.Conn, line *irc.Line) { conn.Join("#channel") }) // And a signal on disconnect quit := make(chan bool) - c.HandleFunc("disconnected", + c.HandleFunc(irc.DISCONNECTED, func(conn *irc.Conn, line *irc.Line) { quit <- true }) // Tell client to connect. diff --git a/client.go b/client.go index 7da934f..a686cf8 100644 --- a/client.go +++ b/client.go @@ -5,7 +5,9 @@ import ( "flag" "fmt" irc "github.com/fluffle/goirc/client" + "math/rand" "os" + "strconv" "strings" ) @@ -18,14 +20,40 @@ func main() { // create new IRC connection c := irc.SimpleClient("GoTest", "gotest") c.EnableStateTracking() - c.HandleFunc("connected", + c.HandleFunc(irc.CONNECTED, func(conn *irc.Conn, line *irc.Line) { conn.Join(*channel) }) // Set up a handler to notify of disconnect events. quit := make(chan bool) - c.HandleFunc("disconnected", + c.HandleFunc(irc.DISCONNECTED, func(conn *irc.Conn, line *irc.Line) { quit <- true }) + // Set up some simple commands, !bark and !roll. + // The !roll command will also get the "!help roll" command also. + c.SimpleCommandFunc("bark", func(conn *irc.Conn, line *irc.Line) { conn.Privmsg(line.Target(), "Woof Woof") }) + c.SimpleCommandHelpFunc("roll", `Rolls a d6, "roll " to roll n dice at once.`, func(conn *irc.Conn, line *irc.Line) { + count := 1 + fields := strings.Fields(line.Message()) + if len(fields) > 1 { + var err error + if count, err = strconv.Atoi(fields[len(fields)-1]); err != nil { + count = 1 + } + } + total := 0 + for i := 0; i < count; i++ { + total += rand.Intn(6) + 1 + } + conn.Privmsg(line.Target(), fmt.Sprintf("%d", total)) + }) + + // Set up some commands that are triggered by a regex in a message. + // It is important to see that UrlRegex could actually respond to some + // of the Url's that YouTubeRegex listens to, because of this we put the + // YouTube command at a higher priority, this way it will take precedence. + c.CommandFunc(irc.YouTubeRegex, irc.YouTubeFunc, 10) + c.CommandFunc(irc.UrlRegex, irc.UrlFunc, 0) + // set up a goroutine to read commands from stdin in := make(chan string, 4) reallyquit := false @@ -36,6 +64,8 @@ func main() { if err != nil { // wha?, maybe ctrl-D... close(in) + reallyquit = true + c.Quit("") break } // no point in sending empty lines down the channel @@ -85,7 +115,6 @@ func main() { fmt.Printf("Connection error: %s\n", err) return } - // wait on quit channel <-quit } diff --git a/client/commands.go b/client/commands.go index 870b6a1..8bc1395 100644 --- a/client/commands.go +++ b/client/commands.go @@ -2,6 +2,34 @@ package client import "strings" +const ( + REGISTER = "REGISTER" + CONNECTED = "CONNECTED" + DISCONNECTED = "DISCONNECTED" + ACTION = "ACTION" + AWAY = "AWAY" + CTCP = "CTCP" + CTCPREPLY = "CTCPREPLY" + INVITE = "INVITE" + JOIN = "JOIN" + KICK = "KICK" + MODE = "MODE" + NICK = "NICK" + NOTICE = "NOTICE" + OPER = "OPER" + PART = "PART" + PASS = "PASS" + PING = "PING" + PONG = "PONG" + PRIVMSG = "PRIVMSG" + QUIT = "QUIT" + TOPIC = "TOPIC" + USER = "USER" + VERSION = "VERSION" + WHO = "WHO" + WHOIS = "WHOIS" +) + // this file contains the various commands you can // send to the server using an Conn connection @@ -14,18 +42,18 @@ import "strings" func (conn *Conn) Raw(rawline string) { conn.out <- rawline } // Pass() sends a PASS command to the server -func (conn *Conn) Pass(password string) { conn.out <- "PASS " + password } +func (conn *Conn) Pass(password string) { conn.out <- PASS + " " + password } // Nick() sends a NICK command to the server -func (conn *Conn) Nick(nick string) { conn.out <- "NICK " + nick } +func (conn *Conn) Nick(nick string) { conn.out <- NICK + " " + nick } // User() sends a USER command to the server func (conn *Conn) User(ident, name string) { - conn.out <- "USER " + ident + " 12 * :" + name + conn.out <- USER + " " + ident + " 12 * :" + name } // Join() sends a JOIN command to the server -func (conn *Conn) Join(channel string) { conn.out <- "JOIN " + channel } +func (conn *Conn) Join(channel string) { conn.out <- JOIN + " " + channel } // Part() sends a PART command to the server with an optional part message func (conn *Conn) Part(channel string, message ...string) { @@ -33,7 +61,7 @@ func (conn *Conn) Part(channel string, message ...string) { if msg != "" { msg = " :" + msg } - conn.out <- "PART " + channel + msg + conn.out <- PART + " " + channel + msg } // Kick() sends a KICK command to remove a nick from a channel @@ -42,7 +70,7 @@ func (conn *Conn) Kick(channel, nick string, message ...string) { if msg != "" { msg = " :" + msg } - conn.out <- "KICK " + channel + " " + nick + msg + conn.out <- KICK + " " + channel + " " + nick + msg } // Quit() sends a QUIT command to the server with an optional quit message @@ -51,20 +79,20 @@ func (conn *Conn) Quit(message ...string) { if msg == "" { msg = "GoBye!" } - conn.out <- "QUIT :" + msg + conn.out <- QUIT + " :" + msg } // Whois() sends a WHOIS command to the server -func (conn *Conn) Whois(nick string) { conn.out <- "WHOIS " + nick } +func (conn *Conn) Whois(nick string) { conn.out <- WHOIS + " " + nick } //Who() sends a WHO command to the server -func (conn *Conn) Who(nick string) { conn.out <- "WHO " + nick } +func (conn *Conn) Who(nick string) { conn.out <- WHO + " " + nick } // Privmsg() sends a PRIVMSG to the target t -func (conn *Conn) Privmsg(t, msg string) { conn.out <- "PRIVMSG " + t + " :" + msg } +func (conn *Conn) Privmsg(t, msg string) { conn.out <- PRIVMSG + " " + t + " :" + msg } // Notice() sends a NOTICE to the target t -func (conn *Conn) Notice(t, msg string) { conn.out <- "NOTICE " + t + " :" + msg } +func (conn *Conn) Notice(t, msg string) { conn.out <- NOTICE + " " + t + " :" + msg } // Ctcp() sends a (generic) CTCP message to the target t // with an optional argument @@ -87,10 +115,10 @@ func (conn *Conn) CtcpReply(t, ctcp string, arg ...string) { } // Version() sends a CTCP "VERSION" to the target t -func (conn *Conn) Version(t string) { conn.Ctcp(t, "VERSION") } +func (conn *Conn) Version(t string) { conn.Ctcp(t, VERSION) } // Action() sends a CTCP "ACTION" to the target t -func (conn *Conn) Action(t, msg string) { conn.Ctcp(t, "ACTION", msg) } +func (conn *Conn) Action(t, msg string) { conn.Ctcp(t, ACTION, msg) } // Topic() sends a TOPIC command to the channel // Topic(channel) retrieves the current channel topic (see "332" handler) @@ -100,7 +128,7 @@ func (conn *Conn) Topic(channel string, topic ...string) { if t != "" { t = " :" + t } - conn.out <- "TOPIC " + channel + t + conn.out <- TOPIC + " " + channel + t } // Mode() sends a MODE command to the server. This one can get complicated if @@ -115,7 +143,7 @@ func (conn *Conn) Mode(t string, modestring ...string) { if mode != "" { mode = " " + mode } - conn.out <- "MODE " + t + mode + conn.out <- MODE + " " + t + mode } // Away() sends an AWAY command to the server @@ -126,15 +154,18 @@ func (conn *Conn) Away(message ...string) { if msg != "" { msg = " :" + msg } - conn.out <- "AWAY" + msg + conn.out <- AWAY + msg } // Invite() sends an INVITE command to the server -func (conn *Conn) Invite(nick, channel string) { - conn.out <- "INVITE " + nick + " " + channel -} +func (conn *Conn) Invite(nick, channel string) { conn.out <- INVITE + " " + nick + " " + channel } // Oper() sends an OPER command to the server -func (conn *Conn) Oper(user, pass string) { - conn.out <- "OPER " + user + " " + pass -} +func (conn *Conn) Oper(user, pass string) { conn.out <- OPER + " " + user + " " + pass } + +// Ping() sends a PING command to the server +// A PONG response is to be expected afterwards +func (conn *Conn) Ping(message string) { conn.out <- PING + " :" + message } + +// Pong() sends a PONG command to the server +func (conn *Conn) Pong(message string) { conn.out <- PONG + " :" + message } diff --git a/client/commands_test.go b/client/commands_test.go index c3bb6ee..05650e3 100644 --- a/client/commands_test.go +++ b/client/commands_test.go @@ -75,4 +75,10 @@ func TestClientCommands(t *testing.T) { c.Oper("user", "pass") s.nc.Expect("OPER user pass") + + c.Ping("woot") + s.nc.Expect("PING :woot") + + c.Pong("pwoot") + s.nc.Expect("PONG :pwoot") } diff --git a/client/connection.go b/client/connection.go index 1e074a9..155ec3f 100644 --- a/client/connection.go +++ b/client/connection.go @@ -21,8 +21,8 @@ type Conn struct { cfg *Config // Handlers and Commands - handlers *hSet - commands *cSet + handlers *handlerSet + commands *commandList // State tracker for nicks and channels st state.Tracker @@ -62,7 +62,7 @@ type Config struct { PingFreq time.Duration // Controls what is stripped from line.Args[1] for Commands - CommandStripNick, CommandStripPrefix bool + CommandStripNick, SimpleCommandStripPrefix bool // Set this to true to disable flood protection and false to re-enable Flood bool @@ -104,8 +104,8 @@ func Client(cfg *Config) (*Conn, error) { cSend: make(chan bool), cLoop: make(chan bool), cPing: make(chan bool), - handlers: handlerSet(), - commands: commandSet(), + handlers: newHandlerSet(), + commands: newCommandList(), stRemovers: make([]Remover, 0, len(stHandlers)), lastsent: time.Now(), } diff --git a/client/connection_test.go b/client/connection_test.go index 681a637..597ef9c 100644 --- a/client/connection_test.go +++ b/client/connection_test.go @@ -32,6 +32,7 @@ func setUp(t *testing.T, start ...bool) (*Conn, *testState) { // Hack to allow tests of send, recv, write etc. // NOTE: the value of the boolean doesn't matter. c.postConnect() + // Sleep 1ms to allow background routines to start. <-time.After(1e6) } @@ -56,7 +57,7 @@ func TestEOF(t *testing.T) { // Set up a handler to detect whether disconnected handlers are called dcon := false - c.HandleFunc("disconnected", func(conn *Conn, line *Line) { + c.HandleFunc(DISCONNECTED, func(conn *Conn, line *Line) { dcon = true }) diff --git a/client/dispatch.go b/client/dispatch.go index dd73865..a51db93 100644 --- a/client/dispatch.go +++ b/client/dispatch.go @@ -1,7 +1,11 @@ package client import ( + "container/list" + "fmt" "github.com/fluffle/golog/logging" + "math" + "regexp" "strings" "sync" ) @@ -11,189 +15,141 @@ type Handler interface { Handle(*Conn, *Line) } -// And when they've been added to the client they are removable. -type Remover interface { - Remove() -} - type HandlerFunc func(*Conn, *Line) func (hf HandlerFunc) Handle(conn *Conn, line *Line) { hf(conn, line) } -type hList struct { - start, end *hNode +// And when they've been added to the client they are removable. +type Remover interface { + Remove() } -type hNode struct { - next, prev *hNode - set *hSet - event string - handler Handler +type RemoverFunc func() + +func (r RemoverFunc) Remove() { + r() } -func (hn *hNode) Handle(conn *Conn, line *Line) { - hn.handler.Handle(conn, line) +type handlerElement struct { + event string + handler Handler } -func (hn *hNode) Remove() { - hn.set.remove(hn) -} - -type hSet struct { - set map[string]*hList +type handlerSet struct { + set map[string]*list.List sync.RWMutex } -func handlerSet() *hSet { - return &hSet{set: make(map[string]*hList)} +func newHandlerSet() *handlerSet { + return &handlerSet{set: make(map[string]*list.List)} } -func (hs *hSet) add(ev string, h Handler) Remover { +func (hs *handlerSet) add(event string, handler Handler) (*list.Element, Remover) { hs.Lock() defer hs.Unlock() - ev = strings.ToLower(ev) - l, ok := hs.set[ev] + event = strings.ToLower(event) + l, ok := hs.set[event] if !ok { - l = &hList{} + l = list.New() + hs.set[event] = l } - hn := &hNode{ - set: hs, - event: ev, - handler: h, - } - if !ok { - l.start = hn - } else { - hn.prev = l.end - l.end.next = hn - } - l.end = hn - hs.set[ev] = l - return hn + element := l.PushBack(&handlerElement{event, handler}) + return element, RemoverFunc(func() { + hs.remove(element) + }) } -func (hs *hSet) remove(hn *hNode) { +func (hs *handlerSet) remove(element *list.Element) { hs.Lock() defer hs.Unlock() - l, ok := hs.set[hn.event] + h := element.Value.(*handlerElement) + l, ok := hs.set[h.event] if !ok { - logging.Error("Removing node for unknown event '%s'", hn.event) + logging.Error("Removing node for unknown event '%s'", h.event) return } - if hn.next == nil { - l.end = hn.prev - } else { - hn.next.prev = hn.prev - } - if hn.prev == nil { - l.start = hn.next - } else { - hn.prev.next = hn.next - } - hn.next = nil - hn.prev = nil - hn.set = nil - if l.start == nil || l.end == nil { - delete(hs.set, hn.event) + l.Remove(element) + if l.Len() == 0 { + delete(hs.set, h.event) } } -func (hs *hSet) dispatch(conn *Conn, line *Line) { +func (hs *handlerSet) dispatch(conn *Conn, line *Line) { hs.RLock() defer hs.RUnlock() - ev := strings.ToLower(line.Cmd) - list, ok := hs.set[ev] + event := strings.ToLower(line.Cmd) + l, ok := hs.set[event] if !ok { return } - for hn := list.start; hn != nil; hn = hn.next { - go hn.Handle(conn, line) + + for e := l.Front(); e != nil; e = e.Next() { + h := e.Value.(*handlerElement) + go h.handler.Handle(conn, line) } } -// An IRC command looks like this: -type Command interface { - Execute(*Conn, *Line) - Help() string +type commandElement struct { + regex string + handler Handler + priority int } -type command struct { - fn HandlerFunc - help string -} - -func (c *command) Execute(conn *Conn, line *Line) { - c.fn(conn, line) -} - -func (c *command) Help() string { - return c.help -} - -type cNode struct { - cmd Command - set *cSet - prefix string -} - -func (cn *cNode) Execute(conn *Conn, line *Line) { - cn.cmd.Execute(conn, line) -} - -func (cn *cNode) Help() string { - return cn.cmd.Help() -} - -func (cn *cNode) Remove() { - cn.set.remove(cn) -} - -type cSet struct { - set map[string]*cNode +type commandList struct { + list *list.List sync.RWMutex } -func commandSet() *cSet { - return &cSet{set: make(map[string]*cNode)} +func newCommandList() *commandList { + return &commandList{list: list.New()} } -func (cs *cSet) add(pf string, c Command) Remover { - cs.Lock() - defer cs.Unlock() - pf = strings.ToLower(pf) - if _, ok := cs.set[pf]; ok { - logging.Error("Command prefix '%s' already registered.", pf) - return nil +func (cl *commandList) add(regex string, handler Handler, priority int) (element *list.Element, remover Remover) { + cl.Lock() + defer cl.Unlock() + c := &commandElement{ + regex: regex, + handler: handler, + priority: priority, } - cn := &cNode{ - cmd: c, - set: cs, - prefix: pf, - } - cs.set[pf] = cn - return cn -} - -func (cs *cSet) remove(cn *cNode) { - cs.Lock() - defer cs.Unlock() - delete(cs.set, cn.prefix) - cn.set = nil -} - -func (cs *cSet) match(txt string) (final Command, prefixlen int) { - cs.RLock() - defer cs.RUnlock() - txt = strings.ToLower(txt) - for prefix, cmd := range cs.set { - if !strings.HasPrefix(txt, prefix) { - continue + // Check for exact regex matches. This will filter out any repeated SimpleCommands. + for e := cl.list.Front(); e != nil; e = e.Next() { + c := e.Value.(*commandElement) + if c.regex == regex { + logging.Error("Command prefix '%s' already registered.", regex) + return } - if final == nil || len(prefix) > prefixlen { - prefixlen = len(prefix) - final = cmd + } + element = cl.list.PushBack(c) + remover = RemoverFunc(func() { + cl.remove(element) + }) + return +} + +func (cl *commandList) remove(element *list.Element) { + cl.Lock() + defer cl.Unlock() + cl.list.Remove(element) +} + +// Matches the command with the highest priority. +func (cl *commandList) match(text string) (handler Handler) { + cl.RLock() + defer cl.RUnlock() + maxPriority := math.MinInt32 + text = strings.ToLower(text) + for e := cl.list.Front(); e != nil; e = e.Next() { + c := e.Value.(*commandElement) + if c.priority > maxPriority { + if regex, error := regexp.Compile(c.regex); error == nil { + if regex.MatchString(text) { + maxPriority = c.priority + handler = c.handler + } + } } } return @@ -205,26 +161,75 @@ func (cs *cSet) match(txt string) (final Command, prefixlen int) { // "PRIVMSG", "JOIN", etc. but all the numeric replies are left as ascii // strings of digits like "332" (mainly because I really didn't feel like // putting massive constant tables in). -func (conn *Conn) Handle(name string, h Handler) Remover { - return conn.handlers.add(name, h) +func (conn *Conn) Handle(name string, handler Handler) Remover { + _, remover := conn.handlers.add(name, handler) + return remover } -func (conn *Conn) HandleFunc(name string, hf HandlerFunc) Remover { - return conn.Handle(name, hf) +func (conn *Conn) HandleFunc(name string, handlerFunc HandlerFunc) Remover { + return conn.Handle(name, handlerFunc) } -func (conn *Conn) Command(prefix string, c Command) Remover { - return conn.commands.add(prefix, c) +func (conn *Conn) Command(regex string, handler Handler, priority int) Remover { + _, remover := conn.commands.add(regex, handler, priority) + return remover } -func (conn *Conn) CommandFunc(prefix string, hf HandlerFunc, help string) Remover { - return conn.Command(prefix, &command{hf, help}) +func (conn *Conn) CommandFunc(regex string, handlerFunc HandlerFunc, priority int) Remover { + return conn.Command(regex, handlerFunc, priority) +} + +var SimpleCommandRegex string = `^!%v(\s|$)` + +// Simple commands are commands that are triggered from a simple prefix +// SimpleCommand("roll" handler) +// !roll +// Because simple commands are simple, they get the highest priority. +func (conn *Conn) SimpleCommand(prefix string, handler Handler) Remover { + stripHandler := func(conn *Conn, line *Line) { + text := line.Message() + if conn.cfg.SimpleCommandStripPrefix { + text = strings.TrimSpace(text[len(prefix):]) + } + if text != line.Message() { + line = line.Copy() + line.Args[1] = text + } + handler.Handle(conn, line) + } + return conn.CommandFunc(fmt.Sprintf(SimpleCommandRegex, strings.ToLower(prefix)), stripHandler, math.MaxInt32) +} + +func (conn *Conn) SimpleCommandFunc(prefix string, handlerFunc HandlerFunc) Remover { + return conn.SimpleCommand(prefix, handlerFunc) +} + +// This will also register a help command to go along with the simple command itself. +// eg. SimpleCommandHelp("bark", "Bot will bark", handler) will make the following commands: +// !bark +// !help bark +func (conn *Conn) SimpleCommandHelp(prefix string, help string, handler Handler) Remover { + commandCommand := conn.SimpleCommand(prefix, handler) + helpCommand := conn.SimpleCommandFunc(fmt.Sprintf("help %v", prefix), HandlerFunc(func(conn *Conn, line *Line) { + conn.Privmsg(line.Target(), help) + })) + return RemoverFunc(func() { + commandCommand.Remove() + helpCommand.Remove() + }) +} + +func (conn *Conn) SimpleCommandHelpFunc(prefix string, help string, handlerFunc HandlerFunc) Remover { + return conn.SimpleCommandHelp(prefix, help, handlerFunc) } func (conn *Conn) dispatch(line *Line) { conn.handlers.dispatch(conn, line) } -func (conn *Conn) cmdMatch(txt string) (Command, int) { - return conn.commands.match(txt) +func (conn *Conn) command(line *Line) { + command := conn.commands.match(line.Message()) + if command != nil { + go command.Handle(conn, line) + } } diff --git a/client/dispatch_test.go b/client/dispatch_test.go index 84b9e54..101afcf 100644 --- a/client/dispatch_test.go +++ b/client/dispatch_test.go @@ -6,7 +6,7 @@ import ( ) func TestHandlerSet(t *testing.T) { - hs := handlerSet() + hs := newHandlerSet() if len(hs.set) != 0 { t.Errorf("New set contains things!") } @@ -17,66 +17,40 @@ func TestHandlerSet(t *testing.T) { } // Add one - hn1 := hs.add("ONE", HandlerFunc(f)).(*hNode) + _, hn1 := hs.add("ONE", HandlerFunc(f)) hl, ok := hs.set["one"] if len(hs.set) != 1 || !ok { t.Errorf("Set doesn't contain 'one' list after add().") } - if hn1.set != hs || hn1.event != "one" || hn1.prev != nil || hn1.next != nil { - t.Errorf("First node for 'one' not created correctly") - } - if hl.start != hn1 || hl.end != hn1 { - t.Errorf("Node not added to empty 'one' list correctly.") + if hl.Len() != 1 { + t.Errorf("List doesn't contain 'one' after add().") } // Add another one... - hn2 := hs.add("one", HandlerFunc(f)).(*hNode) + _, hn2 := hs.add("one", HandlerFunc(f)) if len(hs.set) != 1 { t.Errorf("Set contains more than 'one' list after add().") } - if hn2.set != hs || hn2.event != "one" { - t.Errorf("Second node for 'one' not created correctly") - } - if hn1.prev != nil || hn1.next != hn2 || hn2.prev != hn1 || hn2.next != nil { - t.Errorf("Nodes for 'one' not linked correctly.") - } - if hl.start != hn1 || hl.end != hn2 { - t.Errorf("Node not appended to 'one' list correctly.") + if hl.Len() != 2 { + t.Errorf("List doesn't contain second 'one' after add().") } // Add a third one! - hn3 := hs.add("one", HandlerFunc(f)).(*hNode) + _, hn3 := hs.add("one", HandlerFunc(f)) if len(hs.set) != 1 { t.Errorf("Set contains more than 'one' list after add().") } - if hn3.set != hs || hn3.event != "one" { - t.Errorf("Third node for 'one' not created correctly") - } - if hn1.prev != nil || hn1.next != hn2 || - hn2.prev != hn1 || hn2.next != hn3 || - hn3.prev != hn2 || hn3.next != nil { - t.Errorf("Nodes for 'one' not linked correctly.") - } - if hl.start != hn1 || hl.end != hn3 { - t.Errorf("Node not appended to 'one' list correctly.") + if hl.Len() != 3 { + t.Errorf("List doesn't contain third 'one' after add().") } // And finally a fourth one! - hn4 := hs.add("one", HandlerFunc(f)).(*hNode) + _, hn4 := hs.add("one", HandlerFunc(f)) if len(hs.set) != 1 { t.Errorf("Set contains more than 'one' list after add().") } - if hn4.set != hs || hn4.event != "one" { - t.Errorf("Fourth node for 'one' not created correctly.") - } - if hn1.prev != nil || hn1.next != hn2 || - hn2.prev != hn1 || hn2.next != hn3 || - hn3.prev != hn2 || hn3.next != hn4 || - hn4.prev != hn3 || hn4.next != nil { - t.Errorf("Nodes for 'one' not linked correctly.") - } - if hl.start != hn1 || hl.end != hn4 { - t.Errorf("Node not appended to 'one' list correctly.") + if hl.Len() != 4 { + t.Errorf("List doesn't contain fourth 'one' after add().") } // Dispatch should result in 4 additions. @@ -94,16 +68,8 @@ func TestHandlerSet(t *testing.T) { if len(hs.set) != 1 { t.Errorf("Set list count changed after remove().") } - if hn3.set != nil || hn3.prev != nil || hn3.next != nil { - t.Errorf("Third node for 'one' not removed correctly.") - } - if hn1.prev != nil || hn1.next != hn2 || - hn2.prev != hn1 || hn2.next != hn4 || - hn4.prev != hn2 || hn4.next != nil { - t.Errorf("Third node for 'one' not unlinked correctly.") - } - if hl.start != hn1 || hl.end != hn4 { - t.Errorf("Third node for 'one' changed list pointers.") + if hl.Len() != 3 { + t.Errorf("Third 'one' not removed correctly.") } // Dispatch should result in 3 additions. @@ -114,18 +80,12 @@ func TestHandlerSet(t *testing.T) { } // Remove node 1. - hs.remove(hn1) + hn1.Remove() if len(hs.set) != 1 { t.Errorf("Set list count changed after remove().") } - if hn1.set != nil || hn1.prev != nil || hn1.next != nil { - t.Errorf("First node for 'one' not removed correctly.") - } - if hn2.prev != nil || hn2.next != hn4 || hn4.prev != hn2 || hn4.next != nil { - t.Errorf("First node for 'one' not unlinked correctly.") - } - if hl.start != hn2 || hl.end != hn4 { - t.Errorf("First node for 'one' didn't change list pointers.") + if hl.Len() != 2 { + t.Errorf("First 'one' not removed correctly.") } // Dispatch should result in 2 additions. @@ -140,14 +100,8 @@ func TestHandlerSet(t *testing.T) { if len(hs.set) != 1 { t.Errorf("Set list count changed after remove().") } - if hn4.set != nil || hn4.prev != nil || hn4.next != nil { - t.Errorf("Fourth node for 'one' not removed correctly.") - } - if hn2.prev != nil || hn2.next != nil { - t.Errorf("Fourth node for 'one' not unlinked correctly.") - } - if hl.start != hn2 || hl.end != hn2 { - t.Errorf("Fourth node for 'one' didn't change list pointers.") + if hl.Len() != 1 { + t.Errorf("Fourth 'one' not removed correctly.") } // Dispatch should result in 1 addition. @@ -158,16 +112,10 @@ func TestHandlerSet(t *testing.T) { } // Remove node 2. - hs.remove(hn2) + hn2.Remove() if len(hs.set) != 0 { t.Errorf("Removing last node in 'one' didn't remove list.") } - if hn2.set != nil || hn2.prev != nil || hn2.next != nil { - t.Errorf("Second node for 'one' not removed correctly.") - } - if hl.start != nil || hl.end != nil { - t.Errorf("Second node for 'one' didn't change list pointers.") - } // Dispatch should result in NO additions. hs.dispatch(nil, &Line{Cmd: "One"}) @@ -178,52 +126,43 @@ func TestHandlerSet(t *testing.T) { } func TestCommandSet(t *testing.T) { - cs := commandSet() - if len(cs.set) != 0 { - t.Errorf("New set contains things!") + cl := newCommandList() + if cl.list.Len() != 0 { + t.Errorf("New list contains things!") } - c := &command{ - fn: func(c *Conn, l *Line) {}, - help: "wtf?", + _, cn1 := cl.add("one", HandlerFunc(func(c *Conn, l *Line) {}), 0) + if cl.list.Len() != 1 { + t.Errorf("Command 'one' not added to list correctly.") } - cn1 := cs.add("ONE", c).(*cNode) - if _, ok := cs.set["one"]; !ok || cn1.set != cs || cn1.prefix != "one" { - t.Errorf("Command 'one' not added to set correctly.") - } - - if fail := cs.add("one", c); fail != nil { - t.Errorf("Adding a second 'one' command did not fail as expected.") - } - - cn2 := cs.add("One Two", c).(*cNode) - if _, ok := cs.set["one two"]; !ok || cn2.set != cs || cn2.prefix != "one two" { + _, cn2 := cl.add("one two", HandlerFunc(func(c *Conn, l *Line) {}), 0) + if cl.list.Len() != 2 { t.Errorf("Command 'one two' not added to set correctly.") } - if c, l := cs.match("foo"); c != nil || l != 0 { + if c := cl.match("foo"); c != nil { t.Errorf("Matched 'foo' when we shouldn't.") } - if c, l := cs.match("one"); c.(*cNode) != cn1 || l != 3 { - t.Errorf("Didn't match 'one' when we should have.") + if c := cl.match("one"); c == nil { + t.Errorf("Didn't match when we should have.") } - if c, l := cs.match("one two three"); c.(*cNode) != cn2 || l != 7 { - t.Errorf("Didn't match 'one two' when we should have.") + if c := cl.match("one two three"); c == nil { + t.Errorf("Didn't match when we should have.") } - cs.remove(cn2) - if _, ok := cs.set["one two"]; ok || cn2.set != nil { + cn2.Remove() + if cl.list.Len() != 1 { t.Errorf("Command 'one two' not removed correctly.") } - if c, l := cs.match("one two three"); c.(*cNode) != cn1 || l != 3 { - t.Errorf("Didn't match 'one' when we should have.") + if c := cl.match("one two three"); c == nil { + t.Errorf("Didn't match when we should have.") } cn1.Remove() - if _, ok := cs.set["one"]; ok || cn1.set != nil { - t.Errorf("Command 'one' not removed correctly.") + if cl.list.Len() != 0 { + t.Errorf("Command 'one two' not removed correctly.") } - if c, l := cs.match("one two three"); c != nil || l != 0 { - t.Errorf("Matched 'one' when we shouldn't have.") + if c := cl.match("one two three"); c != nil { + t.Errorf("Matched 'one' when we shouldn't.") } } diff --git a/client/funcs.go b/client/funcs.go new file mode 100644 index 0000000..61c9c12 --- /dev/null +++ b/client/funcs.go @@ -0,0 +1,75 @@ +package client + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "regexp" + "strings" +) + +type youTubeVideo struct { + Entry struct { + Info struct { + Title struct { + Text string `json:"$t"` + } `json:"media$title"` + Description struct { + Text string `json:"$t"` + } `json:"media$description"` + } `json:"media$group"` + Rating struct { + Likes string `json:"numLikes"` + Dislikes string `json:"numDislikes"` + } `json:"yt$rating"` + Statistics struct { + Views string `json:"viewCount"` + } `json:"yt$statistics"` + } `json:entry` +} + +const UrlRegex string = `(\s|^)(http://|https://)(.*?)(\s|$)` + +func UrlFunc(conn *Conn, line *Line) { + text := line.Message() + if regex, err := regexp.Compile(UrlRegex); err == nil { + url := strings.TrimSpace(regex.FindString(text)) + if url != "" { + if resp, err := http.Get(url); err == nil { + if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") { + defer resp.Body.Close() + if content, err := ioutil.ReadAll(resp.Body); err == nil { + if regex, err := regexp.Compile(`(.*?)`); err == nil { + if regex.Match([]byte(content)) { + conn.Privmsg(line.Target(), strings.TrimSpace(regex.FindStringSubmatch(string(content))[1])) + } + } + } + } + } + } + } +} + +const YouTubeRegex string = `(\s|^)(http://|https://)?(www.)?(youtube.com/watch\?v=|youtu.be/)(.*?)(\s|$|\&|#)` + +func YouTubeFunc(conn *Conn, line *Line) { + text := line.Message() + if regex, err := regexp.Compile(YouTubeRegex); err == nil { + if regex.Match([]byte(text)) { + matches := regex.FindStringSubmatch(text) + id := matches[len(matches)-2] + url := fmt.Sprintf("https://gdata.youtube.com/feeds/api/videos/%s?v=2&alt=json", id) + if resp, err := http.Get(url); err == nil { + defer resp.Body.Close() + if contents, err := ioutil.ReadAll(resp.Body); err == nil { + var data youTubeVideo + if err := json.Unmarshal(contents, &data); err == nil { + conn.Privmsg(line.Target(), fmt.Sprintf("%s - %s views (%s likes, %s dislikes)", data.Entry.Info.Title.Text, data.Entry.Statistics.Views, data.Entry.Rating.Likes, data.Entry.Rating.Dislikes)) + } + } + } + } + } +} diff --git a/client/handlers.go b/client/handlers.go index 2e0d816..b6ce6c8 100644 --- a/client/handlers.go +++ b/client/handlers.go @@ -7,20 +7,15 @@ import ( "strings" ) -const ( - REGISTER = "REGISTER" - CONNECTED = "CONNECTED" - DISCONNECTED = "DISCONNECTED" -) - // sets up the internal event handlers to do essential IRC protocol things var intHandlers = map[string]HandlerFunc{ REGISTER: (*Conn).h_REGISTER, - "001": (*Conn).h_001, - "433": (*Conn).h_433, - "CTCP": (*Conn).h_CTCP, - "NICK": (*Conn).h_NICK, - "PING": (*Conn).h_PING, + "001": (*Conn).h_001, + "433": (*Conn).h_433, + CTCP: (*Conn).h_CTCP, + NICK: (*Conn).h_NICK, + PING: (*Conn).h_PING, + PRIVMSG: (*Conn).h_PRIVMSG, } func (conn *Conn) addIntHandlers() { @@ -33,7 +28,7 @@ func (conn *Conn) addIntHandlers() { // Basic ping/pong handler func (conn *Conn) h_PING(line *Line) { - conn.Raw("PONG :" + line.Args[0]) + conn.Pong(line.Args[0]) } // Handler for initial registration with server once tcp connection is made. @@ -86,10 +81,10 @@ func (conn *Conn) h_433(line *Line) { // Handle VERSION requests and CTCP PING func (conn *Conn) h_CTCP(line *Line) { - if line.Args[0] == "VERSION" { - conn.CtcpReply(line.Nick, "VERSION", "powered by goirc...") - } else if line.Args[0] == "PING" { - conn.CtcpReply(line.Nick, "PING", line.Args[2]) + if line.Args[0] == VERSION { + conn.CtcpReply(line.Nick, VERSION, "powered by goirc...") + } else if line.Args[0] == PING { + conn.CtcpReply(line.Nick, PING, line.Args[2]) } } @@ -102,34 +97,19 @@ func (conn *Conn) h_NICK(line *Line) { // Handle PRIVMSGs that trigger Commands func (conn *Conn) h_PRIVMSG(line *Line) { - txt := line.Args[1] - if conn.cfg.CommandStripNick && strings.HasPrefix(txt, conn.cfg.Me.Nick) { + text := line.Message() + if conn.cfg.CommandStripNick && strings.HasPrefix(text, conn.cfg.Me.Nick) { // Look for '^${nick}[:;>,-]? ' l := len(conn.cfg.Me.Nick) - switch txt[l] { + switch text[l] { case ':', ';', '>', ',', '-': l++ } - if txt[l] == ' ' { - txt = strings.TrimSpace(txt[l:]) + if text[l] == ' ' { + text = strings.TrimSpace(text[l:]) } - } - cmd, l := conn.cmdMatch(txt) - if cmd == nil { - return - } - if conn.cfg.CommandStripPrefix { - txt = strings.TrimSpace(txt[l:]) - } - if txt != line.Args[1] { line = line.Copy() - line.Args[1] = txt - } - cmd.Execute(conn, line) -} - -func (conn *Conn) c_HELP(line *Line) { - if cmd, _ := conn.cmdMatch(line.Args[1]); cmd != nil { - conn.Privmsg(line.Args[0], cmd.Help()) + line.Args[1] = text } + conn.command(line) } diff --git a/client/handlers_test.go b/client/handlers_test.go index 556ac76..4a46574 100644 --- a/client/handlers_test.go +++ b/client/handlers_test.go @@ -47,7 +47,7 @@ func Test001(t *testing.T) { l := parseLine(":irc.server.org 001 test :Welcome to IRC test!ident@somehost.com") // Set up a handler to detect whether connected handler is called from 001 hcon := false - c.HandleFunc("connected", func(conn *Conn, line *Line) { + c.HandleFunc(CONNECTED, func(conn *Conn, line *Line) { hcon = true }) @@ -166,7 +166,9 @@ func TestPRIVMSG(t *testing.T) { f := func(conn *Conn, line *Line) { conn.Privmsg(line.Args[0], line.Args[1]) } - c.CommandFunc("prefix", f, "") + // Test legacy simpleCommands, with out the !. + SimpleCommandRegex = `^%v(\s|$)` + c.SimpleCommandFunc("prefix", f) // CommandStripNick and CommandStripPrefix are both false to begin c.h_PRIVMSG(parseLine(":blah!moo@cows.com PRIVMSG #foo :prefix bar")) @@ -183,7 +185,7 @@ func TestPRIVMSG(t *testing.T) { c.h_PRIVMSG(parseLine(":blah!moo@cows.com PRIVMSG #foo :test: prefix bar")) s.nc.Expect("PRIVMSG #foo :prefix bar") - c.cfg.CommandStripPrefix = true + c.cfg.SimpleCommandStripPrefix = true c.h_PRIVMSG(parseLine(":blah!moo@cows.com PRIVMSG #foo :prefix bar")) s.nc.Expect("PRIVMSG #foo :bar") c.h_PRIVMSG(parseLine(":blah!moo@cows.com PRIVMSG #foo :test: prefix bar")) diff --git a/client/line.go b/client/line.go index 706d373..2cb53e1 100644 --- a/client/line.go +++ b/client/line.go @@ -62,7 +62,7 @@ func parseLine(s string) *Line { // So, I think CTCP and (in particular) CTCP ACTION are better handled as // separate events as opposed to forcing people to have gargantuan // handlers to cope with the possibilities. - if (line.Cmd == "PRIVMSG" || line.Cmd == "NOTICE") && + if (line.Cmd == PRIVMSG || line.Cmd == NOTICE) && len(line.Args[1]) > 2 && strings.HasPrefix(line.Args[1], "\001") && strings.HasSuffix(line.Args[1], "\001") { @@ -72,19 +72,42 @@ func parseLine(s string) *Line { // Replace the line with the unwrapped CTCP line.Args[1] = t[1] } - if c := strings.ToUpper(t[0]); c == "ACTION" && line.Cmd == "PRIVMSG" { + if c := strings.ToUpper(t[0]); c == ACTION && line.Cmd == PRIVMSG { // make a CTCP ACTION it's own event a-la PRIVMSG line.Cmd = c } else { // otherwise, dispatch a generic CTCP/CTCPREPLY event that // contains the type of CTCP in line.Args[0] - if line.Cmd == "PRIVMSG" { - line.Cmd = "CTCP" + if line.Cmd == PRIVMSG { + line.Cmd = CTCP } else { - line.Cmd = "CTCPREPLY" + line.Cmd = CTCPREPLY } line.Args = append([]string{c}, line.Args...) } } return line } + +// Return the contents of the message portion of a line. +// This only really makes sense for messages with a :message portion, but there +// are a lot of them. +func (line *Line) Message() string { + if len(line.Args) > 0 { + return line.Args[len(line.Args)-1] + } + return "" +} + +// Return the target of the line. This only really makes sense for PRIVMSG. +// If the line was broadcast from a channel, the target will be that channel. +// If the line was broadcast by a user, the target will be that user. +func (line *Line) Target() string { + if line.Cmd == PRIVMSG { + if !strings.HasPrefix(line.Args[0], "#") { + return line.Nick + } + return line.Args[0] + } + return "" +}