From f0179ad90e66b131bcf5a19c44ea9fb3748307b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CORNIERE=20R=C3=A9mi?= Date: Wed, 18 Dec 2019 01:55:00 +0100 Subject: [PATCH 1/2] Logging. Added menus. Can now send raw stanzas. --- _examples/xmpp_chat_client/config.yml | 3 + _examples/xmpp_chat_client/interface.go | 223 +++++++++++++++--- .../xmpp_chat_client/xmpp_chat_client.go | 135 +++++++---- 3 files changed, 284 insertions(+), 77 deletions(-) diff --git a/_examples/xmpp_chat_client/config.yml b/_examples/xmpp_chat_client/config.yml index 2ebfe1b..ed6e902 100644 --- a/_examples/xmpp_chat_client/config.yml +++ b/_examples/xmpp_chat_client/config.yml @@ -9,4 +9,7 @@ Client : Contacts : "testuser1@localhost;testuser3@localhost" +LogStanzas: + - logger_on: "true" + - logfile_path: "./logs" diff --git a/_examples/xmpp_chat_client/interface.go b/_examples/xmpp_chat_client/interface.go index a64f182..84c2ed6 100644 --- a/_examples/xmpp_chat_client/interface.go +++ b/_examples/xmpp_chat_client/interface.go @@ -1,15 +1,48 @@ package main import ( + "errors" "fmt" "github.com/awesome-gocui/gocui" "log" + "strings" ) const ( - chatLogWindow = "clw" - inputWindow = "iw" - menuWindow = "menw" + // Windows + chatLogWindow = "clw" // Where (received and sent) messages are logged + chatInputWindow = "iw" // Where messages are written + rawInputWindow = "rw" // Where raw stanzas are written + contactsListWindow = "cl" // Where the contacts list is shown, and contacts are selectable + menuWindow = "mw" // Where the menu is shown + + // Menu options + disconnect = "Disconnect" + askServerForRoster = "Ask server for roster" + rawMode = "Switch to Send Raw Mode" + messageMode = "Switch to Send Message Mode" + contactList = "Contacts list" + backFromContacts = "<- Go back" +) + +// To store names of views on top +type viewsState struct { + input string // Which input view is on top + side string // Which side view is on top + contacts []string // Contacts list + currentContact string // Contact we are currently messaging +} + +var ( + // Which window is on top currently on top of the other. + // This is the init setup + viewState = viewsState{ + input: chatInputWindow, + side: menuWindow, + } + menuOptions = []string{contactList, rawMode, askServerForRoster, disconnect} + // Errors + servConnFail = errors.New("failed to connect to server. Check your configuration ? Exiting") ) func setCurrentViewOnTop(g *gocui.Gui, name string) (*gocui.View, error) { @@ -31,7 +64,7 @@ func layout(g *gocui.Gui) error { v.Autoscroll = true } - if v, err := g.SetView(menuWindow, 0, 0, maxX/5-1, 5*maxY/6-1, 0); err != nil { + if v, err := g.SetView(contactsListWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil { if !gocui.IsUnknownView(err) { return err } @@ -40,7 +73,29 @@ func layout(g *gocui.Gui) error { v.Autoscroll = true } - if v, err := g.SetView(inputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil { + if v, err := g.SetView(menuWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = "Menu" + v.Wrap = true + v.Autoscroll = true + fmt.Fprint(v, strings.Join(menuOptions, "\n")) + if _, err = setCurrentViewOnTop(g, menuWindow); err != nil { + return err + } + } + + if v, err := g.SetView(rawInputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = "Write or paste a raw stanza. Press \"Ctrl+E\" to send :" + v.Editable = true + v.Wrap = true + } + + if v, err := g.SetView(chatInputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil { if !gocui.IsUnknownView(err) { return err } @@ -48,7 +103,7 @@ func layout(g *gocui.Gui) error { v.Editable = true v.Wrap = true - if _, err = setCurrentViewOnTop(g, inputWindow); err != nil { + if _, err = setCurrentViewOnTop(g, chatInputWindow); err != nil { return err } } @@ -60,50 +115,83 @@ func quit(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit } -// Sends an input line from the user to the backend while also printing it in the chatlog window. +// Sends an input text from the user to the backend while also printing it in the chatlog window. +// KeyEnter is viewed as "\n" by gocui, so messages should only be one line, whereas raw sending has a different key +// binding and therefor should work with this too (for multiple lines stanzas) func writeInput(g *gocui.Gui, v *gocui.View) error { - log, _ := g.View(chatLogWindow) - for _, line := range v.ViewBufferLines() { - textChan <- line - fmt.Fprintln(log, "Me : ", line) - } + chatLogWindow, _ := g.View(chatLogWindow) + + input := strings.Join(v.ViewBufferLines(), "\n") + + fmt.Fprintln(chatLogWindow, "Me : ", input) + textChan <- input + v.Clear() v.EditDeleteToStartOfLine() return nil } func setKeyBindings(g *gocui.Gui) { + // ========================== + // All views if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { log.Panicln(err) } - if err := g.SetKeybinding(inputWindow, gocui.KeyEnter, gocui.ModNone, writeInput); err != nil { + // ========================== + // Chat input + if err := g.SetKeybinding(chatInputWindow, gocui.KeyEnter, gocui.ModNone, writeInput); err != nil { log.Panicln(err) } - if err := g.SetKeybinding(inputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + if err := g.SetKeybinding(chatInputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { log.Panicln(err) + } + // ========================== + // Raw input + if err := g.SetKeybinding(rawInputWindow, gocui.KeyCtrlE, gocui.ModNone, writeInput); err != nil { + log.Panicln(err) } - if err := g.SetKeybinding(menuWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + + if err := g.SetKeybinding(rawInputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { log.Panicln(err) } + // ========================== + // Menu if err := g.SetKeybinding(menuWindow, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { log.Panicln(err) - } if err := g.SetKeybinding(menuWindow, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { log.Panicln(err) - + } + if err := g.SetKeybinding(menuWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + log.Panicln(err) } if err := g.SetKeybinding(menuWindow, gocui.KeyEnter, gocui.ModNone, getLine); err != nil { log.Panicln(err) } + // ========================== + // Contacts list + if err := g.SetKeybinding(contactsListWindow, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(contactsListWindow, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(contactsListWindow, gocui.KeyEnter, gocui.ModNone, getLine); err != nil { + log.Panicln(err) + } + if err := g.SetKeybinding(contactsListWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil { + log.Panicln(err) + } + } -// When we select a new correspondent, we change it in the client, and we display a message window confirming the change. +// General +// Used to handle menu selections and navigations func getLine(g *gocui.Gui, v *gocui.View) error { var l string var err error @@ -112,34 +200,107 @@ func getLine(g *gocui.Gui, v *gocui.View) error { if l, err = v.Line(cy); err != nil { l = "" } - // Updating the current correspondent, back-end side. - CorrespChan <- l + if viewState.side == menuWindow { + if l == contactList { + cv, _ := g.View(contactsListWindow) + viewState.side = contactsListWindow + g.SetViewOnTop(contactsListWindow) + g.SetCurrentView(contactsListWindow) + if len(cv.ViewBufferLines()) == 0 { + printContactsToWindow(g, viewState.contacts) + } + } else if l == disconnect || l == askServerForRoster { + chlw, _ := g.View(chatLogWindow) + fmt.Fprintln(chlw, infoFormat+" Not yet implemented !") + } else if l == rawMode { + mw, _ := g.View(menuWindow) + viewState.input = rawInputWindow + g.SetViewOnTop(rawInputWindow) + g.SetCurrentView(rawInputWindow) + menuOptions[1] = messageMode + v.Clear() + v.EditDeleteToStartOfLine() + fmt.Fprintln(mw, strings.Join(menuOptions, "\n")) + message := "Now sending in raw stanza mode" + clv, _ := g.View(chatLogWindow) + fmt.Fprintln(clv, infoFormat+message) + } else if l == messageMode { + mw, _ := g.View(menuWindow) + viewState.input = chatInputWindow + g.SetViewOnTop(chatInputWindow) + g.SetCurrentView(chatInputWindow) + menuOptions[1] = rawMode + v.Clear() + v.EditDeleteToStartOfLine() + fmt.Fprintln(mw, strings.Join(menuOptions, "\n")) + message := "Now sending in messages mode" + clv, _ := g.View(chatLogWindow) + fmt.Fprintln(clv, infoFormat+message) + } + } else if viewState.side == contactsListWindow { + if l == backFromContacts { + viewState.side = menuWindow + g.SetViewOnTop(menuWindow) + g.SetCurrentView(menuWindow) + } else if l == "" { + return nil + } else { + // Updating the current correspondent, back-end side. + CorrespChan <- l + viewState.currentContact = l + // Showing the selected contact in contacts list + cl, _ := g.View(contactsListWindow) + cts := cl.ViewBufferLines() + cl.Clear() + printContactsToWindow(g, cts) + // Showing a message to the user, and switching back to input after the new contact is selected. + message := "Now sending messages to : " + l + " in a private conversation" + clv, _ := g.View(chatLogWindow) + fmt.Fprintln(clv, infoFormat+message) + g.SetCurrentView(chatInputWindow) + } + } - // Showing a message to the user, and switching back to input after the new contact is selected. - message := "Now sending messages to : " + l + " in a private conversation" - clv, _ := g.View(chatLogWindow) - fmt.Fprintln(clv, infoFormat+message) - g.SetCurrentView(inputWindow) return nil } -// Changing view between input and "menu" (= basically contacts only right now) when pressing the specific key. +func printContactsToWindow(g *gocui.Gui, contactsList []string) { + cl, _ := g.View(contactsListWindow) + for _, c := range contactsList { + c = strings.ReplaceAll(c, " *", "") + if c == viewState.currentContact { + fmt.Fprintf(cl, c+" *\n") + } else { + fmt.Fprintf(cl, c+"\n") + } + } +} + +// Changing view between input and "menu/contacts" when pressing the specific key. func nextView(g *gocui.Gui, v *gocui.View) error { - if v == nil || v.Name() == inputWindow { - _, err := g.SetCurrentView(menuWindow) + if v == nil || v.Name() == chatInputWindow || v.Name() == rawInputWindow { + _, err := g.SetCurrentView(viewState.side) + return err + } else if v.Name() == menuWindow || v.Name() == contactsListWindow { + _, err := g.SetCurrentView(viewState.input) return err } - _, err := g.SetCurrentView(inputWindow) + + // Should not be reached right now + _, err := g.SetCurrentView(chatInputWindow) return err } func cursorDown(g *gocui.Gui, v *gocui.View) error { if v != nil { cx, cy := v.Cursor() - // Avoid going below the list of contacts + // Avoid going below the list of contacts. Although lines are stored in the view as a slice + // in the used lib. Therefor, if the number of lines is too big, the cursor will go past the last line since + // increasing slice capacity is done by doubling it. Last lines will be "nil" and reachable by the cursor + // in a dynamic context (such as contacts list) cv := g.CurrentView() h := cv.LinesHeight() - if cy+1 >= h-1 { + if cy+1 >= h { return nil } // Lower cursor diff --git a/_examples/xmpp_chat_client/xmpp_chat_client.go b/_examples/xmpp_chat_client/xmpp_chat_client.go index 9e3c2c6..3904e2f 100644 --- a/_examples/xmpp_chat_client/xmpp_chat_client.go +++ b/_examples/xmpp_chat_client/xmpp_chat_client.go @@ -2,10 +2,10 @@ package main /* xmpp_chat_client is a demo client that connect on an XMPP server to chat with other members -Note that this example sends to a very specific user. User logic is not implemented here. */ import ( + "encoding/xml" "flag" "fmt" "github.com/awesome-gocui/gocui" @@ -14,6 +14,9 @@ import ( "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" "log" + "os" + "path" + "strconv" "strings" ) @@ -24,6 +27,8 @@ const ( configFileName = "config" configType = "yaml" + logStanzasOn = "logger_on" + logFilePath = "logfile_path" // Keys in config serverAddressKey = "full_address" clientJid = "jid" @@ -34,16 +39,22 @@ const ( var ( CorrespChan = make(chan string, 1) textChan = make(chan string, 5) + rawTextChan = make(chan string, 5) killChan = make(chan struct{}, 1) + errChan = make(chan error) + + logger *log.Logger ) type config struct { - Server map[string]string `mapstructure:"server"` - Client map[string]string `mapstructure:"client"` - Contacts string `string:"contact"` + Server map[string]string `mapstructure:"server"` + Client map[string]string `mapstructure:"client"` + Contacts string `string:"contact"` + LogStanzas map[string]string `mapstructure:"logstanzas"` } func main() { + // ============================================================ // Parse the flag with the config directory path as argument flag.String("c", defaultConfigFilePath, "Provide a path to the directory that contains the configuration"+ @@ -55,6 +66,22 @@ func main() { // Read configuration c := readConfig() + //================================ + // Setup logger + on, err := strconv.ParseBool(c.LogStanzas[logStanzasOn]) + if err != nil { + log.Panicln(err) + } + if on { + f, err := os.OpenFile(path.Join(c.LogStanzas[logFilePath], "logs.txt"), os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + log.Panicln(err) + } + logger = log.New(f, "", log.Lshortfile|log.Ldate|log.Ltime) + logger.SetOutput(f) + defer f.Close() + } + // ========================== // Create TUI g, err := gocui.NewGui(gocui.OutputNormal, true) @@ -70,7 +97,6 @@ func main() { // ========================== // Run TUI - errChan := make(chan error) go func() { errChan <- g.MainLoop() }() @@ -107,6 +133,10 @@ func startClient(g *gocui.Gui, config *config) { handlerWithGui := func(_ xmpp.Sender, p stanza.Packet) { msg, ok := p.(stanza.Message) + if logger != nil { + logger.Println(msg) + } + v, err := g.View(chatLogWindow) if !ok { fmt.Fprintf(v, "%sIgnoring packet: %T\n", infoFormat, p) @@ -120,8 +150,11 @@ func startClient(g *gocui.Gui, config *config) { _, err := fmt.Fprintf(v, "Error from server : %s : %s \n", msg.Error.Reason, msg.XMLName.Space) return err } - _, err := fmt.Fprintf(v, "%s : %s \n", msg.From, msg.Body) - return err + if len(strings.TrimSpace(msg.Body)) != 0 { + _, err := fmt.Fprintf(v, "%s : %s \n", msg.From, msg.Body) + return err + } + return nil }) } @@ -140,6 +173,8 @@ func startClient(g *gocui.Gui, config *config) { fmt.Fprintf(v, msg) return err }) + fmt.Println("Failed to connect to server. Exiting...") + errChan <- servConnFail return } @@ -147,24 +182,42 @@ func startClient(g *gocui.Gui, config *config) { // Start working //askForRoster(client, g) updateRosterFromConfig(g, config) + // Sending the default contact in a channel. Default value is the first contact in the list from the config. + viewState.currentContact = strings.Split(config.Contacts, configContactSep)[0] + // Informing user of the default contact + clw, _ := g.View(chatLogWindow) + fmt.Fprintf(clw, infoFormat+"Now sending messages to "+viewState.currentContact+" in a private conversation\n") + CorrespChan <- viewState.currentContact startMessaging(client, config) } func startMessaging(client xmpp.Sender, config *config) { var text string - // Update this with a channel. Default value is the first contact in the list from the config. - correspondent := strings.Split(config.Contacts, configContactSep)[0] + var correspondent string for { select { case <-killChan: return case text = <-textChan: - reply := stanza.Message{Attrs: stanza.Attrs{To: correspondent}, Body: text} + reply := stanza.Message{Attrs: stanza.Attrs{To: correspondent, From: config.Client[clientJid], Type: stanza.MessageTypeChat}, Body: text} + if logger != nil { + raw, _ := xml.Marshal(reply) + logger.Println(string(raw)) + } err := client.Send(reply) if err != nil { fmt.Printf("There was a problem sending the message : %v", reply) return } + case text = <-rawTextChan: + if logger != nil { + logger.Println(text) + } + err := client.SendRaw(text) + if err != nil { + fmt.Printf("There was a problem sending the message : %v", text) + return + } case crrsp := <-CorrespChan: correspondent = crrsp } @@ -172,6 +225,7 @@ func startMessaging(client xmpp.Sender, config *config) { } } +// Only reads and parses the configuration func readConfig() *config { viper.SetConfigName(configFileName) // name of config file (without extension) viper.BindPFlags(pflag.CommandLine) @@ -184,6 +238,7 @@ func readConfig() *config { log.Panicln(err) } } + viper.SetConfigType(configType) var config config err = viper.Unmarshal(&config) @@ -191,6 +246,20 @@ func readConfig() *config { panic(fmt.Errorf("Unable to decode Config: %s \n", err)) } + // Check if we have contacts to message + if len(strings.TrimSpace(config.Contacts)) == 0 { + log.Panicln("You appear to have no contacts to message !") + } + // Check logging + config.LogStanzas[logFilePath] = path.Clean(config.LogStanzas[logFilePath]) + on, err := strconv.ParseBool(config.LogStanzas[logStanzasOn]) + if err != nil { + log.Panicln(err) + } + if d, e := isDirectory(config.LogStanzas[logFilePath]); (e != nil || !d) && on { + log.Panicln("The log file path could not be found or is not a directory.") + } + return &config } @@ -203,45 +272,19 @@ func errorHandler(err error) { // Read the client roster from the config. This does not check with the server that the roster is correct. // If user tries to send a message to someone not registered with the server, the server will return an error. func updateRosterFromConfig(g *gocui.Gui, config *config) { - g.Update(func(g *gocui.Gui) error { - menu, _ := g.View(menuWindow) - for _, contact := range strings.Split(config.Contacts, configContactSep) { - fmt.Fprintln(menu, contact) - } - return nil - }) + viewState.contacts = append(strings.Split(config.Contacts, configContactSep), backFromContacts) } // Updates the menu panel of the view with the current user's roster. // Need to add support for Roster IQ stanzas to make this work. func askForRoster(client *xmpp.Client, g *gocui.Gui) { - //ctx, _ := context.WithTimeout(context.Background(), 30*time.Second) - //iqReq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: currentUserJid, To: "localhost", Lang: "en"}) - //disco := iqReq.DiscoInfo() - //iqReq.Payload = disco - // - //// Handle a possible error - //errChan := make(chan error) - //errorHandler := func(err error) { - // errChan <- err - //} - //client.ErrorHandler = errorHandler - //res, err := client.SendIQ(ctx, iqReq) - //if err != nil { - // t.Errorf(err.Error()) - //} - // - //select { - //case <-res: - //} - - //roster := []string{"testuser1", "testuser2", "testuser3@localhost"} - // - //g.Update(func(g *gocui.Gui) error { - // menu, _ := g.View(menuWindow) - // for _, contact := range roster { - // fmt.Fprintln(menu, contact) - // } - // return nil - //}) + // Not implemented yet ! +} + +func isDirectory(path string) (bool, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return false, err + } + return fileInfo.IsDir(), err } From 390336b89472dc4efe8faa27c0b0f85f03a81160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CORNIERE=20R=C3=A9mi?= Date: Mon, 23 Dec 2019 09:04:18 +0100 Subject: [PATCH 2/2] Added Roster IQs Added an overly primitive "disconnect" for the client to use in the chat client example --- _examples/xmpp_chat_client/interface.go | 30 ++++- .../xmpp_chat_client/xmpp_chat_client.go | 19 ++- client.go | 7 +- component.go | 1 - stanza/iq_disco.go | 4 +- stanza/iq_disco_test.go | 2 +- stanza/iq_roster.go | 115 ++++++++++++++++++ stanza/iq_roster_test.go | 109 +++++++++++++++++ stanza/stream.go | 2 + 9 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 stanza/iq_roster.go create mode 100644 stanza/iq_roster_test.go diff --git a/_examples/xmpp_chat_client/interface.go b/_examples/xmpp_chat_client/interface.go index 84c2ed6..6437da5 100644 --- a/_examples/xmpp_chat_client/interface.go +++ b/_examples/xmpp_chat_client/interface.go @@ -15,6 +15,7 @@ const ( rawInputWindow = "rw" // Where raw stanzas are written contactsListWindow = "cl" // Where the contacts list is shown, and contacts are selectable menuWindow = "mw" // Where the menu is shown + disconnectMsg = "msg" // Menu options disconnect = "Disconnect" @@ -188,6 +189,12 @@ func setKeyBindings(g *gocui.Gui) { log.Panicln(err) } + // ========================== + // Disconnect message + if err := g.SetKeybinding(disconnectMsg, gocui.KeyEnter, gocui.ModNone, delMsg); err != nil { + log.Panicln(err) + } + } // General @@ -209,7 +216,20 @@ func getLine(g *gocui.Gui, v *gocui.View) error { if len(cv.ViewBufferLines()) == 0 { printContactsToWindow(g, viewState.contacts) } - } else if l == disconnect || l == askServerForRoster { + } else if l == disconnect { + maxX, maxY := g.Size() + msg := "You disconnected from the server. Press enter to quit." + if v, err := g.SetView(disconnectMsg, maxX/2-30, maxY/2, maxX/2-29+len(msg), maxY/2+2, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + fmt.Fprintln(v, msg) + if _, err := g.SetCurrentView(disconnectMsg); err != nil { + return err + } + } + killChan <- disconnectErr + } else if l == askServerForRoster { chlw, _ := g.View(chatLogWindow) fmt.Fprintln(chlw, infoFormat+" Not yet implemented !") } else if l == rawMode { @@ -326,3 +346,11 @@ func cursorUp(g *gocui.Gui, v *gocui.View) error { } return nil } + +func delMsg(g *gocui.Gui, v *gocui.View) error { + if err := g.DeleteView(disconnectMsg); err != nil { + return err + } + errChan <- gocui.ErrQuit // Quit the program + return nil +} diff --git a/_examples/xmpp_chat_client/xmpp_chat_client.go b/_examples/xmpp_chat_client/xmpp_chat_client.go index 3904e2f..d28c124 100644 --- a/_examples/xmpp_chat_client/xmpp_chat_client.go +++ b/_examples/xmpp_chat_client/xmpp_chat_client.go @@ -6,6 +6,7 @@ xmpp_chat_client is a demo client that connect on an XMPP server to chat with ot import ( "encoding/xml" + "errors" "flag" "fmt" "github.com/awesome-gocui/gocui" @@ -40,10 +41,11 @@ var ( CorrespChan = make(chan string, 1) textChan = make(chan string, 5) rawTextChan = make(chan string, 5) - killChan = make(chan struct{}, 1) + killChan = make(chan error, 1) errChan = make(chan error) - logger *log.Logger + logger *log.Logger + disconnectErr = errors.New("disconnecting client") ) type config struct { @@ -160,7 +162,7 @@ func startClient(g *gocui.Gui, config *config) { router.HandleFunc("message", handlerWithGui) if client, err = xmpp.NewClient(clientCfg, router, errorHandler); err != nil { - panic(fmt.Sprintf("Could not create a new client ! %s", err)) + log.Panicln(fmt.Sprintf("Could not create a new client ! %s", err)) } @@ -196,7 +198,13 @@ func startMessaging(client xmpp.Sender, config *config) { var correspondent string for { select { - case <-killChan: + case err := <-killChan: + if err == disconnectErr { + sc := client.(xmpp.StreamClient) + sc.Disconnect() + } else { + logger.Println(err) + } return case text = <-textChan: reply := stanza.Message{Attrs: stanza.Attrs{To: correspondent, From: config.Client[clientJid], Type: stanza.MessageTypeChat}, Body: text} @@ -265,8 +273,7 @@ func readConfig() *config { // If an error occurs, this is used to kill the client func errorHandler(err error) { - fmt.Printf("%v", err) - killChan <- struct{}{} + killChan <- err } // Read the client roster from the config. This does not check with the server that the roster is correct. diff --git a/client.go b/client.go index 254a793..1c5ea22 100644 --- a/client.go +++ b/client.go @@ -206,7 +206,12 @@ func (c *Client) Resume(state SMState) error { } func (c *Client) Disconnect() { - // TODO: Add a way to wait for stream close acknowledgement from the server for clean disconnect + // TODO : Wait for server response for clean disconnect + presence := stanza.NewPresence(stanza.Attrs{From: c.config.Jid}) + presence.Type = stanza.PresenceTypeUnavailable + c.Send(presence) + c.SendRaw(stanza.StreamClose) + if c.transport != nil { _ = c.transport.Close() } diff --git a/component.go b/component.go index 8b96240..828ba07 100644 --- a/component.go +++ b/component.go @@ -111,7 +111,6 @@ func (c *Component) Resume(sm SMState) error { c.updateState(StatePermanentError) return NewConnError(errors.New("expecting handshake result, got "+v.Name()), true) } - return err } func (c *Component) Disconnect() { diff --git a/stanza/iq_disco.go b/stanza/iq_disco.go index cc94756..7b9acac 100644 --- a/stanza/iq_disco.go +++ b/stanza/iq_disco.go @@ -8,6 +8,7 @@ import ( // Disco Info const ( + // NSDiscoInfo defines the namespace for disco IQ stanzas NSDiscoInfo = "http://jabber.org/protocol/disco#info" ) @@ -21,6 +22,7 @@ type DiscoInfo struct { Features []Feature `xml:"feature"` } +// Namespace lets DiscoInfo implement the IQPayload interface func (d *DiscoInfo) Namespace() string { return d.XMLName.Space } @@ -112,7 +114,7 @@ func (d *DiscoItems) Namespace() string { // DiscoItems builds a default DiscoItems payload func (iq *IQ) DiscoItems() *DiscoItems { d := DiscoItems{ - XMLName: xml.Name{Space: "http://jabber.org/protocol/disco#items", Local: "query"}, + XMLName: xml.Name{Space: NSDiscoItems, Local: "query"}, } iq.Payload = &d return &d diff --git a/stanza/iq_disco_test.go b/stanza/iq_disco_test.go index d659cde..012952e 100644 --- a/stanza/iq_disco_test.go +++ b/stanza/iq_disco_test.go @@ -50,7 +50,7 @@ func TestDiscoInfo_Builder(t *testing.T) { // Implements XEP-0030 example 17 // https://xmpp.org/extensions/xep-0030.html#example-17 func TestDiscoItems_Builder(t *testing.T) { - iq := stanza.NewIQ(stanza.Attrs{Type: "result", From: "catalog.shakespeare.lit", + iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "catalog.shakespeare.lit", To: "romeo@montague.net/orchard", Id: "items-2"}) iq.DiscoItems(). AddItem("catalog.shakespeare.lit", "books", "Books by and about Shakespeare"). diff --git a/stanza/iq_roster.go b/stanza/iq_roster.go new file mode 100644 index 0000000..1923013 --- /dev/null +++ b/stanza/iq_roster.go @@ -0,0 +1,115 @@ +package stanza + +import ( + "encoding/xml" +) + +// ============================================================================ +// Roster + +const ( + // NSRoster is the Roster IQ namespace + NSRoster = "jabber:iq:roster" + // SubscriptionNone indicates the user does not have a subscription to + // the contact's presence, and the contact does not have a subscription + // to the user's presence; this is the default value, so if the subscription + // attribute is not included then the state is to be understood as "none" + SubscriptionNone = "none" + + // SubscriptionTo indicates the user has a subscription to the contact's + // presence, but the contact does not have a subscription to the user's presence. + SubscriptionTo = "to" + + // SubscriptionFrom indicates the contact has a subscription to the user's + // presence, but the user does not have a subscription to the contact's presence + SubscriptionFrom = "from" + + // SubscriptionBoth indicates the user and the contact have subscriptions to each + // other's presence (also called a "mutual subscription") + SubscriptionBoth = "both" +) + +// ---------- +// Namespaces + +// Roster struct represents Roster IQs +type Roster struct { + XMLName xml.Name `xml:"jabber:iq:roster query"` +} + +// Namespace defines the namespace for the RosterIQ +func (r *Roster) Namespace() string { + return r.XMLName.Space +} + +// --------------- +// Builder helpers + +// RosterIQ builds a default Roster payload +func (iq *IQ) RosterIQ() *Roster { + r := Roster{ + XMLName: xml.Name{ + Space: NSRoster, + Local: "query", + }, + } + iq.Payload = &r + return &r +} + +// ----------- +// SubElements + +// RosterItems represents the list of items in a roster IQ +type RosterItems struct { + XMLName xml.Name `xml:"jabber:iq:roster query"` + Items []RosterItem `xml:"item"` +} + +// Namespace lets RosterItems implement the IQPayload interface +func (r *RosterItems) Namespace() string { + return r.XMLName.Space +} + +// RosterItem represents an item in the roster iq +type RosterItem struct { + XMLName xml.Name `xml:"jabber:iq:roster item"` + Jid string `xml:"jid,attr"` + Ask string `xml:"ask,attr,omitempty"` + Name string `xml:"name,attr,omitempty"` + Subscription string `xml:"subscription,attr,omitempty"` + Groups []string `xml:"group"` +} + +// --------------- +// Builder helpers + +// RosterItems builds a default RosterItems payload +func (iq *IQ) RosterItems() *RosterItems { + ri := RosterItems{ + XMLName: xml.Name{Space: "jabber:iq:roster", Local: "query"}, + } + iq.Payload = &ri + return &ri +} + +// AddItem builds an item and ads it to the roster IQ +func (r *RosterItems) AddItem(jid, subscription, ask, name string, groups []string) *RosterItems { + item := RosterItem{ + Jid: jid, + Name: name, + Groups: groups, + Subscription: subscription, + Ask: ask, + } + r.Items = append(r.Items, item) + return r +} + +// ============================================================================ +// Registry init + +func init() { + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSRoster, Local: "query"}, Roster{}) + TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSRoster, Local: "query"}, RosterItems{}) +} diff --git a/stanza/iq_roster_test.go b/stanza/iq_roster_test.go new file mode 100644 index 0000000..7228084 --- /dev/null +++ b/stanza/iq_roster_test.go @@ -0,0 +1,109 @@ +package stanza + +import ( + "encoding/xml" + "reflect" + "testing" +) + +func TestRosterBuilder(t *testing.T) { + iq := NewIQ(Attrs{Type: IQTypeResult, From: "romeo@montague.net/orchard"}) + var noGroup []string + + iq.RosterItems().AddItem("xl8ceawrfu8zdneomw1h6h28d@crypho.com", + SubscriptionBoth, + "", + "xl8ceaw", + []string{"0flucpm8i2jtrjhxw01uf1nd2", + "bm2bajg9ex4e1swiuju9i9nu5", + "rvjpanomi4ejpx42fpmffoac0"}). + AddItem("9aynsym60zbu78jbdvpho7s68@crypho.com", + SubscriptionBoth, + "", + "9aynsym60", + []string{"mzaoy73i6ra5k502182zi1t97"}). + AddItem("admin@crypho.com", + SubscriptionBoth, + "", + "admin", + noGroup) + + parsedIQ, err := checkMarshalling(t, iq) + if err != nil { + return + } + + // Check result + pp, ok := parsedIQ.Payload.(*RosterItems) + if !ok { + t.Errorf("Parsed stanza does not contain correct IQ payload") + } + + // Check items + items := []RosterItem{ + { + XMLName: xml.Name{}, + Name: "xl8ceaw", + Ask: "", + Jid: "xl8ceawrfu8zdneomw1h6h28d@crypho.com", + Subscription: SubscriptionBoth, + Groups: []string{"0flucpm8i2jtrjhxw01uf1nd2", + "bm2bajg9ex4e1swiuju9i9nu5", + "rvjpanomi4ejpx42fpmffoac0"}, + }, + { + XMLName: xml.Name{}, + Name: "9aynsym60", + Ask: "", + Jid: "9aynsym60zbu78jbdvpho7s68@crypho.com", + Subscription: SubscriptionBoth, + Groups: []string{"mzaoy73i6ra5k502182zi1t97"}, + }, + { + XMLName: xml.Name{}, + Name: "admin", + Ask: "", + Jid: "admin@crypho.com", + Subscription: SubscriptionBoth, + Groups: noGroup, + }, + } + if len(pp.Items) != len(items) { + t.Errorf("Items length mismatch: %#v", pp.Items) + } else { + for i, item := range pp.Items { + if item.Jid != items[i].Jid { + t.Errorf("JID Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if !reflect.DeepEqual(item.Groups, items[i].Groups) { + t.Errorf("Node Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if item.Name != items[i].Name { + t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if item.Ask != items[i].Ask { + t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + if item.Subscription != items[i].Subscription { + t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid) + } + } + } +} + +func checkMarshalling(t *testing.T, iq IQ) (*IQ, error) { + // Marshall + data, err := xml.Marshal(iq) + if err != nil { + t.Errorf("cannot marshal iq: %s\n%#v", err, iq) + return nil, err + } + + // Unmarshall + var parsedIQ IQ + err = xml.Unmarshal(data, &parsedIQ) + if err != nil { + t.Errorf("Unmarshal returned error: %s\n%s", err, data) + } + return &parsedIQ, err +} diff --git a/stanza/stream.go b/stanza/stream.go index 203cc83..6ab4bad 100644 --- a/stanza/stream.go +++ b/stanza/stream.go @@ -12,3 +12,5 @@ type Stream struct { Id string `xml:"id,attr"` Version string `xml:"version,attr"` } + +const StreamClose = ""