diff --git a/_examples/xmpp_chat_client/config.yml b/_examples/xmpp_chat_client/config.yml new file mode 100644 index 0000000..ed6e902 --- /dev/null +++ b/_examples/xmpp_chat_client/config.yml @@ -0,0 +1,15 @@ +# Default config for the client +Server : + - full_address: "localhost:5222" + - port: 5222 +Client : + - name: "testuser2" + - jid: "testuser2@localhost" + - pass: "pass123" #Password in a config file yay + +Contacts : "testuser1@localhost;testuser3@localhost" + +LogStanzas: + - logger_on: "true" + - logfile_path: "./logs" + diff --git a/_examples/xmpp_chat_client/go.mod b/_examples/xmpp_chat_client/go.mod new file mode 100644 index 0000000..8d510f6 --- /dev/null +++ b/_examples/xmpp_chat_client/go.mod @@ -0,0 +1,10 @@ +module go-xmpp/_examples/xmpp_chat_client + +go 1.13 + +require ( + github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.6.1 + gosrc.io/xmpp v0.3.1-0.20191212145100-27130d72926b +) diff --git a/_examples/xmpp_chat_client/interface.go b/_examples/xmpp_chat_client/interface.go new file mode 100644 index 0000000..84c2ed6 --- /dev/null +++ b/_examples/xmpp_chat_client/interface.go @@ -0,0 +1,328 @@ +package main + +import ( + "errors" + "fmt" + "github.com/awesome-gocui/gocui" + "log" + "strings" +) + +const ( + // 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) { + if _, err := g.SetCurrentView(name); err != nil { + return nil, err + } + return g.SetViewOnTop(name) +} + +func layout(g *gocui.Gui) error { + maxX, maxY := g.Size() + + if v, err := g.SetView(chatLogWindow, maxX/5, 0, maxX-1, 5*maxY/6-1, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = "Chat log" + v.Wrap = true + v.Autoscroll = true + } + + if v, err := g.SetView(contactsListWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil { + if !gocui.IsUnknownView(err) { + return err + } + v.Title = "Contacts" + v.Wrap = true + v.Autoscroll = true + } + + 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 + } + v.Title = "Write a message :" + v.Editable = true + v.Wrap = true + + if _, err = setCurrentViewOnTop(g, chatInputWindow); err != nil { + return err + } + } + + return nil +} + +func quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} + +// 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 { + 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) + } + + // ========================== + // Chat input + if err := g.SetKeybinding(chatInputWindow, gocui.KeyEnter, gocui.ModNone, writeInput); err != nil { + log.Panicln(err) + } + + 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) + } + +} + +// General +// Used to handle menu selections and navigations +func getLine(g *gocui.Gui, v *gocui.View) error { + var l string + var err error + + _, cy := v.Cursor() + if l, err = v.Line(cy); err != nil { + 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) + } + } + + return nil +} + +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() == 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 + } + + // 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. 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 { + return nil + } + // Lower cursor + if err := v.SetCursor(cx, cy+1); err != nil { + ox, oy := v.Origin() + if err := v.SetOrigin(ox, oy+1); err != nil { + return err + } + } + } + return nil +} + +func cursorUp(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + cx, cy := v.Cursor() + if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 { + if err := v.SetOrigin(ox, oy-1); err != nil { + return err + } + } + } + return nil +} diff --git a/_examples/xmpp_chat_client/xmpp_chat_client.go b/_examples/xmpp_chat_client/xmpp_chat_client.go index 2b2d2e7..3904e2f 100644 --- a/_examples/xmpp_chat_client/xmpp_chat_client.go +++ b/_examples/xmpp_chat_client/xmpp_chat_client.go @@ -2,94 +2,289 @@ 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 ( - . "bufio" + "encoding/xml" + "flag" "fmt" - "os" - + "github.com/awesome-gocui/gocui" + "github.com/spf13/pflag" + "github.com/spf13/viper" "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" + "log" + "os" + "path" + "strconv" + "strings" ) const ( - currentUserAddress = "localhost:5222" - currentUserJid = "testuser@localhost" - currentUserPass = "testpass" - correspondantJid = "testuser2@localhost" + infoFormat = "====== " + // Default configuration + defaultConfigFilePath = "./" + + configFileName = "config" + configType = "yaml" + logStanzasOn = "logger_on" + logFilePath = "logfile_path" + // Keys in config + serverAddressKey = "full_address" + clientJid = "jid" + clientPass = "pass" + configContactSep = ";" ) +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"` + LogStanzas map[string]string `mapstructure:"logstanzas"` +} + func main() { - config := xmpp.Config{ + + // ============================================================ + // Parse the flag with the config directory path as argument + flag.String("c", defaultConfigFilePath, "Provide a path to the directory that contains the configuration"+ + " file you want to use. Config file should be named \"config\" and be of YAML format..") + pflag.CommandLine.AddGoFlagSet(flag.CommandLine) + pflag.Parse() + + // ========================== + // 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) + if err != nil { + log.Panicln(err) + } + defer g.Close() + g.Highlight = true + g.Cursor = true + g.SelFgColor = gocui.ColorGreen + g.SetManagerFunc(layout) + setKeyBindings(g) + + // ========================== + // Run TUI + go func() { + errChan <- g.MainLoop() + }() + + // ========================== + // Start XMPP client + go startClient(g, c) + + select { + case err := <-errChan: + if err == gocui.ErrQuit { + log.Println("Closing client.") + } else { + log.Panicln(err) + } + } +} + +func startClient(g *gocui.Gui, config *config) { + + // ========================== + // Client setup + clientCfg := xmpp.Config{ TransportConfiguration: xmpp.TransportConfiguration{ - Address: currentUserAddress, + Address: config.Server[serverAddressKey], }, - Jid: currentUserJid, - Credential: xmpp.Password(currentUserPass), + Jid: config.Client[clientJid], + Credential: xmpp.Password(config.Client[clientPass]), Insecure: true} var client *xmpp.Client var err error router := xmpp.NewRouter() - router.HandleFunc("message", handleMessage) - if client, err = xmpp.NewClient(config, router, errorHandler); err != nil { - fmt.Println("Error new client") + + 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) + return + } + if err != nil { + return + } + g.Update(func(g *gocui.Gui) error { + if msg.Error.Code != 0 { + _, err := fmt.Fprintf(v, "Error from server : %s : %s \n", msg.Error.Reason, msg.XMLName.Space) + return err + } + if len(strings.TrimSpace(msg.Body)) != 0 { + _, err := fmt.Fprintf(v, "%s : %s \n", msg.From, msg.Body) + return err + } + return nil + }) } - // Connecting client and handling messages - // To use a stream manager, just write something like this instead : - //cm := xmpp.NewStreamManager(client, startMessaging) - //log.Fatal(cm.Run()) //=> this will lock the calling goroutine + 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)) + } + + // ========================== + // Client connection if err = client.Connect(); err != nil { - fmt.Printf("XMPP connection failed: %s", err) + msg := fmt.Sprintf("%sXMPP connection failed: %s", infoFormat, err) + g.Update(func(g *gocui.Gui) error { + v, err := g.View(chatLogWindow) + fmt.Fprintf(v, msg) + return err + }) + fmt.Println("Failed to connect to server. Exiting...") + errChan <- servConnFail return } - startMessaging(client) + // ========================== + // 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) { - reader := NewReader(os.Stdin) - textChan := make(chan string) +func startMessaging(client xmpp.Sender, config *config) { var text string + var correspondent string for { - fmt.Print("Enter text: ") - go readInput(reader, textChan) select { case <-killChan: return case text = <-textChan: - reply := stanza.Message{Attrs: stanza.Attrs{To: correspondantJid}, 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 } + } } -func readInput(reader *Reader, textChan chan string) { - text, _ := reader.ReadString('\n') - textChan <- text +// Only reads and parses the configuration +func readConfig() *config { + viper.SetConfigName(configFileName) // name of config file (without extension) + viper.BindPFlags(pflag.CommandLine) + viper.AddConfigPath(viper.GetString("c")) // path to look for the config file in + err := viper.ReadInConfig() // Find and read the config file + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + log.Fatalf("%s %s", err, "Please make sure you give a path to the directory of the config and not to the config itself.") + } else { + log.Panicln(err) + } + } + + viper.SetConfigType(configType) + var config config + err = viper.Unmarshal(&config) + if err != nil { + 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 } -var killChan = make(chan struct{}) - -// If an error occurs, this is used +// If an error occurs, this is used to kill the client func errorHandler(err error) { fmt.Printf("%v", err) killChan <- struct{}{} } -func handleMessage(s xmpp.Sender, p stanza.Packet) { - msg, ok := p.(stanza.Message) - if !ok { - _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p) - return - } - _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From) +// 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) { + 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) { + // Not implemented yet ! +} + +func isDirectory(path string) (bool, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return false, err + } + return fileInfo.IsDir(), err } diff --git a/cmd/go.mod b/cmd/go.mod index 85df002..1c4684f 100644 --- a/cmd/go.mod +++ b/cmd/go.mod @@ -6,7 +6,7 @@ require ( github.com/bdlm/log v0.1.19 github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7 github.com/spf13/cobra v0.0.5 - github.com/spf13/viper v1.4.0 + github.com/spf13/viper v1.6.1 gosrc.io/xmpp v0.1.1 ) diff --git a/go.mod b/go.mod index f31fe40..d3b3273 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module gosrc.io/xmpp go 1.13 require ( + github.com/awesome-gocui/gocui v0.6.0 // indirect github.com/google/go-cmp v0.3.1 github.com/google/uuid v1.1.1 + github.com/spf13/viper v1.6.1 // indirect golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 nhooyr.io/websocket v1.6.5 + )