~samwhited/xmpp

f29caae7289c41ad567cc9d6bf842701b930bcf7 ā€” Sam Whited 4 months ago 473b6ee
muc: new package

examples/im: use new muc package

Signed-off-by: Sam Whited <sam@samwhited.com>
M CHANGELOG.md => CHANGELOG.md +3 -0
@@ 19,6 19,7 @@ All notable changes to this project will be documented in this file.

- blocklist: new package implementing [XEP-0191: Blocking Command]
- commands: new package implementing [XEP-0050: Ad-Hoc Commands]
- muc: new package implementing [XEP-0045: Multi-User Chat] and [XEP-0249: Direct MUC Invitations]
- stanza: implement [XEP-0203: Delayed Delivery]
- stanza: more general `UnmarshalError` function that doesn't focus on IQs
- stanza: add `Error` method to `Presence` and `Message`


@@ 36,9 37,11 @@ All notable changes to this project will be documented in this file.
- xmpp: empty IQ iters no longer return EOF when there is no payload


[XEP-0045: Multi-User Chat]: https://xmpp.org/extensions/xep-0045.html
[XEP-0191: Blocking Command]: https://xmpp.org/extensions/xep-0191.html
[XEP-0050: Ad-Hoc Commands]: https://xmpp.org/extensions/xep-0050.html
[XEP-0203: Delayed Delivery]: https://xmpp.org/extensions/xep-0203.html
[XEP-0249: Direct MUC Invitations]: https://xmpp.org/extensions/xep-0249.html


## v0.19.0 ā€” 2021-05-02

M examples/im/main.go => examples/im/main.go +23 -23
@@ 25,6 25,8 @@ import (
	"mellium.im/xmpp"
	"mellium.im/xmpp/dial"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
	"mellium.im/xmpp/uri"
	"mellium.im/xmpp/version"


@@ 77,14 79,16 @@ func main() {
	}

	var (
		help    bool
		rawXML  bool
		room    bool
		isURI   bool
		verbose bool
		verReq  bool
		logXML  bool
		subject string
		help     bool
		rawXML   bool
		room     bool
		isURI    bool
		verbose  bool
		verReq   bool
		logXML   bool
		subject  string
		nick     string
		roomPass string
	)
	flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
	flags.BoolVar(&help, "help", help, "Show this help message")


@@ 97,6 101,8 @@ func main() {
	flags.BoolVar(&verReq, "ver", verReq, "Request the software version of the remote entity instead of sending messages.")
	flags.StringVar(&addr, "addr", addr, "The XMPP address to connect to, overrides $XMPP_ADDR.")
	flags.StringVar(&subject, "subject", subject, "Set the subject of the message or chat room.")
	flags.StringVar(&nick, "nick", nick, "A nickname to set when joining a chat room.")
	flags.StringVar(&roomPass, "pass", roomPass, "A password to use when joining protected rooms.")

	err = flags.Parse(os.Args[1:])
	switch err {


@@ 199,8 205,11 @@ func main() {
	if err != nil {
		logger.Fatalf("error logging in: %v", err)
	}

	mucClient := &muc.Client{}
	mux := mux.New(muc.HandleClient(mucClient))
	go func() {
		err := session.Serve(nil)
		err := session.Serve(mux)
		if err != nil {
			logger.Printf("error handling session responses: %v", err)
		}


@@ 251,21 260,12 @@ func main() {

	if room {
		debug.Printf("joining the chat room %sā€¦", addr)
		// Join the MUC.
		joinPresence := struct {
			stanza.Presence
			X struct {
				History struct {
					MaxStanzas int `xml:"maxstanzas,attr"`
				} `xml:"history"`
			} `xml:"http://jabber.org/protocol/muc x"`
		}{
			Presence: stanza.Presence{
				From: originJID,
				To:   parsedToAddr,
			},
		roomJID, _ := parsedToAddr.WithResource(nick)
		opts := []muc.Option{muc.MaxBytes(0)}
		if roomPass != "" {
			opts = append(opts, muc.Password(roomPass))
		}
		err = session.Encode(ctx, joinPresence)
		_, err = mucClient.Join(ctx, roomJID, session, opts...)
		if err != nil {
			log.Fatalf("error joining MUC %s: %v", addr, err)
		}

A muc/affiliation_string.go => muc/affiliation_string.go +52 -0
@@ 0,0 1,52 @@
// Code generated by "stringer -type=Affiliation,Role,Privileges -linecomment"; DO NOT EDIT.

package muc

import "strconv"

const _Affiliation_name = "noneowneradminmemberoutcast"

var _Affiliation_index = [...]uint8{0, 4, 9, 14, 20, 27}

func (i Affiliation) String() string {
	if i >= Affiliation(len(_Affiliation_index)-1) {
		return "Affiliation(" + strconv.FormatInt(int64(i), 10) + ")"
	}
	return _Affiliation_name[_Affiliation_index[i]:_Affiliation_index[i+1]]
}

const _Role_name = "nonemoderatorparticipantvisitor"

var _Role_index = [...]uint8{0, 4, 13, 24, 31}

func (i Role) String() string {
	if i >= Role(len(_Role_index)-1) {
		return "Role(" + strconv.FormatInt(int64(i), 10) + ")"
	}
	return _Role_name[_Role_index[i]:_Role_index[i+1]]
}

const _Privileges_name = "presentreceive-messagesreceive-presencebroadcast-presencechange-availabilitychange-nicksend-private-messagesend-invitessend-messagesmodify-subjectkickgrant-voicerevoke-voice"

var _Privileges_map = map[Privileges]string{
	1:    _Privileges_name[0:7],
	2:    _Privileges_name[7:23],
	4:    _Privileges_name[23:39],
	8:    _Privileges_name[39:57],
	16:   _Privileges_name[57:76],
	32:   _Privileges_name[76:87],
	64:   _Privileges_name[87:107],
	128:  _Privileges_name[107:119],
	256:  _Privileges_name[119:132],
	512:  _Privileges_name[132:146],
	1024: _Privileges_name[146:150],
	2048: _Privileges_name[150:161],
	4096: _Privileges_name[161:173],
}

func (i Privileges) String() string {
	if str, ok := _Privileges_map[i]; ok {
		return str
	}
	return "Privileges(" + strconv.FormatInt(int64(i), 10) + ")"
}

A muc/integration_test.go => muc/integration_test.go +226 -0
@@ 0,0 1,226 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

//go:build integration
// +build integration

package muc_test

import (
	"context"
	"crypto/tls"
	"errors"
	"testing"

	"mellium.im/sasl"
	"mellium.im/xmpp"
	"mellium.im/xmpp/disco"
	"mellium.im/xmpp/internal/integration"
	"mellium.im/xmpp/internal/integration/prosody"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
)

func TestIntegrationJoinRoom(t *testing.T) {
	prosodyRun := prosody.Test(context.TODO(), t,
		integration.Log(),
		integration.LogXML(),
		prosody.MUC("muc.localhost"),
		prosody.ListenC2S(),
	)
	prosodyRun(integrationJoinRoom)
}

func integrationJoinRoom(ctx context.Context, t *testing.T, cmd *integration.Cmd) {
	j, pass := cmd.User()
	session, err := cmd.DialClient(ctx, j, t,
		xmpp.StartTLS(&tls.Config{
			InsecureSkipVerify: true,
		}),
		xmpp.SASL("", pass, sasl.Plain),
		xmpp.BindResource(),
	)
	if err != nil {
		t.Fatalf("error connecting: %v", err)
	}
	mucClient := &muc.Client{}
	go func() {
		m := mux.New(muc.HandleClient(mucClient))
		err := session.Serve(m)
		if err != nil {
			t.Logf("error from serve: %v", err)
		}
	}()

	// Fetch rooms and make sure they're empty.
	roomJID := jid.MustParse("bridgecrew@muc.localhost/Picard")
	iter := disco.FetchItems(ctx, disco.Item{
		JID: roomJID.Domain(),
	}, session)
	for iter.Next() {
		t.Errorf("did not expect any rooms initially, got: %v", iter.Item())
	}
	if err = iter.Err(); err != nil {
		t.Fatalf("error fetching rooms: %v", err)
	}
	err = iter.Close()
	if err != nil {
		t.Fatalf("error closing initial iter: %v", err)
	}

	channel, err := mucClient.Join(ctx, roomJID, session)
	if err != nil {
		t.Fatalf("error joining MUC: %v", err)
	}

	iter = disco.FetchItems(ctx, disco.Item{
		JID: roomJID.Domain(),
	}, session)
	for iter.Next() {
		t.Errorf("did not expect any private rooms, got: %v", iter.Item())
	}
	if err = iter.Err(); err != nil {
		t.Fatalf("error fetching rooms: %v", err)
	}
	err = iter.Close()
	if err != nil {
		t.Fatalf("error closing initial iter: %v", err)
	}

	roomForm, err := muc.GetConfig(ctx, roomJID.Bare(), session)
	if err != nil {
		t.Fatalf("error fetching config: %v", err)
	}
	_, err = roomForm.Set("muc#roomconfig_publicroom", true)
	if err != nil {
		t.Errorf("error making room public: %v", err)
	}

	err = muc.SetConfig(ctx, roomJID.Bare(), roomForm, session)
	if err != nil {
		t.Fatalf("error setting room config: %v", err)
	}

	// Fetch rooms again and make sure the new one was created.
	var items []disco.Item
	iter = disco.FetchItems(ctx, disco.Item{
		JID: roomJID.Domain(),
	}, session)
	for iter.Next() {
		items = append(items, iter.Item())
	}
	if err = iter.Err(); err != nil {
		t.Fatalf("error fetching rooms: %v", err)
	}
	err = iter.Close()
	if err != nil {
		t.Fatalf("error closing final iter: %v", err)
	}
	if len(items) != 1 || !items[0].JID.Equal(roomJID.Bare()) {
		t.Fatalf("wrong rooms created: want=%v, got=%v", roomJID.Bare(), items)
	}

	err = channel.Leave(ctx, "")
	if err != nil {
		t.Fatalf("error leaving room: %v", err)
	}

	// Fetch rooms and make sure they're empty (room was not persistent and was
	// destroyed when we left, indicating that we did in fact leave correctly).
	iter = disco.FetchItems(ctx, disco.Item{
		JID: roomJID.Domain(),
	}, session)
	for iter.Next() {
		t.Errorf("did not expect any rooms after part, got: %v", iter.Item())
	}
	if err = iter.Err(); err != nil {
		t.Fatalf("error fetching rooms: %v", err)
	}
	err = iter.Close()
	if err != nil {
		t.Fatalf("error closing initial iter: %v", err)
	}
}

func TestIntegrationJoinErr(t *testing.T) {
	prosodyRun := prosody.Test(context.TODO(), t,
		integration.Log(),
		integration.LogXML(),
		prosody.MUC("muc.localhost"),
		prosody.ListenC2S(),
	)
	prosodyRun(integrationJoinErr)
}

func integrationJoinErr(ctx context.Context, t *testing.T, cmd *integration.Cmd) {
	j, pass := cmd.User()
	session, err := cmd.DialClient(ctx, j, t,
		xmpp.StartTLS(&tls.Config{
			InsecureSkipVerify: true,
		}),
		xmpp.SASL("", pass, sasl.Plain),
		xmpp.BindResource(),
	)
	if err != nil {
		t.Fatalf("error connecting: %v", err)
	}
	mucClient := &muc.Client{}
	go func() {
		m := mux.New(muc.HandleClient(mucClient))
		err := session.Serve(m)
		if err != nil {
			t.Logf("error from serve: %v", err)
		}
	}()

	roomJID := jid.MustParse("bridgecrew@muc.localhost/Picard")
	channel, err := mucClient.Join(ctx, roomJID, session)
	if err != nil {
		t.Fatalf("error creating room: %v", err)
	}

	// Configure the room to make it password protected, then join without a
	// password to trigger an error.
	roomForm, err := muc.GetConfig(ctx, roomJID.Bare(), session)
	if err != nil {
		t.Fatalf("error fetching config: %v", err)
	}
	_, err = roomForm.Set("muc#roomconfig_maxusers", 0)
	if err != nil {
		t.Errorf("error making room public: %v", err)
	}
	_, err = roomForm.Set("muc#roomconfig_persistentroom", true)
	if err != nil {
		t.Errorf("error making room persistent: %v", err)
	}
	_, err = roomForm.Set("muc#roomconfig_passwordprotectedroom", true)
	if err != nil {
		t.Errorf("error locking room: %v", err)
	}
	_, err = roomForm.Set("muc#roomconfig_roomsecret", "cantjoinme")
	if err != nil {
		t.Errorf("error locking room: %v", err)
	}
	err = muc.SetConfig(ctx, roomJID.Bare(), roomForm, session)
	if err != nil {
		t.Fatalf("error setting room config: %v", err)
	}
	err = channel.Leave(ctx, "")
	if err != nil {
		t.Fatalf("error leaving the room: %v", err)
	}

	channel2, err := mucClient.Join(ctx, roomJID, session)
	if channel2 != nil {
		t.Errorf("expected nil channel when joining results in an error, got: %v", channel)
	}
	noAuth := stanza.Error{
		Condition: stanza.NotAuthorized,
	}
	if !errors.Is(err, noAuth) {
		t.Fatalf("wrong error type, want=%T (%[1]v), got=%T (%[2]v)", noAuth, err)
	}
}

A muc/invites.go => muc/invites.go +240 -0
@@ 0,0 1,240 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc

import (
	"context"
	"encoding/xml"

	"mellium.im/xmlstream"
	"mellium.im/xmpp"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
)

var directName = xml.Name{Space: NSConf, Local: "x"}

type inviteHandler struct {
	F func(Invitation)
}

func (h inviteHandler) HandleMessage(msg stanza.Message, t xmlstream.TokenReadEncoder) error {
	d := xml.NewTokenDecoder(t)
	// Pop the <message> token.
	_, err := d.Token()
	if err != nil {
		return err
	}
	var x Invitation
	err = d.Decode(&x)
	if err != nil {
		return err
	}

	if h.F != nil {
		h.F(x)
		return nil
	}
	return nil
}

// HandleInvite returns an option that registers a handler for direct MUC
// invitations.
// To handle mediated invitations register a client handler using HandleClient.
func HandleInvite(f func(Invitation)) mux.Option {
	return func(m *mux.ServeMux) {
		msgName := xml.Name{Space: NSConf, Local: "x"}

		mux.Message(stanza.NormalMessage, msgName, inviteHandler{
			F: f,
		})(m)
	}
}

// Invitation is a mediated or direct MUC invitation.
// When the XML is marshaled or unmarshaled the namespace determines whether the
// invitation was direct or mediated.
// The default is mediated.
type Invitation struct {
	XMLName  xml.Name
	Continue bool
	JID      jid.JID
	Password string
	Reason   string
	Thread   string
}

// MarshalDirect returns the invitation as a direct MUC invitation (sent
// directly to the invitee).
func (i Invitation) MarshalDirect() xml.TokenReader {
	attr := []xml.Attr{{
		Name:  xml.Name{Local: "jid"},
		Value: i.JID.String(),
	}}
	if i.Continue {
		attr = append(attr, xml.Attr{
			Name:  xml.Name{Local: "continue"},
			Value: "true",
		})
		if i.Thread != "" {
			attr = append(attr, xml.Attr{
				Name:  xml.Name{Local: "thread"},
				Value: i.Thread,
			})
		}
	}
	if i.Password != "" {
		attr = append(attr, xml.Attr{
			Name:  xml.Name{Local: "password"},
			Value: i.Password,
		})
	}
	if i.Reason != "" {
		attr = append(attr, xml.Attr{
			Name:  xml.Name{Local: "reason"},
			Value: i.Reason,
		})
	}
	return xmlstream.Wrap(
		nil,
		xml.StartElement{Name: i.XMLName, Attr: attr},
	)
}

// MarshalMediated returns the invitation as a mediated MUC invitation (sent
// to the room and then forwarded to the invitee).
func (i Invitation) MarshalMediated() xml.TokenReader {
	var reasonEl, passEl, continueEl xml.TokenReader
	if i.Reason != "" {
		reasonEl = xmlstream.Wrap(
			xmlstream.Token(xml.CharData(i.Reason)),
			xml.StartElement{Name: xml.Name{Local: "reason"}},
		)
	}
	if i.Password != "" {
		passEl = xmlstream.Wrap(
			xmlstream.Token(xml.CharData(i.Password)),
			xml.StartElement{Name: xml.Name{Local: "password"}},
		)
	}
	if i.Continue {
		var attr []xml.Attr
		if i.Thread != "" {
			attr = []xml.Attr{{
				Name:  xml.Name{Local: "thread"},
				Value: i.Thread,
			}}
		}
		continueEl = xmlstream.Wrap(
			nil,
			xml.StartElement{Name: xml.Name{Local: "continue"}, Attr: attr},
		)
	}
	return xmlstream.Wrap(
		xmlstream.MultiReader(
			xmlstream.Wrap(
				xmlstream.MultiReader(
					reasonEl,
					continueEl,
				),
				xml.StartElement{
					Name: xml.Name{Local: "invite"},
					Attr: []xml.Attr{{Name: xml.Name{Local: "to"}, Value: i.JID.String()}},
				},
			),
			passEl,
		),
		xml.StartElement{Name: xml.Name{Space: NSUser, Local: "x"}},
	)
}

// TokenReader satisfies the xmlstream.Marshaler interface.
//
// It calls either MarshalDirect or MarshalMediated depending on the invitations
// XMLName field.
func (i Invitation) TokenReader() xml.TokenReader {
	// Direct invite
	if i.XMLName == directName {
		return i.MarshalDirect()
	}

	// Mediated invite
	return i.MarshalMediated()
}

// WriteXML satisfies the xmlstream.WriterTo interface.
// It is like MarshalXML except it writes tokens to w.
func (i Invitation) WriteXML(w xmlstream.TokenWriter) (n int, err error) {
	return xmlstream.Copy(w, i.TokenReader())
}

// MarshalXML implements xml.Marshaler.
func (i Invitation) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
	_, err := i.WriteXML(e)
	return err
}

// UnmarshalXML implements xml.Unmarshaler.
func (i *Invitation) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
	if start.Name == directName {
		s := struct {
			XMLName  xml.Name `xml:"jabber:x:conference x"`
			Continue bool     `xml:"continue,attr"`
			JID      jid.JID  `xml:"jid,attr"`
			Pass     string   `xml:"password,attr"`
			Reason   string   `xml:"reason,attr"`
			Thread   string   `xml:"thread,attr"`
		}{}
		err := d.DecodeElement(&s, &start)
		if err != nil {
			return err
		}
		i.XMLName = s.XMLName
		i.Continue = s.Continue
		i.JID = s.JID
		i.Password = s.Pass
		i.Reason = s.Reason
		i.Thread = s.Thread
		return nil
	}

	s := struct {
		XMLName xml.Name `xml:"http://jabber.org/protocol/muc#user x"`
		Invite  struct {
			To       jid.JID `xml:"to,attr"`
			Reason   string  `xml:"reason"`
			Continue struct {
				XMLName xml.Name
				Thread  string `xml:"thread,attr"`
			} `xml:"continue"`
		} `xml:"invite"`
		Pass string `xml:"password"`
	}{}
	err := d.DecodeElement(&s, &start)
	if err != nil {
		return err
	}
	i.XMLName = s.XMLName
	i.Continue = s.Invite.Continue.XMLName.Local != ""
	i.JID = s.Invite.To
	i.Password = s.Pass
	i.Reason = s.Invite.Reason
	i.Thread = s.Invite.Continue.Thread
	return nil
}

// Invite sends a direct MUC invitation using the provided session.
// This is useful when a mediated invitation (one sent through the channel using
// the Invite method) is being blocked by a user that does not allow contact
// from unrecognized JIDs.
// Changing the XMLName field of the invite has no effect.
func Invite(ctx context.Context, to jid.JID, invite Invitation, s *xmpp.Session) error {
	invite.XMLName = directName
	return s.Send(ctx, stanza.Message{
		To:   to,
		Type: stanza.NormalMessage,
	}.Wrap(invite.MarshalDirect()))
}

A muc/invites_test.go => muc/invites_test.go +103 -0
@@ 0,0 1,103 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc_test

import (
	"encoding/xml"
	"testing"

	"mellium.im/xmlstream"
	"mellium.im/xmpp/internal/xmpptest"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
)

var (
	_ xml.Marshaler       = muc.Invitation{}
	_ xml.Unmarshaler     = (*muc.Invitation)(nil)
	_ xmlstream.Marshaler = muc.Invitation{}
	_ xmlstream.WriterTo  = muc.Invitation{}
)

var inviteEncodingTestCases = []xmpptest.EncodingTestCase{
	0: {
		Value:       &muc.Invitation{},
		XML:         `<x xmlns="http://jabber.org/protocol/muc#user"><invite to=""></invite></x>`,
		NoUnmarshal: true,
	},
	1: {
		Value: &muc.Invitation{XMLName: xml.Name{Space: muc.NSUser, Local: "x"}},
		XML:   `<x xmlns="http://jabber.org/protocol/muc#user"><invite to=""></invite></x>`,
	},
	2: {
		Value: &muc.Invitation{
			XMLName:  xml.Name{Space: muc.NSUser, Local: "x"},
			Continue: true,
			Thread:   "123",
			JID:      jid.MustParse("bridgecrew@muc.localhost"),
			Password: "NCC-1701-D",
			Reason:   "Senior officers to the bridge.",
		},
		XML: `<x xmlns="http://jabber.org/protocol/muc#user"><invite to="bridgecrew@muc.localhost"><reason>Senior officers to the bridge.</reason><continue thread="123"></continue></invite><password>NCC-1701-D</password></x>`,
	},
	3: {
		Value: &muc.Invitation{
			XMLName:  xml.Name{Space: muc.NSUser, Local: "x"},
			Thread:   "123",
			JID:      jid.MustParse("bridgecrew@muc.localhost"),
			Password: "NCC-1701-D",
			Reason:   "Senior officers to the bridge.",
		},
		XML:         `<x xmlns="http://jabber.org/protocol/muc#user"><invite to="bridgecrew@muc.localhost"><reason>Senior officers to the bridge.</reason></invite><password>NCC-1701-D</password></x>`,
		NoUnmarshal: true,
	},
	4: {
		Value: &muc.Invitation{
			XMLName:  xml.Name{Space: muc.NSUser, Local: "x"},
			JID:      jid.MustParse("bridgecrew@muc.localhost"),
			Continue: true,
		},
		XML: `<x xmlns="http://jabber.org/protocol/muc#user"><invite to="bridgecrew@muc.localhost"><continue></continue></invite></x>`,
	},

	5: {
		Value: &muc.Invitation{XMLName: xml.Name{Space: muc.NSConf, Local: "x"}},
		XML:   `<x xmlns="jabber:x:conference" jid=""></x>`,
	},
	6: {
		Value: &muc.Invitation{
			XMLName:  xml.Name{Space: muc.NSConf, Local: "x"},
			Continue: true,
			Thread:   "123",
			JID:      jid.MustParse("bridgecrew@muc.localhost"),
			Password: "NCC-1701-D",
			Reason:   "Senior officers to the bridge.",
		},
		XML: `<x xmlns="jabber:x:conference" jid="bridgecrew@muc.localhost" continue="true" thread="123" password="NCC-1701-D" reason="Senior officers to the bridge."></x>`,
	},
	7: {
		Value: &muc.Invitation{
			XMLName:  xml.Name{Space: muc.NSConf, Local: "x"},
			Thread:   "123",
			JID:      jid.MustParse("bridgecrew@muc.localhost"),
			Password: "NCC-1701-D",
			Reason:   "Senior officers to the bridge.",
		},
		XML:         `<x xmlns="jabber:x:conference" jid="bridgecrew@muc.localhost" password="NCC-1701-D" reason="Senior officers to the bridge."></x>`,
		NoUnmarshal: true,
	},
	8: {
		Value: &muc.Invitation{
			XMLName:  xml.Name{Space: muc.NSConf, Local: "x"},
			Continue: true,
			JID:      jid.MustParse("bridgecrew@muc.localhost"),
		},
		XML: `<x xmlns="jabber:x:conference" jid="bridgecrew@muc.localhost" continue="true"></x>`,
	},
}

func TestActions(t *testing.T) {
	xmpptest.RunEncodingTests(t, inviteEncodingTestCases)
}

A muc/muc.go => muc/muc.go +230 -0
@@ 0,0 1,230 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

// Package muc implements Multi-User Chat.
package muc // import "mellium.im/xmpp/muc"

import (
	"context"
	"encoding/xml"
	"sync"

	"mellium.im/xmlstream"
	"mellium.im/xmpp"
	"mellium.im/xmpp/form"
	"mellium.im/xmpp/internal/attr"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
)

// Various namespaces used by this package, provided as a convenience.
const (
	NS      = `http://jabber.org/protocol/muc`
	NSUser  = `http://jabber.org/protocol/muc#user`
	NSOwner = `http://jabber.org/protocol/muc#owner`

	// NSConf is the legacy conference namespace, now only used for direct MUC
	// invitations and backwards compatibility.
	NSConf = `jabber:x:conference`
)

// GetConfig requests a room config form.
func GetConfig(ctx context.Context, room jid.JID, s *xmpp.Session) (*form.Data, error) {
	return GetConfigIQ(ctx, stanza.IQ{
		To: room,
	}, s)
}

// GetConfigIQ is like GetConfig except that it lets you customize the IQ.
// Changing the type of the IQ has no effect.
func GetConfigIQ(ctx context.Context, iq stanza.IQ, s *xmpp.Session) (*form.Data, error) {
	if iq.Type != stanza.GetIQ {
		iq.Type = stanza.GetIQ
	}
	formResp := struct {
		XMLName  xml.Name  `xml:"http://jabber.org/protocol/muc#owner query"`
		DataForm form.Data `xml:"jabber:x:data x"`
	}{
		DataForm: form.Data{},
	}
	err := s.UnmarshalIQElement(ctx, xmlstream.Wrap(
		nil,
		xml.StartElement{Name: xml.Name{Space: NSOwner, Local: "query"}},
	), iq, &formResp)
	return &formResp.DataForm, err
}

// SetConfig sets the room config.
// The form should be the one provided by a call to GetConfig with various
// values set.
func SetConfig(ctx context.Context, room jid.JID, form *form.Data, s *xmpp.Session) error {
	return SetConfigIQ(ctx, stanza.IQ{
		To: room,
	}, form, s)
}

// SetConfigIQ is like SetConfig except that it lets you customize the IQ.
// Changing the type of the IQ has no effect.
func SetConfigIQ(ctx context.Context, iq stanza.IQ, form *form.Data, s *xmpp.Session) error {
	if iq.Type != stanza.SetIQ {
		iq.Type = stanza.SetIQ
	}
	submission, _ := form.Submit()
	r, err := s.SendIQElement(ctx, xmlstream.Wrap(
		submission,
		xml.StartElement{Name: xml.Name{Space: NSOwner, Local: "query"}},
	), iq)
	if err != nil {
		return err
	}
	return r.Close()
}

// HandleClient returns an option that registers the handler for use with a
// multiplexer.
func HandleClient(h *Client) mux.Option {
	return func(m *mux.ServeMux) {
		userPresence := xml.Name{Space: NSUser, Local: "x"}

		mux.Presence(stanza.AvailablePresence, userPresence, h)(m)
		mux.Presence(stanza.UnavailablePresence, userPresence, h)(m)
		mux.Message(stanza.NormalMessage, userPresence, h)(m)
	}
}

// Client is an xmpp.Handler that handles MUC payloads from a client
// perspective.
type Client struct {
	managed  map[string]*Channel
	managedM sync.Mutex

	// HandleInvite will be called if we receive a mediated MUC invitation.
	HandleInvite func(Invitation)
}

// HandleMessage satisfies mux.MessageHandler.
// it is used by the multiplexer and normally does not need to be called by the
// user.
func (c *Client) HandleMessage(p stanza.Message, r xmlstream.TokenReadEncoder) error {
	d := xml.NewTokenDecoder(r)
	msg := struct {
		stanza.Message
		X Invitation `xml:"http://jabber.org/protocol/muc#user x"`
	}{}
	err := d.Decode(&msg)
	if err != nil {
		return err
	}

	if msg.X.XMLName.Local != "" && c.HandleInvite != nil {
		c.HandleInvite(msg.X)
		return nil
	}
	return nil
}

// HandlePresence satisfies mux.PresenceHandler.
// it is used by the multiplexer and normally does not need to be called by the
// user.
func (c *Client) HandlePresence(p stanza.Presence, r xmlstream.TokenReadEncoder) error {
	// If this is a self-presence, check if we're joining or departing and send on
	// the channel.
	c.managedM.Lock()
	defer c.managedM.Unlock()
	channel, ok := c.managed[p.From.String()]
	// TODO: what do we do with presences that aren't managed?
	if !ok {
		return nil
	}

	switch p.Type {
	case stanza.AvailablePresence:
		channel.join <- p.From
	case stanza.UnavailablePresence:
		channel.depart <- struct{}{}
		delete(c.managed, channel.addr.String())
	}
	return nil
}

// Join a MUC on the provided session.
// Room should be a full JID in which the desired nickname is the resourcepart.
//
// Join blocks until the full room roster has been received.
func (c *Client) Join(ctx context.Context, room jid.JID, s *xmpp.Session, opt ...Option) (*Channel, error) {
	return c.JoinPresence(ctx, stanza.Presence{
		To: room,
	}, s, opt...)
}

// JoinPresence is like Join except that it gives you more control over the
// presence.
// Changing the presence type has no effect.
func (c *Client) JoinPresence(ctx context.Context, p stanza.Presence, s *xmpp.Session, opt ...Option) (*Channel, error) {
	if p.Type != "" {
		p.Type = ""
	}
	if p.ID == "" {
		p.ID = attr.RandomID()
	}

	c.managedM.Lock()

	channel := &Channel{
		addr:    p.To,
		client:  c,
		session: s,

		join:   make(chan jid.JID, 1),
		depart: make(chan struct{}),
	}
	if c.managed == nil {
		c.managed = make(map[string]*Channel)
	}
	c.managed[p.To.String()] = channel
	c.managedM.Unlock()

	conf := config{}
	for _, o := range opt {
		o(&conf)
	}
	channel.pass = conf.password

	errChan := make(chan error)
	go func(errChan chan<- error) {
		resp, err := s.SendPresenceElement(ctx, conf.TokenReader(), p)
		//err := s.Send(ctx, p.Wrap(conf.TokenReader()))
		if err != nil {
			errChan <- err
			return
		}
		/* #nosec */
		defer resp.Close()
		// Pop the start presence token.
		_, err = resp.Token()
		if err != nil {
			errChan <- err
			return
		}

		stanzaError, err := stanza.UnmarshalError(resp)
		if err != nil {
			errChan <- err
			return
		}
		errChan <- stanzaError
	}(errChan)

	select {
	case err := <-errChan:
		return nil, err
	case roomAddr := <-channel.join:
		channel.addr = roomAddr
	case <-ctx.Done():
		return nil, ctx.Err()
	}

	return channel, nil
}

A muc/muc_test.go => muc/muc_test.go +188 -0
@@ 0,0 1,188 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc_test

import (
	"context"
	"encoding/xml"
	"errors"
	"strings"
	"testing"

	"mellium.im/xmlstream"
	"mellium.im/xmpp/internal/xmpptest"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
)

func TestJoinPartMuc(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	h := &muc.Client{}
	m := mux.New(muc.HandleClient(h))
	s := xmpptest.NewClientServer(
		xmpptest.ClientHandler(m),
		xmpptest.ServerHandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error {
			// Send back a self presence, indicating that the join is complete.
			p, err := stanza.NewPresence(*start)
			if err != nil {
				return err
			}
			p.To, p.From = p.From, p.To
			_, err = xmlstream.Copy(t, p.Wrap(xmlstream.Wrap(
				nil,
				xml.StartElement{Name: xml.Name{Space: muc.NSUser, Local: "x"}},
			)))
			return err
		}),
	)

	channel, err := h.Join(context.Background(), j, s.Client)
	if err != nil {
		t.Fatalf("error joining: %v", err)
	}

	if !channel.Me().Equal(j) {
		t.Errorf("wrong JID: want=%v, got=%v", j, channel.Me())
	}
	if !channel.Addr().Equal(j.Bare()) {
		t.Errorf("wrong JID: want=%v, got=%v", j.Bare(), channel.Addr())
	}

	err = channel.Leave(context.Background(), "")
	if err != nil {
		t.Fatalf("error leaving: %v", err)
	}
	if channel.Joined() {
		t.Errorf("expected channel to be unjoined")
	}
}

func TestJoinError(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	h := &muc.Client{}
	m := mux.New(muc.HandleClient(h))
	s := xmpptest.NewClientServer(
		xmpptest.ClientHandler(m),
		xmpptest.ServerHandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error {
			// Send back an error indicating that we couldn't join.
			p, err := stanza.NewPresence(*start)
			if err != nil {
				return err
			}
			p.Type = stanza.ErrorPresence
			p.To, p.From = p.From, p.To
			se := stanza.Error{
				By:        p.To.Bare(),
				Type:      stanza.Modify,
				Condition: stanza.NotAcceptable,
			}
			_, err = xmlstream.Copy(t, p.Wrap(xmlstream.MultiReader(
				xmlstream.Wrap(
					nil,
					xml.StartElement{Name: xml.Name{Space: muc.NS, Local: "x"}},
				),
				se.TokenReader(),
			)))
			return err
		}),
	)

	_, err := h.Join(context.Background(), j, s.Client)
	if !errors.Is(err, stanza.Error{}) {
		t.Fatalf("expected a stanza error but got: %v", err)
	}
}

func TestPartError(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	h := &muc.Client{}
	m := mux.New(muc.HandleClient(h))
	errHotelCalifornia := stanza.Error{
		Type:      stanza.Auth,
		Condition: stanza.NotAllowed,
	}
	s := xmpptest.NewClientServer(
		xmpptest.ClientHandler(m),
		xmpptest.ServerHandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error {
			// Send back a self presence, indicating that the join is complete.
			p, err := stanza.NewPresence(*start)
			if err != nil {
				return err
			}
			p.To, p.From = p.From, p.To
			switch p.Type {
			case "":
				_, err = xmlstream.Copy(t, p.Wrap(xmlstream.Wrap(
					nil,
					xml.StartElement{Name: xml.Name{Space: muc.NSUser, Local: "x"}},
				)))
			case stanza.UnavailablePresence:
				p.Type = stanza.ErrorPresence
				_, err = xmlstream.Copy(t, p.Wrap(xmlstream.MultiReader(
					xmlstream.Wrap(
						nil,
						xml.StartElement{Name: xml.Name{Space: muc.NS, Local: "x"}},
					),
					errHotelCalifornia.TokenReader(),
				)))
			}
			return err
		}),
	)

	channel, err := h.Join(context.Background(), j, s.Client)
	if err != nil {
		t.Fatalf("error joining: %v", err)
	}

	err = channel.Leave(context.Background(), "")
	if !errors.Is(err, errHotelCalifornia) {
		t.Errorf("wrong error leaving: %v", err)
	}
	if channel.Joined() {
		t.Errorf("expected channel to be unjoined")
	}
}

func TestJoinCancel(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	s := xmpptest.NewClientServer()
	h := &muc.Client{}

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, err := h.Join(ctx, j, s.Client)
	if !errors.Is(err, context.Canceled) {
		t.Fatalf("wrong error: want=%v, got=%v", context.Canceled, err)
	}
}

func TestGetForm(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	const iqID = "1234"
	s := xmpptest.NewClientServer(
		xmpptest.ServerHandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error {
			reply := `<iq type='result' id='` + iqID + `' to='me@localhost/cHKubP5q' from='` + j.Bare().String() + `'><query xmlns='http://jabber.org/protocol/muc#owner'><x type='form' xmlns='jabber:x:data'><title>Configuration</title><instructions>Complete and submit this form to configure the room.</instructions><field var='FORM_TYPE' type='hidden'><value>http://jabber.org/protocol/muc#roomconfig</value></field></x></query></iq>`
			d := xml.NewDecoder(strings.NewReader(reply))
			_, err := xmlstream.Copy(t, d)
			return err
		}),
	)

	formData, err := muc.GetConfigIQ(context.Background(), stanza.IQ{
		ID: iqID,
		To: j.Bare(),
	}, s.Client)
	if err != nil {
		t.Fatalf("error fetching form: %v", err)
	}

	const expected = "Configuration"
	if title := formData.Title(); title != expected {
		t.Errorf("wrong title, form decode failed: want=%q, got=%q", expected, title)
	}
}

A muc/options.go => muc/options.go +186 -0
@@ 0,0 1,186 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc

import (
	"encoding/xml"
	"math"
	"strconv"
	"time"

	"mellium.im/xmlstream"
)

type historyConfig struct {
	maxStanzas *uint64
	maxChars   *uint64
	seconds    *uint64
	since      *string
}

func optionalString(s string, name xml.Name) xml.TokenReader {
	if s == "" {
		return nil
	}

	return xmlstream.Wrap(
		xmlstream.Token(xml.CharData(s)),
		xml.StartElement{Name: name},
	)
}

// TokenReader satisfies the xmlstream.Marshaler interface.
func (h historyConfig) TokenReader() xml.TokenReader {
	if h.maxStanzas == nil && h.maxChars == nil && h.seconds == nil && h.since == nil {
		return nil
	}

	attrs := make([]xml.Attr, 0, 4)
	if h.maxStanzas != nil {
		attrs = append(attrs, xml.Attr{
			Name:  xml.Name{Local: "maxstanzas"},
			Value: strconv.FormatUint(*h.maxStanzas, 10),
		})
	}
	if h.maxChars != nil {
		attrs = append(attrs, xml.Attr{
			Name:  xml.Name{Local: "maxchars"},
			Value: strconv.FormatUint(*h.maxChars, 10),
		})
	}
	if h.seconds != nil {
		attrs = append(attrs, xml.Attr{
			Name:  xml.Name{Local: "seconds"},
			Value: strconv.FormatUint(*h.seconds, 10),
		})
	}
	if h.since != nil {
		attrs = append(attrs, xml.Attr{
			Name:  xml.Name{Local: "since"},
			Value: *h.since,
		})
	}

	return xmlstream.Wrap(
		nil,
		xml.StartElement{Name: xml.Name{Local: "history"}, Attr: attrs},
	)
}

type config struct {
	history  historyConfig
	password string
}

// TokenReader satisfies the xmlstream.Marshaler interface.
func (c config) TokenReader() xml.TokenReader {
	return xmlstream.Wrap(
		xmlstream.MultiReader(
			c.history.TokenReader(),
			optionalString(c.password, xml.Name{Local: "password"}),
		),
		xml.StartElement{Name: xml.Name{Space: NS, Local: "x"}},
	)
}

// WriteXML satisfies the xmlstream.WriterTo interface.
// It is like MarshalXML except it writes tokens to w.
func (c config) WriteXML(w xmlstream.TokenWriter) (n int, err error) {
	return xmlstream.Copy(w, c.TokenReader())
}

// MarshalXML implements xml.Marshaler.
func (c config) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
	_, err := c.WriteXML(e)
	return err
}

// UnmarshalXML implements xml.Unmarshaler.
func (c *config) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
	iter := xmlstream.NewIter(d)
	for iter.Next() {
		start, r := iter.Current()
		switch start.Name.Local {
		case "history":
			for _, attr := range start.Attr {
				switch attr.Name.Local {
				case "maxchars":
					v, err := strconv.ParseUint(attr.Value, 10, 64)
					if err != nil {
						return err
					}
					c.history.maxChars = &v
				case "maxstanzas":
					v, err := strconv.ParseUint(attr.Value, 10, 64)
					if err != nil {
						return err
					}
					c.history.maxStanzas = &v
				case "seconds":
					v, err := strconv.ParseUint(attr.Value, 10, 64)
					if err != nil {
						return err
					}
					c.history.seconds = &v
				case "since":
					c.history.since = &attr.Value
				}
			}
		case "password":
			tok, err := r.Token()
			if err != nil {
				return nil
			}
			cdata, ok := tok.(xml.CharData)
			if ok {
				c.password = string(cdata)
			}
		}
	}
	return iter.Err()
}

// Option is used to configure joining a channel.
type Option func(*config)

// MaxHistory configures the maximum number of messages that will be sent to the
// client when joining the room.
func MaxHistory(messages uint64) Option {
	return func(c *config) {
		c.history.maxStanzas = &messages
	}
}

// MaxBytes configures the maximum number of bytes of XML that will be sent to
// the client when joining the room.
func MaxBytes(b uint64) Option {
	return func(c *config) {
		c.history.maxChars = &b
	}
}

// Duration configures the room to send history received within a window of
// time.
func Duration(d time.Duration) Option {
	return func(c *config) {
		s := uint64(math.Abs(math.Round(d.Seconds())))
		c.history.seconds = &s
	}
}

// Since configures the room to send history received since the provided time.
func Since(t time.Time) Option {
	return func(c *config) {
		s := t.UTC().Format(time.RFC3339Nano)
		c.history.since = &s
	}
}

// Password is used to join password protected rooms.
func Password(p string) Option {
	return func(c *config) {
		c.password = p
	}
}

A muc/options_test.go => muc/options_test.go +98 -0
@@ 0,0 1,98 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc

import (
	"testing"
	"time"

	"mellium.im/xmpp/internal/xmpptest"
)

var marshalTestCases = []xmpptest.EncodingTestCase{
	0: {
		Value: func() *config {
			c := &config{}
			MaxHistory(1)(c)
			MaxBytes(2)(c)
			Duration(3 * time.Second)(c)
			Since(time.Time{})(c)
			Password("test")(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="1" maxchars="2" seconds="3" since="0001-01-01T00:00:00Z"></history><password>test</password></x>`,
	},
	1: {
		Value: func() *config {
			c := &config{}
			MaxHistory(1)(c)
			MaxBytes(2)(c)
			Duration(3 * time.Second)(c)
			Since(time.Time{})(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="1" maxchars="2" seconds="3" since="0001-01-01T00:00:00Z"></history></x>`,
	},
	2: {
		Value: func() *config {
			c := &config{}
			Password("test")(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><password>test</password></x>`,
	},
	3: {
		Value: &config{},
		XML:   `<x xmlns="http://jabber.org/protocol/muc"></x>`,
	},
	4: {
		Value: func() *config {
			c := &config{}
			MaxBytes(2)(c)
			Duration(3 * time.Second)(c)
			Since(time.Time{})(c)
			Password("test")(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><history maxchars="2" seconds="3" since="0001-01-01T00:00:00Z"></history><password>test</password></x>`,
	},
	5: {
		Value: func() *config {
			c := &config{}
			MaxHistory(1)(c)
			Duration(3 * time.Second)(c)
			Since(time.Time{})(c)
			Password("test")(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="1" seconds="3" since="0001-01-01T00:00:00Z"></history><password>test</password></x>`,
	},
	6: {
		Value: func() *config {
			c := &config{}
			MaxHistory(1)(c)
			MaxBytes(2)(c)
			Since(time.Time{})(c)
			Password("test")(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="1" maxchars="2" since="0001-01-01T00:00:00Z"></history><password>test</password></x>`,
	},
	7: {
		Value: func() *config {
			c := &config{}
			MaxHistory(1)(c)
			MaxBytes(2)(c)
			Duration(3 * time.Second)(c)
			Password("test")(c)
			return c
		}(),
		XML: `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="1" maxchars="2" seconds="3"></history><password>test</password></x>`,
	},
}

func TestEncode(t *testing.T) {
	xmpptest.RunEncodingTests(t, marshalTestCases)
}

A muc/room.go => muc/room.go +124 -0
@@ 0,0 1,124 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc

import (
	"context"
	"encoding/xml"

	"mellium.im/xmlstream"
	"mellium.im/xmpp"
	"mellium.im/xmpp/internal/attr"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/stanza"
)

// Channel represents a group chat, conference, or chatroom.
//
// Channel aims to be as stateless as possible, so details such as the channel
// subject and participant list are not stored.
// Instead, it is up to the user to store this information and associate it with
// the channel (probably by mapping details to the channel address).
type Channel struct {
	addr    jid.JID
	pass    string
	client  *Client
	session *xmpp.Session

	join   chan jid.JID
	depart chan struct{}
}

// Addr returns the address of the channel.
func (c *Channel) Addr() jid.JID {
	return c.addr.Bare()
}

// Me returns the users last-known address in the channel.
func (c *Channel) Me() jid.JID {
	return c.addr
}

// Joined returns true if this room is still being managed by the service.
func (c *Channel) Joined() bool {
	c.client.managedM.Lock()
	defer c.client.managedM.Unlock()
	_, ok := c.client.managed[c.addr.Bare().String()]
	return ok
}

// Leave exits the MUC, causing Joined to begin to return false.
func (c *Channel) Leave(ctx context.Context, status string) error {
	return c.LeavePresence(ctx, status, stanza.Presence{})
}

// LeavePresence is like Leave except that it gives you more control over the
// presence.
// Changing the presence type or to attributes have no effect.
func (c *Channel) LeavePresence(ctx context.Context, status string, p stanza.Presence) error {
	if p.Type != stanza.UnavailablePresence {
		p.Type = stanza.UnavailablePresence
	}
	if !p.To.Equal(c.addr) {
		p.To = c.addr
	}
	if p.ID == "" {
		p.ID = attr.RandomID()
	}

	var inner xml.TokenReader
	if status != "" {
		inner = xmlstream.Wrap(
			xmlstream.Token(xml.CharData(status)),
			xml.StartElement{Name: xml.Name{Local: "status"}},
		)
	}
	errChan := make(chan error)
	go func(errChan chan<- error) {
		resp, err := c.session.SendPresenceElement(ctx, inner, p)
		//err := s.Send(ctx, p.Wrap(conf.TokenReader()))
		if err != nil {
			errChan <- err
			return
		}
		/* #nosec */
		defer resp.Close()
		// Pop the start presence token.
		_, err = resp.Token()
		if err != nil {
			errChan <- err
			return
		}
		stanzaError, err := stanza.UnmarshalError(resp)
		if err != nil {
			errChan <- err
			return
		}
		errChan <- stanzaError
	}(errChan)

	select {
	case err := <-errChan:
		return err
	case <-c.depart:
	}
	return nil
}

// Invite sends a mediated invitation (an invitation sent from the channel
// itself) to the user.
//
// For direct invitations sent from your own account (ie. to avoid users who
// block all unrecognized JIDs) see the Invite function.
func (c *Channel) Invite(ctx context.Context, reason string, to jid.JID) error {
	return c.session.Send(ctx, stanza.Message{
		To:   c.addr.Bare(),
		Type: stanza.NormalMessage,
	}.Wrap(Invitation{
		JID:      to,
		Password: c.pass,
		Reason:   reason,
	}.MarshalMediated()))
}

A muc/room_integration_test.go => muc/room_integration_test.go +110 -0
@@ 0,0 1,110 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

//go:build integration
// +build integration

package muc_test

import (
	"context"
	"crypto/tls"
	"testing"
	"time"

	"mellium.im/sasl"
	"mellium.im/xmpp"
	"mellium.im/xmpp/internal/integration"
	"mellium.im/xmpp/internal/integration/prosody"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
	"mellium.im/xmpp/mux"
)

const (
	userOne  = "foo@localhost"
	userTwo  = "bar@localhost"
	userPass = "Pass"
)

func TestIntegrationMediatedInvite(t *testing.T) {
	prosodyRun := prosody.Test(context.TODO(), t,
		integration.Log(),
		integration.LogXML(),
		prosody.MUC("muc.localhost"),
		prosody.CreateUser(context.TODO(), userOne, userPass),
		prosody.CreateUser(context.TODO(), userTwo, userPass),
		prosody.ListenC2S(),
	)
	prosodyRun(integrationMediatedInvite)
}

func integrationMediatedInvite(ctx context.Context, t *testing.T, cmd *integration.Cmd) {
	userOneJID := jid.MustParse(userOne)
	userTwoJID := jid.MustParse(userTwo)
	userOneSession, err := cmd.DialClient(ctx, userOneJID, t,
		xmpp.StartTLS(&tls.Config{
			InsecureSkipVerify: true,
		}),
		xmpp.SASL("", userPass, sasl.Plain),
		xmpp.BindResource(),
	)
	if err != nil {
		t.Fatalf("error connecting %s: %v", userOne, err)
	}
	userTwoSession, err := cmd.DialClient(ctx, userTwoJID, t,
		xmpp.StartTLS(&tls.Config{
			InsecureSkipVerify: true,
		}),
		xmpp.SASL("", userPass, sasl.Plain),
		xmpp.BindResource(),
	)
	if err != nil {
		t.Fatalf("error connecting %s: %v", userTwo, err)
	}

	mucClient := &muc.Client{}
	go func() {
		m := mux.New(muc.HandleClient(mucClient))
		err := userOneSession.Serve(m)
		if err != nil {
			t.Logf("error from %s serve: %v", userOne, err)
		}
	}()
	errChan := make(chan error)
	go func(errChan chan<- error) {
		inviteClient := &muc.Client{
			HandleInvite: func(i muc.Invitation) {
				errChan <- nil
			},
		}
		m := mux.New(muc.HandleClient(inviteClient))
		// TODO: implement server side of invites and test the handler here.
		err := userTwoSession.Serve(m)
		if err != nil {
			t.Logf("error from %s serve: %v", userTwo, err)
		}
	}(errChan)

	roomJID := jid.MustParse("bridgecrew@muc.localhost/Picard")
	channel, err := mucClient.Join(ctx, roomJID, userOneSession)
	if err != nil {
		t.Fatalf("error joining MUC: %v", err)
	}

	err = channel.Invite(ctx, "invited!", userTwoSession.LocalAddr())
	if err != nil {
		t.Fatalf("error sending invite: %v", err)
	}
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	select {
	case err := <-errChan:
		if err != nil {
			t.Fatalf("error receiving invite: %v", err)
		}
	case <-ctx.Done():
		t.Fatalf("invite not received: %v", ctx.Err())
	}
}

A muc/room_test.go => muc/room_test.go +89 -0
@@ 0,0 1,89 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc_test

import (
	"context"
	"encoding/xml"
	"testing"

	"mellium.im/xmlstream"
	"mellium.im/xmpp/internal/xmpptest"
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
)

func TestMediatedInvite(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	h := &muc.Client{}
	inviteChan := make(chan muc.Invitation, 1)
	m := mux.New(muc.HandleClient(h))
	server := mux.New(
		mux.PresenceFunc("", xml.Name{Local: "x"}, func(p stanza.Presence, r xmlstream.TokenReadEncoder) error {
			// Send back a self presence, indicating that the join is complete.
			p.To, p.From = p.From, p.To
			_, err := xmlstream.Copy(r, p.Wrap(xmlstream.Wrap(
				nil,
				xml.StartElement{Name: xml.Name{Space: muc.NSUser, Local: "x"}},
			)))
			return err
		}),
		mux.MessageFunc(stanza.NormalMessage, xml.Name{Local: "x"}, func(m stanza.Message, r xmlstream.TokenReadEncoder) error {
			d := xml.NewTokenDecoder(r)
			_, err := d.Token()
			if err != nil {
				close(inviteChan)
				return err
			}
			var invite muc.Invitation
			err = d.Decode(&invite)
			inviteChan <- invite
			return err
		}),
	)
	s := xmpptest.NewClientServer(
		xmpptest.ClientHandler(m),
		xmpptest.ServerHandler(server),
	)

	channel, err := h.Join(context.Background(), j, s.Client)
	if err != nil {
		t.Fatalf("error joining: %v", err)
	}

	const expectedReason = "reason"
	err = channel.Invite(context.Background(), expectedReason, s.Client.LocalAddr())
	if err != nil {
		t.Fatalf("error sending invite: %v", err)
	}
	invite := <-inviteChan

	if invite.Reason != expectedReason {
		t.Errorf("wrong reason: want=%v, got=%v", expectedReason, invite.Reason)
	}
}

func TestDirectInvite(t *testing.T) {
	inviteChan := make(chan muc.Invitation, 1)
	s := xmpptest.NewClientServer(
		xmpptest.ServerHandler(mux.New(
			muc.HandleInvite(func(invite muc.Invitation) {
				inviteChan <- invite
			}),
		)),
	)

	const expectedReason = "reason"
	muc.Invite(context.Background(), s.Server.LocalAddr(), muc.Invitation{
		Reason: expectedReason,
	}, s.Client)
	invite := <-inviteChan

	if invite.Reason != expectedReason {
		t.Errorf("wrong reason: want=%v, got=%v", expectedReason, invite.Reason)
	}
}

A muc/types.go => muc/types.go +120 -0
@@ 0,0 1,120 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

//go:generate go run -tags=tools golang.org/x/tools/cmd/stringer -type=Affiliation,Role,Privileges -linecomment

package muc

import (
	"encoding/xml"
	"errors"
)

// Affiliation indicates a users affiliation to the room.
type Affiliation uint8

// A list of room affiliations.
const (
	AffiliationNone Affiliation = iota // none

	// Support for the owner affiliation is required.
	AffiliationOwner // owner

	// Support for these affiliations is recommended, but optional.
	AffiliationAdmin   // admin
	AffiliationMember  // member
	AffiliationOutcast // outcast
)

// UnmarshalXMLAttr satisfies xml.UnmarshalerAttr.
func (a *Affiliation) UnmarshalXMLAttr(attr xml.Attr) error {
	switch attr.Value {
	case AffiliationNone.String():
		*a = AffiliationNone
	case AffiliationOwner.String():
		*a = AffiliationOwner
	case AffiliationAdmin.String():
		*a = AffiliationAdmin
	case AffiliationMember.String():
		*a = AffiliationMember
	case AffiliationOutcast.String():
		*a = AffiliationOutcast
	default:
		return errors.New("muc: unrecognized affiliation")
	}
	return nil
}

// MarshalXMLAttr satisfies xml.MarshalerAttr.
func (a *Affiliation) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
	return xml.Attr{Name: name, Value: a.String()}, nil
}

// Role indicates a users role in the room.
type Role uint8

// A list of user roles.
const (
	RoleNone Role = iota // none

	// Support for these roles is required.
	RoleModerator   // moderator
	RoleParticipant // participant

	// Support for these roles is recommended, but optional.
	RoleVisitor // visitor
)

// UnmarshalXMLAttr satisfies xml.UnmarshalerAttr.
func (r *Role) UnmarshalXMLAttr(attr xml.Attr) error {
	switch attr.Value {
	case RoleNone.String():
		*r = RoleNone
	case RoleModerator.String():
		*r = RoleModerator
	case RoleParticipant.String():
		*r = RoleParticipant
	case RoleVisitor.String():
		*r = RoleVisitor
	default:
		return errors.New("muc: unrecognized role")
	}
	return nil
}

// MarshalXMLAttr satisfies xml.MarshalerAttr.
func (r *Role) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
	if r == nil {
		return xml.Attr{}, nil
	}
	return xml.Attr{Name: name, Value: r.String()}, nil
}

// Privileges is a bit mask indicating the various privileges assigned to a room
// user.
type Privileges uint16

// A list of possible privileges.
const (
	PrivilegePresent            Privileges = 1 << iota // present
	PrivilegeReceiveMessages                           // receive-messages
	PrivilegeReceivePresence                           // receive-presence
	PrivilegeBroadcastPresence                         // broadcast-presence
	PrivilegeChangeAvailability                        // change-availability
	PrivilegeChangeNick                                // change-nick
	PrivilegePrivateMessage                            // send-private-message
	PrivilegeSendInvites                               // send-invites
	PrivilegeSendMessages                              // send-messages
	PrivilegeModifySubject                             // modify-subject
	PrivilegeKick                                      // kick
	PrivilegeGrantVoice                                // grant-voice
	PrivilegeRevokeVoice                               // revoke-voice

	// Common default privilages for each role.
	// These are just common defaults provided as a convenience, it is not
	// guaranteed that a user of a given role has this set of privileges.
	PrivilegesVisitor     = PrivilegePresent | PrivilegeReceiveMessages | PrivilegeReceivePresence | PrivilegeBroadcastPresence | PrivilegeChangeAvailability | PrivilegeChangeNick | PrivilegePrivateMessage | PrivilegeSendInvites
	PrivilegesParticipant = PrivilegesVisitor | PrivilegeSendMessages | PrivilegeModifySubject
	PrivilegesModerator   = PrivilegesParticipant | PrivilegeKick | PrivilegeGrantVoice | PrivilegeRevokeVoice
)

A muc/types_test.go => muc/types_test.go +18 -0
@@ 0,0 1,18 @@
// Copyright 2021 The Mellium Contributors.
// Use of this source code is governed by the BSD 2-clause
// license that can be found in the LICENSE file.

package muc_test

import (
	"encoding/xml"

	"mellium.im/xmpp/muc"
)

var (
	_ xml.MarshalerAttr   = (*muc.Role)(nil)
	_ xml.UnmarshalerAttr = (*muc.Role)(nil)
	_ xml.MarshalerAttr   = (*muc.Affiliation)(nil)
	_ xml.UnmarshalerAttr = (*muc.Affiliation)(nil)
)