Add constants (enumlike) for stanza types and simplify packet creation (#62)
* Add constants (enumlike) for stanza types * NewIQ, NewMessage and NewPresence are now initialized with the Attrs struct * Update examples * Do not export backoff code. For now, we do not need to expose backoff in the documentation * Make presence priority an int8
This commit is contained in:
parent
145fce6b3f
commit
d9fdff0839
|
@ -83,7 +83,7 @@ func discoInfo(c xmpp.Sender, p xmpp.Packet, opts xmpp.ComponentOptions) {
|
|||
return
|
||||
}
|
||||
|
||||
iqResp := xmpp.NewIQ("result", iq.To, iq.From, iq.Id, "en")
|
||||
iqResp := xmpp.NewIQ(xmpp.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id})
|
||||
|
||||
switch info.Node {
|
||||
case "":
|
||||
|
@ -192,7 +192,7 @@ func handleDelegation(s xmpp.Sender, p xmpp.Packet) {
|
|||
|
||||
if pubsub.Publish.XMLName.Local == "publish" {
|
||||
// Prepare pubsub IQ reply
|
||||
iqResp := xmpp.NewIQ("result", forwardedIQ.To, forwardedIQ.From, forwardedIQ.Id, "en")
|
||||
iqResp := xmpp.NewIQ(xmpp.Attrs{Type: "result", From: forwardedIQ.To, To: forwardedIQ.From, Id: forwardedIQ.Id})
|
||||
payload := xmpp.PubSub{
|
||||
XMLName: xml.Name{
|
||||
Space: "http://jabber.org/protocol/pubsub",
|
||||
|
@ -201,7 +201,7 @@ func handleDelegation(s xmpp.Sender, p xmpp.Packet) {
|
|||
}
|
||||
iqResp.Payload = &payload
|
||||
// Wrap the reply in delegation 'forward'
|
||||
iqForward := xmpp.NewIQ("result", iq.To, iq.From, iq.Id, "en")
|
||||
iqForward := xmpp.NewIQ(xmpp.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id})
|
||||
delegPayload := xmpp.Delegation{
|
||||
XMLName: xml.Name{
|
||||
Space: "urn:xmpp:delegation:1",
|
||||
|
|
|
@ -8,3 +8,5 @@ require (
|
|||
github.com/processone/soundcloud v1.0.0
|
||||
gosrc.io/xmpp v0.1.1-0.20190619120342-a6cbc0c08f52
|
||||
)
|
||||
|
||||
replace gosrc.io/xmpp => gosrc.io/xmpp v0.1.1-0.20190619153249-b1dde2330764
|
||||
|
|
|
@ -8,4 +8,7 @@ golang.org/x/net v0.0.0-20190110200230-915654e7eabc h1:Yx9JGxI1SBhVLFjpAkWMaO1TF
|
|||
golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522 h1:bhOzK9QyoD0ogCnFro1m2mz41+Ib0oOhfJnBp5MR4K4=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gosrc.io/xmpp v0.1.1-0.20190619120342-a6cbc0c08f52 h1:H5BezaFYvDL9r72ng90ICneftomo1iXx6+BxxZ9jBtg=
|
||||
gosrc.io/xmpp v0.1.1-0.20190619120342-a6cbc0c08f52/go.mod h1:WvSgrZF7lMvjd1SH8nVGi7ZGr6gNU7oUuBdwpFTs9nY=
|
||||
gosrc.io/xmpp v0.1.1-0.20190619153249-b1dde2330764 h1:jlYtpqdRoBC3Gke7MacXsVpSZL0g5nIBG/b9JVxpAVY=
|
||||
gosrc.io/xmpp v0.1.1-0.20190619153249-b1dde2330764/go.mod h1:WvSgrZF7lMvjd1SH8nVGi7ZGr6gNU7oUuBdwpFTs9nY=
|
||||
|
|
|
@ -59,7 +59,7 @@ func discoInfo(c xmpp.Sender, p xmpp.Packet, opts xmpp.ComponentOptions) {
|
|||
return
|
||||
}
|
||||
|
||||
iqResp := xmpp.NewIQ("result", iq.To, iq.From, iq.Id, "en")
|
||||
iqResp := xmpp.NewIQ(xmpp.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"})
|
||||
identity := xmpp.Identity{
|
||||
Name: opts.Name,
|
||||
Category: opts.Category,
|
||||
|
@ -95,7 +95,7 @@ func discoItems(c xmpp.Sender, p xmpp.Packet) {
|
|||
return
|
||||
}
|
||||
|
||||
iqResp := xmpp.NewIQ("result", iq.To, iq.From, iq.Id, "en")
|
||||
iqResp := xmpp.NewIQ(xmpp.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"})
|
||||
|
||||
var payload xmpp.DiscoItems
|
||||
if discoItems.Node == "" {
|
||||
|
@ -116,7 +116,7 @@ func handleVersion(c xmpp.Sender, p xmpp.Packet) {
|
|||
return
|
||||
}
|
||||
|
||||
iqResp := xmpp.NewIQ("result", iq.To, iq.From, iq.Id, "en")
|
||||
iqResp := xmpp.NewIQ(xmpp.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"})
|
||||
var payload xmpp.Version
|
||||
payload.Name = "Fluux XMPP Component"
|
||||
payload.Version = "0.0.1"
|
||||
|
|
|
@ -43,7 +43,7 @@ func handleMessage(s xmpp.Sender, p xmpp.Packet) {
|
|||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From)
|
||||
reply := xmpp.Message{PacketAttrs: xmpp.PacketAttrs{To: msg.From}, Body: msg.Body}
|
||||
reply := xmpp.Message{Attrs: xmpp.Attrs{To: msg.From}, Body: msg.Body}
|
||||
_ = s.Send(reply)
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ func main() {
|
|||
Address: *address,
|
||||
Jid: *jid,
|
||||
Password: *password,
|
||||
// PacketLogger: os.Stdout,
|
||||
Insecure: true,
|
||||
}
|
||||
|
||||
|
@ -91,7 +92,7 @@ func handleIQ(s xmpp.Sender, p xmpp.Packet, player *mpg123.Player) {
|
|||
playSCURL(player, url)
|
||||
setResponse := new(xmpp.ControlSetResponse)
|
||||
// FIXME: Broken
|
||||
reply := xmpp.IQ{PacketAttrs: xmpp.PacketAttrs{To: iq.From, Type: "result", Id: iq.Id}, Payload: setResponse}
|
||||
reply := xmpp.IQ{Attrs: xmpp.Attrs{To: iq.From, Type: "result", Id: iq.Id}, Payload: setResponse}
|
||||
_ = s.Send(reply)
|
||||
// TODO add Soundclound artist / title retrieval
|
||||
sendUserTune(s, "Radiohead", "Spectre")
|
||||
|
@ -102,7 +103,7 @@ func handleIQ(s xmpp.Sender, p xmpp.Packet, player *mpg123.Player) {
|
|||
|
||||
func sendUserTune(s xmpp.Sender, artist string, title string) {
|
||||
tune := xmpp.Tune{Artist: artist, Title: title}
|
||||
iq := xmpp.NewIQ("set", "", "", "usertune-1", "en")
|
||||
iq := xmpp.NewIQ(xmpp.Attrs{Type: "set", Id: "usertune-1", Lang: "en"})
|
||||
payload := xmpp.PubSub{Publish: &xmpp.Publish{Node: "http://jabber.org/protocol/tune", Item: xmpp.Item{Tune: &tune}}}
|
||||
iq.Payload = &payload
|
||||
_ = s.Send(iq)
|
||||
|
|
2
auth.go
2
auth.go
|
@ -33,7 +33,7 @@ func authPlain(socket io.ReadWriter, decoder *xml.Decoder, user string, password
|
|||
fmt.Fprintf(socket, "<auth xmlns='%s' mechanism='PLAIN'>%s</auth>", nsSASL, enc)
|
||||
|
||||
// Next message should be either success or failure.
|
||||
val, err := next(decoder)
|
||||
val, err := nextPacket(decoder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
28
backoff.go
28
backoff.go
|
@ -13,7 +13,7 @@ It can be used in several ways:
|
|||
- Using ticker channel to trigger callback function on tick
|
||||
|
||||
The functions for Backoff are not threadsafe, but you can:
|
||||
- Keep the attempt counter on your end and use DurationForAttempt(int)
|
||||
- Keep the attempt counter on your end and use durationForAttempt(int)
|
||||
- Use lock in your own code to protect the Backoff structure.
|
||||
|
||||
TODO: Implement Backoff Ticker channel
|
||||
|
@ -34,11 +34,11 @@ const (
|
|||
defaultCap int = 180000 // 3 minutes
|
||||
)
|
||||
|
||||
// Backoff can provide increasing duration with the number of attempt
|
||||
// backoff provides increasing duration with the number of attempt
|
||||
// performed. The structure is used to support exponential backoff on
|
||||
// connection attempts to avoid hammering the server we are connecting
|
||||
// to.
|
||||
type Backoff struct {
|
||||
type backoff struct {
|
||||
NoJitter bool
|
||||
Base int
|
||||
Factor int
|
||||
|
@ -47,20 +47,20 @@ type Backoff struct {
|
|||
attempt int
|
||||
}
|
||||
|
||||
// Duration returns the duration to apply to the current attempt.
|
||||
func (b *Backoff) Duration() time.Duration {
|
||||
d := b.DurationForAttempt(b.attempt)
|
||||
// duration returns the duration to apply to the current attempt.
|
||||
func (b *backoff) duration() time.Duration {
|
||||
d := b.durationForAttempt(b.attempt)
|
||||
b.attempt++
|
||||
return d
|
||||
}
|
||||
|
||||
// Wait sleeps for backoff duration for current attempt.
|
||||
func (b *Backoff) Wait() {
|
||||
time.Sleep(b.Duration())
|
||||
// wait sleeps for backoff duration for current attempt.
|
||||
func (b *backoff) wait() {
|
||||
time.Sleep(b.duration())
|
||||
}
|
||||
|
||||
// DurationForAttempt returns a duration for an attempt number, in a stateless way.
|
||||
func (b *Backoff) DurationForAttempt(attempt int) time.Duration {
|
||||
// durationForAttempt returns a duration for an attempt number, in a stateless way.
|
||||
func (b *backoff) durationForAttempt(attempt int) time.Duration {
|
||||
b.setDefault()
|
||||
expBackoff := math.Min(float64(b.Cap), float64(b.Base)*math.Pow(float64(b.Factor), float64(b.attempt)))
|
||||
d := int(math.Trunc(expBackoff))
|
||||
|
@ -70,13 +70,13 @@ func (b *Backoff) DurationForAttempt(attempt int) time.Duration {
|
|||
return time.Duration(d) * time.Millisecond
|
||||
}
|
||||
|
||||
// Reset sets back the number of attempts to 0. This is to be called after a successfull operation has been performed,
|
||||
// reset sets back the number of attempts to 0. This is to be called after a successful operation has been performed,
|
||||
// to reset the exponential backoff interval.
|
||||
func (b *Backoff) Reset() {
|
||||
func (b *backoff) reset() {
|
||||
b.attempt = 0
|
||||
}
|
||||
|
||||
func (b *Backoff) setDefault() {
|
||||
func (b *backoff) setDefault() {
|
||||
if b.Base == 0 {
|
||||
b.Base = defaultBase
|
||||
}
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
package xmpp_test
|
||||
package xmpp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gosrc.io/xmpp"
|
||||
)
|
||||
|
||||
func TestDurationForAttempt_NoJitter(t *testing.T) {
|
||||
b := xmpp.Backoff{Base: 25, NoJitter: true}
|
||||
b := backoff{Base: 25, NoJitter: true}
|
||||
bInMS := time.Duration(b.Base) * time.Millisecond
|
||||
if b.DurationForAttempt(0) != bInMS {
|
||||
t.Errorf("incorrect default duration for attempt #0 (%d) = %d", b.DurationForAttempt(0)/time.Millisecond, bInMS/time.Millisecond)
|
||||
if b.durationForAttempt(0) != bInMS {
|
||||
t.Errorf("incorrect default duration for attempt #0 (%d) = %d", b.durationForAttempt(0)/time.Millisecond, bInMS/time.Millisecond)
|
||||
}
|
||||
var prevDuration, d time.Duration
|
||||
for i := 0; i < 10; i++ {
|
||||
d = b.DurationForAttempt(i)
|
||||
d = b.durationForAttempt(i)
|
||||
if !(d >= prevDuration) {
|
||||
t.Errorf("duration should be increasing between attempts. #%d (%d) > %d", i, d, prevDuration)
|
||||
}
|
||||
|
|
|
@ -54,14 +54,14 @@ func (c *ServerCheck) Check() error {
|
|||
}
|
||||
|
||||
// Set xml decoder and extract streamID from reply (not used for now)
|
||||
_, err = initDecoder(decoder)
|
||||
_, err = initStream(decoder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// extract stream features
|
||||
var f StreamFeatures
|
||||
packet, err := next(decoder)
|
||||
packet, err := nextPacket(decoder)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("stream open decode features: %s", err)
|
||||
return err
|
||||
|
|
|
@ -200,7 +200,7 @@ func (c *Client) SendRaw(packet string) error {
|
|||
// Loop: Receive data from server
|
||||
func (c *Client) recv(keepaliveQuit chan<- struct{}) (err error) {
|
||||
for {
|
||||
val, err := next(c.Session.decoder)
|
||||
val, err := nextPacket(c.Session.decoder)
|
||||
if err != nil {
|
||||
close(keepaliveQuit)
|
||||
c.updateState(StateDisconnected)
|
||||
|
|
|
@ -78,7 +78,7 @@ func (c *Component) Connect() error {
|
|||
c.decoder = xml.NewDecoder(conn)
|
||||
|
||||
// 2. Initialize xml decoder and extract streamID from reply
|
||||
streamId, err := initDecoder(c.decoder)
|
||||
streamId, err := initStream(c.decoder)
|
||||
if err != nil {
|
||||
return errors.New("cannot init decoder " + err.Error())
|
||||
}
|
||||
|
@ -89,7 +89,7 @@ func (c *Component) Connect() error {
|
|||
}
|
||||
|
||||
// 4. Check server response for authentication
|
||||
val, err := next(c.decoder)
|
||||
val, err := nextPacket(c.decoder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -119,7 +119,7 @@ func (c *Component) SetHandler(handler EventHandler) {
|
|||
// Receiver Go routine receiver
|
||||
func (c *Component) recv() (err error) {
|
||||
for {
|
||||
val, err := next(c.decoder)
|
||||
val, err := nextPacket(c.decoder)
|
||||
if err != nil {
|
||||
c.updateState(StateDisconnected)
|
||||
return err
|
||||
|
|
16
doc.go
16
doc.go
|
@ -1,13 +1,23 @@
|
|||
/*
|
||||
Fluux XMPP is a Go XMPP library, focusing on simplicity, simple automation, and IoT.
|
||||
Fluux XMPP is an modern and full-featured XMPP library that can be used to build clients or
|
||||
server components.
|
||||
|
||||
The goal is to make simple to write simple adhoc XMPP clients:
|
||||
The goal is to make simple to write modern compliant XMPP software:
|
||||
|
||||
- For automation (like for example monitoring of an XMPP service),
|
||||
- For building connected "things" by plugging them on an XMPP server,
|
||||
- For writing simple chatbots to control a service or a thing.
|
||||
- For writing XMPP servers components. Fluux XMPP supports:
|
||||
- XEP-0114: Jabber Component Protocol
|
||||
- XEP-0355: Namespace Delegation
|
||||
- XEP-0356: Privileged Entity
|
||||
|
||||
Fluux XMPP can be used to build XMPP clients or XMPP components.
|
||||
The library is designed to have minimal dependencies. For now, the library does not depend on any other library.
|
||||
|
||||
The library includes a StreamManager that provides features like autoreconnect exponential back-off.
|
||||
|
||||
The library is implementing latest versions of the XMPP specifications (RFC 6120 and RFC 6121), and includes
|
||||
support for many extensions.
|
||||
|
||||
Clients
|
||||
|
||||
|
|
118
error.go
Normal file
118
error.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
package xmpp
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
/*
|
||||
TODO support ability to put Raw payload inside IQ
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// XMPP Errors
|
||||
|
||||
// Err is an XMPP stanza payload that is used to report error on message,
|
||||
// presence or iq stanza.
|
||||
// It is intended to be added in the payload of the erroneous stanza.
|
||||
type Err struct {
|
||||
XMLName xml.Name `xml:"error"`
|
||||
Code int `xml:"code,attr,omitempty"`
|
||||
Type ErrorType `xml:"type,attr"` // required
|
||||
Reason string
|
||||
Text string `xml:"urn:ietf:params:xml:ns:xmpp-stanzas text,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Err) Namespace() string {
|
||||
return x.XMLName.Space
|
||||
}
|
||||
|
||||
// UnmarshalXML implements custom parsing for IQs
|
||||
func (x *Err) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
x.XMLName = start.Name
|
||||
|
||||
// Extract attributes
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local == "type" {
|
||||
x.Type = ErrorType(attr.Value)
|
||||
}
|
||||
if attr.Name.Local == "code" {
|
||||
if code, err := strconv.Atoi(attr.Value); err == nil {
|
||||
x.Code = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check subelements to extract error text and reason (from local namespace).
|
||||
for {
|
||||
t, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch tt := t.(type) {
|
||||
|
||||
case xml.StartElement:
|
||||
elt := new(Node)
|
||||
|
||||
err = d.DecodeElement(elt, &tt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
textName := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"}
|
||||
if elt.XMLName == textName {
|
||||
x.Text = string(elt.Content)
|
||||
} else if elt.XMLName.Space == "urn:ietf:params:xml:ns:xmpp-stanzas" {
|
||||
x.Reason = elt.XMLName.Local
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
if tt == start.End() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x Err) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
|
||||
if x.Code == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode start element and attributes
|
||||
start.Name = xml.Name{Local: "error"}
|
||||
|
||||
code := xml.Attr{
|
||||
Name: xml.Name{Local: "code"},
|
||||
Value: strconv.Itoa(x.Code),
|
||||
}
|
||||
start.Attr = append(start.Attr, code)
|
||||
|
||||
if len(x.Type) > 0 {
|
||||
typ := xml.Attr{
|
||||
Name: xml.Name{Local: "type"},
|
||||
Value: string(x.Type),
|
||||
}
|
||||
start.Attr = append(start.Attr, typ)
|
||||
}
|
||||
err = e.EncodeToken(start)
|
||||
|
||||
// SubTags
|
||||
// Reason
|
||||
if x.Reason != "" {
|
||||
reason := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: x.Reason}
|
||||
e.EncodeToken(xml.StartElement{Name: reason})
|
||||
e.EncodeToken(xml.EndElement{Name: reason})
|
||||
}
|
||||
|
||||
// Text
|
||||
if x.Text != "" {
|
||||
text := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"}
|
||||
e.EncodeToken(xml.StartElement{Name: text})
|
||||
e.EncodeToken(xml.CharData(x.Text))
|
||||
e.EncodeToken(xml.EndElement{Name: text})
|
||||
}
|
||||
|
||||
return e.EncodeToken(xml.EndElement{Name: start.Name})
|
||||
}
|
13
error_enum.go
Normal file
13
error_enum.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package xmpp
|
||||
|
||||
// ErrorType is a Enum of error attribute type
|
||||
type ErrorType string
|
||||
|
||||
// RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace
|
||||
const (
|
||||
ErrorTypeAuth ErrorType = "auth"
|
||||
ErrorTypeCancel ErrorType = "cancel"
|
||||
ErrorTypeContinue ErrorType = "continue"
|
||||
ErrorTypeModify ErrorType = "motify"
|
||||
ErrorTypeWait ErrorType = "wait"
|
||||
)
|
141
iq.go
141
iq.go
|
@ -3,127 +3,20 @@ package xmpp
|
|||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
/*
|
||||
TODO support ability to put Raw payload inside IQ
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// XMPP Errors
|
||||
|
||||
// Err is an XMPP stanza payload that is used to report error on message,
|
||||
// presence or iq stanza.
|
||||
// It is intended to be added in the payload of the erroneous stanza.
|
||||
type Err struct {
|
||||
XMLName xml.Name `xml:"error"`
|
||||
Code int `xml:"code,attr,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Reason string
|
||||
Text string `xml:"urn:ietf:params:xml:ns:xmpp-stanzas text,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Err) Namespace() string {
|
||||
return x.XMLName.Space
|
||||
}
|
||||
|
||||
// UnmarshalXML implements custom parsing for IQs
|
||||
func (x *Err) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
x.XMLName = start.Name
|
||||
|
||||
// Extract attributes
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local == "type" {
|
||||
x.Type = attr.Value
|
||||
}
|
||||
if attr.Name.Local == "code" {
|
||||
if code, err := strconv.Atoi(attr.Value); err == nil {
|
||||
x.Code = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check subelements to extract error text and reason (from local namespace).
|
||||
for {
|
||||
t, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch tt := t.(type) {
|
||||
|
||||
case xml.StartElement:
|
||||
elt := new(Node)
|
||||
|
||||
err = d.DecodeElement(elt, &tt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
textName := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"}
|
||||
if elt.XMLName == textName {
|
||||
x.Text = string(elt.Content)
|
||||
} else if elt.XMLName.Space == "urn:ietf:params:xml:ns:xmpp-stanzas" {
|
||||
x.Reason = elt.XMLName.Local
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
if tt == start.End() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x Err) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
|
||||
if x.Code == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode start element and attributes
|
||||
start.Name = xml.Name{Local: "error"}
|
||||
|
||||
code := xml.Attr{
|
||||
Name: xml.Name{Local: "code"},
|
||||
Value: strconv.Itoa(x.Code),
|
||||
}
|
||||
start.Attr = append(start.Attr, code)
|
||||
|
||||
if len(x.Type) > 0 {
|
||||
typ := xml.Attr{
|
||||
Name: xml.Name{Local: "type"},
|
||||
Value: x.Type,
|
||||
}
|
||||
start.Attr = append(start.Attr, typ)
|
||||
}
|
||||
err = e.EncodeToken(start)
|
||||
|
||||
// SubTags
|
||||
// Reason
|
||||
if x.Reason != "" {
|
||||
reason := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: x.Reason}
|
||||
e.EncodeToken(xml.StartElement{Name: reason})
|
||||
e.EncodeToken(xml.EndElement{Name: reason})
|
||||
}
|
||||
|
||||
// Text
|
||||
if x.Text != "" {
|
||||
text := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"}
|
||||
e.EncodeToken(xml.StartElement{Name: text})
|
||||
e.EncodeToken(xml.CharData(x.Text))
|
||||
e.EncodeToken(xml.EndElement{Name: text})
|
||||
}
|
||||
|
||||
return e.EncodeToken(xml.EndElement{Name: start.Name})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IQ Packet
|
||||
|
||||
// IQ implements RFC 6120 - A.5 Client Namespace (a part)
|
||||
type IQ struct { // Info/Query
|
||||
XMLName xml.Name `xml:"iq"`
|
||||
PacketAttrs
|
||||
// MUST have a ID
|
||||
Attrs
|
||||
// We can only have one payload on IQ:
|
||||
// "An IQ stanza of type "get" or "set" MUST contain exactly one
|
||||
// child element, which specifies the semantics of the particular
|
||||
|
@ -133,16 +26,16 @@ type IQ struct { // Info/Query
|
|||
RawXML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
func NewIQ(iqtype, from, to, id, lang string) IQ {
|
||||
type IQPayload interface {
|
||||
Namespace() string
|
||||
}
|
||||
|
||||
func NewIQ(a Attrs) IQ {
|
||||
// TODO generate IQ ID if not set
|
||||
// TODO ensure that type is set, as it is required
|
||||
return IQ{
|
||||
XMLName: xml.Name{Local: "iq"},
|
||||
PacketAttrs: PacketAttrs{
|
||||
Id: id,
|
||||
From: from,
|
||||
To: to,
|
||||
Type: iqtype,
|
||||
Lang: lang,
|
||||
},
|
||||
Attrs: a,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -182,7 +75,7 @@ func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|||
iq.Id = attr.Value
|
||||
}
|
||||
if attr.Name.Local == "type" {
|
||||
iq.Type = attr.Value
|
||||
iq.Type = StanzaType(attr.Value)
|
||||
}
|
||||
if attr.Name.Local == "to" {
|
||||
iq.To = attr.Value
|
||||
|
@ -190,9 +83,6 @@ func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|||
if attr.Name.Local == "from" {
|
||||
iq.From = attr.Value
|
||||
}
|
||||
if attr.Name.Local == "lang" {
|
||||
iq.Lang = attr.Value
|
||||
}
|
||||
}
|
||||
|
||||
// decode inner elements
|
||||
|
@ -223,6 +113,7 @@ func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|||
iq.Payload = iqExt
|
||||
continue
|
||||
}
|
||||
// TODO: If unknown decode as generic node
|
||||
return fmt.Errorf("unexpected element in iq: %s %s", tt.Name.Space, tt.Name.Local)
|
||||
case xml.EndElement:
|
||||
if tt == start.End() {
|
||||
|
@ -233,11 +124,7 @@ func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// Generic IQ Payload
|
||||
|
||||
type IQPayload interface {
|
||||
Namespace() string
|
||||
}
|
||||
// Generic / unknown content
|
||||
|
||||
// Node is a generic structure to represent XML data. It is used to parse
|
||||
// unreferenced or custom stanza payload.
|
||||
|
|
|
@ -16,7 +16,7 @@ func TestUnmarshalIqs(t *testing.T) {
|
|||
parsedIQ xmpp.IQ
|
||||
}{
|
||||
{"<iq id=\"1\" type=\"set\" to=\"test@localhost\"/>",
|
||||
xmpp.IQ{XMLName: xml.Name{Space: "", Local: "iq"}, PacketAttrs: xmpp.PacketAttrs{To: "test@localhost", Type: "set", Id: "1"}}},
|
||||
xmpp.IQ{XMLName: xml.Name{Local: "iq"}, Attrs: xmpp.Attrs{Type: xmpp.IQTypeSet, To: "test@localhost", Id: "1"}}},
|
||||
//{"<iq xmlns=\"jabber:client\" id=\"2\" type=\"set\" to=\"test@localhost\" from=\"server\"><set xmlns=\"urn:xmpp:iot:control\"/></iq>", IQ{XMLName: xml.Name{Space: "jabber:client", Local: "iq"}, PacketAttrs: PacketAttrs{To: "test@localhost", From: "server", Type: "set", Id: "2"}, Payload: cs1}},
|
||||
}
|
||||
|
||||
|
@ -35,7 +35,7 @@ func TestUnmarshalIqs(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateIq(t *testing.T) {
|
||||
iq := xmpp.NewIQ("result", "admin@localhost", "test@localhost", "1", "en")
|
||||
iq := xmpp.NewIQ(xmpp.Attrs{Type: xmpp.IQTypeResult, From: "admin@localhost", To: "test@localhost", Id: "1"})
|
||||
payload := xmpp.DiscoInfo{
|
||||
Identity: xmpp.Identity{
|
||||
Name: "Test Gateway",
|
||||
|
@ -93,7 +93,7 @@ func TestErrorTag(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDiscoItems(t *testing.T) {
|
||||
iq := xmpp.NewIQ("get", "romeo@montague.net/orchard", "catalog.shakespeare.lit", "items3", "en")
|
||||
iq := xmpp.NewIQ(xmpp.Attrs{Type: xmpp.IQTypeGet, From: "romeo@montague.net/orchard", To: "catalog.shakespeare.lit", Id: "items3"})
|
||||
payload := xmpp.DiscoItems{
|
||||
Node: "music",
|
||||
}
|
||||
|
|
16
message.go
16
message.go
|
@ -7,9 +7,11 @@ import (
|
|||
// ============================================================================
|
||||
// Message Packet
|
||||
|
||||
// Message implements RFC 6120 - A.5 Client Namespace (a part)
|
||||
type Message struct {
|
||||
XMLName xml.Name `xml:"message"`
|
||||
PacketAttrs
|
||||
Attrs
|
||||
|
||||
Subject string `xml:"subject,omitempty"`
|
||||
Body string `xml:"body,omitempty"`
|
||||
Thread string `xml:"thread,omitempty"`
|
||||
|
@ -21,16 +23,10 @@ func (Message) Name() string {
|
|||
return "message"
|
||||
}
|
||||
|
||||
func NewMessage(msgtype, from, to, id, lang string) Message {
|
||||
func NewMessage(a Attrs) Message {
|
||||
return Message{
|
||||
XMLName: xml.Name{Local: "message"},
|
||||
PacketAttrs: PacketAttrs{
|
||||
Id: id,
|
||||
From: from,
|
||||
To: to,
|
||||
Type: msgtype,
|
||||
Lang: lang,
|
||||
},
|
||||
Attrs: a,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,7 +59,7 @@ func (msg *Message) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|||
msg.Id = attr.Value
|
||||
}
|
||||
if attr.Name.Local == "type" {
|
||||
msg.Type = attr.Value
|
||||
msg.Type = StanzaType(attr.Value)
|
||||
}
|
||||
if attr.Name.Local == "to" {
|
||||
msg.To = attr.Value
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
)
|
||||
|
||||
func TestGenerateMessage(t *testing.T) {
|
||||
message := xmpp.NewMessage("chat", "admin@localhost", "test@localhost", "1", "en")
|
||||
message := xmpp.NewMessage(xmpp.Attrs{Type: xmpp.MessageTypeChat, From: "admin@localhost", To: "test@localhost", Id: "1"})
|
||||
message.Body = "Hi"
|
||||
message.Subject = "Msg Subject"
|
||||
|
||||
|
|
|
@ -4,12 +4,12 @@ type Packet interface {
|
|||
Name() string
|
||||
}
|
||||
|
||||
// PacketAttrs represents the common structure for base XMPP packets.
|
||||
type PacketAttrs struct {
|
||||
// Attrs represents the common structure for base XMPP packets.
|
||||
type Attrs struct {
|
||||
Type StanzaType `xml:"type,attr,omitempty"`
|
||||
Id string `xml:"id,attr,omitempty"`
|
||||
From string `xml:"from,attr,omitempty"`
|
||||
To string `xml:"to,attr,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Lang string `xml:"lang,attr,omitempty"`
|
||||
}
|
||||
|
||||
|
|
25
packet_enum.go
Normal file
25
packet_enum.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package xmpp
|
||||
|
||||
type StanzaType string
|
||||
|
||||
// RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace
|
||||
const (
|
||||
IQTypeError StanzaType = "error"
|
||||
IQTypeGet StanzaType = "get"
|
||||
IQTypeResult StanzaType = "result"
|
||||
IQTypeSet StanzaType = "set"
|
||||
|
||||
MessageTypeChat StanzaType = "chat"
|
||||
MessageTypeError StanzaType = "error"
|
||||
MessageTypeGroupchat StanzaType = "groupchat"
|
||||
MessageTypeHeadline StanzaType = "headline"
|
||||
MessageTypeNormal StanzaType = "normal" // Default
|
||||
|
||||
PresenceTypeError StanzaType = "error"
|
||||
PresenceTypeProbe StanzaType = "probe"
|
||||
PresenceTypeSubscribe StanzaType = "subscribe"
|
||||
PresenceTypeSubscribed StanzaType = "subscribed"
|
||||
PresenceTypeUnavailable StanzaType = "unavailable"
|
||||
PresenceTypeUnsubscribe StanzaType = "unsubscribe"
|
||||
PresenceTypeUnsubscribed StanzaType = "unsubscribed"
|
||||
)
|
30
parser.go
30
parser.go
|
@ -14,29 +14,29 @@ import (
|
|||
// reattach features (allowing to resume an existing stream at the point the connection was interrupted, without
|
||||
// getting through the authentication process.
|
||||
// TODO We should handle stream error from XEP-0114 ( <conflict/> or <host-unknown/> )
|
||||
func initDecoder(p *xml.Decoder) (sessionID string, err error) {
|
||||
func initStream(p *xml.Decoder) (sessionID string, err error) {
|
||||
for {
|
||||
var t xml.Token
|
||||
t, err = p.Token()
|
||||
if err != nil {
|
||||
return
|
||||
return sessionID, err
|
||||
}
|
||||
|
||||
switch elem := t.(type) {
|
||||
case xml.StartElement:
|
||||
if elem.Name.Space != NSStream || elem.Name.Local != "stream" {
|
||||
err = errors.New("xmpp: expected <stream> but got <" + elem.Name.Local + "> in " + elem.Name.Space)
|
||||
return
|
||||
return sessionID, err
|
||||
}
|
||||
|
||||
// Parse Stream attributes
|
||||
// Parse XMPP stream attributes
|
||||
for _, attrs := range elem.Attr {
|
||||
switch attrs.Name.Local {
|
||||
case "id":
|
||||
sessionID = attrs.Value
|
||||
}
|
||||
}
|
||||
return
|
||||
return sessionID, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -58,10 +58,12 @@ func nextStart(p *xml.Decoder) (xml.StartElement, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// next scans XML token stream for next element and then assign a structure to decode
|
||||
// that elements.
|
||||
// nextPacket scans XML token stream for next complete XMPP stanza.
|
||||
// Once the type of stanza has been identified, a structure is created to decode
|
||||
// that stanza and returned.
|
||||
// TODO Use an interface to return packets interface xmppDecoder
|
||||
func next(p *xml.Decoder) (Packet, error) {
|
||||
// TODO make auth and bind use nextPacket instead of directly nextStart
|
||||
func nextPacket(p *xml.Decoder) (Packet, error) {
|
||||
// Read start element to find out how we want to parse the XMPP packet
|
||||
se, err := nextStart(p)
|
||||
if err != nil {
|
||||
|
@ -84,6 +86,13 @@ func next(p *xml.Decoder) (Packet, error) {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: From all the decoder, we can return a pointer to the actual concrete type, instead of directly that
|
||||
type.
|
||||
That way, we have a consistent way to do type assertion, always matching against pointers.
|
||||
*/
|
||||
|
||||
// decodeStream will fully decode a stream packet
|
||||
func decodeStream(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
||||
switch se.Name.Local {
|
||||
case "error":
|
||||
|
@ -96,6 +105,7 @@ func decodeStream(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// decodeSASL decodes a packet related to SASL authentication.
|
||||
func decodeSASL(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
||||
switch se.Name.Local {
|
||||
case "success":
|
||||
|
@ -108,6 +118,7 @@ func decodeSASL(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// decodeClient decodes all known packets in the client namespace.
|
||||
func decodeClient(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
||||
switch se.Name.Local {
|
||||
case "message":
|
||||
|
@ -122,9 +133,10 @@ func decodeClient(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// decodeClient decodes all known packets in the component namespace.
|
||||
func decodeComponent(p *xml.Decoder, se xml.StartElement) (Packet, error) {
|
||||
switch se.Name.Local {
|
||||
case "handshake":
|
||||
case "handshake": // handshake is used to authenticate components
|
||||
return handshake.decode(p, se)
|
||||
case "message":
|
||||
return message.decode(p, se)
|
||||
|
|
16
presence.go
16
presence.go
|
@ -5,12 +5,13 @@ import "encoding/xml"
|
|||
// ============================================================================
|
||||
// Presence Packet
|
||||
|
||||
// Presence implements RFC 6120 - A.5 Client Namespace (a part)
|
||||
type Presence struct {
|
||||
XMLName xml.Name `xml:"presence"`
|
||||
PacketAttrs
|
||||
Show string `xml:"show,omitempty"` // away, chat, dnd, xa
|
||||
Attrs
|
||||
Show PresenceShow `xml:"show,omitempty"`
|
||||
Status string `xml:"status,omitempty"`
|
||||
Priority int `xml:"priority,omitempty"`
|
||||
Priority int8 `xml:"priority,omitempty"` // default: 0
|
||||
Error Err `xml:"error,omitempty"`
|
||||
}
|
||||
|
||||
|
@ -18,15 +19,10 @@ func (Presence) Name() string {
|
|||
return "presence"
|
||||
}
|
||||
|
||||
func NewPresence(from, to, id, lang string) Presence {
|
||||
func NewPresence(a Attrs) Presence {
|
||||
return Presence{
|
||||
XMLName: xml.Name{Local: "presence"},
|
||||
PacketAttrs: PacketAttrs{
|
||||
Id: id,
|
||||
From: from,
|
||||
To: to,
|
||||
Lang: lang,
|
||||
},
|
||||
Attrs: a,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
12
presence_enum.go
Normal file
12
presence_enum.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package xmpp
|
||||
|
||||
// PresenceShow is a Enum of presence element show
|
||||
type PresenceShow string
|
||||
|
||||
// RFC 6120: part of A.5 Client Namespace and A.6 Server Namespace
|
||||
const (
|
||||
PresenceShowAway PresenceShow = "away"
|
||||
PresenceShowChat PresenceShow = "chat"
|
||||
PresenceShowDND PresenceShow = "dnd"
|
||||
PresenceShowXA PresenceShow = "xa"
|
||||
)
|
|
@ -10,8 +10,8 @@ import (
|
|||
)
|
||||
|
||||
func TestGeneratePresence(t *testing.T) {
|
||||
presence := xmpp.NewPresence("admin@localhost", "test@localhost", "1", "en")
|
||||
presence.Show = "chat"
|
||||
presence := xmpp.NewPresence(xmpp.Attrs{From: "admin@localhost", To: "test@localhost", Id: "1"})
|
||||
presence.Show = xmpp.PresenceShowChat
|
||||
|
||||
data, err := xml.Marshal(presence)
|
||||
if err != nil {
|
||||
|
@ -32,13 +32,13 @@ func TestPresenceSubElt(t *testing.T) {
|
|||
// Test structure to ensure that show, status and priority are correctly defined as presence
|
||||
// package sub-elements
|
||||
type pres struct {
|
||||
Show string `xml:"show"`
|
||||
Show xmpp.PresenceShow `xml:"show"`
|
||||
Status string `xml:"status"`
|
||||
Priority int `xml:"priority"`
|
||||
Priority int8 `xml:"priority"`
|
||||
}
|
||||
|
||||
presence := xmpp.NewPresence("admin@localhost", "test@localhost", "1", "en")
|
||||
presence.Show = "xa"
|
||||
presence := xmpp.NewPresence(xmpp.Attrs{From: "admin@localhost", To: "test@localhost", Id: "1"})
|
||||
presence.Show = xmpp.PresenceShowXA
|
||||
presence.Status = "Coding"
|
||||
presence.Priority = 10
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ func TestNameMatcher(t *testing.T) {
|
|||
|
||||
// Check that a message packet is properly matched
|
||||
conn := NewSenderMock()
|
||||
// TODO: We want packet creation code to use struct to use default values
|
||||
msg := xmpp.NewMessage("chat", "", "test@localhost", "1", "")
|
||||
msg := xmpp.NewMessage(xmpp.Attrs{Type: xmpp.MessageTypeChat, To: "test@localhost", Id: "1"})
|
||||
msg.Body = "Hello"
|
||||
router.Route(conn, msg)
|
||||
if conn.String() != successFlag {
|
||||
|
@ -29,7 +28,7 @@ func TestNameMatcher(t *testing.T) {
|
|||
|
||||
// Check that an IQ packet is not matched
|
||||
conn = NewSenderMock()
|
||||
iq := xmpp.NewIQ("get", "", "localhost", "1", "")
|
||||
iq := xmpp.NewIQ(xmpp.Attrs{Type: xmpp.IQTypeGet, To: "localhost", Id: "1"})
|
||||
iq.Payload = &xmpp.DiscoInfo{}
|
||||
router.Route(conn, iq)
|
||||
if conn.String() == successFlag {
|
||||
|
@ -47,7 +46,8 @@ func TestIQNSMatcher(t *testing.T) {
|
|||
|
||||
// Check that an IQ with proper namespace does match
|
||||
conn := NewSenderMock()
|
||||
iqDisco := xmpp.NewIQ("get", "", "localhost", "1", "")
|
||||
iqDisco := xmpp.NewIQ(xmpp.Attrs{Type: xmpp.IQTypeGet, To: "localhost", Id: "1"})
|
||||
// TODO: Add a function to generate payload with proper namespace initialisation
|
||||
iqDisco.Payload = &xmpp.DiscoInfo{
|
||||
XMLName: xml.Name{
|
||||
Space: xmpp.NSDiscoInfo,
|
||||
|
@ -60,7 +60,8 @@ func TestIQNSMatcher(t *testing.T) {
|
|||
|
||||
// Check that another namespace is not matched
|
||||
conn = NewSenderMock()
|
||||
iqVersion := xmpp.NewIQ("get", "", "localhost", "1", "")
|
||||
iqVersion := xmpp.NewIQ(xmpp.Attrs{Type: xmpp.IQTypeGet, To: "localhost", Id: "1"})
|
||||
// TODO: Add a function to generate payload with proper namespace initialisation
|
||||
iqVersion.Payload = &xmpp.DiscoInfo{
|
||||
XMLName: xml.Name{
|
||||
Space: "jabber:iq:version",
|
||||
|
@ -240,7 +241,7 @@ func (s SenderMock) String() string {
|
|||
|
||||
func TestSenderMock(t *testing.T) {
|
||||
conn := NewSenderMock()
|
||||
msg := xmpp.NewMessage("", "", "test@localhost", "1", "")
|
||||
msg := xmpp.NewMessage(xmpp.Attrs{To: "test@localhost", Id: "1"})
|
||||
msg.Body = "Hello"
|
||||
if err := conn.Send(msg); err != nil {
|
||||
t.Error("Could not send message")
|
||||
|
|
|
@ -92,7 +92,7 @@ func (s *Session) open(domain string) (f StreamFeatures) {
|
|||
}
|
||||
|
||||
// Set xml decoder and extract streamID from reply
|
||||
s.StreamId, s.err = initDecoder(s.decoder) // TODO refactor / rename
|
||||
s.StreamId, s.err = initStream(s.decoder) // TODO refactor / rename
|
||||
if s.err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -104,7 +104,7 @@ func (sm *StreamManager) Stop() {
|
|||
|
||||
// connect manages the reconnection loop and apply the define backoff to avoid overloading the server.
|
||||
func (sm *StreamManager) connect() error {
|
||||
var backoff Backoff // TODO: Group backoff calculation features with connection manager?
|
||||
var backoff backoff // TODO: Group backoff calculation features with connection manager?
|
||||
|
||||
for {
|
||||
var err error
|
||||
|
@ -118,7 +118,7 @@ func (sm *StreamManager) connect() error {
|
|||
return xerrors.Errorf("unrecoverable connect error %w", actualErr)
|
||||
}
|
||||
}
|
||||
backoff.Wait()
|
||||
backoff.wait()
|
||||
} else { // We are connected, we can leave the retry loop
|
||||
break
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue