package ircevent import ( "errors" "fmt" "runtime/debug" "strconv" "strings" "time" "github.com/ergochat/irc-go/ircmsg" ) const ( // fake events that we manage specially registrationEvent = "\x00REGISTRATION" disconnectEvent = "\x00DISCONNECT" ) // Tuple type for uniquely identifying callbacks type CallbackID struct { command string id uint64 } // Register a callback to handle an IRC command (or numeric). A callback is a // function which takes only an ircmsg.Message object as parameter. Valid commands // are all IRC commands, including numerics. This function returns the ID of the // registered callback for later management. func (irc *Connection) AddCallback(command string, callback func(ircmsg.Message)) CallbackID { return irc.addCallback(command, Callback(callback), false, 0) } func (irc *Connection) addCallback(command string, callback Callback, prepend bool, idNum uint64) CallbackID { command = strings.ToUpper(command) if command == "" || strings.HasPrefix(command, "*") || command == "BATCH" { return CallbackID{} } irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() if irc.events == nil { irc.events = make(map[string][]callbackPair) } if idNum == 0 { idNum = irc.callbackCounter irc.callbackCounter++ } id := CallbackID{command: command, id: idNum} newPair := callbackPair{id: id.id, callback: callback} current := irc.events[command] newList := make([]callbackPair, len(current)+1) start := 0 if prepend { newList[start] = newPair start++ } copy(newList[start:], current) if !prepend { newList[len(newList)-1] = newPair } irc.events[command] = newList return id } // Remove callback i (ID) from the given event code. func (irc *Connection) RemoveCallback(id CallbackID) { irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() switch id.command { case registrationEvent: irc.removeCallbackNoMutex(RPL_ENDOFMOTD, id.id) irc.removeCallbackNoMutex(ERR_NOMOTD, id.id) case "BATCH": irc.removeBatchCallbackNoMutex(id.id) default: irc.removeCallbackNoMutex(id.command, id.id) } } func (irc *Connection) removeCallbackNoMutex(code string, id uint64) { current := irc.events[code] if len(current) == 0 { return } newList := make([]callbackPair, 0, len(current)-1) for _, p := range current { if p.id != id { newList = append(newList, p) } } irc.events[code] = newList } // Remove all callbacks from a given event code. func (irc *Connection) ClearCallback(command string) { command = strings.ToUpper(command) irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() delete(irc.events, command) } // Replace callback i (ID) associated with a given event code with a new callback function. func (irc *Connection) ReplaceCallback(id CallbackID, callback func(ircmsg.Message)) bool { irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() list := irc.events[id.command] for i, p := range list { if p.id == id.id { list[i] = callbackPair{id: id.id, callback: callback} return true } } return false } // AddBatchCallback adds a callback for handling BATCH'ed server responses. // All available BATCH callbacks will be invoked in an undefined order, // stopping at the first one to return a value of true (indicating successful // processing). If no batch callback returns true, the batch will be "flattened" // (i.e., its messages will be processed individually by the normal event // handlers). Batch callbacks can be removed as usual with RemoveCallback. func (irc *Connection) AddBatchCallback(callback func(*Batch) bool) CallbackID { irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() idNum := irc.callbackCounter irc.callbackCounter++ nbc := make([]batchCallbackPair, len(irc.batchCallbacks)+1) copy(nbc, irc.batchCallbacks) nbc[len(nbc)-1] = batchCallbackPair{id: idNum, callback: callback} irc.batchCallbacks = nbc return CallbackID{command: "BATCH", id: idNum} } func (irc *Connection) removeBatchCallbackNoMutex(idNum uint64) { current := irc.batchCallbacks if len(current) == 0 { return } newList := make([]batchCallbackPair, 0, len(current)-1) for _, p := range current { if p.id != idNum { newList = append(newList, p) } } irc.batchCallbacks = newList } // Convenience function to add a callback that will be called once the // connection is completed (this is traditionally referred to as "connection // registration"). func (irc *Connection) AddConnectCallback(callback func(ircmsg.Message)) (id CallbackID) { // XXX: forcibly use the same ID number for both copies of the callback id376 := irc.AddCallback(RPL_ENDOFMOTD, callback) irc.addCallback(ERR_NOMOTD, callback, false, id376.id) return CallbackID{command: registrationEvent, id: id376.id} } // Adds a callback to be run when disconnection from the server is detected; // this may be a connectivity failure, a server-initiated disconnection, or // a client-initiated Quit(). These callbacks are run after the last message // from the server is processed, before any reconnection attempt. The contents // of the Message object supplied to the callback are undefined. func (irc *Connection) AddDisconnectCallback(callback func(ircmsg.Message)) (id CallbackID) { return irc.AddCallback(disconnectEvent, callback) } func (irc *Connection) getCallbacks(code string) (result []callbackPair) { code = strings.ToUpper(code) irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() return irc.events[code] } func (irc *Connection) getBatchCallbacks() (result []batchCallbackPair) { irc.eventsMutex.Lock() defer irc.eventsMutex.Unlock() return irc.batchCallbacks } var ( // ad-hoc internal errors for batch processing // these indicate invalid data from the server (or else local corruption) errorDuplicateBatchID = errors.New("found duplicate batch ID") errorNoParentBatchID = errors.New("parent batch ID not found") errorBatchNotOpen = errors.New("tried to close batch, but batch ID not found") errorUnknownLabel = errors.New("received labeled response from server, but we don't recognize the label") ) func (irc *Connection) handleBatchCommand(msg ircmsg.Message) { if len(msg.Params) < 1 || len(msg.Params[0]) < 2 { irc.Log.Printf("Invalid BATCH command from server\n") return } start := msg.Params[0][0] == '+' if !start && msg.Params[0][0] != '-' { irc.Log.Printf("Invalid BATCH ID from server: %s\n", msg.Params[0]) return } batchID := msg.Params[0][1:] isNested, parentBatchID := msg.GetTag("batch") var label int64 if start { if present, labelStr := msg.GetTag("label"); present { label = deserializeLabel(labelStr) } } finishedBatch, callback, err := func() (finishedBatch *Batch, callback LabelCallback, err error) { irc.batchMutex.Lock() defer irc.batchMutex.Unlock() if start { if _, ok := irc.batches[batchID]; ok { err = errorDuplicateBatchID return } batchObj := new(Batch) batchObj.Message = msg irc.batches[batchID] = batchInProgress{ createdAt: time.Now(), batch: batchObj, label: label, } if isNested { parentBip := irc.batches[parentBatchID] if parentBip.batch == nil { err = errorNoParentBatchID return } parentBip.batch.Items = append(parentBip.batch.Items, batchObj) } } else { bip := irc.batches[batchID] if bip.batch == nil { err = errorBatchNotOpen return } delete(irc.batches, batchID) if !isNested { finishedBatch = bip.batch if bip.label != 0 { callback = irc.getLabelCallbackNoMutex(bip.label) if callback == nil { err = errorUnknownLabel } } } } return }() if err != nil { irc.Log.Printf("batch error: %v (batchID=`%s`, parentBatchID=`%s`)", err, batchID, parentBatchID) } else if callback != nil { callback(finishedBatch) } else if finishedBatch != nil { irc.HandleBatch(finishedBatch) } } func (irc *Connection) getLabelCallbackNoMutex(label int64) (callback LabelCallback) { if lc, ok := irc.labelCallbacks[label]; ok { callback = lc.callback delete(irc.labelCallbacks, label) } return } func (irc *Connection) getLabelCallback(label int64) (callback LabelCallback) { irc.batchMutex.Lock() defer irc.batchMutex.Unlock() return irc.getLabelCallbackNoMutex(label) } // HandleBatch handles a *Batch using available handlers, "flattening" it if // no handler succeeds. This can be used in a batch or labeled-response callback // to process inner batches. func (irc *Connection) HandleBatch(batch *Batch) { if batch == nil { return } success := false for _, bh := range irc.getBatchCallbacks() { if bh.callback(batch) { success = true break } } if !success { irc.handleBatchNaively(batch) } } // recursively "flatten" the nested batch; process every command individually func (irc *Connection) handleBatchNaively(batch *Batch) { if batch.Command != "BATCH" { irc.HandleMessage(batch.Message) } for _, item := range batch.Items { irc.handleBatchNaively(item) } } func (irc *Connection) handleBatchedCommand(msg ircmsg.Message, batchID string) { irc.batchMutex.Lock() defer irc.batchMutex.Unlock() bip := irc.batches[batchID] if bip.batch == nil { irc.Log.Printf("ignoring command with unknown batch ID %s\n", batchID) return } bip.batch.Items = append(bip.batch.Items, &Batch{Message: msg}) } // Execute all callbacks associated with a given event. func (irc *Connection) runCallbacks(msg ircmsg.Message) { if !irc.AllowPanic { defer irc.handleCallbackPanic() } // handle batch start or end if irc.batchNegotiated() { if msg.Command == "BATCH" { irc.handleBatchCommand(msg) return } else if hasBatchTag, batchID := msg.GetTag("batch"); hasBatchTag { irc.handleBatchedCommand(msg, batchID) return } } // handle labeled single command, or labeled ACK if irc.labelNegotiated() { if hasLabel, labelStr := msg.GetTag("label"); hasLabel { var labelCallback LabelCallback if label := deserializeLabel(labelStr); label != 0 { labelCallback = irc.getLabelCallback(label) } if labelCallback == nil { irc.Log.Printf("received unrecognized label from server: %s\n", labelStr) return } else { labelCallback(&Batch{ Message: msg, }) } return } } // OK, it's a normal IRC command irc.HandleMessage(msg) } func (irc *Connection) handleCallbackPanic() { if r := recover(); r != nil { irc.Log.Printf("Caught panic in callback: %v\n%s", r, debug.Stack()) } } func (irc *Connection) runDisconnectCallbacks() { if !irc.AllowPanic { defer irc.handleCallbackPanic() } callbackPairs := irc.getCallbacks(disconnectEvent) for _, pair := range callbackPairs { pair.callback(ircmsg.Message{}) } } // HandleMessage handles an IRC line using the available handlers. This can be // used in a batch or labeled-response callback to process an individual line. func (irc *Connection) HandleMessage(event ircmsg.Message) { if irc.EnableCTCP { eventRewriteCTCP(&event) } callbackPairs := irc.getCallbacks(event.Command) // just run the callbacks in serial, since it's not safe for them // to take a long time to execute in any case for _, pair := range callbackPairs { pair.callback(event) } } // Set up some initial callbacks to handle the IRC/CTCP protocol. func (irc *Connection) setupCallbacks() { irc.stateMutex.Lock() needBaseCallbacks := !irc.hasBaseCallbacks irc.hasBaseCallbacks = true irc.stateMutex.Unlock() if !needBaseCallbacks { return } // PING: we must respond with the correct PONG irc.AddCallback("PING", func(e ircmsg.Message) { irc.Send("PONG", lastParam(&e)) }) // PONG: record time to make sure the server is responding to us irc.AddCallback("PONG", func(e ircmsg.Message) { irc.recordPong(lastParam(&e)) }) // 433: ERR_NICKNAMEINUSE " :Nickname is already in use" // 437: ERR_UNAVAILRESOURCE " :Nick/channel is temporarily unavailable" irc.AddCallback(ERR_NICKNAMEINUSE, irc.handleUnavailableNick) irc.AddCallback(ERR_UNAVAILRESOURCE, irc.handleUnavailableNick) // 001: RPL_WELCOME "Welcome to the Internet Relay Network !@" // Set irc.currentNick to the actually used nick in this connection. irc.AddCallback(RPL_WELCOME, irc.handleRplWelcome) // 005: RPL_ISUPPORT, conveys supported server features irc.AddCallback(RPL_ISUPPORT, irc.handleISupport) // respond to NICK from the server (in response to our own NICK, or sent unprompted) // #84: prepend so this runs before the client's own NICK callbacks irc.addCallback("NICK", func(e ircmsg.Message) { if e.Nick() == irc.CurrentNick() && len(e.Params) > 0 { irc.setCurrentNick(e.Params[0]) } }, true, 0) irc.AddCallback("ERROR", func(e ircmsg.Message) { if !irc.isQuitting() { irc.Log.Printf("ERROR received from server: %s", strings.Join(e.Params, " ")) } }) irc.AddCallback("CAP", irc.handleCAP) if irc.UseSASL { irc.setupSASLCallbacks() } if irc.EnableCTCP { irc.setupCTCPCallbacks() } // prepend our own callbacks for the end of registration, // so they happen before any client-added callbacks irc.addCallback(RPL_ENDOFMOTD, irc.handleRegistration, true, 0) irc.addCallback(ERR_NOMOTD, irc.handleRegistration, true, 0) irc.AddCallback("FAIL", irc.handleStandardReplies) irc.AddCallback("WARN", irc.handleStandardReplies) irc.AddCallback("NOTE", irc.handleStandardReplies) } func (irc *Connection) handleRplWelcome(e ircmsg.Message) { irc.stateMutex.Lock() defer irc.stateMutex.Unlock() // set the nickname we actually received from the server if len(e.Params) > 0 { irc.currentNick = e.Params[0] } } func (irc *Connection) handleRegistration(e ircmsg.Message) { // wake up Connect() if applicable defer func() { select { case irc.welcomeChan <- empty{}: default: } }() irc.stateMutex.Lock() defer irc.stateMutex.Unlock() if irc.registered { return } irc.registered = true // mark the isupport complete irc.isupport = irc.isupportPartial irc.isupportPartial = nil } func (irc *Connection) handleUnavailableNick(e ircmsg.Message) { // only try to change the nick if we're not registered yet, // otherwise we'll change in response to pingLoop unsuccessfully // trying to restore the intended nick (swapping one undesired nick // for another) var nickToTry string irc.stateMutex.Lock() if irc.currentNick == "" { nickToTry = fmt.Sprintf("%s_%d", irc.Nick, irc.nickCounter) irc.nickCounter++ } irc.stateMutex.Unlock() if nickToTry != "" { irc.Send("NICK", nickToTry) } } func (irc *Connection) handleISupport(e ircmsg.Message) { irc.stateMutex.Lock() defer irc.stateMutex.Unlock() // TODO handle 005 changes after registration if irc.isupportPartial == nil { return } if len(e.Params) < 3 { return } for _, token := range e.Params[1 : len(e.Params)-1] { equalsIdx := strings.IndexByte(token, '=') if equalsIdx == -1 { irc.isupportPartial[token] = "" // no value } else { irc.isupportPartial[token[:equalsIdx]] = unescapeISupportValue(token[equalsIdx+1:]) } } } func unescapeISupportValue(in string) (out string) { if strings.IndexByte(in, '\\') == -1 { return in } var buf strings.Builder for i := 0; i < len(in); { if in[i] == '\\' && i+3 < len(in) && in[i+1] == 'x' { hex := in[i+2 : i+4] if octet, err := strconv.ParseInt(hex, 16, 8); err == nil { buf.WriteByte(byte(octet)) i += 4 continue } } buf.WriteByte(in[i]) i++ } return buf.String() } func (irc *Connection) handleCAP(e ircmsg.Message) { if len(e.Params) < 3 { return } ack := false // CAP PARAMS... switch e.Params[1] { case "LS": irc.handleCAPLS(e.Params[2:]) case "ACK": ack = true fallthrough case "NAK": for _, token := range strings.Fields(e.Params[2]) { name, _ := splitCAPToken(token) if sliceContains(name, irc.RequestCaps) { select { case irc.capsChan <- capResult{capName: name, ack: ack}: default: } } } } } func (irc *Connection) handleCAPLS(params []string) { var capsToReq, capsNotFound []string defer func() { for _, c := range capsToReq { irc.Send("CAP", "REQ", c) } for _, c := range capsNotFound { select { case irc.capsChan <- capResult{capName: c, ack: false}: default: } } }() irc.stateMutex.Lock() defer irc.stateMutex.Unlock() if irc.registered { // TODO server could probably trick us into panic here by sending // additional LS before the end of registration return } if irc.capsAdvertised == nil { irc.capsAdvertised = make(map[string]string) } // multiline responses to CAP LS 302 start with a 4-parameter form: // CAP * LS * :account-notify away-notify [...] // and end with a 3-parameter form: // CAP * LS :userhost-in-names znc.in/playback [...] final := len(params) == 1 for _, token := range strings.Fields(params[len(params)-1]) { name, value := splitCAPToken(token) irc.capsAdvertised[name] = value } if final { for _, c := range irc.RequestCaps { if _, ok := irc.capsAdvertised[c]; ok { capsToReq = append(capsToReq, c) } else { capsNotFound = append(capsNotFound, c) } } } } // labeled-response func (irc *Connection) registerLabel(callback LabelCallback) string { irc.batchMutex.Lock() defer irc.batchMutex.Unlock() irc.labelCounter++ // increment first: 0 is an invalid label label := irc.labelCounter irc.labelCallbacks[label] = pendingLabel{ createdAt: time.Now(), callback: callback, } return serializeLabel(label) } func (irc *Connection) unregisterLabel(labelStr string) { label := deserializeLabel(labelStr) if label == 0 { return } irc.batchMutex.Lock() defer irc.batchMutex.Unlock() delete(irc.labelCallbacks, label) } // expire open batches from the server that weren't closed in a // timely fashion. `force` expires all label callbacks regardless // of time created (so they can be cleaned up when the connection // fails). func (irc *Connection) expireBatches(force bool) { var failedCallbacks []LabelCallback defer func() { for _, bcb := range failedCallbacks { bcb(nil) } }() irc.batchMutex.Lock() defer irc.batchMutex.Unlock() now := time.Now() for label, lcb := range irc.labelCallbacks { if force || now.Sub(lcb.createdAt) > irc.KeepAlive { failedCallbacks = append(failedCallbacks, lcb.callback) delete(irc.labelCallbacks, label) } } for batchID, bip := range irc.batches { if now.Sub(bip.createdAt) > irc.KeepAlive { delete(irc.batches, batchID) } } } func splitCAPToken(token string) (name, value string) { equalIdx := strings.IndexByte(token, '=') if equalIdx == -1 { return token, "" } else { return token[:equalIdx], token[equalIdx+1:] } } func (irc *Connection) handleStandardReplies(e ircmsg.Message) { // unconditionally print messages for FAIL and WARN; // re. NOTE, if debug is enabled, we print the raw line anyway switch e.Command { case "FAIL", "WARN": irc.Log.Printf("Received error code from server: %s %s\n", e.Command, strings.Join(e.Params, " ")) } } const ( labelBase = 32 ) func serializeLabel(label int64) string { return strconv.FormatInt(label, labelBase) } func deserializeLabel(str string) int64 { if p, err := strconv.ParseInt(str, labelBase, 64); err == nil { return p } return 0 }