~samwhited/xmpp

a3e4231bda43a7c8b0b9bfbe9a55b7fc516b4724 — Sam Whited 3 months ago f29caae
muc: add ability to set affiliation and handle changes

Signed-off-by: Sam Whited <sam@samwhited.com>
5 files changed, 276 insertions(+), 3 deletions(-)

M muc/muc.go
M muc/room.go
M muc/room_integration_test.go
M muc/room_test.go
M muc/types.go
M muc/muc.go => muc/muc.go +40 -2
@@ 24,6 24,7 @@ const (
	NS      = `http://jabber.org/protocol/muc`
	NSUser  = `http://jabber.org/protocol/muc#user`
	NSOwner = `http://jabber.org/protocol/muc#owner`
	NSAdmin = `http://jabber.org/protocol/muc#admin`

	// NSConf is the legacy conference namespace, now only used for direct MUC
	// invitations and backwards compatibility.


@@ 101,7 102,8 @@ type Client struct {
	managedM sync.Mutex

	// HandleInvite will be called if we receive a mediated MUC invitation.
	HandleInvite func(Invitation)
	HandleInvite       func(Invitation)
	HandleUserPresence func(stanza.Presence, Item)
}

// HandleMessage satisfies mux.MessageHandler.


@@ 125,6 127,26 @@ func (c *Client) HandleMessage(p stanza.Message, r xmlstream.TokenReadEncoder) e
	return nil
}

type mucPresence struct {
	stanza.Presence
	X struct {
		XMLName xml.Name
		Item    Item `xml:"item"`
		Status  []struct {
			Code int `xml:"code,attr"`
		} `xml:"status,omitempty"`
	} `xml:"x"`
}

func (p *mucPresence) HasStatus(code int) bool {
	for _, status := range p.X.Status {
		if status.Code == code {
			return true
		}
	}
	return false
}

// HandlePresence satisfies mux.PresenceHandler.
// it is used by the multiplexer and normally does not need to be called by the
// user.


@@ 138,10 160,26 @@ func (c *Client) HandlePresence(p stanza.Presence, r xmlstream.TokenReadEncoder)
	if !ok {
		return nil
	}
	d := xml.NewTokenDecoder(r)
	var decodedPresence mucPresence
	err := d.Decode(&decodedPresence)
	if err != nil {
		return err
	}

	switch p.Type {
	case stanza.AvailablePresence:
		channel.join <- p.From
		// TODO: make consts for the statuses when possible. Wait until we can
		// determine if they can be generated or have to be hand rolled first.
		// See: https://github.com/xsf/registrar/pull/38
		if decodedPresence.HasStatus(110) && channel.join != nil {
			channel.join <- p.From
			channel.join = nil
			return nil
		}
		if decodedPresence.X.XMLName.Space == NSUser && c.HandleUserPresence != nil {
			c.HandleUserPresence(decodedPresence.Presence, decodedPresence.X.Item)
		}
	case stanza.UnavailablePresence:
		channel.depart <- struct{}{}
		delete(c.managed, channel.addr.String())

M muc/room.go => muc/room.go +33 -0
@@ 122,3 122,36 @@ func (c *Channel) Invite(ctx context.Context, reason string, to jid.JID) error {
		Reason:   reason,
	}.MarshalMediated()))
}

// SetAffiliation changes the affiliation of the provided JID which should be
// the users real bare-JID (not their room JID).
func (c *Channel) SetAffiliation(ctx context.Context, a Affiliation, j jid.JID, nick, reason string) error {
	var reasonEl xml.TokenReader
	if reason != "" {
		reasonEl = xmlstream.Wrap(
			xmlstream.Token(xml.CharData(reason)),
			xml.StartElement{Name: xml.Name{Local: "reason"}},
		)
	}
	attr := []xml.Attr{
		{Name: xml.Name{Local: "affiliation"}, Value: a.String()},
		{Name: xml.Name{Local: "jid"}, Value: j.Bare().String()},
	}
	if nick != "" {
		attr = append(attr, xml.Attr{Name: xml.Name{Local: "nick"}, Value: nick})
	}
	payload := xmlstream.Wrap(
		xmlstream.Wrap(
			reasonEl,
			xml.StartElement{
				Name: xml.Name{Local: "item"},
				Attr: attr,
			},
		),
		xml.StartElement{Name: xml.Name{Space: NSAdmin, Local: "query"}},
	)
	return c.session.UnmarshalIQElement(ctx, payload, stanza.IQ{
		Type: stanza.SetIQ,
		To:   c.addr.Bare(),
	}, nil)
}

M muc/room_integration_test.go => muc/room_integration_test.go +106 -1
@@ 20,6 20,7 @@ import (
	"mellium.im/xmpp/jid"
	"mellium.im/xmpp/muc"
	"mellium.im/xmpp/mux"
	"mellium.im/xmpp/stanza"
)

const (


@@ 40,6 41,18 @@ func TestIntegrationMediatedInvite(t *testing.T) {
	prosodyRun(integrationMediatedInvite)
}

func TestIntegrationSetAffiliation(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(integrationSetAffiliation)
}

func integrationMediatedInvite(ctx context.Context, t *testing.T, cmd *integration.Cmd) {
	userOneJID := jid.MustParse(userOne)
	userTwoJID := jid.MustParse(userTwo)


@@ 80,7 93,6 @@ func integrationMediatedInvite(ctx context.Context, t *testing.T, cmd *integrati
			},
		}
		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)


@@ 108,3 120,96 @@ func integrationMediatedInvite(ctx context.Context, t *testing.T, cmd *integrati
		t.Fatalf("invite not received: %v", ctx.Err())
	}
}

func integrationSetAffiliation(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)
	}

	mucClientOne := &muc.Client{}
	go func() {
		m := mux.New(muc.HandleClient(mucClientOne))
		err := userOneSession.Serve(m)
		if err != nil {
			t.Logf("error from %s serve: %v", userOne, err)
		}
	}()

	itemChan := make(chan muc.Item)
	mucClientTwo := &muc.Client{
		HandleUserPresence: func(_ stanza.Presence, i muc.Item) {
			itemChan <- i
		},
	}
	go func(itemChan chan<- muc.Item) {
		m := mux.New(muc.HandleClient(mucClientTwo))
		err := userTwoSession.Serve(m)
		if err != nil {
			t.Logf("error from %s serve: %v", userTwo, err)
		}
	}(itemChan)

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

	// TODO: make a prosody option for creating and configuring a room.
	roomForm, err := muc.GetConfig(ctx, roomJID.Bare(), userOneSession)
	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, userOneSession)
	if err != nil {
		t.Fatalf("error setting room config: %v", err)
	}

	roomJIDTwo, err := roomJID.WithResource("CrusherMD")
	if err != nil {
		t.Fatalf("bad resource in test: %v", err)
	}
	_, err = mucClientTwo.Join(ctx, roomJIDTwo, userTwoSession)
	if err != nil {
		t.Fatalf("error joining MUC as %s: %v", roomJIDTwo.Resourcepart(), err)
	}

	err = channelOne.SetAffiliation(ctx, muc.AffiliationMember, userTwoJID, "Crusher", "Permission to speak freely")
	if err != nil {
		t.Fatalf("error setting affiliation: %v", err)
	}
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	select {
	case item := <-itemChan:
		if item.Affiliation != muc.AffiliationMember {
			t.Fatalf("wrong affiliation: want=%v, got=%v", muc.AffiliationMember, item.Affiliation)
		}
	case <-ctx.Done():
		t.Fatalf("invite not received: %v", ctx.Err())
	}
}

M muc/room_test.go => muc/room_test.go +84 -0
@@ 7,6 7,8 @@ package muc_test
import (
	"context"
	"encoding/xml"
	"strconv"
	"strings"
	"testing"

	"mellium.im/xmlstream"


@@ 87,3 89,85 @@ func TestDirectInvite(t *testing.T) {
		t.Errorf("wrong reason: want=%v, got=%v", expectedReason, invite.Reason)
	}
}

var affiliationTestCases = []struct {
	Affiliation muc.Affiliation `xml:"affiliation,attr"`
	JID         jid.JID         `xml:"jid,attr"`
	Nick        string          `xml:"nick,attr"`
	Reason      string          `xml:"reason"`
	x           string
}{
	0: {
		x: `<item xmlns="http://jabber.org/protocol/muc#admin" affiliation="none" jid=""></item>`,
	},
	1: {
		Affiliation: muc.AffiliationMember,
		Nick:        "nick",
		JID:         jid.MustParse("me@example.net/removethis"),
		Reason:      "reason",
		x:           `<item xmlns="http://jabber.org/protocol/muc#admin" affiliation="member" jid="me@example.net" nick="nick"><reason xmlns="http://jabber.org/protocol/muc#admin">reason</reason></item>`,
	},
	2: {
		Affiliation: muc.AffiliationMember,
		Nick:        "nick",
		JID:         jid.MustParse("me@example.net/removethis"),
		x:           `<item xmlns="http://jabber.org/protocol/muc#admin" affiliation="member" jid="me@example.net" nick="nick"></item>`,
	},
}

func TestSetAffiliation(t *testing.T) {
	j := jid.MustParse("room@example.net/me")
	h := &muc.Client{}
	handled := make(chan string, 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.IQFunc(stanza.SetIQ, xml.Name{Space: muc.NSAdmin, Local: "query"}, func(iq stanza.IQ, r xmlstream.TokenReadEncoder, _ *xml.StartElement) error {
			var buf strings.Builder
			defer func() {
				handled <- buf.String()
			}()
			e := xml.NewEncoder(&buf)
			_, err := xmlstream.Copy(e, xmlstream.Inner(r))
			if err != nil {
				return err
			}
			err = e.Flush()
			if err != nil {
				return err
			}
			_, err = xmlstream.Copy(r, iq.Result(nil))
			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)
	}

	for i, tc := range affiliationTestCases {
		t.Run(strconv.Itoa(i), func(t *testing.T) {
			err = channel.SetAffiliation(context.Background(), tc.Affiliation, tc.JID, tc.Nick, tc.Reason)
			if err != nil {
				t.Fatalf("error setting affiliation: %v", err)
			}
			x := <-handled
			if x != tc.x {
				t.Fatalf("wrong output:\nwant=%s,\n got=%s", tc.x, x)
			}
		})
	}
}

M muc/types.go => muc/types.go +13 -0
@@ 9,8 9,21 @@ package muc
import (
	"encoding/xml"
	"errors"

	"mellium.im/xmpp/jid"
)

// Item represents a user in the channel.
// Various fields will be set when a user joins the channel or when the channel
// informs us of upates to the users information.
type Item struct {
	JID         jid.JID     `xml:"jid,attr,omitempty"`
	Affiliation Affiliation `xml:"affiliation,attr,omitempty"`
	Nick        string      `xml:"nick,attr,omitempty"`
	Role        Role        `xml:"role,attr,omitempty"`
	Reason      string      `xml:"reason"`
}

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