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 = ""