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 { - log.Panicln(err) - - } - if err := g.SetKeybinding(menuWindow, 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(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 }