diff --git a/go-xmpp b/go-xmpp
deleted file mode 160000
index bc2b7ce..0000000
--- a/go-xmpp
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit bc2b7ceee4b4d4c9ba5ec2451afdd43462f0b188
diff --git a/go-xmpp/.github/workflows/test.yaml b/go-xmpp/.github/workflows/test.yaml
new file mode 100644
index 0000000..043d9d8
--- /dev/null
+++ b/go-xmpp/.github/workflows/test.yaml
@@ -0,0 +1,38 @@
+name: Run tests
+
+on:
+ pull_request:
+ paths:
+ - '**.go'
+ - 'go.*'
+ - .github/workflows/test.yaml
+
+ push:
+ paths:
+ - '**.go'
+ - 'go.*'
+ - .github/workflows/test.yaml
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go 1.25
+ uses: actions/setup-go@v1
+ with:
+ go-version: 1.25
+ id: go
+ - uses: actions/checkout@v1
+ - name: Run tests
+ run: |
+ go test ./... -v -race -coverprofile cover.out -covermode=atomic
+ - name: Convert coverage to lcov
+ uses: jandelgado/gcov2lcov-action@v1
+ with:
+ infile: cover.out
+ outfile: coverage.lcov
+ - name: Coveralls
+ uses: coverallsapp/github-action@v1
+ with:
+ github-token: ${{ secrets.github_token }}
+ path-to-lcov: coverage.lcov
diff --git a/go-xmpp/.gitignore b/go-xmpp/.gitignore
new file mode 100755
index 0000000..79a4126
--- /dev/null
+++ b/go-xmpp/.gitignore
@@ -0,0 +1,36 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof
+coverage.out
+coverage.txt
+
+.idea/
+*.iml
+.DS_Store
+
+# Do not commit codeship key
+codeship.aes
+codeship.env
+
+priv/
\ No newline at end of file
diff --git a/go-xmpp/CHANGELOG.md b/go-xmpp/CHANGELOG.md
new file mode 100644
index 0000000..41f58ae
--- /dev/null
+++ b/go-xmpp/CHANGELOG.md
@@ -0,0 +1,58 @@
+# Fluux XMPP Changelog
+
+## v0.5.0
+
+### Changes
+
+- Added support for XEP-0198 (Stream management)
+- Added message queue : when using "SendX" methods on a client, messages are also stored in a queue. When requesting
+acks from the server, sent messages will be discarded, and unsent ones will be sent again. (see https://xmpp.org/extensions/xep-0198.html#acking)
+- Added support for stanza_errors (see https://xmpp.org/rfcs/rfc3920.html#def C.2. Stream error namespace and https://xmpp.org/rfcs/rfc6120.html#schemas-streamerror)
+- Added separate hooks for connection and reconnection on the client. One can now specify different actions to get triggered on client connect
+and reconnect, at client init time.
+- Client state update is now thread safe
+- Changed the Config struct to use pointer semantics
+- Tests
+- Refactoring, including removing some Fprintf statements in favor of Marshal + Write and using structs from the library
+instead of strings
+
+## v0.4.0
+
+### Changes
+
+- Added support for XEP-0060 (PubSub)
+(no support for 6.5.4 Returning Some Items yet as it needs XEP-0059, Result Sets)
+- Added support for XEP-0050 (Commands)
+- Added support for XEP-0004 (Forms)
+- Updated the client example with a TUI
+- Make keepalive interval configurable #134
+- Fix updating of EventManager.CurrentState #136
+- Added callbacks for error management in Component and Client. Users must now provide a callback function when using NewClient/Component.
+- Moved JID from xmpp package to stanza package
+
+## v0.3.0
+
+### Changes
+
+- Update requirements to go1.13
+- Add a websocket transport
+- Add Client.SendIQ method
+- Add IQ result routes to the Router
+- Fix SIGSEGV in xmpp_component (#126)
+- Add tests for Component and code style fixes
+
+## v0.2.0
+
+### Changes
+
+- XMPP Over Websocket support
+- Add support for getting IQ responses to client IQ queries (synchronously or asynchronously, passing an handler
+ function).
+- Implement X-OAUTH2 authentication method. You can read more details here:
+ [Understanding ejabberd OAuth Support & Roadmap: Step 4](https://blog.process-one.net/understanding-ejabberd-oauth-support-roadmap/)
+- Fix issues in the stanza builder when trying to add text inside and XMPP node.
+- Fix issues with unescaped % characters in XMPP payload.
+
+### Code migration guide
+
+TODO
diff --git a/go-xmpp/CODE_OF_CONDUCT.md b/go-xmpp/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..c248c38
--- /dev/null
+++ b/go-xmpp/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at contact@process-one.net. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/go-xmpp/CONTRIBUTING.md b/go-xmpp/CONTRIBUTING.md
new file mode 100644
index 0000000..1d625f5
--- /dev/null
+++ b/go-xmpp/CONTRIBUTING.md
@@ -0,0 +1,139 @@
+# Contributing
+
+We'd love for you to contribute to our source code and to make our project even better than it is
+today! Here are the guidelines we'd like you to follow:
+
+* [Code of Conduct](#coc)
+* [Questions and Problems](#question)
+* [Issues and Bugs](#issue)
+* [Feature Requests](#feature)
+* [Issue Submission Guidelines](#submit)
+* [Pull Request Submission Guidelines](#submit-pr)
+* [Signing the CLA](#cla)
+
+## Code of Conduct
+
+Help us keep our community open-minded and inclusive. Please read and follow our [Code of Conduct][coc].
+
+## Questions, Bugs, Features
+
+### Got a Question or Problem?
+
+Do not open issues for general support questions as we want to keep GitHub issues for bug reports
+and feature requests. You've got much better chances of getting your question answered on dedicated
+support platforms, the best being [Stack Overflow][stackoverflow].
+
+Stack Overflow is a much better place to ask questions since:
+
+- there are thousands of people willing to help on Stack Overflow
+- questions and answers stay available for public viewing so your question / answer might help
+ someone else
+- Stack Overflow's voting system assures that the best answers are prominently visible.
+
+To save your and our time, we will systematically close all issues that are requests for general
+support and redirect people to the section you are reading right now.
+
+### Found an Issue or Bug?
+
+If you find a bug in the source code, you can help us by submitting an issue to our
+[GitHub Repository][github]. Even better, you can submit a Pull Request with a fix.
+
+### Missing a Feature?
+
+You can request a new feature by submitting an issue to our [GitHub Repository][github-issues].
+
+If you would like to implement a new feature then consider what kind of change it is:
+
+* **Major Changes** that you wish to contribute to the project should be discussed first in an
+ [GitHub issue][github-issues] that clearly outlines the changes and benefits of the feature.
+* **Small Changes** can directly be crafted and submitted to the [GitHub Repository][github]
+ as a Pull Request. See the section about [Pull Request Submission Guidelines](#submit-pr).
+
+## Issue Submission Guidelines
+
+Before you submit your issue search the archive, maybe your question was already answered.
+
+If your issue appears to be a bug, and hasn't been reported, open a new issue. Help us to maximize
+the effort we can spend fixing issues and adding new features, by not reporting duplicate issues.
+
+The "[new issue][github-new-issue]" form contains a number of prompts that you should fill out to
+make it easier to understand and categorize the issue.
+
+## Pull Request Submission Guidelines
+
+By submitting a pull request for a code or doc contribution, you need to have the right
+to grant your contribution's copyright license to ProcessOne. Please check [ProcessOne CLA][cla]
+for details.
+
+Before you submit your pull request consider the following guidelines:
+
+* Search [GitHub][github-pr] for an open or closed Pull Request
+ that relates to your submission. You don't want to duplicate effort.
+* Make your changes in a new git branch:
+
+ ```shell
+ git checkout -b my-fix-branch master
+ ```
+* Test your changes and, if relevant, expand the automated test suite.
+* Create your patch commit, including appropriate test cases.
+* If the changes affect public APIs, change or add relevant documentation.
+* Commit your changes using a descriptive commit message.
+
+ ```shell
+ git commit -a
+ ```
+ Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files.
+
+* Push your branch to GitHub:
+
+ ```shell
+ git push origin my-fix-branch
+ ```
+
+* In GitHub, send a pull request to `master` branch. This will trigger the continuous integration and run the test.
+We will also notify you if you have not yet signed the [contribution agreement][cla].
+
+* If you find that the continunous integration has failed, look into the logs to find out
+if your changes caused test failures, the commit message was malformed etc. If you find that the
+tests failed or times out for unrelated reasons, you can ping a team member so that the build can be
+restarted.
+
+* If we suggest changes, then:
+
+ * Make the required updates.
+ * Test your changes and test cases.
+ * Commit your changes to your branch (e.g. `my-fix-branch`).
+ * Push the changes to your GitHub repository (this will update your Pull Request).
+
+ You can also amend the initial commits and force push them to the branch.
+
+ ```shell
+ git rebase master -i
+ git push origin my-fix-branch -f
+ ```
+
+ This is generally easier to follow, but separate commits are useful if the Pull Request contains
+ iterations that might be interesting to see side-by-side.
+
+That's it! Thank you for your contribution!
+
+## Signing the Contributor License Agreement (CLA)
+
+Upon submitting a Pull Request, we will ask you to sign our CLA if you haven't done
+so before. It's a quick process, we promise, and you will be able to do it all online
+
+You can read [ProcessOne Contribution License Agreement][cla] in PDF.
+
+This is part of the legal framework of the open-source ecosystem that adds some red tape,
+but protects both the contributor and the company / foundation behind the project. It also
+gives us the option to relicense the code with a more permissive license in the future.
+
+
+[coc]: https://github.com/FluuxIO/go-xmpp/blob/master/CODE_OF_CONDUCT.md
+[stackoverflow]: https://stackoverflow.com/
+[github]: https://github.com/FluuxIO/go-xmpp
+[github-issues]: https://github.com/FluuxIO/go-xmpp/issues
+[github-new-issue]: https://github.com/FluuxIO/go-xmpp/issues/new
+[github-pr]: https://github.com/FluuxIO/go-xmpp/pulls
+[cla]: https://www.process-one.net/resources/ejabberd-cla.pdf
+[license]: https://github.com/FluuxIO/go-xmpp/blob/master/LICENSE
diff --git a/go-xmpp/LICENSE b/go-xmpp/LICENSE
new file mode 100644
index 0000000..6b4272c
--- /dev/null
+++ b/go-xmpp/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2017, ProcessOne SARL
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/go-xmpp/README.md b/go-xmpp/README.md
new file mode 100644
index 0000000..8c91c28
--- /dev/null
+++ b/go-xmpp/README.md
@@ -0,0 +1,158 @@
+# Fluux XMPP
+
+[](https://godoc.org/gosrc.io/xmpp) [](https://goreportcard.com/report/fluux.io/xmpp) [](https://coveralls.io/github/FluuxIO/go-xmpp?branch=master)
+
+Fluux XMPP is a Go XMPP library, focusing on simplicity, simple automation, and IoT.
+
+The goal is to make simple to write simple XMPP clients and components:
+
+- For automation (like for example monitoring of an XMPP service),
+- For building connected "things" by plugging them on an XMPP server,
+- For writing simple chatbot to control a service or a thing,
+- For writing XMPP servers components.
+
+The library is designed to have minimal dependencies. Currently it requires at least Go 1.13.
+
+## Configuration and connection
+
+### Allowing Insecure TLS connection during development
+
+It is not recommended to disable the check for domain name and certificate chain. Doing so would open your client
+to man-in-the-middle attacks.
+
+However, in development, XMPP servers often use self-signed certificates. In that situation, it is better to add the
+root CA that signed the certificate to your trusted list of root CA. It avoids changing the code and limit the risk
+of shipping an insecure client to production.
+
+That said, if you really want to allow your client to trust any TLS certificate, you can customize Go standard
+`tls.Config` and set it in Config struct.
+
+Here is an example code to configure a client to allow connecting to a server with self-signed certificate. Note the
+`InsecureSkipVerify` option. When using this `tls.Config` option, all the checks on the certificate are skipped.
+
+```go
+config := xmpp.Config{
+ Address: "localhost:5222",
+ Jid: "test@localhost",
+ Credential: xmpp.Password("Test"),
+ TLSConfig: tls.Config{InsecureSkipVerify: true},
+}
+```
+
+## Supported specifications
+
+### Clients
+
+- [RFC 6120: XMPP Core](https://xmpp.org/rfcs/rfc6120.html)
+- [RFC 6121: XMPP Instant Messaging and Presence](https://xmpp.org/rfcs/rfc6121.html)
+
+### Components
+
+ - [XEP-0114: Jabber Component Protocol](https://xmpp.org/extensions/xep-0114.html)
+ - [XEP-0355: Namespace Delegation](https://xmpp.org/extensions/xep-0355.html)
+ - [XEP-0356: Privileged Entity](https://xmpp.org/extensions/xep-0356.html)
+
+### Extensions
+ - [XEP-0060: Publish-Subscribe](https://xmpp.org/extensions/xep-0060.html)
+ Note : "6.5.4 Returning Some Items" requires support for [XEP-0059: Result Set Management](https://xmpp.org/extensions/xep-0059.html),
+ and is therefore not supported yet.
+ - [XEP-0004: Data Forms](https://xmpp.org/extensions/xep-0004.html)
+ - [XEP-0050: Ad-Hoc Commands](https://xmpp.org/extensions/xep-0050.html)
+
+## Package overview
+
+### Stanza subpackage
+
+XMPP stanzas are basic and extensible XML elements. Stanzas (or sometimes special stanzas called 'nonzas') are used to
+leverage the XMPP protocol features. During a session, a client (or a component) and a server will be exchanging stanzas
+back and forth.
+
+At a low-level, stanzas are XML fragments. However, Fluux XMPP library provides the building blocks to interact with
+stanzas at a high-level, providing a Go-friendly API.
+
+The `stanza` subpackage provides support for XMPP stream parsing, marshalling and unmarshalling of XMPP stanza. It is a
+bridge between high-level Go structure and low-level XMPP protocol.
+
+Parsing, marshalling and unmarshalling is automatically handled by Fluux XMPP client library. As a developer, you will
+generally manipulates only the high-level structs provided by the stanza package.
+
+The XMPP protocol, as the name implies is extensible. If your application is using custom stanza extensions, you can
+implement your own extensions directly in your own application.
+
+To learn more about the stanza package, you can read more in the
+[stanza package documentation](https://github.com/FluuxIO/go-xmpp/blob/master/stanza/README.md).
+
+### Router
+
+TODO
+
+### Getting IQ response from server
+
+TODO
+
+## Examples
+
+We have several [examples](https://github.com/FluuxIO/go-xmpp/tree/master/_examples) to help you get started using
+Fluux XMPP library.
+
+Here is the demo "echo" client:
+
+```go
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ config := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: "localhost:5222",
+ },
+ Jid: "test@localhost",
+ Credential: xmpp.Password("test"),
+ StreamLogger: os.Stdout,
+ Insecure: true,
+ // TLSConfig: tls.Config{InsecureSkipVerify: true},
+ }
+
+ router := xmpp.NewRouter()
+ router.HandleFunc("message", handleMessage)
+
+ client, err := xmpp.NewClient(&config, router, errorHandler)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // If you pass the client to a connection manager, it will handle the reconnect policy
+ // for you automatically.
+ cm := xmpp.NewStreamManager(client, nil)
+ log.Fatal(cm.Run())
+}
+
+func handleMessage(s xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p)
+ return
+ }
+
+ _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From)
+ reply := stanza.Message{Attrs: stanza.Attrs{To: msg.From}, Body: msg.Body}
+ _ = s.Send(reply)
+}
+
+func errorHandler(err error) {
+ fmt.Println(err.Error())
+}
+
+```
+
+## Reference documentation
+
+The code documentation is available on GoDoc: [gosrc.io/xmpp](https://godoc.org/gosrc.io/xmpp)
diff --git a/go-xmpp/_examples/custom_stanza/README.md b/go-xmpp/_examples/custom_stanza/README.md
new file mode 100644
index 0000000..1eb3b28
--- /dev/null
+++ b/go-xmpp/_examples/custom_stanza/README.md
@@ -0,0 +1,5 @@
+# Custom Stanza example
+
+This module show how to implement a custom extension for your own client, without having to modify or fork Fluux XMPP.
+
+It help integrating your custom extension in the standard stream parsing, marshalling and unmarshalling workflow.
diff --git a/go-xmpp/_examples/custom_stanza/custom_stanza.go b/go-xmpp/_examples/custom_stanza/custom_stanza.go
new file mode 100644
index 0000000..b4bfb52
--- /dev/null
+++ b/go-xmpp/_examples/custom_stanza/custom_stanza.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "encoding/xml"
+ "fmt"
+ "log"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "service.localhost", Id: "custom-pl-1"})
+ if err != nil {
+ log.Fatalf("failed to create IQ: %v", err)
+ }
+ payload := CustomPayload{XMLName: xml.Name{Space: "my:custom:payload", Local: "query"}, Node: "test"}
+ iq.Payload = payload
+
+ data, err := xml.Marshal(iq)
+ if err != nil {
+ log.Fatalf("Cannot marshal iq with custom payload: %s", err)
+ }
+
+ var parsedIQ stanza.IQ
+ if err = xml.Unmarshal(data, &parsedIQ); err != nil {
+ log.Fatalf("Cannot unmarshal(%s): %s", data, err)
+ }
+
+ parsedPayload, ok := parsedIQ.Payload.(*CustomPayload)
+ if !ok {
+ log.Fatalf("Incorrect payload type: %#v", parsedIQ.Payload)
+ }
+
+ fmt.Printf("Parsed Payload: %#v", parsedPayload)
+
+ if parsedPayload.Node != "test" {
+ log.Fatalf("Incorrect node value: %s", parsedPayload.Node)
+ }
+}
+
+type CustomPayload struct {
+ XMLName xml.Name `xml:"my:custom:payload query"`
+ Node string `xml:"node,attr,omitempty"`
+}
+
+func (c CustomPayload) Namespace() string {
+ return c.XMLName.Space
+}
+
+func (c CustomPayload) GetSet() *stanza.ResultSet {
+ return nil
+}
+func init() {
+ stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{Space: "my:custom:payload", Local: "query"}, CustomPayload{})
+}
diff --git a/go-xmpp/_examples/delegation/README.md b/go-xmpp/_examples/delegation/README.md
new file mode 100644
index 0000000..4389a36
--- /dev/null
+++ b/go-xmpp/_examples/delegation/README.md
@@ -0,0 +1,5 @@
+# Advanced component: delegation
+
+`delegation` is an example of advanced component supporting Namespace Delegation
+([XEP-0355](https://xmpp.org/extensions/xep-0355.html)) and privileged entity
+([XEP-356](https://xmpp.org/extensions/xep-0356.html)).
diff --git a/go-xmpp/_examples/delegation/delegation.go b/go-xmpp/_examples/delegation/delegation.go
new file mode 100644
index 0000000..d022633
--- /dev/null
+++ b/go-xmpp/_examples/delegation/delegation.go
@@ -0,0 +1,220 @@
+package main
+
+import (
+ "encoding/xml"
+ "fmt"
+ "log"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ opts := xmpp.ComponentOptions{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: "localhost:9999",
+ Domain: "service.localhost",
+ },
+ Domain: "service.localhost",
+ Secret: "mypass",
+
+ // TODO: Move that part to a component discovery handler
+ Name: "Test Component",
+ Category: "gateway",
+ Type: "service",
+ }
+
+ router := xmpp.NewRouter()
+ router.HandleFunc("message", handleMessage)
+ router.NewRoute().
+ IQNamespaces(stanza.NSDiscoInfo).
+ HandlerFunc(func(s xmpp.Sender, p stanza.Packet) {
+ discoInfo(s, p, opts)
+ })
+ router.NewRoute().
+ IQNamespaces("urn:xmpp:delegation:1").
+ HandlerFunc(handleDelegation)
+
+ component, err := xmpp.NewComponent(opts, router, func(err error) {
+ log.Println(err)
+ })
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // If you pass the component to a stream manager, it will handle the reconnect policy
+ // for you automatically.
+ // TODO: Post Connect could be a feature of the router or the client. Move it somewhere else.
+ cm := xmpp.NewStreamManager(component, nil)
+ log.Fatal(cm.Run())
+}
+
+func handleMessage(_ xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ return
+ }
+ var msgProcessed bool
+ for _, ext := range msg.Extensions {
+ delegation, ok := ext.(*stanza.Delegation)
+ if ok {
+ msgProcessed = true
+ fmt.Printf("Delegation confirmed for namespace %s\n", delegation.Delegated.Namespace)
+ }
+ }
+ // TODO: Decode privilege message
+ //
+
+ if !msgProcessed {
+ fmt.Printf("Ignored received message, not related to delegation: %v\n", msg)
+ }
+}
+
+const (
+ pubsubNode = "urn:xmpp:delegation:1::http://jabber.org/protocol/pubsub"
+ pepNode = "urn:xmpp:delegation:1:bare:http://jabber.org/protocol/pubsub"
+)
+
+// TODO: replace xmpp.Sender by ctx xmpp.Context ?
+// ctx.Stream.Send / SendRaw
+// ctx.Opts
+func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) {
+ // Type conversion & sanity checks
+ iq, ok := p.(*stanza.IQ)
+ if !ok {
+ return
+ }
+ info, ok := iq.Payload.(*stanza.DiscoInfo)
+ if !ok {
+ return
+ }
+
+ iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id})
+ if err != nil {
+ log.Fatalf("failed to create IQ response: %v", err)
+ }
+
+ switch info.Node {
+ case "":
+ discoInfoRoot(iqResp, opts)
+ case pubsubNode:
+ discoInfoPubSub(iqResp)
+ case pepNode:
+ discoInfoPEP(iqResp)
+ }
+
+ _ = c.Send(iqResp)
+}
+
+func discoInfoRoot(iqResp *stanza.IQ, opts xmpp.ComponentOptions) {
+ disco := iqResp.DiscoInfo()
+ disco.AddIdentity(opts.Name, opts.Category, opts.Type)
+ disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1")
+}
+
+func discoInfoPubSub(iqResp *stanza.IQ) {
+ payload := stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: stanza.NSDiscoInfo,
+ Local: "query",
+ },
+ Node: pubsubNode,
+ Features: []stanza.Feature{
+ {Var: "http://jabber.org/protocol/pubsub"},
+ {Var: "http://jabber.org/protocol/pubsub#publish"},
+ {Var: "http://jabber.org/protocol/pubsub#subscribe"},
+ {Var: "http://jabber.org/protocol/pubsub#publish-options"},
+ },
+ }
+ iqResp.Payload = &payload
+}
+
+func discoInfoPEP(iqResp *stanza.IQ) {
+ identity := stanza.Identity{
+ Category: "pubsub",
+ Type: "pep",
+ }
+ payload := stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: stanza.NSDiscoInfo,
+ Local: "query",
+ },
+ Identity: []stanza.Identity{identity},
+ Node: pepNode,
+ Features: []stanza.Feature{
+ {Var: "http://jabber.org/protocol/pubsub#access-presence"},
+ {Var: "http://jabber.org/protocol/pubsub#auto-create"},
+ {Var: "http://jabber.org/protocol/pubsub#auto-subscribe"},
+ {Var: "http://jabber.org/protocol/pubsub#config-node"},
+ {Var: "http://jabber.org/protocol/pubsub#create-and-configure"},
+ {Var: "http://jabber.org/protocol/pubsub#create-nodes"},
+ {Var: "http://jabber.org/protocol/pubsub#filtered-notifications"},
+ {Var: "http://jabber.org/protocol/pubsub#persistent-items"},
+ {Var: "http://jabber.org/protocol/pubsub#publish"},
+ {Var: "http://jabber.org/protocol/pubsub#retrieve-items"},
+ {Var: "http://jabber.org/protocol/pubsub#subscribe"},
+ },
+ }
+ iqResp.Payload = &payload
+}
+
+func handleDelegation(s xmpp.Sender, p stanza.Packet) {
+ // Type conversion & sanity checks
+ iq, ok := p.(*stanza.IQ)
+ if !ok {
+ return
+ }
+
+ delegation, ok := iq.Payload.(*stanza.Delegation)
+ if !ok {
+ return
+ }
+ forwardedPacket := delegation.Forwarded.Stanza
+ fmt.Println(forwardedPacket)
+ forwardedIQ, ok := forwardedPacket.(*stanza.IQ)
+ if !ok {
+ return
+ }
+
+ pubsub, ok := forwardedIQ.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ // We only support pubsub delegation
+ return
+ }
+
+ if pubsub.Publish.XMLName.Local == "publish" {
+ // Prepare pubsub IQ reply
+ iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: forwardedIQ.To, To: forwardedIQ.From, Id: forwardedIQ.Id})
+ if err != nil {
+ log.Fatalf("failed to create iqResp: %v", err)
+ }
+ payload := stanza.PubSubGeneric{
+ XMLName: xml.Name{
+ Space: "http://jabber.org/protocol/pubsub",
+ Local: "pubsub",
+ },
+ }
+ iqResp.Payload = &payload
+ // Wrap the reply in delegation 'forward'
+ iqForward, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id})
+ if err != nil {
+ log.Fatalf("failed to create iqForward: %v", err)
+ }
+ delegPayload := stanza.Delegation{
+ XMLName: xml.Name{
+ Space: "urn:xmpp:delegation:1",
+ Local: "delegation",
+ },
+ Forwarded: &stanza.Forwarded{
+ XMLName: xml.Name{
+ Space: "urn:xmpp:forward:0",
+ Local: "forward",
+ },
+ Stanza: iqResp,
+ },
+ }
+ iqForward.Payload = &delegPayload
+ _ = s.Send(iqForward)
+ // TODO: The component should actually broadcast the mood to subscribers
+ }
+}
diff --git a/go-xmpp/_examples/go.mod b/go-xmpp/_examples/go.mod
new file mode 100644
index 0000000..16b2ec9
--- /dev/null
+++ b/go-xmpp/_examples/go.mod
@@ -0,0 +1,11 @@
+module gosrc.io/xmpp/_examples
+
+go 1.25
+
+require (
+ github.com/processone/mpg123 v1.0.0
+ github.com/processone/soundcloud v1.0.0
+ gosrc.io/xmpp v0.4.0
+)
+
+replace gosrc.io/xmpp => ./../
diff --git a/go-xmpp/_examples/go.sum b/go-xmpp/_examples/go.sum
new file mode 100644
index 0000000..b5e9447
--- /dev/null
+++ b/go-xmpp/_examples/go.sum
@@ -0,0 +1,218 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/awesome-gocui/gocui v0.6.0/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA=
+github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
+github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
+github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
+github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
+github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
+github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
+github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/processone/mpg123 v1.0.0 h1:o2WOyGZRM255or1Zc/LtF/jARn51B+9aQl72Qace0GA=
+github.com/processone/mpg123 v1.0.0/go.mod h1:X/FeL+h8vD1bYsG9tIWV3M2c4qNTZOficyvPVBP08go=
+github.com/processone/soundcloud v1.0.0 h1:/+i6+Yveb7Y6IFGDSkesYI+HddblzcRTQClazzVHxoE=
+github.com/processone/soundcloud v1.0.0/go.mod h1:kDLeWpkRtN3C8kIReQdxoiRi92P9xR6yW6qLOJnNWfY=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
+github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
+go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+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=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
+gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
+gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8=
+nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg=
+nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY=
+nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/go-xmpp/_examples/muc_bot/README.md b/go-xmpp/_examples/muc_bot/README.md
new file mode 100644
index 0000000..24414fa
--- /dev/null
+++ b/go-xmpp/_examples/muc_bot/README.md
@@ -0,0 +1,3 @@
+# XMPP Multi-User (MUC) chat bot example
+
+This code shows how to build a simple basic XMPP Multi-User chat bot using Fluux Go XMPP library.
diff --git a/go-xmpp/_examples/xmpp_chat_client/README.md b/go-xmpp/_examples/xmpp_chat_client/README.md
new file mode 100644
index 0000000..d4db360
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_chat_client/README.md
@@ -0,0 +1,51 @@
+# Chat TUI example
+This is a simple chat example, with a TUI.
+It shows the library usage and a few of its capabilities.
+## How to run
+### Build
+You can build the client using :
+```
+ go build -o example_client
+```
+and then run with (on unix for example):
+```
+ ./example_client
+```
+or you can simply build + run in one command while at the example directory root, like this:
+```
+ go run xmpp_chat_client.go interface.go
+```
+
+### Configuration
+The example needs a configuration file to run. A sample file is provided.
+By default, the example will look for a file named "config" in the current directory.
+To provide a different configuration file, pass the following argument to the example :
+```
+ go run xmpp_chat_client.go interface.go -c /path/to/config
+```
+where /path/to/config is the path to the directory containing the configuration file. The configuration file must be named
+"config" and be using the yaml format.
+
+Required fields are :
+```yaml
+Server :
+ - full_address: "localhost:5222"
+Client : # This is you
+ - jid: "testuser2@localhost"
+ - pass: "pass123" #Password in a config file yay
+
+# Contacts list, ";" separated
+Contacts : "testuser1@localhost;testuser3@localhost"
+# Should we log stanzas ?
+LogStanzas:
+ - logger_on: "true"
+ - logfile_path: "./logs" # Path to directory, not file.
+```
+
+## How to use
+Shortcuts :
+ - ctrl+space : switch between input window and menu window.
+ - While in input window :
+ - enter : sends a message if in message mode (see menu options)
+ - ctrl+e : sends a raw stanza when in raw mode (see menu options)
+ - ctrl+c : quit
\ No newline at end of file
diff --git a/go-xmpp/_examples/xmpp_chat_client/config.yml b/go-xmpp/_examples/xmpp_chat_client/config.yml
new file mode 100644
index 0000000..6c6498b
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_chat_client/config.yml
@@ -0,0 +1,13 @@
+# Sample config for the client
+Server :
+ - full_address: "localhost:5222"
+Client :
+ - jid: "testuser2@localhost"
+ - pass: "pass123" #Password in a config file yay
+
+Contacts : "testuser1@localhost;testuser3@localhost"
+
+LogStanzas:
+ - logger_on: "true"
+ - logfile_path: "./logs"
+
diff --git a/go-xmpp/_examples/xmpp_chat_client/go.mod b/go-xmpp/_examples/xmpp_chat_client/go.mod
new file mode 100644
index 0000000..60d9744
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_chat_client/go.mod
@@ -0,0 +1,10 @@
+module go-xmpp/_examples/xmpp_chat_client
+
+go 1.13
+
+require (
+ github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986
+ github.com/spf13/pflag v1.0.5
+ github.com/spf13/viper v1.6.1
+ gosrc.io/xmpp v0.4.0
+)
diff --git a/go-xmpp/_examples/xmpp_chat_client/interface.go b/go-xmpp/_examples/xmpp_chat_client/interface.go
new file mode 100644
index 0000000..9f2c758
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_chat_client/interface.go
@@ -0,0 +1,371 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "github.com/awesome-gocui/gocui"
+ "log"
+ "strings"
+)
+
+const (
+ // Windows
+ chatLogWindow = "clw" // Where (received and sent) messages are logged
+ chatInputWindow = "iw" // Where messages are written
+ rawInputWindow = "rw" // Where raw stanzas are written
+ contactsListWindow = "cl" // Where the contacts list is shown, and contacts are selectable
+ menuWindow = "mw" // Where the menu is shown
+ disconnectMsg = "msg"
+
+ // Windows titles
+ chatLogWindowTitle = "Chat log"
+ menuWindowTitle = "Menu"
+ chatInputWindowTitle = "Write a message :"
+ rawInputWindowTitle = "Write or paste a raw stanza. Press \"Ctrl+E\" to send :"
+ contactsListWindowTitle = "Contacts"
+
+ // Menu options
+ disconnect = "Disconnect"
+ askServerForRoster = "Ask server for roster"
+ rawMode = "Switch to Send Raw Mode"
+ messageMode = "Switch to Send Message Mode"
+ contactList = "Contacts list"
+ backFromContacts = "<- Go back"
+)
+
+// To store names of views on top
+type viewsState struct {
+ input string // Which input view is on top
+ side string // Which side view is on top
+ contacts []string // Contacts list
+ currentContact string // Contact we are currently messaging
+}
+
+var (
+ // Which window is on top currently on top of the other.
+ // This is the init setup
+ viewState = viewsState{
+ input: chatInputWindow,
+ side: menuWindow,
+ }
+ menuOptions = []string{contactList, rawMode, askServerForRoster, disconnect}
+ // Errors
+ servConnFail = errors.New("failed to connect to server. Check your configuration ? Exiting")
+)
+
+func setCurrentViewOnTop(g *gocui.Gui, name string) (*gocui.View, error) {
+ if _, err := g.SetCurrentView(name); err != nil {
+ return nil, err
+ }
+ return g.SetViewOnTop(name)
+}
+
+func layout(g *gocui.Gui) error {
+ maxX, maxY := g.Size()
+
+ if v, err := g.SetView(chatLogWindow, maxX/5, 0, maxX-1, 5*maxY/6-1, 0); err != nil {
+ if !gocui.IsUnknownView(err) {
+ return err
+ }
+ v.Title = chatLogWindowTitle
+ v.Wrap = true
+ v.Autoscroll = true
+ }
+
+ if v, err := g.SetView(contactsListWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil {
+ if !gocui.IsUnknownView(err) {
+ return err
+ }
+ v.Title = contactsListWindowTitle
+ v.Wrap = true
+ // If we set this to true, the contacts list will "fit" in the window but if the number
+ // of contacts exceeds the maximum height, some contacts will be hidden...
+ // If set to false, we can scroll up and down the contact list... infinitely. Meaning lower lines
+ // will be unlimited and empty... Didn't find a way to quickfix yet.
+ v.Autoscroll = false
+ }
+
+ if v, err := g.SetView(menuWindow, 0, 0, maxX/5-1, 5*maxY/6-2, 0); err != nil {
+ if !gocui.IsUnknownView(err) {
+ return err
+ }
+ v.Title = menuWindowTitle
+ v.Wrap = true
+ v.Autoscroll = true
+ fmt.Fprint(v, strings.Join(menuOptions, "\n"))
+ if _, err = setCurrentViewOnTop(g, menuWindow); err != nil {
+ return err
+ }
+ }
+
+ if v, err := g.SetView(rawInputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil {
+ if !gocui.IsUnknownView(err) {
+ return err
+ }
+ v.Title = rawInputWindowTitle
+ v.Editable = true
+ v.Wrap = true
+ }
+
+ if v, err := g.SetView(chatInputWindow, 0, 5*maxY/6-1, maxX/1-1, maxY-1, 0); err != nil {
+ if !gocui.IsUnknownView(err) {
+ return err
+ }
+ v.Title = chatInputWindowTitle
+ v.Editable = true
+ v.Wrap = true
+
+ if _, err = setCurrentViewOnTop(g, chatInputWindow); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func quit(g *gocui.Gui, v *gocui.View) error {
+ return gocui.ErrQuit
+}
+
+// Sends an input text from the user to the backend while also printing it in the chatlog window.
+// KeyEnter is viewed as "\n" by gocui, so messages should only be one line, whereas raw sending has a different key
+// binding and therefor should work with this too (for multiple lines stanzas)
+func writeInput(g *gocui.Gui, v *gocui.View) error {
+ chatLogWindow, _ := g.View(chatLogWindow)
+
+ input := strings.Join(v.ViewBufferLines(), "\n")
+
+ fmt.Fprintln(chatLogWindow, "Me : ", input)
+ if viewState.input == rawInputWindow {
+ rawTextChan <- input
+ } else {
+ textChan <- input
+ }
+
+ v.Clear()
+ v.EditDeleteToStartOfLine()
+ return nil
+}
+
+func setKeyBindings(g *gocui.Gui) {
+ // ==========================
+ // All views
+ if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
+ log.Panicln(err)
+ }
+
+ // ==========================
+ // Chat input
+ if err := g.SetKeybinding(chatInputWindow, gocui.KeyEnter, gocui.ModNone, writeInput); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding(chatInputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil {
+ log.Panicln(err)
+ }
+
+ // ==========================
+ // Raw input
+ if err := g.SetKeybinding(rawInputWindow, gocui.KeyCtrlE, gocui.ModNone, writeInput); err != nil {
+ log.Panicln(err)
+ }
+
+ if err := g.SetKeybinding(rawInputWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil {
+ log.Panicln(err)
+ }
+
+ // ==========================
+ // Menu
+ if err := g.SetKeybinding(menuWindow, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil {
+ log.Panicln(err)
+ }
+ if err := g.SetKeybinding(menuWindow, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil {
+ log.Panicln(err)
+ }
+ if err := g.SetKeybinding(menuWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil {
+ log.Panicln(err)
+ }
+ if err := g.SetKeybinding(menuWindow, gocui.KeyEnter, gocui.ModNone, getLine); err != nil {
+ log.Panicln(err)
+ }
+
+ // ==========================
+ // Contacts list
+ if err := g.SetKeybinding(contactsListWindow, gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil {
+ log.Panicln(err)
+ }
+ if err := g.SetKeybinding(contactsListWindow, gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil {
+ log.Panicln(err)
+ }
+ if err := g.SetKeybinding(contactsListWindow, gocui.KeyEnter, gocui.ModNone, getLine); err != nil {
+ log.Panicln(err)
+ }
+ if err := g.SetKeybinding(contactsListWindow, gocui.KeyCtrlSpace, gocui.ModNone, nextView); err != nil {
+ log.Panicln(err)
+ }
+
+ // ==========================
+ // Disconnect message
+ if err := g.SetKeybinding(disconnectMsg, gocui.KeyEnter, gocui.ModNone, delMsg); err != nil {
+ log.Panicln(err)
+ }
+}
+
+// General
+// Used to handle menu selections and navigations
+func getLine(g *gocui.Gui, v *gocui.View) error {
+ var l string
+ var err error
+
+ _, cy := v.Cursor()
+ if l, err = v.Line(cy); err != nil {
+ l = ""
+ }
+ if viewState.side == menuWindow {
+ if l == contactList {
+ cv, _ := g.View(contactsListWindow)
+ viewState.side = contactsListWindow
+ g.SetViewOnTop(contactsListWindow)
+ g.SetCurrentView(contactsListWindow)
+ if len(cv.ViewBufferLines()) == 0 {
+ printContactsToWindow(g, viewState.contacts)
+ }
+ } else if l == disconnect {
+ maxX, maxY := g.Size()
+ msg := "You disconnected from the server. Press enter to quit."
+ if v, err := g.SetView(disconnectMsg, maxX/2-30, maxY/2, maxX/2-29+len(msg), maxY/2+2, 0); err != nil {
+ if !gocui.IsUnknownView(err) {
+ return err
+ }
+ fmt.Fprintln(v, msg)
+ if _, err := g.SetCurrentView(disconnectMsg); err != nil {
+ return err
+ }
+ }
+ killChan <- disconnectErr
+ } else if l == askServerForRoster {
+ chlw, _ := g.View(chatLogWindow)
+ fmt.Fprintln(chlw, infoFormat+"Asking server for contacts list...")
+ rosterChan <- struct{}{}
+ } else if l == rawMode {
+ mw, _ := g.View(menuWindow)
+ viewState.input = rawInputWindow
+ g.SetViewOnTop(rawInputWindow)
+ g.SetCurrentView(rawInputWindow)
+ menuOptions[1] = messageMode
+ v.Clear()
+ v.EditDeleteToStartOfLine()
+ fmt.Fprintln(mw, strings.Join(menuOptions, "\n"))
+ message := "Now sending in raw stanza mode"
+ clv, _ := g.View(chatLogWindow)
+ fmt.Fprintln(clv, infoFormat+message)
+ } else if l == messageMode {
+ mw, _ := g.View(menuWindow)
+ viewState.input = chatInputWindow
+ g.SetViewOnTop(chatInputWindow)
+ g.SetCurrentView(chatInputWindow)
+ menuOptions[1] = rawMode
+ v.Clear()
+ v.EditDeleteToStartOfLine()
+ fmt.Fprintln(mw, strings.Join(menuOptions, "\n"))
+ message := "Now sending in messages mode"
+ clv, _ := g.View(chatLogWindow)
+ fmt.Fprintln(clv, infoFormat+message)
+ }
+ } else if viewState.side == contactsListWindow {
+ if l == backFromContacts {
+ viewState.side = menuWindow
+ g.SetViewOnTop(menuWindow)
+ g.SetCurrentView(menuWindow)
+ } else if l == "" {
+ return nil
+ } else {
+ // Updating the current correspondent, back-end side.
+ CorrespChan <- l
+ viewState.currentContact = l
+ // Showing the selected contact in contacts list
+ cl, _ := g.View(contactsListWindow)
+ cts := cl.ViewBufferLines()
+ cl.Clear()
+ printContactsToWindow(g, cts)
+ // Showing a message to the user, and switching back to input after the new contact is selected.
+ message := "Now sending messages to : " + l + " in a private conversation"
+ clv, _ := g.View(chatLogWindow)
+ fmt.Fprintln(clv, infoFormat+message)
+ g.SetCurrentView(chatInputWindow)
+ }
+ }
+
+ return nil
+}
+
+func printContactsToWindow(g *gocui.Gui, contactsList []string) {
+ cl, _ := g.View(contactsListWindow)
+ for _, c := range contactsList {
+ c = strings.ReplaceAll(c, " *", "")
+ if c == viewState.currentContact {
+ fmt.Fprintf(cl, c+" *\n")
+ } else {
+ fmt.Fprintf(cl, c+"\n")
+ }
+ }
+}
+
+// Changing view between input and "menu/contacts" when pressing the specific key.
+func nextView(g *gocui.Gui, v *gocui.View) error {
+ if v == nil || v.Name() == chatInputWindow || v.Name() == rawInputWindow {
+ _, err := g.SetCurrentView(viewState.side)
+ return err
+ } else if v.Name() == menuWindow || v.Name() == contactsListWindow {
+ _, err := g.SetCurrentView(viewState.input)
+ return err
+ }
+
+ // Should not be reached right now
+ _, err := g.SetCurrentView(chatInputWindow)
+ return err
+}
+
+func cursorDown(g *gocui.Gui, v *gocui.View) error {
+ if v != nil {
+ cx, cy := v.Cursor()
+ // Avoid going below the list of contacts. Although lines are stored in the view as a slice
+ // in the used lib. Therefor, if the number of lines is too big, the cursor will go past the last line since
+ // increasing slice capacity is done by doubling it. Last lines will be "nil" and reachable by the cursor
+ // in a dynamic context (such as contacts list)
+ cv := g.CurrentView()
+ h := cv.LinesHeight()
+ if cy+1 >= h {
+ return nil
+ }
+ // Lower cursor
+ if err := v.SetCursor(cx, cy+1); err != nil {
+ ox, oy := v.Origin()
+ if err := v.SetOrigin(ox, oy+1); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func cursorUp(g *gocui.Gui, v *gocui.View) error {
+ if v != nil {
+ ox, oy := v.Origin()
+ cx, cy := v.Cursor()
+ if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 {
+ if err := v.SetOrigin(ox, oy-1); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func delMsg(g *gocui.Gui, v *gocui.View) error {
+ if err := g.DeleteView(disconnectMsg); err != nil {
+ return err
+ }
+ errChan <- gocui.ErrQuit // Quit the program
+ return nil
+}
diff --git a/go-xmpp/_examples/xmpp_chat_client/xmpp_chat_client.go b/go-xmpp/_examples/xmpp_chat_client/xmpp_chat_client.go
new file mode 100644
index 0000000..51e3bcf
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_chat_client/xmpp_chat_client.go
@@ -0,0 +1,339 @@
+package main
+
+/*
+xmpp_chat_client is a demo client that connect on an XMPP server to chat with other members
+*/
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "flag"
+ "fmt"
+ "github.com/awesome-gocui/gocui"
+ "github.com/spf13/pflag"
+ "github.com/spf13/viper"
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+ "log"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ infoFormat = "====== "
+ // Default configuration
+ defaultConfigFilePath = "./"
+
+ configFileName = "config"
+ configType = "yaml"
+ logStanzasOn = "logger_on"
+ logFilePath = "logfile_path"
+ // Keys in config
+ serverAddressKey = "full_address"
+ clientJid = "jid"
+ clientPass = "pass"
+ configContactSep = ";"
+)
+
+var (
+ CorrespChan = make(chan string, 1)
+ textChan = make(chan string, 5)
+ rawTextChan = make(chan string, 5)
+ killChan = make(chan error, 1)
+ errChan = make(chan error)
+ rosterChan = make(chan struct{})
+
+ logger *log.Logger
+ disconnectErr = errors.New("disconnecting client")
+)
+
+type config struct {
+ Server map[string]string `mapstructure:"server"`
+ Client map[string]string `mapstructure:"client"`
+ Contacts string `string:"contact"`
+ LogStanzas map[string]string `mapstructure:"logstanzas"`
+}
+
+func main() {
+
+ // ============================================================
+ // Parse the flag with the config directory path as argument
+ flag.String("c", defaultConfigFilePath, "Provide a path to the directory that contains the configuration"+
+ " file you want to use. Config file should be named \"config\" and be in YAML format..")
+ pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
+ pflag.Parse()
+
+ // ==========================
+ // Read configuration
+ c := readConfig()
+
+ //================================
+ // Setup logger
+ on, err := strconv.ParseBool(c.LogStanzas[logStanzasOn])
+ if err != nil {
+ log.Panicln(err)
+ }
+ if on {
+ f, err := os.OpenFile(path.Join(c.LogStanzas[logFilePath], "logs.txt"), os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
+ if err != nil {
+ log.Panicln(err)
+ }
+ logger = log.New(f, "", log.Lshortfile|log.Ldate|log.Ltime)
+ logger.SetOutput(f)
+ defer f.Close()
+ }
+
+ // ==========================
+ // Create TUI
+ g, err := gocui.NewGui(gocui.OutputNormal, true)
+ if err != nil {
+ log.Panicln(err)
+ }
+ defer g.Close()
+ g.Highlight = true
+ g.Cursor = true
+ g.SelFgColor = gocui.ColorGreen
+ g.SetManagerFunc(layout)
+ setKeyBindings(g)
+
+ // ==========================
+ // Run TUI
+ go func() {
+ errChan <- g.MainLoop()
+ }()
+
+ // ==========================
+ // Start XMPP client
+ go startClient(g, c)
+
+ select {
+ case err := <-errChan:
+ if err == gocui.ErrQuit {
+ log.Println("Closing client.")
+ } else {
+ log.Panicln(err)
+ }
+ }
+}
+
+func startClient(g *gocui.Gui, config *config) {
+
+ // ==========================
+ // Client setup
+ clientCfg := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: config.Server[serverAddressKey],
+ },
+ Jid: config.Client[clientJid],
+ Credential: xmpp.Password(config.Client[clientPass]),
+ Insecure: true}
+
+ var client *xmpp.Client
+ var err error
+ router := xmpp.NewRouter()
+
+ handlerWithGui := func(_ xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if logger != nil {
+ m, _ := xml.Marshal(msg)
+ logger.Println(string(m))
+ }
+
+ v, err := g.View(chatLogWindow)
+ if !ok {
+ fmt.Fprintf(v, "%sIgnoring packet: %T\n", infoFormat, p)
+ return
+ }
+ if err != nil {
+ return
+ }
+ g.Update(func(g *gocui.Gui) error {
+ if msg.Error.Code != 0 {
+ _, err := fmt.Fprintf(v, "Error from server : %s : %s \n", msg.Error.Reason, msg.XMLName.Space)
+ return err
+ }
+ if len(strings.TrimSpace(msg.Body)) != 0 {
+ _, err := fmt.Fprintf(v, "%s : %s \n", msg.From, msg.Body)
+ return err
+ }
+ return nil
+ })
+ }
+
+ router.HandleFunc("message", handlerWithGui)
+ if client, err = xmpp.NewClient(clientCfg, router, errorHandler); err != nil {
+ log.Panicln(fmt.Sprintf("Could not create a new client ! %s", err))
+
+ }
+
+ // ==========================
+ // Client connection
+ if err = client.Connect(); err != nil {
+ msg := fmt.Sprintf("%sXMPP connection failed: %s", infoFormat, err)
+ g.Update(func(g *gocui.Gui) error {
+ v, err := g.View(chatLogWindow)
+ fmt.Fprintf(v, msg)
+ return err
+ })
+ fmt.Println("Failed to connect to server. Exiting...")
+ errChan <- servConnFail
+ return
+ }
+
+ // ==========================
+ // Start working
+ updateRosterFromConfig(config)
+ // Sending the default contact in a channel. Default value is the first contact in the list from the config.
+ viewState.currentContact = strings.Split(config.Contacts, configContactSep)[0]
+ // Informing user of the default contact
+ clw, _ := g.View(chatLogWindow)
+ fmt.Fprintf(clw, infoFormat+"Now sending messages to "+viewState.currentContact+" in a private conversation\n")
+ CorrespChan <- viewState.currentContact
+ startMessaging(client, config, g)
+}
+
+func startMessaging(client xmpp.Sender, config *config, g *gocui.Gui) {
+ var text string
+ var correspondent string
+ for {
+ select {
+ case err := <-killChan:
+ if err == disconnectErr {
+ sc := client.(xmpp.StreamClient)
+ sc.Disconnect()
+ } else {
+ logger.Println(err)
+ }
+ return
+ case text = <-textChan:
+ reply := stanza.Message{Attrs: stanza.Attrs{To: correspondent, Type: stanza.MessageTypeChat}, Body: text}
+ if logger != nil {
+ raw, _ := xml.Marshal(reply)
+ logger.Println(string(raw))
+ }
+ err := client.Send(reply)
+ if err != nil {
+ fmt.Printf("There was a problem sending the message : %v", reply)
+ return
+ }
+ case text = <-rawTextChan:
+ if logger != nil {
+ logger.Println(text)
+ }
+ err := client.SendRaw(text)
+ if err != nil {
+ fmt.Printf("There was a problem sending the message : %v", text)
+ return
+ }
+ case crrsp := <-CorrespChan:
+ correspondent = crrsp
+ case <-rosterChan:
+ askForRoster(client, g, config)
+ }
+
+ }
+}
+
+// Only reads and parses the configuration
+func readConfig() *config {
+ viper.SetConfigName(configFileName) // name of config file (without extension)
+ viper.BindPFlags(pflag.CommandLine)
+ viper.AddConfigPath(viper.GetString("c")) // path to look for the config file in
+ err := viper.ReadInConfig() // Find and read the config file
+ if err := viper.ReadInConfig(); err != nil {
+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+ log.Fatalf("%s %s", err, "Please make sure you give a path to the directory of the config and not to the config itself.")
+ } else {
+ log.Panicln(err)
+ }
+ }
+
+ viper.SetConfigType(configType)
+ var config config
+ err = viper.Unmarshal(&config)
+ if err != nil {
+ panic(fmt.Errorf("Unable to decode Config: %s \n", err))
+ }
+
+ // Check if we have contacts to message
+ if len(strings.TrimSpace(config.Contacts)) == 0 {
+ log.Panicln("You appear to have no contacts to message !")
+ }
+ // Check logging
+ config.LogStanzas[logFilePath] = path.Clean(config.LogStanzas[logFilePath])
+ on, err := strconv.ParseBool(config.LogStanzas[logStanzasOn])
+ if err != nil {
+ log.Panicln(err)
+ }
+ if d, e := isDirectory(config.LogStanzas[logFilePath]); (e != nil || !d) && on {
+ log.Panicln("The log file path could not be found or is not a directory.")
+ }
+
+ return &config
+}
+
+// If an error occurs, this is used to kill the client
+func errorHandler(err error) {
+ killChan <- err
+}
+
+// Read the client roster from the config. This does not check with the server that the roster is correct.
+// If user tries to send a message to someone not registered with the server, the server will return an error.
+func updateRosterFromConfig(config *config) {
+ viewState.contacts = append(strings.Split(config.Contacts, configContactSep), backFromContacts)
+ // Put a "go back" button at the end of the list
+ viewState.contacts = append(viewState.contacts, backFromContacts)
+}
+
+// Updates the menu panel of the view with the current user's roster, by asking the server.
+func askForRoster(client xmpp.Sender, g *gocui.Gui, config *config) {
+ // Craft a roster request
+ req := stanza.NewIQ(stanza.Attrs{From: config.Client[clientJid], Type: stanza.IQTypeGet})
+ req.RosterItems()
+ if logger != nil {
+ m, _ := xml.Marshal(req)
+ logger.Println(string(m))
+ }
+ ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
+
+ // Send the roster request to the server
+ c, err := client.SendIQ(ctx, req)
+ if err != nil {
+ logger.Panicln(err)
+ }
+
+ // Sending a IQ has a channel spawned to process the response once we receive it.
+ // In order not to block the client, we spawn a goroutine to update the TUI once the server has responded.
+ go func() {
+ serverResp := <-c
+ if logger != nil {
+ m, _ := xml.Marshal(serverResp)
+ logger.Println(string(m))
+ }
+ // Update contacts with the response from the server
+ chlw, _ := g.View(chatLogWindow)
+ if rosterItems, ok := serverResp.Payload.(*stanza.RosterItems); ok {
+ viewState.contacts = []string{}
+ for _, item := range rosterItems.Items {
+ viewState.contacts = append(viewState.contacts, item.Jid)
+ }
+ // Put a "go back" button at the end of the list
+ viewState.contacts = append(viewState.contacts, backFromContacts)
+ fmt.Fprintln(chlw, infoFormat+"Contacts list updated !")
+ return
+ }
+ fmt.Fprintln(chlw, infoFormat+"Failed to update contact list !")
+ }()
+}
+
+func isDirectory(path string) (bool, error) {
+ fileInfo, err := os.Stat(path)
+ if err != nil {
+ return false, err
+ }
+ return fileInfo.IsDir(), err
+}
diff --git a/go-xmpp/_examples/xmpp_component/README.md b/go-xmpp/_examples/xmpp_component/README.md
new file mode 100644
index 0000000..3cd25dd
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_component/README.md
@@ -0,0 +1,22 @@
+# xmpp_component
+
+This component will connect to ejabberd and act as a subdomain "service" of your primary XMPP domain
+(in that case localhost).
+
+This component does nothing expect connect and show up in service discovery.
+
+To be able to connect this component, you need to add a listener to your XMPP server.
+
+Here is an example ejabberd configuration for that component listener:
+
+```yaml
+listen:
+...
+ -
+ port: 8888
+ module: ejabberd_service
+ password: "mypass"
+```
+
+ejabberd will listen for a component (service) on port 8888 and allows it to connect using the
+secret "mypass".
\ No newline at end of file
diff --git a/go-xmpp/_examples/xmpp_component/xmpp_component.go b/go-xmpp/_examples/xmpp_component/xmpp_component.go
new file mode 100644
index 0000000..e3a70ce
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_component/xmpp_component.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+ "fmt"
+ "log"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ opts := xmpp.ComponentOptions{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: "localhost:8888",
+ Domain: "service2.localhost",
+ },
+ Domain: "service2.localhost",
+ Secret: "mypass",
+ Name: "Test Component",
+ Category: "gateway",
+ Type: "service",
+ }
+
+ router := xmpp.NewRouter()
+ router.HandleFunc("message", handleMessage)
+ router.NewRoute().
+ IQNamespaces(stanza.NSDiscoInfo).
+ HandlerFunc(func(s xmpp.Sender, p stanza.Packet) {
+ discoInfo(s, p, opts)
+ })
+ router.NewRoute().
+ IQNamespaces(stanza.NSDiscoItems).
+ HandlerFunc(discoItems)
+ router.NewRoute().
+ IQNamespaces("jabber:iq:version").
+ HandlerFunc(handleVersion)
+
+ component, err := xmpp.NewComponent(opts, router, handleError)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // If you pass the component to a stream manager, it will handle the reconnect policy
+ // for you automatically.
+ // TODO: Post Connect could be a feature of the router or the client. Move it somewhere else.
+ cm := xmpp.NewStreamManager(component, nil)
+ log.Fatal(cm.Run())
+}
+
+func handleError(err error) {
+ fmt.Println(err.Error())
+}
+
+func handleMessage(_ xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ return
+ }
+ fmt.Println("Received message:", msg.Body)
+}
+
+func discoInfo(c xmpp.Sender, p stanza.Packet, opts xmpp.ComponentOptions) {
+ // Type conversion & sanity checks
+ iq, ok := p.(*stanza.IQ)
+ if !ok || iq.Type != stanza.IQTypeGet {
+ return
+ }
+
+ iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"})
+ // TODO: fix this...
+ if err != nil {
+ return
+ }
+ disco := iqResp.DiscoInfo()
+ disco.AddIdentity(opts.Name, opts.Category, opts.Type)
+ disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1")
+ _ = c.Send(iqResp)
+}
+
+// TODO: Handle iq error responses
+func discoItems(c xmpp.Sender, p stanza.Packet) {
+ // Type conversion & sanity checks
+ iq, ok := p.(*stanza.IQ)
+ if !ok || iq.Type != stanza.IQTypeGet {
+ return
+ }
+
+ discoItems, ok := iq.Payload.(*stanza.DiscoItems)
+ if !ok {
+ return
+ }
+
+ // TODO: fix this...
+ iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"})
+ if err != nil {
+ return
+ }
+ items := iqResp.DiscoItems()
+
+ if discoItems.Node == "" {
+ items.AddItem("service.localhost", "node1", "test node")
+ }
+ _ = c.Send(iqResp)
+}
+
+func handleVersion(c xmpp.Sender, p stanza.Packet) {
+ // Type conversion & sanity checks
+ iq, ok := p.(*stanza.IQ)
+ if !ok {
+ return
+ }
+
+ iqResp, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id, Lang: "en"})
+ if err != nil {
+ return
+ }
+ iqResp.Version().SetInfo("Fluux XMPP Component", "0.0.1", "")
+ _ = c.Send(iqResp)
+}
diff --git a/go-xmpp/_examples/xmpp_component2/README.md b/go-xmpp/_examples/xmpp_component2/README.md
new file mode 100644
index 0000000..9ae4885
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_component2/README.md
@@ -0,0 +1,4 @@
+# xmpp_component2
+
+
+This program is an example of the simplest XMPP component: it connects to an XMPP server using XEP 114 protocol, perform a discovery query on the server and print the response.
diff --git a/go-xmpp/_examples/xmpp_component2/main.go b/go-xmpp/_examples/xmpp_component2/main.go
new file mode 100644
index 0000000..01415a0
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_component2/main.go
@@ -0,0 +1,79 @@
+package main
+
+/*
+
+Connect to an XMPP server using XEP 114 protocol, perform a discovery query on the server and print the response
+
+*/
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+const (
+ domain = "mycomponent.localhost"
+ address = "build.vpn.p1:8888"
+)
+
+// Init and return a component
+func makeComponent() *xmpp.Component {
+ opts := xmpp.ComponentOptions{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: address,
+ Domain: domain,
+ },
+ Domain: domain,
+ Secret: "secret",
+ }
+ router := xmpp.NewRouter()
+ c, err := xmpp.NewComponent(opts, router, handleError)
+ if err != nil {
+ panic(err)
+ }
+ return c
+}
+
+func handleError(err error) {
+ fmt.Println(err.Error())
+}
+
+func main() {
+ c := makeComponent()
+
+ // Connect Component to the server
+ fmt.Printf("Connecting to %v\n", address)
+ err := c.Connect()
+ if err != nil {
+ panic(err)
+ }
+
+ // make a disco iq
+ iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet,
+ From: domain,
+ To: "localhost",
+ Id: "my-iq1"})
+ if err != nil {
+ log.Fatalf("failed to create IQ: %v", err)
+ }
+ disco := iqReq.DiscoInfo()
+ iqReq.Payload = disco
+
+ // res is the channel used to receive the result iq
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ res, _ := c.SendIQ(ctx, iqReq)
+
+ select {
+ case iqResponse := <-res:
+ // Got response from server
+ fmt.Print(iqResponse.Payload)
+ case <-time.After(100 * time.Millisecond):
+ cancel()
+ panic("No iq response was received in time")
+ }
+}
diff --git a/go-xmpp/_examples/xmpp_echo/xmpp_echo.go b/go-xmpp/_examples/xmpp_echo/xmpp_echo.go
new file mode 100644
index 0000000..d7a35c8
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_echo/xmpp_echo.go
@@ -0,0 +1,56 @@
+/*
+xmpp_echo is a demo client that connect on an XMPP server and echo message received back to original sender.
+*/
+
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ config := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: "localhost:5222",
+ },
+ Jid: "test@localhost",
+ Credential: xmpp.Password("test"),
+ StreamLogger: os.Stdout,
+ Insecure: true,
+ // TLSConfig: tls.Config{InsecureSkipVerify: true},
+ }
+
+ router := xmpp.NewRouter()
+ router.HandleFunc("message", handleMessage)
+
+ client, err := xmpp.NewClient(&config, router, errorHandler)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // If you pass the client to a connection manager, it will handle the reconnect policy
+ // for you automatically.
+ cm := xmpp.NewStreamManager(client, nil)
+ log.Fatal(cm.Run())
+}
+
+func handleMessage(s xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p)
+ return
+ }
+
+ _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From)
+ reply := stanza.Message{Attrs: stanza.Attrs{To: msg.From}, Body: msg.Body}
+ _ = s.Send(reply)
+}
+
+func errorHandler(err error) {
+ fmt.Println(err.Error())
+}
diff --git a/go-xmpp/_examples/xmpp_jukebox/README.md b/go-xmpp/_examples/xmpp_jukebox/README.md
new file mode 100644
index 0000000..9eeb3a4
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_jukebox/README.md
@@ -0,0 +1,37 @@
+# Jukebox example
+
+## Requirements
+- You need mpg123 installed on your computer because the example runs it as a command :
+[Official MPG123 website](https://mpg123.de/)
+Most linux distributions have a package for it.
+- You need a soundcloud ID to play a music from the website through mpg123. You currently cannot play music files with this example.
+Your user ID is available in your account settings on the [soundcloud website](https://soundcloud.com/)
+**One is provided for convenience.**
+- You need a running jabber server. You can run your local instance of [ejabberd](https://www.ejabberd.im/) for example.
+- You need a registered user on the running jabber server.
+
+## Run
+You can edit the soundcloud ID in the example file with your own, or use the provided one :
+```go
+const scClientID = "dde6a0075614ac4f3bea423863076b22"
+```
+
+To run the example, build it with (while in the example directory) :
+```
+go build xmpp_jukebox.go
+```
+
+then run it with (update the command arguments accordingly):
+```
+./xmpp_jukebox -jid=MY_USERE@MY_DOMAIN/jukebox -password=MY_PASSWORD -address=MY_SERVER:MY_SERVER_PORT
+```
+Make sure to have a resource, for instance "/jukebox", on your jid.
+
+Then you can send the following stanza to "MY_USERE@MY_DOMAIN/jukebox" (with the resource) to play a song (update the soundcloud URL accordingly) :
+```xml
+
+
+
+
+
+```
diff --git a/go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go b/go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go
new file mode 100644
index 0000000..31f000a
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_jukebox/xmpp_jukebox.go
@@ -0,0 +1,149 @@
+// Can be launched with:
+// ./xmpp_jukebox -jid=test@localhost/jukebox -password=test -address=localhost:5222
+package main
+
+import (
+ "encoding/xml"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/processone/mpg123"
+ "github.com/processone/soundcloud"
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+// Get the actual song Stream URL from SoundCloud website song URL and play it with mpg123 player.
+const scClientID = "dde6a0075614ac4f3bea423863076b22"
+
+func main() {
+ jid := flag.String("jid", "", "jukebok XMPP Jid, resource is optional")
+ password := flag.String("password", "", "XMPP account password")
+ address := flag.String("address", "", "If needed, XMPP server DNSName or IP and optional port (ie myserver:5222)")
+ flag.Parse()
+
+ // 1. Create mpg player
+ player, err := mpg123.NewPlayer()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // 2. Prepare XMPP client
+ config := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: *address,
+ },
+ Jid: *jid,
+ Credential: xmpp.Password(*password),
+ // StreamLogger: os.Stdout,
+ Insecure: true,
+ }
+
+ router := xmpp.NewRouter()
+ router.NewRoute().
+ Packet("message").
+ HandlerFunc(func(s xmpp.Sender, p stanza.Packet) {
+ handleMessage(s, p, player)
+ })
+ router.NewRoute().
+ Packet("iq").
+ HandlerFunc(func(s xmpp.Sender, p stanza.Packet) {
+ handleIQ(s, p, player)
+ })
+
+ client, err := xmpp.NewClient(&config, router, errorHandler)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ cm := xmpp.NewStreamManager(client, nil)
+ log.Fatal(cm.Run())
+}
+func errorHandler(err error) {
+ fmt.Println(err.Error())
+}
+
+func handleMessage(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ return
+ }
+ command := strings.Trim(msg.Body, " ")
+ if command == "stop" {
+ player.Stop()
+ } else {
+ playSCURL(player, command)
+ sendUserTune(s, "Radiohead", "Spectre")
+ }
+}
+
+func handleIQ(s xmpp.Sender, p stanza.Packet, player *mpg123.Player) {
+ iq, ok := p.(*stanza.IQ)
+ if !ok {
+ return
+ }
+ switch payload := iq.Payload.(type) {
+ // We support IOT Control IQ
+ case *stanza.ControlSet:
+ var url string
+ for _, element := range payload.Fields {
+ if element.XMLName.Local == "string" && element.Name == "url" {
+ url = strings.Trim(element.Value, " ")
+ break
+ }
+ }
+
+ playSCURL(player, url)
+ setResponse := new(stanza.ControlSetResponse)
+ // FIXME: Broken
+ reply := stanza.IQ{Attrs: stanza.Attrs{To: iq.From, Type: "result", Id: iq.Id}, Payload: setResponse}
+ _ = s.Send(&reply)
+ // TODO add Soundclound artist / title retrieval
+ sendUserTune(s, "Radiohead", "Spectre")
+ default:
+ _, _ = fmt.Fprintf(os.Stdout, "Other IQ Payload: %T\n", iq.Payload)
+ }
+}
+
+func sendUserTune(s xmpp.Sender, artist string, title string) {
+ rq, err := stanza.NewPublishItemRq("localhost",
+ "http://jabber.org/protocol/tune",
+ "",
+ stanza.Item{
+ XMLName: xml.Name{Space: "http://jabber.org/protocol/tune", Local: "tune"},
+ Any: &stanza.Node{
+ Nodes: []stanza.Node{
+ {
+ XMLName: xml.Name{Local: "artist"},
+ Content: artist,
+ },
+ {
+ XMLName: xml.Name{Local: "title"},
+ Content: title,
+ },
+ },
+ },
+ })
+ if err != nil {
+ fmt.Printf("failed to build the publish request : %s", err.Error())
+ return
+ }
+ _ = s.Send(rq)
+}
+
+func playSCURL(p *mpg123.Player, rawURL string) {
+ songID, _ := soundcloud.GetSongID(rawURL)
+ // TODO: Maybe we need to check the track itself to get the stream URL from reply ?
+ url := soundcloud.FormatStreamURL(songID)
+
+ _ = p.Play(strings.ReplaceAll(url, "YOUR_SOUNDCLOUD_CLIENTID", scClientID))
+}
+
+// TODO
+// - Have a player API to play, play next, or add to queue
+// - Have the ability to parse custom packet to play sound
+// - Use PEP to display tunes status
+// - Ability to "speak" messages
diff --git a/go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go b/go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go
new file mode 100644
index 0000000..a993049
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_oauth2/xmpp_oauth2.go
@@ -0,0 +1,54 @@
+/*
+xmpp_oauth2 is a demo client that connect on an XMPP server using OAuth2 and prints received messages.
+*/
+
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ config := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: "localhost:5222",
+ // TLSConfig: tls.Config{InsecureSkipVerify: true},
+ },
+ Jid: "test@localhost",
+ Credential: xmpp.OAuthToken("OdAIsBlY83SLBaqQoClAn7vrZSHxixT8"),
+ StreamLogger: os.Stdout,
+ // Insecure: true,
+ }
+
+ router := xmpp.NewRouter()
+ router.HandleFunc("message", handleMessage)
+
+ client, err := xmpp.NewClient(&config, router, errorHandler)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // If you pass the client to a connection manager, it will handle the reconnect policy
+ // for you automatically.
+ cm := xmpp.NewStreamManager(client, nil)
+ log.Fatal(cm.Run())
+}
+
+func errorHandler(err error) {
+ fmt.Println(err.Error())
+}
+
+func handleMessage(s xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p)
+ return
+ }
+
+ _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From)
+}
diff --git a/go-xmpp/_examples/xmpp_pubsub_client/README.md b/go-xmpp/_examples/xmpp_pubsub_client/README.md
new file mode 100644
index 0000000..b86cedd
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_pubsub_client/README.md
@@ -0,0 +1,17 @@
+# PubSub client example
+
+## Description
+This is a simple example of a client that :
+* Creates a node on a service
+* Subscribes to that node
+* Publishes to that node
+* Gets the notification from the publication and prints it on screen
+
+## Requirements
+You need to have a running jabber server, like [ejabberd](https://www.ejabberd.im/) that supports [XEP-0060](https://xmpp.org/extensions/xep-0060.html).
+
+## How to use
+Just run :
+```
+ go run xmpp_ps_client.go
+```
diff --git a/go-xmpp/_examples/xmpp_pubsub_client/xmpp_ps_client.go b/go-xmpp/_examples/xmpp_pubsub_client/xmpp_ps_client.go
new file mode 100644
index 0000000..2308071
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_pubsub_client/xmpp_ps_client.go
@@ -0,0 +1,278 @@
+package main
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+ "log"
+ "time"
+)
+
+const (
+ userJID = "testuser2@localhost"
+ serverAddress = "localhost:5222"
+ nodeName = "lel_node"
+ serviceName = "pubsub.localhost"
+)
+
+var invalidResp = errors.New("invalid response")
+
+func main() {
+
+ config := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: serverAddress,
+ },
+ Jid: userJID,
+ Credential: xmpp.Password("pass123"),
+ // StreamLogger: os.Stdout,
+ Insecure: true,
+ }
+ router := xmpp.NewRouter()
+ router.NewRoute().Packet("message").
+ HandlerFunc(func(s xmpp.Sender, p stanza.Packet) {
+ data, _ := xml.Marshal(p)
+ log.Println("Received a message ! => \n" + string(data))
+ })
+
+ client, err := xmpp.NewClient(&config, router, func(err error) { log.Println(err) })
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // ==========================
+ // Client connection
+ err = client.Connect()
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // ==========================
+ // Create a node
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ createNode(ctx, cancel, client)
+
+ // ================================================================================
+ // Configure the node. This can also be done in a single message with the creation
+ configureNode(ctx, cancel, client)
+
+ // ====================================
+ // Subscribe to this node :
+ subToNode(ctx, cancel, client)
+
+ // ==========================
+ // Publish to that node
+ pubToNode(ctx, cancel, client)
+
+ // =============================
+ // Let's purge the node :
+ purgeRq, _ := stanza.NewPurgeAllItems(serviceName, nodeName)
+ purgeCh, err := client.SendIQ(ctx, purgeRq)
+ if err != nil {
+ log.Fatalf("could not send purge request: %v", err)
+ }
+ select {
+ case purgeResp := <-purgeCh:
+
+ if purgeResp.Type == stanza.IQTypeError {
+ cancel()
+ if vld, err := purgeResp.IsValid(); !vld {
+ log.Fatalf(invalidResp.Error()+" %v"+" reason: %v", purgeResp, err)
+ }
+ log.Fatalf("error while purging node : %s", purgeResp.Error.Text)
+ }
+ log.Println("node successfully purged")
+ case <-time.After(1000 * time.Millisecond):
+ cancel()
+ log.Fatal("No iq response was received in time while purging node")
+ }
+
+ cancel()
+}
+
+func createNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) {
+ rqCreate, err := stanza.NewCreateNode(serviceName, nodeName)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+ createCh, err := client.SendIQ(ctx, rqCreate)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ } else {
+
+ if createCh != nil {
+ select {
+ case respCr := <-createCh:
+ // Got response from server
+ if respCr.Type == stanza.IQTypeError {
+ if vld, err := respCr.IsValid(); !vld {
+ log.Fatalf(invalidResp.Error()+" %+v"+" reason: %s", respCr, err)
+ }
+ if respCr.Error.Reason != "conflict" {
+ log.Fatalf("%+v", respCr.Error.Text)
+ }
+ log.Println(respCr.Error.Text)
+ } else {
+ fmt.Print("successfully created channel")
+ }
+ case <-time.After(100 * time.Millisecond):
+ cancel()
+ log.Fatal("No iq response was received in time while creating node")
+ }
+ }
+ }
+}
+
+func configureNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) {
+ // First, ask for a form with the config options
+ confRq, _ := stanza.NewConfigureNode(serviceName, nodeName)
+ confReqCh, err := client.SendIQ(ctx, confRq)
+ if err != nil {
+ log.Fatalf("could not send iq : %v", err)
+ }
+ select {
+ case confForm := <-confReqCh:
+ // If the request was successful, we now have a form with configuration options to update
+ fields, err := confForm.GetFormFields()
+ if err != nil {
+ log.Fatal("No config fields found !")
+ }
+
+ // These are some common fields expected to be present. Change processing to your liking
+ if fields["pubsub#max_payload_size"] != nil {
+ fields["pubsub#max_payload_size"].ValuesList[0] = "100000"
+ }
+
+ if fields["pubsub#notification_type"] != nil {
+ fields["pubsub#notification_type"].ValuesList[0] = "headline"
+ }
+
+ // Send the modified fields as a form
+ submitConf, err := stanza.NewFormSubmissionOwner(serviceName,
+ nodeName,
+ []*stanza.Field{
+ fields["pubsub#max_payload_size"],
+ fields["pubsub#notification_type"],
+ })
+
+ c, _ := client.SendIQ(ctx, submitConf)
+ select {
+ case confResp := <-c:
+ if confResp.Type == stanza.IQTypeError {
+ cancel()
+ if vld, err := confResp.IsValid(); !vld {
+ log.Fatalf(invalidResp.Error()+" %v"+" reason: %v", confResp, err)
+ }
+ log.Fatalf("node configuration failed : %s", confResp.Error.Text)
+ }
+ log.Println("node configuration was successful")
+ return
+
+ case <-time.After(300 * time.Millisecond):
+ cancel()
+ log.Fatal("No iq response was received in time while configuring the node")
+ }
+
+ case <-time.After(300 * time.Millisecond):
+ cancel()
+ log.Fatal("No iq response was received in time while asking for the config form")
+ }
+}
+
+func subToNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) {
+ rqSubscribe, err := stanza.NewSubRq(serviceName, stanza.SubInfo{
+ Node: nodeName,
+ Jid: userJID,
+ })
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+ subRespCh, _ := client.SendIQ(ctx, rqSubscribe)
+ if subRespCh != nil {
+ select {
+ case <-subRespCh:
+ log.Println("Subscribed to the service")
+ case <-time.After(300 * time.Millisecond):
+ cancel()
+ log.Fatal("No iq response was received in time while subscribing")
+ }
+ }
+}
+
+func pubToNode(ctx context.Context, cancel context.CancelFunc, client *xmpp.Client) {
+ pub, err := stanza.NewPublishItemRq(serviceName, nodeName, "", stanza.Item{
+ Publisher: "testuser2",
+ Any: &stanza.Node{
+ XMLName: xml.Name{
+ Space: "http://www.w3.org/2005/Atom",
+ Local: "entry",
+ },
+ Nodes: []stanza.Node{
+ {
+ XMLName: xml.Name{Space: "", Local: "title"},
+ Attrs: nil,
+ Content: "My pub item title",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "summary"},
+ Attrs: nil,
+ Content: "My pub item content summary",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "link"},
+ Attrs: []xml.Attr{
+ {
+ Name: xml.Name{Space: "", Local: "rel"},
+ Value: "alternate",
+ },
+ {
+ Name: xml.Name{Space: "", Local: "type"},
+ Value: "text/html",
+ },
+ {
+ Name: xml.Name{Space: "", Local: "href"},
+ Value: "http://denmark.lit/2003/12/13/atom03",
+ },
+ },
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "id"},
+ Attrs: nil,
+ Content: "My pub item content ID",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "published"},
+ Attrs: nil,
+ Content: "2003-12-13T18:30:02Z",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "updated"},
+ Attrs: nil,
+ Content: "2003-12-13T18:30:02Z",
+ Nodes: nil,
+ },
+ },
+ },
+ })
+
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+ pubRespCh, _ := client.SendIQ(ctx, pub)
+ if pubRespCh != nil {
+ select {
+ case <-pubRespCh:
+ log.Println("Published item to the service")
+ case <-time.After(300 * time.Millisecond):
+ cancel()
+ log.Fatal("No iq response was received in time while publishing")
+ }
+ }
+}
diff --git a/go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go b/go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go
new file mode 100644
index 0000000..3a0c1ba
--- /dev/null
+++ b/go-xmpp/_examples/xmpp_websocket/xmpp_websocket.go
@@ -0,0 +1,52 @@
+/*
+xmpp_websocket is a demo client that connect on an XMPP server using websocket and prints received messages.ß
+*/
+
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func main() {
+ config := xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: "wss://localhost:5443/ws",
+ },
+ Jid: "test@localhost",
+ Credential: xmpp.Password("test"),
+ StreamLogger: os.Stdout,
+ }
+
+ router := xmpp.NewRouter()
+ router.HandleFunc("message", handleMessage)
+
+ client, err := xmpp.NewClient(&config, router, errorHandler)
+ if err != nil {
+ log.Fatalf("%+v", err)
+ }
+
+ // If you pass the client to a connection manager, it will handle the reconnect policy
+ // for you automatically.
+ cm := xmpp.NewStreamManager(client, nil)
+ log.Fatal(cm.Run())
+}
+
+func errorHandler(err error) {
+ fmt.Println(err.Error())
+}
+
+func handleMessage(s xmpp.Sender, p stanza.Packet) {
+ msg, ok := p.(stanza.Message)
+ if !ok {
+ _, _ = fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", p)
+ return
+ }
+
+ _, _ = fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", msg.Body, msg.From)
+}
diff --git a/go-xmpp/auth.go b/go-xmpp/auth.go
new file mode 100644
index 0000000..902371b
--- /dev/null
+++ b/go-xmpp/auth.go
@@ -0,0 +1,105 @@
+package xmpp
+
+import (
+ "encoding/base64"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// Credential is used to pass the type of secret that will be used to connect to XMPP server.
+// It can be either a password or an OAuth 2 bearer token.
+type Credential struct {
+ secret string
+ mechanisms []string
+}
+
+func Password(pwd string) Credential {
+ credential := Credential{
+ secret: pwd,
+ mechanisms: []string{"PLAIN"},
+ }
+ return credential
+}
+
+func OAuthToken(token string) Credential {
+ credential := Credential{
+ secret: token,
+ mechanisms: []string{"X-OAUTH2"},
+ }
+ return credential
+}
+
+// ============================================================================
+// Authentication flow for SASL mechanisms
+
+func authSASL(socket io.ReadWriter, decoder *xml.Decoder, f stanza.StreamFeatures, user string, credential Credential) (err error) {
+ var matchingMech string
+ for _, mech := range credential.mechanisms {
+ if isSupportedMech(mech, f.Mechanisms.Mechanism) {
+ matchingMech = mech
+ break
+ }
+ }
+
+ switch matchingMech {
+ case "PLAIN", "X-OAUTH2":
+ // TODO: Implement other type of SASL mechanisms
+ return authPlain(socket, decoder, matchingMech, user, credential.secret)
+ default:
+ err := fmt.Errorf("no matching authentication (%v) supported by server: %v", credential.mechanisms, f.Mechanisms.Mechanism)
+ return NewConnError(err, true)
+ }
+}
+
+// Plain authentication: send base64-encoded \x00 user \x00 password
+func authPlain(socket io.ReadWriter, decoder *xml.Decoder, mech string, user string, secret string) error {
+ raw := "\x00" + user + "\x00" + secret
+ enc := make([]byte, base64.StdEncoding.EncodedLen(len(raw)))
+ base64.StdEncoding.Encode(enc, []byte(raw))
+
+ a := stanza.SASLAuth{
+ Mechanism: mech,
+ Value: string(enc),
+ }
+ data, err := xml.Marshal(a)
+ if err != nil {
+ return err
+ }
+ n, err := socket.Write(data)
+ if err != nil {
+ return err
+ } else if n == 0 {
+ return errors.New("failed to write authSASL nonza to socket : wrote 0 bytes")
+ }
+
+ // Next message should be either success or failure.
+ val, err := stanza.NextPacket(decoder)
+ if err != nil {
+ return err
+ }
+
+ switch v := val.(type) {
+ case stanza.SASLSuccess:
+ case stanza.SASLFailure:
+ // v.Any is type of sub-element in failure, which gives a description of what failed.
+ err := errors.New("auth failure: " + v.Any.Local)
+ return NewConnError(err, true)
+ default:
+ return errors.New("expected SASL success or failure, got " + v.Name())
+ }
+ return err
+}
+
+// isSupportedMech returns true if the mechanism is supported in the provided list.
+func isSupportedMech(mech string, mechanisms []string) bool {
+ for _, m := range mechanisms {
+ if mech == m {
+ return true
+ }
+ }
+ return false
+}
diff --git a/go-xmpp/backoff.go b/go-xmpp/backoff.go
new file mode 100644
index 0000000..2dfec8d
--- /dev/null
+++ b/go-xmpp/backoff.go
@@ -0,0 +1,101 @@
+/*
+Interesting reference on backoff:
+- Exponential Backoff And Jitter (AWS Blog):
+ https://www.awsarchitectureblog.com/2015/03/backoff.html
+
+We use Jitter as a default for exponential backoff, as the goal of
+this module is not to provide precise 'ticks', but good behaviour to
+implement retries that are helping the server to recover faster in
+case of congestion.
+
+It can be used in several ways:
+- Using duration to get next sleep time.
+- 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)
+- Use lock in your own code to protect the Backoff structure.
+
+TODO: Implement Backoff Ticker channel
+TODO: Implement throttler interface. Throttler could be used to implement various reconnect strategies.
+*/
+
+package xmpp
+
+import (
+ "math"
+ "math/rand"
+ "time"
+)
+
+const (
+ defaultBase int = 20 // Backoff base, in ms
+ defaultFactor int = 2
+ defaultCap int = 180000 // 3 minutes
+)
+
+// 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 {
+ NoJitter bool
+ Base int
+ Factor int
+ Cap int
+ lastDuration int
+ attempt int
+}
+
+// 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())
+}
+
+// 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))
+ if !b.NoJitter {
+ d = rand.Intn(d)
+ }
+ return time.Duration(d) * time.Millisecond
+}
+
+// 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() {
+ b.attempt = 0
+}
+
+func (b *backoff) setDefault() {
+ if b.Base == 0 {
+ b.Base = defaultBase
+ }
+
+ if b.Cap == 0 {
+ b.Cap = defaultCap
+ }
+
+ if b.Factor == 0 {
+ b.Factor = defaultFactor
+ }
+}
+
+/*
+We use full jitter as default for now as it seems to provide good behaviour for reconnect.
+
+Base is the default interval between attempts (if backoff Factor was equal to 1)
+
+Attempt is the number of retry for operation. If we start attempt at 0, first sleep equals base.
+
+Cap is the maximum sleep time duration we tolerate between attempts
+*/
diff --git a/go-xmpp/backoff_test.go b/go-xmpp/backoff_test.go
new file mode 100644
index 0000000..9a7fde7
--- /dev/null
+++ b/go-xmpp/backoff_test.go
@@ -0,0 +1,22 @@
+package xmpp
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDurationForAttempt_NoJitter(t *testing.T) {
+ 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)
+ }
+ var prevDuration, d time.Duration
+ for i := 0; i < 10; i++ {
+ d = b.durationForAttempt(i)
+ if !(d >= prevDuration) {
+ t.Errorf("duration should be increasing between attempts. #%d (%d) > %d", i, d, prevDuration)
+ }
+ prevDuration = d
+ }
+}
diff --git a/go-xmpp/bi_dir_iterator.go b/go-xmpp/bi_dir_iterator.go
new file mode 100644
index 0000000..5293b1b
--- /dev/null
+++ b/go-xmpp/bi_dir_iterator.go
@@ -0,0 +1,12 @@
+package xmpp
+
+type BiDirIterator interface {
+ // Next returns the next element of this iterator, if a response is available within t milliseconds
+ Next(t int) (BiDirIteratorElt, error)
+ // Previous returns the previous element of this iterator, if a response is available within t milliseconds
+ Previous(t int) (BiDirIteratorElt, error)
+}
+
+type BiDirIteratorElt interface {
+ NoOp()
+}
diff --git a/go-xmpp/cert_checker.go b/go-xmpp/cert_checker.go
new file mode 100644
index 0000000..30a265a
--- /dev/null
+++ b/go-xmpp/cert_checker.go
@@ -0,0 +1,151 @@
+package xmpp
+
+import (
+ "crypto/tls"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// TODO: Should I move this as an extension of the client?
+// I should probably make the code more modular, but keep concern separated to keep it simple.
+type ServerCheck struct {
+ address string
+ domain string
+}
+
+func NewChecker(address, domain string) (*ServerCheck, error) {
+ client := ServerCheck{}
+
+ var err error
+ var host string
+ if client.address, host, err = extractParams(address); err != nil {
+ return &client, err
+ }
+
+ if domain != "" {
+ client.domain = domain
+ } else {
+ client.domain = host
+ }
+
+ return &client, nil
+}
+
+// Check triggers actual TCP connection, based on previously defined parameters.
+func (c *ServerCheck) Check() error {
+ var tcpconn net.Conn
+ var err error
+
+ timeout := 15 * time.Second
+ tcpconn, err = net.DialTimeout("tcp", c.address, timeout)
+ if err != nil {
+ return err
+ }
+
+ decoder := xml.NewDecoder(tcpconn)
+
+ // Send stream open tag
+ if _, err = fmt.Fprintf(tcpconn, clientStreamOpen, c.domain); err != nil {
+ return err
+ }
+
+ // Set xml decoder and extract streamID from reply (not used for now)
+ _, err = stanza.InitStream(decoder)
+ if err != nil {
+ return err
+ }
+
+ // extract stream features
+ var f stanza.StreamFeatures
+ packet, err := stanza.NextPacket(decoder)
+ if err != nil {
+ err = fmt.Errorf("stream open decode features: %s", err)
+ return err
+ }
+
+ switch p := packet.(type) {
+ case stanza.StreamFeatures:
+ f = p
+ case stanza.StreamError:
+ return errors.New("open stream error: " + p.Error.Local)
+ default:
+ return errors.New("expected packet received while expecting features, got " + p.Name())
+ }
+
+ if _, ok := f.DoesStartTLS(); ok {
+ _, err = fmt.Fprintf(tcpconn, "")
+ if err != nil {
+ return err
+ }
+
+ var k stanza.TLSProceed
+ if err = decoder.DecodeElement(&k, nil); err != nil {
+ return fmt.Errorf("expecting starttls proceed: %s", err)
+ }
+
+ var tlsConfig tls.Config
+ tlsConfig.ServerName = c.domain
+ tlsConn := tls.Client(tcpconn, &tlsConfig)
+ // We convert existing connection to TLS
+ if err = tlsConn.Handshake(); err != nil {
+ return err
+ }
+
+ // We check that cert matches hostname
+ if err = tlsConn.VerifyHostname(c.domain); err != nil {
+ return err
+ }
+
+ if err = checkExpiration(tlsConn); err != nil {
+ return err
+ }
+ return nil
+ }
+ return errors.New("TLS not supported on server")
+}
+
+// Check expiration date for the whole certificate chain and returns an error
+// if the expiration date is in less than 48 hours.
+func checkExpiration(tlsConn *tls.Conn) error {
+ checkedCerts := make(map[string]struct{})
+ for _, chain := range tlsConn.ConnectionState().VerifiedChains {
+ for _, cert := range chain {
+ if _, checked := checkedCerts[string(cert.Signature)]; checked {
+ continue
+ }
+ checkedCerts[string(cert.Signature)] = struct{}{}
+
+ // Check the expiration.
+ timeNow := time.Now()
+ expiresInHours := int64(cert.NotAfter.Sub(timeNow).Hours())
+ // fmt.Printf("Cert '%s' expires in %d days\n", cert.Subject.CommonName, expiresInHours/24)
+ if expiresInHours <= 48 {
+ return fmt.Errorf("certificate '%s' will expire on %s", cert.Subject.CommonName, cert.NotAfter)
+ }
+ }
+ }
+ return nil
+}
+
+func extractParams(addr string) (string, string, error) {
+ var err error
+ hostport := strings.Split(addr, ":")
+ if len(hostport) > 2 {
+ err = errors.New("too many colons in xmpp server address")
+ return addr, hostport[0], err
+ }
+
+ // Address is composed of two parts, we are good
+ if len(hostport) == 2 && hostport[1] != "" {
+ return addr, hostport[0], err
+ }
+
+ // Port was not passed, we append XMPP default port:
+ return strings.Join([]string{hostport[0], "5222"}, ":"), hostport[0], err
+}
diff --git a/go-xmpp/client.go b/go-xmpp/client.go
new file mode 100644
index 0000000..0a29605
--- /dev/null
+++ b/go-xmpp/client.go
@@ -0,0 +1,449 @@
+package xmpp
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+//=============================================================================
+// EventManager
+
+// SyncConnState represents the current connection state.
+type SyncConnState struct {
+ sync.RWMutex
+ // Current state of the client. Please use the dedicated getter and setter for this field as they are thread safe.
+ state ConnState
+}
+type ConnState = uint8
+
+// getState is a thread-safe getter for the current state
+func (scs *SyncConnState) getState() ConnState {
+ var res ConnState
+ scs.RLock()
+ res = scs.state
+ scs.RUnlock()
+ return res
+}
+
+// setState is a thread-safe setter for the current
+func (scs *SyncConnState) setState(cs ConnState) {
+ scs.Lock()
+ scs.state = cs
+ scs.Unlock()
+}
+
+// This is a the list of events happening on the connection that the
+// client can be notified about.
+const (
+ StateDisconnected ConnState = iota
+ StateResuming
+ StateSessionEstablished
+ StateStreamError
+ StatePermanentError
+ InitialPresence = ""
+)
+
+// Event is a structure use to convey event changes related to client state. This
+// is for example used to notify the client when the client get disconnected.
+type Event struct {
+ State SyncConnState
+ Description string
+ StreamError string
+ SMState SMState
+}
+
+// SMState holds Stream Management information regarding the session that can be
+// used to resume session after disconnect
+type SMState struct {
+ // Stream Management ID
+ Id string
+ // Inbound stanza count
+ Inbound uint
+
+ // IP affinity
+ preferredReconAddr string
+
+ // Error
+ StreamErrorGroup stanza.StanzaErrorGroup
+
+ // Track sent stanzas
+ *stanza.UnAckQueue
+
+ // TODO Store max and timestamp, to check if we should retry resumption or not
+}
+
+// EventHandler is use to pass events about state of the connection to
+// client implementation.
+type EventHandler func(Event) error
+
+type EventManager struct {
+ // Store current state. Please use "getState" and "setState" to access and/or modify this.
+ CurrentState SyncConnState
+
+ // Callback used to propagate connection state changes
+ Handler EventHandler
+}
+
+// updateState changes the CurrentState in the event manager. The state read is threadsafe but there is no guarantee
+// regarding the triggered callback function.
+func (em *EventManager) updateState(state ConnState) {
+ em.CurrentState.setState(state)
+ if em.Handler != nil {
+ em.Handler(Event{State: em.CurrentState})
+ }
+}
+
+// disconnected changes the CurrentState in the event manager to "disconnected". The state read is threadsafe but there is no guarantee
+// regarding the triggered callback function.
+func (em *EventManager) disconnected(state SMState) {
+ em.CurrentState.setState(StateDisconnected)
+ if em.Handler != nil {
+ em.Handler(Event{State: em.CurrentState, SMState: state})
+ }
+}
+
+// streamError changes the CurrentState in the event manager to "streamError". The state read is threadsafe but there is no guarantee
+// regarding the triggered callback function.
+func (em *EventManager) streamError(error, desc string) {
+ em.CurrentState.setState(StateStreamError)
+ if em.Handler != nil {
+ em.Handler(Event{State: em.CurrentState, StreamError: error, Description: desc})
+ }
+}
+
+// Client
+// ============================================================================
+
+var ErrCanOnlySendGetOrSetIq = errors.New("SendIQ can only send get and set IQ stanzas")
+
+// Client is the main structure used to connect as a client on an XMPP
+// server.
+type Client struct {
+ // Store user defined options and states
+ config *Config
+ // Session gather data that can be accessed by users of this library
+ Session *Session
+ transport Transport
+ // Router is used to dispatch packets
+ router *Router
+ // Track and broadcast connection state
+ EventManager
+ // Handle errors from client execution
+ ErrorHandler func(error)
+
+ // Post connection hook. This will be executed on first connection
+ PostConnectHook func() error
+
+ // Post resume hook. This will be executed after the client resumes a lost connection using StreamManagement (XEP-0198)
+ PostResumeHook func() error
+}
+
+/*
+Setting up the client / Checking the parameters
+*/
+
+// NewClient generates a new XMPP client, based on Config passed as parameters.
+// If host is not specified, the DNS SRV should be used to find the host from the domain part of the Jid.
+// Default the port to 5222.
+func NewClient(config *Config, r *Router, errorHandler func(error)) (c *Client, err error) {
+ if config.KeepaliveInterval == 0 {
+ config.KeepaliveInterval = time.Second * 30
+ }
+ // Parse Jid
+ if config.parsedJid, err = stanza.NewJid(config.Jid); err != nil {
+ err = errors.New("missing jid")
+ return nil, NewConnError(err, true)
+ }
+
+ if config.Credential.secret == "" {
+ err = errors.New("missing credential")
+ return nil, NewConnError(err, true)
+ }
+
+ // Fallback to jid domain
+ if config.Address == "" {
+ config.Address = config.parsedJid.Domain
+
+ // Fetch SRV DNS-Entries
+ _, srvEntries, err := net.LookupSRV("xmpp-client", "tcp", config.parsedJid.Domain)
+
+ if err == nil && len(srvEntries) > 0 {
+ // If we found matching DNS records, use the entry with highest weight
+ bestSrv := srvEntries[0]
+ for _, srv := range srvEntries {
+ if srv.Priority <= bestSrv.Priority && srv.Weight >= bestSrv.Weight {
+ bestSrv = srv
+ config.Address = ensurePort(srv.Target, int(srv.Port))
+ }
+ }
+ }
+ }
+ if config.Domain == "" {
+ // Fallback to jid domain
+ config.Domain = config.parsedJid.Domain
+ }
+
+ c = new(Client)
+ c.config = config
+ c.router = r
+ c.ErrorHandler = errorHandler
+
+ if c.config.ConnectTimeout == 0 {
+ c.config.ConnectTimeout = 15 // 15 second as default
+ }
+
+ if config.TransportConfiguration.Domain == "" {
+ config.TransportConfiguration.Domain = config.parsedJid.Domain
+ }
+ c.config.TransportConfiguration.ConnectTimeout = c.config.ConnectTimeout
+ c.transport = NewClientTransport(c.config.TransportConfiguration)
+
+ if config.StreamLogger != nil {
+ c.transport.LogTraffic(config.StreamLogger)
+ }
+
+ return
+}
+
+// Connect establishes a first time connection to a XMPP server.
+// It calls the PostConnectHook
+func (c *Client) Connect() error {
+ err := c.connect()
+ if err != nil {
+ return err
+ }
+ // TODO: Do we always want to send initial presence automatically ?
+ // Do we need an option to avoid that or do we rely on client to send the presence itself ?
+ err = c.sendWithWriter(c.transport, []byte(InitialPresence))
+ // Execute the post first connection hook. Typically this holds "ask for roster" and this type of actions.
+ if c.PostConnectHook != nil {
+ err = c.PostConnectHook()
+ if err != nil {
+ return err
+ }
+ }
+
+ // Start the keepalive go routine
+ keepaliveQuit := make(chan struct{})
+ go keepalive(c.transport, c.config.KeepaliveInterval, keepaliveQuit)
+ // Start the receiver go routine
+ go c.recv(keepaliveQuit)
+ return err
+}
+
+// connect establishes an actual TCP connection, based on previously defined parameters, as well as a XMPP session
+func (c *Client) connect() error {
+ var state SMState
+ var err error
+ // This is the TCP connection
+ streamId, err := c.transport.Connect()
+ if err != nil {
+ return err
+ }
+
+ // Client is ok, we now open XMPP session with TLS negotiation if possible and session resume or binding
+ // depending on state.
+ if c.Session, err = NewSession(c, state); err != nil {
+ // Try to get the stream close tag from the server.
+ go func() {
+ for {
+ val, err := stanza.NextPacket(c.transport.GetDecoder())
+ if err != nil {
+ c.ErrorHandler(err)
+ c.disconnected(state)
+ return
+ }
+ switch val.(type) {
+ case stanza.StreamClosePacket:
+ // TCP messages should arrive in order, so we can expect to get nothing more after this occurs
+ c.transport.ReceivedStreamClose()
+ return
+ }
+ }
+ }()
+ c.Disconnect()
+ return err
+ }
+ c.Session.StreamId = streamId
+ c.updateState(StateSessionEstablished)
+
+ return err
+}
+
+// Resume attempts resuming a Stream Managed session, based on the provided stream management
+// state. See XEP-0198
+func (c *Client) Resume() error {
+ c.EventManager.updateState(StateResuming)
+ err := c.connect()
+ if err != nil {
+ return err
+ }
+ // Execute post reconnect hook. This can be different from the first connection hook, and not trigger roster retrieval
+ // for example.
+ if c.PostResumeHook != nil {
+ err = c.PostResumeHook()
+ }
+ return err
+}
+
+// Disconnect disconnects the client from the server, sending a stream close nonza and closing the TCP connection.
+func (c *Client) Disconnect() error {
+ if c.transport != nil {
+ err := c.transport.Close()
+ if c.Session != nil {
+ c.disconnected(c.Session.SMState)
+ }
+ return err
+ }
+ // No transport so no connection.
+ return nil
+}
+
+func (c *Client) SetHandler(handler EventHandler) {
+ c.Handler = handler
+}
+
+// Send marshals XMPP stanza and sends it to the server.
+func (c *Client) Send(packet stanza.Packet) error {
+ conn := c.transport
+ if conn == nil {
+ return errors.New("client is not connected")
+ }
+
+ data, err := xml.Marshal(packet)
+ if err != nil {
+ return errors.New("cannot marshal packet " + err.Error())
+ }
+
+ // Store stanza as non-acked as part of stream management
+ // See https://xmpp.org/extensions/xep-0198.html#scenarios
+ if c.config.StreamManagementEnable {
+ if _, ok := packet.(stanza.SMRequest); !ok {
+ toStore := stanza.UnAckedStz{Stz: string(data)}
+ c.Session.SMState.UnAckQueue.Push(&toStore)
+ }
+ }
+
+ return c.sendWithWriter(c.transport, data)
+}
+
+// SendIQ sends an IQ set or get stanza to the server. If a result is received
+// the provided handler function will automatically be called.
+//
+// The provided context should have a timeout to prevent the client from waiting
+// forever for an IQ result. For example:
+//
+// ctx, _ := context.WithTimeout(context.Background(), 30 * time.Second)
+// result := <- client.SendIQ(ctx, iq)
+//
+func (c *Client) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
+ if iq.Attrs.Type != stanza.IQTypeSet && iq.Attrs.Type != stanza.IQTypeGet {
+ return nil, ErrCanOnlySendGetOrSetIq
+ }
+ if err := c.Send(iq); err != nil {
+ return nil, err
+ }
+ return c.router.NewIQResultRoute(ctx, iq.Attrs.Id), nil
+}
+
+// SendRaw sends an XMPP stanza as a string to the server.
+// It can be invalid XML or XMPP content. In that case, the server will
+// disconnect the client. It is up to the user of this method to
+// carefully craft the XML content to produce valid XMPP.
+func (c *Client) SendRaw(packet string) error {
+ conn := c.transport
+ if conn == nil {
+ return errors.New("client is not connected")
+ }
+
+ // Store stanza as non-acked as part of stream management
+ // See https://xmpp.org/extensions/xep-0198.html#scenarios
+ if c.config.StreamManagementEnable {
+ toStore := stanza.UnAckedStz{Stz: packet}
+ c.Session.SMState.UnAckQueue.Push(&toStore)
+ }
+ return c.sendWithWriter(c.transport, []byte(packet))
+}
+
+func (c *Client) sendWithWriter(writer io.Writer, packet []byte) error {
+ var err error
+ _, err = writer.Write(packet)
+ return err
+}
+
+// ============================================================================
+// Go routines
+
+// Loop: Receive data from server
+func (c *Client) recv(keepaliveQuit chan<- struct{}) {
+ defer close(keepaliveQuit)
+
+ for {
+ val, err := stanza.NextPacket(c.transport.GetDecoder())
+ if err != nil {
+ c.ErrorHandler(err)
+ c.disconnected(c.Session.SMState)
+ return
+ }
+
+ // Handle stream errors
+ switch packet := val.(type) {
+ case stanza.StreamError:
+ c.router.route(c, val)
+ c.streamError(packet.Error.Local, packet.Text)
+ c.ErrorHandler(errors.New("stream error: " + packet.Error.Local))
+ // We don't return here, because we want to wait for the stream close tag from the server, or timeout.
+ c.Disconnect()
+ // Process Stream management nonzas
+ case stanza.SMRequest:
+ answer := stanza.SMAnswer{XMLName: xml.Name{
+ Space: stanza.NSStreamManagement,
+ Local: "a",
+ }, H: c.Session.SMState.Inbound}
+ err = c.Send(answer)
+ if err != nil {
+ c.ErrorHandler(err)
+ return
+ }
+ case stanza.StreamClosePacket:
+ // TCP messages should arrive in order, so we can expect to get nothing more after this occurs
+ c.transport.ReceivedStreamClose()
+ c.Disconnect()
+ continue
+ default:
+ c.Session.SMState.Inbound++
+ }
+ // Do normal route processing in a go-routine so we can immediately
+ // start receiving other stanzas. This also allows route handlers to
+ // send and receive more stanzas.
+ go c.router.route(c, val)
+ }
+}
+
+// Loop: send whitespace keepalive to server
+// This is use to keep the connection open, but also to detect connection loss
+// and trigger proper client connection shutdown.
+func keepalive(transport Transport, interval time.Duration, quit <-chan struct{}) {
+ ticker := time.NewTicker(interval)
+ for {
+ select {
+ case <-ticker.C:
+ if err := transport.Ping(); err != nil {
+ // When keepalive fails, we force close the transport. In all cases, the recv will also fail.
+ ticker.Stop()
+ _ = transport.Close()
+ return
+ }
+ case <-quit:
+ ticker.Stop()
+ return
+ }
+ }
+}
diff --git a/go-xmpp/client_internal_test.go b/go-xmpp/client_internal_test.go
new file mode 100644
index 0000000..140eab7
--- /dev/null
+++ b/go-xmpp/client_internal_test.go
@@ -0,0 +1,608 @@
+package xmpp
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "gosrc.io/xmpp/stanza"
+ "strconv"
+ "testing"
+ "time"
+)
+
+const (
+ streamManagementID = "test-stream_management-id"
+)
+
+func TestClient_Send(t *testing.T) {
+ buffer := bytes.NewBufferString("")
+ client := Client{}
+ data := []byte("https://da.wikipedia.org/wiki/J%C3%A6vnd%C3%B8gn")
+ if err := client.sendWithWriter(buffer, data); err != nil {
+ t.Errorf("Writing failed: %v", err)
+ }
+
+ if buffer.String() != string(data) {
+ t.Errorf("Incorrect value sent to buffer: '%s'", buffer.String())
+ }
+}
+
+// Stream management test.
+// Connection is established, then the server sends supported features and so on.
+// After the bind, client attempts a stream management enablement, and server replies in kind.
+func Test_StreamManagement(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+
+ client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ enableStreamManagement(t, sc, false, true)
+ serverDone <- struct{}{}
+ }, testClientStreamManagement, true, true)
+ go func() {
+ var state SMState
+ var err error
+ // Client is ok, we now open XMPP session
+ if client.Session, err = NewSession(client, state); err != nil {
+ t.Fatalf("failed to open XMPP session: %s", err)
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, clientDone)
+ waitForEntity(t, serverDone)
+ mock.Stop()
+}
+
+// Absence of stream management test.
+// Connection is established, then the server sends supported features and so on.
+// Client has stream management disabled in its config, and should not ask for it. Server is not set up to reply.
+func Test_NoStreamManagement(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+
+ // Setup Mock server
+ client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesNoStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ serverDone <- struct{}{}
+ }, testClientStreamManagement, true, false)
+
+ go func() {
+ var state SMState
+
+ // Client is ok, we now open XMPP session
+ var err error
+ if client.Session, err = NewSession(client, state); err != nil {
+ t.Fatalf("failed to open XMPP session: %s", err)
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, clientDone)
+ waitForEntity(t, serverDone)
+
+ mock.Stop()
+}
+
+func Test_StreamManagementNotSupported(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+
+ client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesNoStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ serverDone <- struct{}{}
+ }, testClientStreamManagement, true, true)
+
+ go func() {
+ var state SMState
+ var err error
+ // Client is ok, we now open XMPP session
+ if client.Session, err = NewSession(client, state); err != nil {
+ t.Fatalf("failed to open XMPP session: %s", err)
+ }
+ clientDone <- struct{}{}
+ }()
+
+ // Wait for client
+ waitForEntity(t, clientDone)
+
+ // Check if client got a positive stream management response from the server
+ if client.Session.Features.DoesStreamManagement() {
+ t.Fatalf("server does not provide stream management")
+ }
+
+ // Wait for server
+ waitForEntity(t, serverDone)
+ mock.Stop()
+}
+
+func Test_StreamManagementNoResume(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+
+ client, mock := initSrvCliForResumeTests(t, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ enableStreamManagement(t, sc, false, false)
+ serverDone <- struct{}{}
+ }, testClientStreamManagement, true, true)
+
+ go func() {
+ var state SMState
+ var err error
+ // Client is ok, we now open XMPP session
+ if client.Session, err = NewSession(client, state); err != nil {
+ t.Fatalf("failed to open XMPP session: %s", err)
+ }
+ clientDone <- struct{}{}
+ }()
+ waitForEntity(t, clientDone)
+ if IsStreamResumable(client) {
+ t.Fatalf("server does not support resumption but client says stream is resumable")
+ }
+ waitForEntity(t, serverDone)
+ mock.Stop()
+}
+
+func Test_StreamManagementResume(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ enableStreamManagement(t, sc, false, true)
+ discardPresence(t, sc)
+ serverDone <- struct{}{}
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true,
+ StreamManagementEnable: true,
+ streamManagementResume: true} // Enable stream management
+
+ var client *Client
+ router := NewRouter()
+ client, err := NewClient(&config, router, clientDefaultErrorHandler)
+ if err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ // =================================================================
+ // Connect client, then disconnect it so we can resume the session
+ go func() {
+ err = client.Connect()
+ if err != nil {
+ t.Fatalf("could not connect client to mock server: %s", err)
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, clientDone)
+
+ // ===========================================================================================
+ // Check that the client correctly went into "disconnected" state, after being disconnected
+ statusCorrectChan := make(chan struct{})
+ kill := make(chan struct{})
+
+ transp, ok := client.transport.(*XMPPTransport)
+ if !ok {
+ t.Fatalf("problem with client transport ")
+ }
+
+ transp.conn.Close()
+
+ waitForEntity(t, serverDone)
+ mock.Stop()
+
+ go checkClientResumeStatus(client, statusCorrectChan, kill)
+ select {
+ case <-statusCorrectChan:
+ // Test passed
+ case <-time.After(5 * time.Second):
+ kill <- struct{}{}
+ t.Fatalf("Client is not in disconnected state while it should be. Timed out")
+ }
+
+ // Check if the client can have its connection resumed using its state but also its configuration
+ if !IsStreamResumable(client) {
+ t.Fatalf("should support resumption")
+ }
+
+ // Reboot server. We need to make a new one because (at least for now) the mock server can only have one handler
+ // and they should be different between a first connection and a stream resume since exchanged messages
+ // are different (See XEP-0198)
+ mock2 := ServerMock{}
+ mock2.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ // Reconnect
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ resumeStream(t, sc)
+ serverDone <- struct{}{}
+ })
+
+ // Reconnect
+ go func() {
+ err = client.Resume()
+ if err != nil {
+ t.Fatalf("could not connect client to mock server: %s", err)
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, clientDone)
+ waitForEntity(t, serverDone)
+
+ mock2.Stop()
+}
+
+func Test_StreamManagementFail(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ enableStreamManagement(t, sc, true, true)
+ serverDone <- struct{}{}
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true,
+ StreamManagementEnable: true,
+ streamManagementResume: true} // Enable stream management
+
+ var client *Client
+ router := NewRouter()
+ client, err := NewClient(&config, router, clientDefaultErrorHandler)
+ if err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ var state SMState
+ go func() {
+ _, err = client.transport.Connect()
+ if err != nil {
+ return
+ }
+
+ // Client is ok, we now open XMPP session
+ if client.Session, err = NewSession(client, state); err == nil {
+ t.Fatalf("test is supposed to err")
+ }
+ if client.Session.SMState.StreamErrorGroup == nil {
+ t.Fatalf("error was not stored correctly in session state")
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, serverDone)
+ waitForEntity(t, clientDone)
+
+ mock.Stop()
+}
+
+func Test_SendStanzaQueueWithSM(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ enableStreamManagement(t, sc, false, true)
+
+ // Ignore the initial presence sent to the server by the client so we can move on to the next packet.
+ discardPresence(t, sc)
+
+ // Used here to silently discard the IQ sent by the client, in order to later trigger a resend
+ skipPacket(t, sc)
+ // Respond to the client ACK request with a number of processed stanzas of 0. This should trigger a resend
+ // of previously ignored stanza to the server, which this handler element will be expecting.
+ respondWithAck(t, sc, 0)
+ serverDone <- struct{}{}
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true,
+ StreamManagementEnable: true,
+ streamManagementResume: true} // Enable stream management
+
+ var client *Client
+ router := NewRouter()
+ client, err := NewClient(&config, router, clientDefaultErrorHandler)
+ if err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ go func() {
+ err = client.Connect()
+
+ client.SendRaw(`
+
+
+`)
+
+ // Last stanza was discarded silently by the server. Let's ask an ack for it. This should trigger resend as the server
+ // will respond with an acknowledged number of stanzas of 0.
+ r := stanza.SMRequest{}
+ client.Send(r)
+ clientDone <- struct{}{}
+ }()
+ waitForEntity(t, serverDone)
+ waitForEntity(t, clientDone)
+
+ mock.Stop()
+}
+
+//========================================================================
+// Helper functions for tests
+
+func skipPacket(t *testing.T, sc *ServerConn) {
+ var p stanza.IQ
+ se, err := stanza.NextStart(sc.decoder)
+
+ if err != nil {
+ t.Fatalf("cannot read packet: %s", err)
+ return
+ }
+ if err := sc.decoder.DecodeElement(&p, &se); err != nil {
+ t.Fatalf("cannot decode packet: %s", err)
+ return
+ }
+}
+
+func respondWithAck(t *testing.T, sc *ServerConn, h int) {
+
+ // Mock server reads the ack request
+ var p stanza.SMRequest
+ se, err := stanza.NextStart(sc.decoder)
+
+ if err != nil {
+ t.Fatalf("cannot read packet: %s", err)
+ return
+ }
+ if err := sc.decoder.DecodeElement(&p, &se); err != nil {
+ t.Fatalf("cannot decode packet: %s", err)
+ return
+ }
+
+ // Mock server sends the ack response
+ a := stanza.SMAnswer{
+ H: uint(h),
+ }
+ data, err := xml.Marshal(a)
+ _, err = sc.connection.Write(data)
+ if err != nil {
+ t.Fatalf("failed to send response ack")
+ }
+
+ // Mock server reads the re-sent stanza that was previously discarded intentionally
+ var p2 stanza.IQ
+ nse, err := stanza.NextStart(sc.decoder)
+
+ if err != nil {
+ t.Fatalf("cannot read packet: %s", err)
+ return
+ }
+ if err := sc.decoder.DecodeElement(&p2, &nse); err != nil {
+ t.Fatalf("cannot decode packet: %s", err)
+ return
+ }
+}
+
+func sendFeaturesStreamManagment(t *testing.T, sc *ServerConn) {
+ // This is a basic server, supporting only 2 features after auth: stream management & session binding
+ features := `
+
+
+`
+ if _, err := fmt.Fprintln(sc.connection, features); err != nil {
+ t.Fatalf("cannot send stream feature: %s", err)
+ }
+}
+
+func sendFeaturesNoStreamManagment(t *testing.T, sc *ServerConn) {
+ // This is a basic server, supporting only 2 features after auth: stream management & session binding
+ features := `
+
+`
+ if _, err := fmt.Fprintln(sc.connection, features); err != nil {
+ t.Fatalf("cannot send stream feature: %s", err)
+ }
+}
+
+// enableStreamManagement is a function for the mock server that can either mock a successful session, or fail depending on
+// the value of the "fail" boolean. True means the session should fail.
+func enableStreamManagement(t *testing.T, sc *ServerConn, fail bool, resume bool) {
+ // Decode element into pointer storage
+ var ed stanza.SMEnable
+ se, err := stanza.NextStart(sc.decoder)
+
+ if err != nil {
+ t.Fatalf("cannot read stream management enable: %s", err)
+ return
+ }
+ if err := sc.decoder.DecodeElement(&ed, &se); err != nil {
+ t.Fatalf("cannot decode stream management enable: %s", err)
+ return
+ }
+
+ if fail {
+ f := stanza.SMFailed{
+ H: nil,
+ StreamErrorGroup: &stanza.UnexpectedRequest{},
+ }
+ data, err := xml.Marshal(f)
+ if err != nil {
+ t.Fatalf("failed to marshall error response: %s", err)
+ }
+ sc.connection.Write(data)
+ } else {
+ e := &stanza.SMEnabled{
+ Resume: strconv.FormatBool(resume),
+ Id: streamManagementID,
+ }
+ data, err := xml.Marshal(e)
+ if err != nil {
+ t.Fatalf("failed to marshall error response: %s", err)
+ }
+ sc.connection.Write(data)
+ }
+}
+
+func resumeStream(t *testing.T, sc *ServerConn) {
+ h := uint(0)
+ response := stanza.SMResumed{
+ PrevId: streamManagementID,
+ H: &h,
+ }
+
+ data, err := xml.Marshal(response)
+ if err != nil {
+ t.Fatalf("failed to marshall stream management enabled response : %s", err)
+ }
+
+ writtenChan := make(chan struct{})
+
+ go func() {
+ sc.connection.Write(data)
+ writtenChan <- struct{}{}
+ }()
+ select {
+ case <-writtenChan:
+ // We're done here
+ return
+ case <-time.After(defaultTimeout):
+ t.Fatalf("failed to write enabled nonza to client")
+ }
+}
+
+func checkClientResumeStatus(client *Client, statusCorrectChan chan struct{}, killChan chan struct{}) {
+ for {
+ if client.CurrentState.getState() == StateDisconnected {
+ statusCorrectChan <- struct{}{}
+ }
+ select {
+ case <-killChan:
+ return
+ case <-time.After(time.Millisecond * 10):
+ // Keep checking status value
+ }
+ }
+}
+
+func initSrvCliForResumeTests(t *testing.T, serverHandler func(*testing.T, *ServerConn), port int, StreamManagementEnable, StreamManagementResume bool) (*Client, *ServerMock) {
+ mock := &ServerMock{}
+ testServerAddress := fmt.Sprintf("%s:%d", testClientDomain, port)
+
+ mock.Start(t, testServerAddress, serverHandler)
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testServerAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true,
+ StreamManagementEnable: StreamManagementEnable,
+ streamManagementResume: StreamManagementResume}
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Fatalf("connect create XMPP client: %s", err)
+ }
+
+ if _, err = client.transport.Connect(); err != nil {
+ t.Fatalf("XMPP connection failed: %s", err)
+ }
+
+ return client, mock
+}
+
+func waitForEntity(t *testing.T, entityDone chan struct{}) {
+ select {
+ case <-entityDone:
+ case <-time.After(defaultTimeout):
+ t.Fatalf("test timed out")
+ }
+}
diff --git a/go-xmpp/client_test.go b/go-xmpp/client_test.go
new file mode 100644
index 0000000..0ff3997
--- /dev/null
+++ b/go-xmpp/client_test.go
@@ -0,0 +1,659 @@
+package xmpp
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+const (
+ // Default port is not standard XMPP port to avoid interfering
+ // with local running XMPP server
+ testXMPPAddress = "localhost:15222"
+ testClientDomain = "localhost"
+)
+
+func TestEventManager(t *testing.T) {
+ mgr := EventManager{}
+ mgr.updateState(StateResuming)
+ if mgr.CurrentState.getState() != StateResuming {
+ t.Fatal("CurrentState not updated by updateState()")
+ }
+
+ mgr.disconnected(SMState{})
+
+ if mgr.CurrentState.getState() != StateDisconnected {
+ t.Fatalf("CurrentState not reset by disconnected()")
+ }
+
+ mgr.streamError(ErrTLSNotSupported.Error(), "")
+
+ if mgr.CurrentState.getState() != StateStreamError {
+ t.Fatalf("CurrentState not set by streamError()")
+ }
+}
+
+func TestClient_Connect(t *testing.T) {
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, handlerClientConnectSuccess)
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true}
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ if err = client.Connect(); err != nil {
+ t.Errorf("XMPP connection failed: %s", err)
+ }
+
+ mock.Stop()
+}
+
+func TestClient_NoInsecure(t *testing.T) {
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ handlerAbortTLS(t, sc)
+ closeConn(t, sc)
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ }
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("cannot create XMPP client: %s", err)
+ }
+
+ if err = client.Connect(); err == nil {
+ // When insecure is not allowed:
+ t.Errorf("should fail as insecure connection is not allowed and server does not support TLS")
+ }
+
+ mock.Stop()
+}
+
+// Check that the client is properly tracking features, as session negotiation progresses.
+func TestClient_FeaturesTracking(t *testing.T) {
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ handlerAbortTLS(t, sc)
+ closeConn(t, sc)
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ }
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("cannot create XMPP client: %s", err)
+ }
+
+ if err = client.Connect(); err == nil {
+ // When insecure is not allowed:
+ t.Errorf("should fail as insecure connection is not allowed and server does not support TLS")
+ }
+
+ mock.Stop()
+}
+
+func TestClient_RFC3921Session(t *testing.T) {
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, handlerClientConnectWithSession)
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true,
+ }
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ if err = client.Connect(); err != nil {
+ t.Errorf("XMPP connection failed: %s", err)
+ }
+
+ mock.Stop()
+}
+
+// Testing sending an IQ to the mock server and reading its response.
+func TestClient_SendIQ(t *testing.T) {
+ done := make(chan struct{})
+ // Handler for Mock server
+ h := func(t *testing.T, sc *ServerConn) {
+ handlerClientConnectSuccess(t, sc)
+ discardPresence(t, sc)
+ respondToIQ(t, sc)
+ done <- struct{}{}
+ }
+ client, mock := mockClientConnection(t, h, testClientIqPort)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"})
+ if err != nil {
+ t.Fatalf("failed to create the IQ request: %v", err)
+ }
+
+ disco := iqReq.DiscoInfo()
+ iqReq.Payload = disco
+
+ // Handle a possible error
+ errChan := make(chan error)
+ errorHandler := func(err error) {
+ errChan <- err
+ }
+ client.ErrorHandler = errorHandler
+ res, err := client.SendIQ(ctx, iqReq)
+ if err != nil {
+ t.Error(err)
+ }
+
+ select {
+ case <-res: // If the server responds with an IQ, we pass the test
+ case err := <-errChan: // If the server sends an error, or there is a connection error
+ cancel()
+ t.Fatal(err.Error())
+ case <-time.After(defaultChannelTimeout): // If we timeout
+ cancel()
+ t.Fatal("Failed to receive response, to sent IQ, from mock server")
+ }
+ select {
+ case <-done:
+ mock.Stop()
+ case <-time.After(defaultChannelTimeout):
+ cancel()
+ t.Fatal("The mock server failed to finish its job !")
+ }
+ cancel()
+}
+
+func TestClient_SendIQFail(t *testing.T) {
+ done := make(chan struct{})
+ // Handler for Mock server
+ h := func(t *testing.T, sc *ServerConn) {
+ handlerClientConnectSuccess(t, sc)
+ discardPresence(t, sc)
+ respondToIQ(t, sc)
+ done <- struct{}{}
+ }
+ client, mock := mockClientConnection(t, h, testClientIqFailPort)
+
+ //==================
+ // Create an IQ to send
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"})
+ if err != nil {
+ t.Fatalf("failed to create IQ request: %v", err)
+ }
+ disco := iqReq.DiscoInfo()
+ iqReq.Payload = disco
+ // Removing the id to make the stanza invalid. The IQ constructor makes a random one if none is specified
+ // so we need to overwrite it.
+ iqReq.Id = ""
+
+ // Handle a possible error
+ errChan := make(chan error)
+ errorHandler := func(err error) {
+ errChan <- err
+ }
+ client.ErrorHandler = errorHandler
+ res, _ := client.SendIQ(ctx, iqReq)
+
+ // Test
+ select {
+ case <-res: // If the server responds with an IQ
+ t.Errorf("Server should not respond with an IQ since the request is expected to be invalid !")
+ case <-errChan: // If the server sends an error, the test passes
+ case <-time.After(defaultChannelTimeout): // If we timeout
+ t.Errorf("Failed to receive response, to sent IQ, from mock server")
+ }
+ select {
+ case <-done:
+ mock.Stop()
+ case <-time.After(defaultChannelTimeout):
+ cancel()
+ t.Errorf("The mock server failed to finish its job !")
+ }
+ cancel()
+}
+
+func TestClient_SendRaw(t *testing.T) {
+ done := make(chan struct{})
+ // Handler for Mock server
+ h := func(t *testing.T, sc *ServerConn) {
+ handlerClientConnectSuccess(t, sc)
+ discardPresence(t, sc)
+ respondToIQ(t, sc)
+ closeConn(t, sc)
+ done <- struct{}{}
+ }
+ type testCase struct {
+ req string
+ shouldErr bool
+ port int
+ }
+ testRequests := make(map[string]testCase)
+ // Sending a correct IQ of type get. Not supposed to err
+ testRequests["Correct IQ"] = testCase{
+ req: ``,
+ shouldErr: false,
+ port: testClientRawPort + 100,
+ }
+ // Sending an IQ with a missing ID. Should err
+ testRequests["IQ with missing ID"] = testCase{
+ req: ``,
+ shouldErr: true,
+ port: testClientRawPort,
+ }
+
+ // A handler for the client.
+ // In the failing test, the server returns a stream error, which triggers this handler, client side.
+ errChan := make(chan error)
+ errHandler := func(err error) {
+ errChan <- err
+ }
+
+ // Tests for all the IQs
+ for name, tcase := range testRequests {
+ t.Run(name, func(st *testing.T) {
+ //Connecting to a mock server, initialized with given port and handler function
+ c, m := mockClientConnection(t, h, tcase.port)
+ c.ErrorHandler = errHandler
+ // Sending raw xml from test case
+ err := c.SendRaw(tcase.req)
+ if err != nil {
+ t.Errorf("Error sending Raw string")
+ }
+ // Just wait a little so the message has time to arrive
+ select {
+ // We don't use the default "long" timeout here because waiting it out means passing the test.
+ case <-time.After(100 * time.Millisecond):
+ c.Disconnect()
+ case err = <-errChan:
+ if err == nil && tcase.shouldErr {
+ t.Errorf("Failed to get closing stream err")
+ } else if err != nil && !tcase.shouldErr {
+ t.Errorf("This test is not supposed to err !")
+ }
+ }
+ select {
+ case <-done:
+ m.Stop()
+ case <-time.After(defaultChannelTimeout):
+ t.Errorf("The mock server failed to finish its job !")
+ }
+ })
+ }
+}
+
+func TestClient_Disconnect(t *testing.T) {
+ c, m := mockClientConnection(t, func(t *testing.T, sc *ServerConn) {
+ handlerClientConnectSuccess(t, sc)
+ closeConn(t, sc)
+ }, testClientBasePort)
+ err := c.transport.Ping()
+ if err != nil {
+ t.Errorf("Could not ping but not disconnected yet")
+ }
+ c.Disconnect()
+ err = c.transport.Ping()
+ if err == nil {
+ t.Errorf("Did not disconnect properly")
+ }
+ m.Stop()
+}
+
+func TestClient_DisconnectStreamManager(t *testing.T) {
+ // Init mock server
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ handlerAbortTLS(t, sc)
+ closeConn(t, sc)
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ }
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("cannot create XMPP client: %s", err)
+ }
+
+ sman := NewStreamManager(client, nil)
+ errChan := make(chan error)
+ runSMan := func(errChan chan error) {
+ errChan <- sman.Run()
+ }
+
+ go runSMan(errChan)
+ select {
+ case <-errChan:
+ case <-time.After(defaultChannelTimeout):
+ // When insecure is not allowed:
+ t.Errorf("should fail as insecure connection is not allowed and server does not support TLS")
+ }
+ mock.Stop()
+}
+
+func Test_ClientPostConnectHook(t *testing.T) {
+ done := make(chan struct{})
+ // Handler for Mock server
+ h := func(t *testing.T, sc *ServerConn) {
+ handlerClientConnectSuccess(t, sc)
+ done <- struct{}{}
+ }
+
+ hookChan := make(chan struct{})
+ mock := &ServerMock{}
+ testServerAddress := fmt.Sprintf("%s:%d", testClientDomain, testClientPostConnectHook)
+
+ mock.Start(t, testServerAddress, h)
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testServerAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true}
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ // The post connection client hook should just write to a channel that we will read later.
+ client.PostConnectHook = func() error {
+ go func() {
+ hookChan <- struct{}{}
+ }()
+ return nil
+ }
+ // Handle a possible error
+ errChan := make(chan error)
+ errorHandler := func(err error) {
+ errChan <- err
+ }
+ client.ErrorHandler = errorHandler
+ if err = client.Connect(); err != nil {
+ t.Errorf("XMPP connection failed: %s", err)
+ }
+
+ // Check if the post connection client hook was correctly called
+ select {
+ case err := <-errChan: // If the server sends an error, or there is a connection error
+ t.Fatal(err.Error())
+ case <-time.After(defaultChannelTimeout): // If we timeout
+ t.Fatal("Failed to call post connection client hook")
+ case <-hookChan:
+ // Test succeeded, channel was written to.
+ }
+
+ select {
+ case <-done:
+ mock.Stop()
+ case <-time.After(defaultChannelTimeout):
+ t.Fatal("The mock server failed to finish its job !")
+ }
+}
+
+func Test_ClientPostReconnectHook(t *testing.T) {
+ hookChan := make(chan struct{})
+ // Setup Mock server
+ mock := ServerMock{}
+ mock.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ bind(t, sc)
+ enableStreamManagement(t, sc, false, true)
+ })
+
+ // Test / Check result
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testXMPPAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true,
+ StreamManagementEnable: true,
+ streamManagementResume: true} // Enable stream management
+
+ var client *Client
+ router := NewRouter()
+ client, err := NewClient(&config, router, clientDefaultErrorHandler)
+ if err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ client.PostResumeHook = func() error {
+ go func() {
+ hookChan <- struct{}{}
+ }()
+ return nil
+ }
+
+ err = client.Connect()
+ if err != nil {
+ t.Fatalf("could not connect client to mock server: %s", err)
+ }
+
+ transp, ok := client.transport.(*XMPPTransport)
+ if !ok {
+ t.Fatalf("problem with client transport ")
+ }
+
+ transp.conn.Close()
+ mock.Stop()
+
+ // Check if the client can have its connection resumed using its state but also its configuration
+ if !IsStreamResumable(client) {
+ t.Fatalf("should support resumption")
+ }
+
+ // Reboot server. We need to make a new one because (at least for now) the mock server can only have one handler
+ // and they should be different between a first connection and a stream resume since exchanged messages
+ // are different (See XEP-0198)
+ mock2 := ServerMock{}
+ mock2.Start(t, testXMPPAddress, func(t *testing.T, sc *ServerConn) {
+ // Reconnect
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendFeaturesStreamManagment(t, sc) // Send post auth features
+ resumeStream(t, sc)
+ })
+
+ // Reconnect
+ err = client.Resume()
+ if err != nil {
+ t.Fatalf("could not connect client to mock server: %s", err)
+ }
+
+ select {
+ case <-time.After(defaultChannelTimeout): // If we timeout
+ t.Fatal("Failed to call post connection client hook")
+ case <-hookChan:
+ // Test succeeded, channel was written to.
+ }
+
+ mock2.Stop()
+}
+
+//=============================================================================
+// Basic XMPP Server Mock Handlers.
+
+// Test connection with a basic straightforward workflow
+func handlerClientConnectSuccess(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendBindFeature(t, sc) // Send post auth features
+ bind(t, sc)
+}
+
+// closeConn closes the connection on request from the client
+func closeConn(t *testing.T, sc *ServerConn) {
+ for {
+ cls, err := stanza.NextPacket(sc.decoder)
+ if err != nil {
+ t.Errorf("cannot read from socket: %s", err)
+ return
+ }
+ switch cls.(type) {
+ case stanza.StreamClosePacket:
+ sc.connection.Write([]byte(stanza.StreamClose))
+ return
+ }
+ }
+
+}
+
+// We expect client will abort on TLS
+func handlerAbortTLS(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+ sendStreamFeatures(t, sc) // Send initial features
+}
+
+// Test connection with mandatory session (RFC-3921)
+func handlerClientConnectWithSession(t *testing.T, sc *ServerConn) {
+ checkClientOpenStream(t, sc)
+
+ sendStreamFeatures(t, sc) // Send initial features
+ readAuth(t, sc.decoder)
+ sc.connection.Write([]byte(""))
+
+ checkClientOpenStream(t, sc) // Reset stream
+ sendRFC3921Feature(t, sc) // Send post auth features
+ bind(t, sc)
+ session(t, sc)
+}
+
+func checkClientOpenStream(t *testing.T, sc *ServerConn) {
+ err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout))
+ if err != nil {
+ t.Fatalf("failed to set deadline: %v", err)
+ }
+ defer sc.connection.SetDeadline(time.Time{})
+
+ for { // TODO clean up. That for loop is not elegant and I prefer bounded recursion.
+ var token xml.Token
+ token, err := sc.decoder.Token()
+ if err != nil {
+ t.Fatalf("cannot read next token: %s", err)
+ }
+
+ switch elem := token.(type) {
+ // Wait for first startElement
+ case xml.StartElement:
+ if elem.Name.Space != stanza.NSStream || elem.Name.Local != "stream" {
+ err = errors.New("xmpp: expected but got <" + elem.Name.Local + "> in " + elem.Name.Space)
+ return
+ }
+ if _, err := fmt.Fprintf(sc.connection, serverStreamOpen, "localhost", "streamid1", stanza.NSClient, stanza.NSStream); err != nil {
+ t.Errorf("cannot write server stream open: %s", err)
+ }
+ return
+ }
+
+ }
+}
+
+func mockClientConnection(t *testing.T, serverHandler func(*testing.T, *ServerConn), port int) (*Client, *ServerMock) {
+ mock := &ServerMock{}
+ testServerAddress := fmt.Sprintf("%s:%d", testClientDomain, port)
+
+ mock.Start(t, testServerAddress, serverHandler)
+ config := Config{
+ TransportConfiguration: TransportConfiguration{
+ Address: testServerAddress,
+ },
+ Jid: "test@localhost",
+ Credential: Password("test"),
+ Insecure: true}
+
+ var client *Client
+ var err error
+ router := NewRouter()
+ if client, err = NewClient(&config, router, clientDefaultErrorHandler); err != nil {
+ t.Errorf("connect create XMPP client: %s", err)
+ }
+
+ if err = client.Connect(); err != nil {
+ t.Errorf("XMPP connection failed: %s", err)
+ }
+
+ return client, mock
+}
+
+// This really should not be used as is.
+// It's just meant to be a placeholder when error handling is not needed at this level
+func clientDefaultErrorHandler(err error) {
+}
diff --git a/go-xmpp/cmd/fluuxmpp/README.md b/go-xmpp/cmd/fluuxmpp/README.md
new file mode 100644
index 0000000..de53d4b
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/README.md
@@ -0,0 +1,198 @@
+# fluuxmpp
+
+fluuxIO's XMPP command-line tool
+
+## Installation
+
+To install `fluuxmpp` in your Go path:
+
+```
+$ go get -u gosrc.io/xmpp/cmd/fluuxmpp
+```
+
+## Usage
+
+```
+$ fluuxmpp --help
+fluuxIO's xmpp comandline tool
+
+Usage:
+ fluuxmpp [command]
+
+Available Commands:
+ check is a command-line to check if you XMPP TLS certificate is valid and warn you before it expires
+ help Help about any command
+ send is a command-line tool to send to send XMPP messages to users
+
+Flags:
+ -h, --help help for fluuxmpp
+
+Use "fluuxmpp [command] --help" for more information about a command.
+```
+
+### check tls
+
+```
+$ fluuxmpp check --help
+is a command-line to check if you XMPP TLS certificate is valid and warn you before it expires
+
+Usage:
+ fluuxmpp check [flags]
+
+Examples:
+fluuxmpp check chat.sum7.eu:5222 --domain meckerspace.de
+
+Flags:
+ -d, --domain string domain if host handle multiple domains
+ -h, --help help for check
+```
+
+### sending messages
+
+```
+$ fluuxmpp send --help
+is a command-line tool to send to send XMPP messages to users
+
+Usage:
+ fluuxmpp send [message] [flags]
+
+Examples:
+fluuxmpp send to@chat.sum7.eu "Hello World!"
+
+Flags:
+ --addr string host[:port]
+ --config string config file (default is ~/.config/fluuxmpp.yml)
+ -h, --help help for send
+ --jid string using jid (required)
+ -m, --muc recipient is a muc (join it before sending messages)
+ --password string using password for your jid (required)
+```
+
+
+## Examples
+
+### check tls
+
+If you server is on standard port and XMPP domains matches the hostname you can simply use:
+
+```
+$ fluuxmpp check chat.sum7.eu
+ info All checks passed
+ ⇢ address="chat.sum7.eu" domain=""
+ ⇢ main.go:43 main.runCheck
+ ⇢ 2019-07-16T22:01:39.765+02:00
+```
+
+You can also pass the port and the XMPP domain if different from the server hostname:
+
+```
+$ fluuxmpp check chat.sum7.eu:5222 --domain meckerspace.de
+ info All checks passed
+ ⇢ address="chat.sum7.eu:5222" domain="meckerspace.de"
+ ⇢ main.go:43 main.runCheck
+ ⇢ 2019-07-16T22:01:33.270+02:00
+```
+
+Error code will be non-zero in case of error. You can thus use it directly with your usual
+monitoring scripts.
+
+
+### sending messages
+
+Message from arguments:
+```bash
+$ fluuxmpp send to@example.org "Hello World!"
+ info client connected
+ ⇢ cmd.go:56 main.glob..func1.1
+ ⇢ 2019-07-17T23:42:43.310+02:00
+ info send message
+ ⇢ muc=false text="Hello World!" to="to@example.org"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:42:43.310+02:00
+```
+
+Message from STDIN:
+```bash
+$ journalctl -f | fluuxmpp send to@example.org -
+ info client connected
+ ⇢ cmd.go:56 main.glob..func1.1
+ ⇢ 2019-07-17T23:40:03.177+02:00
+ info send message
+ ⇢ muc=false text="-- Logs begin at Mon 2019-07-08 22:16:54 CEST. --" to="to@example.org"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:40:03.178+02:00
+ info send message
+ ⇢ muc=false text="Jul 17 23:36:46 RECHNERNAME systemd[755]: Started Fetch mails." to="to@example.org"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:40:03.178+02:00
+^C
+```
+
+
+Multiple recipients:
+```bash
+$ fluuxmpp send to1@example.org,to2@example.org "Multiple recipient"
+ info client connected
+ ⇢ cmd.go:56 main.glob..func1.1
+ ⇢ 2019-07-17T23:47:57.650+02:00
+ info send message
+ ⇢ muc=false text="Multiple recipient" to="to1@example.org"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:47:57.651+02:00
+ info send message
+ ⇢ muc=false text="Multiple recipient" to="to2@example.org"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:47:57.652+02:00
+```
+
+Send to MUC:
+```bash
+journalctl -f | fluuxmpp send testit@conference.chat.sum7.eu - --muc
+ info client connected
+ ⇢ cmd.go:56 main.glob..func1.1
+ ⇢ 2019-07-17T23:52:56.269+02:00
+ info send message
+ ⇢ muc=true text="-- Logs begin at Mon 2019-07-08 22:16:54 CEST. --" to="testit@conference.chat.sum7.eu"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:52:56.270+02:00
+ info send message
+ ⇢ muc=true text="Jul 17 23:48:58 RECHNERNAME systemd[755]: mail.service: Succeeded." to="testit@conference.chat.sum7.eu"
+ ⇢ send.go:31 main.send
+ ⇢ 2019-07-17T23:52:56.277+02:00
+^C
+```
+
+## Authentification
+
+### Configuration file
+
+In `/etc/`, `~/.config` and `.` (here).
+You could create the file name `fluuxmpp` with you favorite file extension (e.g. `toml`, `yml`).
+
+e.g. ~/.config/fluuxmpp.toml
+```toml
+jid = "bot@example.org"
+password = "secret"
+
+addr = "example.com:5222"
+```
+
+### Environment variables
+
+```bash
+export FLUXXMPP_JID='bot@example.org';
+export FLUXXMPP_PASSWORD='secret';
+
+export FLUXXMPP_ADDR='example.com:5222';
+
+fluuxmpp send to@example.org "Hello Welt";
+```
+
+### Parameters
+
+Warning: This should not be used for production systems, as all users on the system
+can read the running processes, and their parameters (and thus the password).
+
+```bash
+fluuxmpp send to@example.org "Hello World!" --jid bot@example.org --password secret --addr example.com:5222;
+```
diff --git a/go-xmpp/cmd/fluuxmpp/TODO.md b/go-xmpp/cmd/fluuxmpp/TODO.md
new file mode 100644
index 0000000..61866f3
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/TODO.md
@@ -0,0 +1,21 @@
+# TODO
+
+## check
+### Features
+
+- Use a config file to define the checks to perform as client on an XMPP server.
+
+## send
+
+### Issues
+
+- Remove global variable (like mucToleave)
+- Does not report error when trying to connect to a non open port (for example localhost with no server running).
+
+### Features
+
+- configuration
+ - allow unencrypted
+ - skip tls verification
+- support muc and single user at same time
+- send html -> parse console colors to xhtml (is there a easy way or lib for it ?)
diff --git a/go-xmpp/cmd/fluuxmpp/check.go b/go-xmpp/cmd/fluuxmpp/check.go
new file mode 100644
index 0000000..0ddfe3e
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/check.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "github.com/bdlm/log"
+ "github.com/spf13/cobra"
+ "gosrc.io/xmpp"
+)
+
+var domain = ""
+var cmdCheck = &cobra.Command{
+ Use: "check ",
+ Short: "is a command-line to check if you XMPP TLS certificate is valid and warn you before it expires",
+ Example: "fluuxmpp check chat.sum7.eu:5222 --domain meckerspace.de",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ runCheck(args[0], domain)
+ },
+}
+
+func init() {
+ cmdRoot.AddCommand(cmdCheck)
+ cmdCheck.Flags().StringVarP(&domain, "domain", "d", "", "domain if host handle multiple domains")
+}
+
+func runCheck(address, domain string) {
+ logger := log.WithFields(map[string]interface{}{
+ "address": address,
+ "domain": domain,
+ })
+ client, err := xmpp.NewChecker(address, domain)
+
+ if err != nil {
+ log.Fatal("Error: ", err)
+ }
+
+ if err = client.Check(); err != nil {
+ logger.Fatal("Failed connection check: ", err)
+ }
+
+ logger.Println("All checks passed")
+}
diff --git a/go-xmpp/cmd/fluuxmpp/doc.go b/go-xmpp/cmd/fluuxmpp/doc.go
new file mode 100644
index 0000000..d01e4da
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/doc.go
@@ -0,0 +1,5 @@
+/*
+
+fluuxmpp: fluuxIO's xmpp comandline tool
+*/
+package main
diff --git a/go-xmpp/cmd/fluuxmpp/log.go b/go-xmpp/cmd/fluuxmpp/log.go
new file mode 100644
index 0000000..eb67593
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/log.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "os"
+
+ "github.com/bdlm/log"
+ stdLogger "github.com/bdlm/std/logger"
+)
+
+type hook struct{}
+
+func (h *hook) Fire(entry *log.Entry) error {
+ switch entry.Level {
+ case log.PanicLevel:
+ entry.Logger.Out = os.Stderr
+ case log.FatalLevel:
+ entry.Logger.Out = os.Stderr
+ case log.ErrorLevel:
+ entry.Logger.Out = os.Stderr
+ case log.WarnLevel:
+ entry.Logger.Out = os.Stdout
+ case log.InfoLevel:
+ entry.Logger.Out = os.Stdout
+ case log.DebugLevel:
+ entry.Logger.Out = os.Stdout
+ default:
+ }
+
+ return nil
+}
+
+func (h *hook) Levels() []stdLogger.Level {
+ return log.AllLevels
+}
diff --git a/go-xmpp/cmd/fluuxmpp/main.go b/go-xmpp/cmd/fluuxmpp/main.go
new file mode 100644
index 0000000..176666d
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/main.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "github.com/bdlm/log"
+ "github.com/spf13/cobra"
+)
+
+// cmdRoot represents the base command when called without any subcommands
+var cmdRoot = &cobra.Command{
+ Use: "fluuxmpp",
+ Short: "fluuxIO's xmpp comandline tool",
+}
+
+func main() {
+ log.AddHook(&hook{})
+ if err := cmdRoot.Execute(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/go-xmpp/cmd/fluuxmpp/send.go b/go-xmpp/cmd/fluuxmpp/send.go
new file mode 100644
index 0000000..27c1a67
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/send.go
@@ -0,0 +1,141 @@
+package main
+
+import (
+ "bufio"
+ "gosrc.io/xmpp/stanza"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/bdlm/log"
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ "gosrc.io/xmpp"
+)
+
+var configFile = ""
+
+// FIXME: Remove global variables
+var isMUCRecipient = false
+
+var cmdSend = &cobra.Command{
+ Use: "send [message]",
+ Short: "is a command-line tool to send to send XMPP messages to users",
+ Example: `fluuxmpp send to@chat.sum7.eu "Hello World!"`,
+ Args: cobra.ExactArgs(2),
+ Run: sendxmpp,
+}
+
+func sendxmpp(cmd *cobra.Command, args []string) {
+ receiver := strings.Split(args[0], ",")
+ msgText := args[1]
+
+ var err error
+ client, err := xmpp.NewClient(&xmpp.Config{
+ TransportConfiguration: xmpp.TransportConfiguration{
+ Address: viper.GetString("addr"),
+ },
+ Jid: viper.GetString("jid"),
+ Credential: xmpp.Password(viper.GetString("password")),
+ },
+ xmpp.NewRouter(),
+ func(err error) {
+ log.Println(err)
+ })
+
+ if err != nil {
+ log.Errorf("error when starting xmpp client: %s", err)
+ return
+ }
+
+ wg := sync.WaitGroup{}
+ wg.Add(1)
+
+ // FIXME: Remove global variables
+ var mucsToLeave []*stanza.Jid
+
+ cm := xmpp.NewStreamManager(client, func(c xmpp.Sender) {
+ defer wg.Done()
+
+ log.Info("client connected")
+
+ if isMUCRecipient {
+ for _, muc := range receiver {
+ jid, err := stanza.NewJid(muc)
+ if err != nil {
+ log.WithField("muc", muc).Errorf("skipping invalid muc jid: %w", err)
+ continue
+ }
+ jid.Resource = "sendxmpp"
+
+ if err := joinMUC(c, jid); err != nil {
+ log.WithField("muc", muc).Errorf("error joining muc: %w", err)
+ continue
+ }
+ mucsToLeave = append(mucsToLeave, jid)
+ }
+ }
+
+ if msgText != "-" {
+ send(c, receiver, msgText)
+ return
+ }
+
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ send(c, receiver, scanner.Text())
+ }
+
+ if err := scanner.Err(); err != nil {
+ log.Errorf("error on reading stdin: %s", err)
+ }
+ })
+ go func() {
+ err := cm.Run()
+ log.Panic("closed connection:", err)
+ wg.Done()
+ }()
+
+ wg.Wait()
+
+ leaveMUCs(client, mucsToLeave)
+}
+
+func init() {
+ cmdRoot.AddCommand(cmdSend)
+
+ cobra.OnInitialize(initConfigFile)
+ cmdSend.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is ~/.config/fluuxmpp.yml)")
+
+ cmdSend.Flags().StringP("jid", "", "", "using jid (required)")
+ viper.BindPFlag("jid", cmdSend.Flags().Lookup("jid"))
+
+ cmdSend.Flags().StringP("password", "", "", "using password for your jid (required)")
+ viper.BindPFlag("password", cmdSend.Flags().Lookup("password"))
+
+ cmdSend.Flags().StringP("addr", "", "", "host[:port]")
+ viper.BindPFlag("addr", cmdSend.Flags().Lookup("addr"))
+
+ cmdSend.Flags().BoolVarP(&isMUCRecipient, "muc", "m", false, "recipient is a muc (join it before sending messages)")
+}
+
+// initConfig reads in config file and ENV variables if set.
+func initConfigFile() {
+ if configFile != "" {
+ viper.SetConfigFile(configFile)
+ }
+
+ viper.SetConfigName("fluuxmpp")
+ viper.AddConfigPath("/etc/")
+ viper.AddConfigPath("$HOME/.config")
+ viper.AddConfigPath(".")
+
+ viper.SetEnvPrefix("FLUXXMPP")
+ viper.AutomaticEnv()
+
+ // If a config file is found, read it in.
+ if err := viper.ReadInConfig(); err != nil {
+ log.Warnf("no configuration found (somebody could read your password from process argument list): %s", err)
+ }
+}
diff --git a/go-xmpp/cmd/fluuxmpp/xmppmuc.go b/go-xmpp/cmd/fluuxmpp/xmppmuc.go
new file mode 100644
index 0000000..a00fdfc
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/xmppmuc.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "github.com/bdlm/log"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func joinMUC(c xmpp.Sender, toJID *stanza.Jid) error {
+ return c.Send(stanza.Presence{Attrs: stanza.Attrs{To: toJID.Full()},
+ Extensions: []stanza.PresExtension{
+ stanza.MucPresence{
+ History: stanza.History{MaxStanzas: stanza.NewNullableInt(0)},
+ }},
+ })
+}
+
+func leaveMUCs(c xmpp.Sender, mucsToLeave []*stanza.Jid) {
+ for _, muc := range mucsToLeave {
+ if err := c.Send(stanza.Presence{Attrs: stanza.Attrs{
+ To: muc.Full(),
+ Type: stanza.PresenceTypeUnavailable,
+ }}); err != nil {
+ log.WithField("muc", muc).Errorf("error on leaving muc: %s", err)
+ }
+ }
+}
diff --git a/go-xmpp/cmd/fluuxmpp/xmppsend.go b/go-xmpp/cmd/fluuxmpp/xmppsend.go
new file mode 100644
index 0000000..a2c79c4
--- /dev/null
+++ b/go-xmpp/cmd/fluuxmpp/xmppsend.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "github.com/bdlm/log"
+
+ "gosrc.io/xmpp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func send(c xmpp.Sender, recipient []string, msgText string) {
+ msg := stanza.Message{
+ Attrs: stanza.Attrs{Type: stanza.MessageTypeChat},
+ Body: msgText,
+ }
+
+ if isMUCRecipient {
+ msg.Type = stanza.MessageTypeGroupchat
+ }
+
+ for _, to := range recipient {
+ msg.To = to
+ if err := c.Send(msg); err != nil {
+ log.WithFields(map[string]interface{}{
+ "muc": isMUCRecipient,
+ "to": to,
+ "text": msgText,
+ }).Errorf("error on send message: %s", err)
+ } else {
+ log.WithFields(map[string]interface{}{
+ "muc": isMUCRecipient,
+ "to": to,
+ "text": msgText,
+ }).Info("send message")
+ }
+ }
+}
diff --git a/go-xmpp/cmd/go.mod b/go-xmpp/cmd/go.mod
new file mode 100644
index 0000000..7d61113
--- /dev/null
+++ b/go-xmpp/cmd/go.mod
@@ -0,0 +1,13 @@
+module gosrc.io/xmpp/cmd
+
+go 1.25
+
+require (
+ github.com/bdlm/log v0.1.19
+ github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7
+ github.com/spf13/cobra v0.0.5
+ github.com/spf13/viper v1.6.1
+ gosrc.io/xmpp v0.1.1
+)
+
+replace gosrc.io/xmpp => ./../
diff --git a/go-xmpp/cmd/go.sum b/go-xmpp/cmd/go.sum
new file mode 100644
index 0000000..af7eda1
--- /dev/null
+++ b/go-xmpp/cmd/go.sum
@@ -0,0 +1,259 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/awesome-gocui/gocui v0.6.0/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA=
+github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s=
+github.com/bdlm/log v0.1.19 h1:GqVFZC+khJCEbtTmkaDL/araNDwxTeLBmdMK8pbRoBE=
+github.com/bdlm/log v0.1.19/go.mod h1:30V5Zwc5Vt5ePq5rd9KJ6JQ/A5aFUcKzq5fYtO7c9qc=
+github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7 h1:ggZyn+N8eoBh/qLla2kUtqm/ysjnkbzUxTQY+6LMshY=
+github.com/bdlm/std v0.0.0-20180922040903-fd3b596111c7/go.mod h1:E4vIYZDcEPVbE/Dbxc7GpA3YJpXnsF5csRt8LptMGWI=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
+github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
+github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
+github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
+github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
+github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
+github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
+github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
+github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
+github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
+github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/spf13/viper v1.6.1 h1:VPZzIkznI1YhVMRi6vNFLHSwhnhReBfgTxIPccpfdZk=
+github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
+github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
+github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
+go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190927073244-c990c680b611 h1:q9u40nxWT5zRClI/uU9dHCiYGottAg6Nzz4YUQyHxdA=
+golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+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=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
+gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
+gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
+gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8=
+nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg=
+nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY=
+nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/go-xmpp/component.go b/go-xmpp/component.go
new file mode 100644
index 0000000..ec0e8df
--- /dev/null
+++ b/go-xmpp/component.go
@@ -0,0 +1,230 @@
+package xmpp
+
+import (
+ "context"
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "gosrc.io/xmpp/stanza"
+ "io"
+)
+
+type ComponentOptions struct {
+ TransportConfiguration
+
+ // =================================
+ // Component Connection Info
+
+ // Domain is the XMPP server subdomain that the component will handle
+ Domain string
+ // Secret is the "password" used by the XMPP server to secure component access
+ Secret string
+
+ // =================================
+ // Component discovery
+
+ // Component human readable name, that will be shown in XMPP discovery
+ Name string
+ // Typical categories and types: https://xmpp.org/registrar/disco-categories.html
+ Category string
+ Type string
+
+ // =================================
+ // Communication with developer client / StreamManager
+
+ // Track and broadcast connection state
+ EventManager
+}
+
+// Component implements an XMPP extension allowing to extend XMPP server
+// using external components. Component specifications are defined
+// in XEP-0114, XEP-0355 and XEP-0356.
+type Component struct {
+ ComponentOptions
+ router *Router
+
+ transport Transport
+
+ // read / write
+ socketProxy io.ReadWriter // TODO
+ ErrorHandler func(error)
+}
+
+func NewComponent(opts ComponentOptions, r *Router, errorHandler func(error)) (*Component, error) {
+ c := Component{ComponentOptions: opts, router: r, ErrorHandler: errorHandler}
+ return &c, nil
+}
+
+// Connect triggers component connection to XMPP server component port.
+// TODO: Failed handshake should be a permanent error
+func (c *Component) Connect() error {
+ return c.Resume()
+}
+
+func (c *Component) Resume() error {
+ var err error
+ var streamId string
+ if c.ComponentOptions.TransportConfiguration.Domain == "" {
+ c.ComponentOptions.TransportConfiguration.Domain = c.ComponentOptions.Domain
+ }
+ c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration)
+ if err != nil {
+ c.updateState(StatePermanentError)
+ return NewConnError(err, true)
+ }
+
+ if streamId, err = c.transport.Connect(); err != nil {
+ c.updateState(StatePermanentError)
+ return NewConnError(err, true)
+ }
+
+ // Authentication
+ if err := c.sendWithWriter(c.transport, []byte(fmt.Sprintf("%s", c.handshake(streamId)))); err != nil {
+ c.updateState(StateStreamError)
+
+ return NewConnError(errors.New("cannot send handshake "+err.Error()), false)
+ }
+
+ // Check server response for authentication
+ val, err := stanza.NextPacket(c.transport.GetDecoder())
+ if err != nil {
+ c.updateState(StatePermanentError)
+ return NewConnError(err, true)
+ }
+
+ switch v := val.(type) {
+ case stanza.StreamError:
+ c.streamError("conflict", "no auth loop")
+ return NewConnError(errors.New("handshake failed "+v.Error.Local), true)
+ case stanza.Handshake:
+ // Start the receiver go routine
+ c.updateState(StateSessionEstablished)
+ go c.recv()
+ return err // Should be empty at this point
+ default:
+ c.updateState(StatePermanentError)
+ return NewConnError(errors.New("expecting handshake result, got "+v.Name()), true)
+ }
+}
+
+func (c *Component) Disconnect() error {
+ // TODO: Add a way to wait for stream close acknowledgement from the server for clean disconnect
+ if c.transport != nil {
+ return c.transport.Close()
+ }
+ // No transport so no connection.
+ return nil
+}
+
+func (c *Component) SetHandler(handler EventHandler) {
+ c.Handler = handler
+}
+
+// Receiver Go routine receiver
+func (c *Component) recv() {
+ for {
+ val, err := stanza.NextPacket(c.transport.GetDecoder())
+ if err != nil {
+ c.updateState(StateDisconnected)
+ c.ErrorHandler(err)
+ return
+ }
+ // Handle stream errors
+ switch p := val.(type) {
+ case stanza.StreamError:
+ c.router.route(c, val)
+ c.streamError(p.Error.Local, p.Text)
+ c.ErrorHandler(errors.New("stream error: " + p.Error.Local))
+ // We don't return here, because we want to wait for the stream close tag from the server, or timeout.
+ c.Disconnect()
+ case stanza.StreamClosePacket:
+ // TCP messages should arrive in order, so we can expect to get nothing more after this occurs
+ c.transport.ReceivedStreamClose()
+ return
+ }
+ c.router.route(c, val)
+ }
+}
+
+// Send marshalls XMPP stanza and sends it to the server.
+func (c *Component) Send(packet stanza.Packet) error {
+ transport := c.transport
+ if transport == nil {
+ return errors.New("component is not connected")
+ }
+
+ data, err := xml.Marshal(packet)
+ if err != nil {
+ return errors.New("cannot marshal packet " + err.Error())
+ }
+
+ if err := c.sendWithWriter(transport, data); err != nil {
+ return errors.New("cannot send packet " + err.Error())
+ }
+ return nil
+}
+
+func (c *Component) sendWithWriter(writer io.Writer, packet []byte) error {
+ var err error
+ _, err = writer.Write(packet)
+ return err
+}
+
+// SendIQ sends an IQ set or get stanza to the server. If a result is received
+// the provided handler function will automatically be called.
+//
+// The provided context should have a timeout to prevent the client from waiting
+// forever for an IQ result. For example:
+//
+// ctx, _ := context.WithTimeout(context.Background(), 30 * time.Second)
+// result := <- client.SendIQ(ctx, iq)
+//
+func (c *Component) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
+ if iq.Attrs.Type != stanza.IQTypeSet && iq.Attrs.Type != stanza.IQTypeGet {
+ return nil, ErrCanOnlySendGetOrSetIq
+ }
+ if err := c.Send(iq); err != nil {
+ return nil, err
+ }
+ return c.router.NewIQResultRoute(ctx, iq.Attrs.Id), nil
+}
+
+// SendRaw sends an XMPP stanza as a string to the server.
+// It can be invalid XML or XMPP content. In that case, the server will
+// disconnect the component. It is up to the user of this method to
+// carefully craft the XML content to produce valid XMPP.
+func (c *Component) SendRaw(packet string) error {
+ transport := c.transport
+ if transport == nil {
+ return errors.New("component is not connected")
+ }
+
+ var err error
+ err = c.sendWithWriter(transport, []byte(packet))
+ return err
+}
+
+// handshake generates an authentication token based on StreamID and shared secret.
+func (c *Component) handshake(streamId string) string {
+ // 1. Concatenate the Stream ID received from the server with the shared secret.
+ concatStr := streamId + c.Secret
+
+ // 2. Hash the concatenated string according to the SHA1 algorithm, i.e., SHA1( concat (sid, password)).
+ h := sha1.New()
+ h.Write([]byte(concatStr))
+ hash := h.Sum(nil)
+
+ // 3. Ensure that the hash output is in hexadecimal format, not binary or base64.
+ // 4. Convert the hash output to all lowercase characters.
+ encodedStr := hex.EncodeToString(hash)
+
+ return encodedStr
+}
+
+/*
+TODO: Add support for discovery management directly in component
+TODO: Support multiple identities on disco info
+TODO: Support returning features on disco info
+*/
diff --git a/go-xmpp/component_test.go b/go-xmpp/component_test.go
new file mode 100644
index 0000000..b5b9f35
--- /dev/null
+++ b/go-xmpp/component_test.go
@@ -0,0 +1,512 @@
+package xmpp
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "gosrc.io/xmpp/stanza"
+)
+
+// Tests are ran in parallel, so each test creating a server must use a different port so we do not get any
+// conflict. Using iota for this should do the trick.
+const (
+ defaultChannelTimeout = 5 * time.Second
+)
+
+func TestHandshake(t *testing.T) {
+ opts := ComponentOptions{
+ Domain: "test.localhost",
+ Secret: "mypass",
+ }
+ c := Component{ComponentOptions: opts}
+
+ streamID := "1263952298440005243"
+ expected := "c77e2ef0109fbbc5161e83b51629cd1353495332"
+
+ result := c.handshake(streamID)
+ if result != expected {
+ t.Errorf("incorrect handshake calculation '%s' != '%s'", result, expected)
+ }
+}
+
+// Tests connection process with a handshake exchange
+// Tests multiple session IDs. All serverConnections should generate a unique stream ID
+func TestGenerateHandshakeId(t *testing.T) {
+ clientDone := make(chan struct{})
+ serverDone := make(chan struct{})
+ // Using this array with a channel to make a queue of values to test
+ // These are stream IDs that will be used to test the connection process, mixing them with the "secret" to generate
+ // some handshake value
+ var uuidsArray = [5]string{}
+ for i := 1; i < len(uuidsArray); i++ {
+ id, _ := uuid.NewRandom()
+ uuidsArray[i] = id.String()
+ }
+
+ // Channel to pass stream IDs as a queue
+ var uchan = make(chan string, len(uuidsArray))
+ // Populate test channel
+ for _, elt := range uuidsArray {
+ uchan <- elt
+ }
+
+ // Performs a Component connection with a handshake. It expects to have an ID sent its way through the "uchan"
+ // channel of this file. Otherwise it will hang for ever.
+ h := func(t *testing.T, sc *ServerConn) {
+ checkOpenStreamHandshakeID(t, sc, <-uchan)
+ readHandshakeComponent(t, sc.decoder)
+ sc.connection.Write([]byte("")) // That's all the server needs to return (see xep-0114)
+ serverDone <- struct{}{}
+ }
+
+ // Init mock server
+ testComponentAddess := fmt.Sprintf("%s:%d", testComponentDomain, testHandshakePort)
+ mock := ServerMock{}
+ mock.Start(t, testComponentAddess, h)
+
+ // Init component
+ opts := ComponentOptions{
+ TransportConfiguration: TransportConfiguration{
+ Address: testComponentAddess,
+ Domain: "localhost",
+ },
+ Domain: testComponentDomain,
+ Secret: "mypass",
+ Name: "Test Component",
+ Category: "gateway",
+ Type: "service",
+ }
+ router := NewRouter()
+ c, err := NewComponent(opts, router, componentDefaultErrorHandler)
+ if err != nil {
+ t.Errorf("%+v", err)
+ }
+ c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration)
+ if err != nil {
+ t.Errorf("%+v", err)
+ }
+
+ // Try connecting, and storing the resulting streamID in a map.
+ go func() {
+ m := make(map[string]bool)
+ for range uuidsArray {
+ idChan := make(chan string)
+ go func() {
+ streamId, err := c.transport.Connect()
+ if err != nil {
+ t.Fatalf("failed to mock component connection to get a handshake: %s", err)
+ }
+ idChan <- streamId
+ }()
+
+ var streamId string
+ select {
+ case streamId = <-idChan:
+ case <-time.After(defaultTimeout):
+ t.Fatalf("test timed out")
+ }
+
+ hs := stanza.Handshake{
+ Value: c.handshake(streamId),
+ }
+ m[hs.Value] = true
+ hsRaw, err := xml.Marshal(hs)
+ if err != nil {
+ t.Fatalf("could not marshal handshake: %s", err)
+ }
+ c.SendRaw(string(hsRaw))
+ waitForEntity(t, serverDone)
+ c.transport.Close()
+ }
+ if len(uuidsArray) != len(m) {
+ t.Errorf("Handshake does not produce a unique id. Expected: %d unique ids, got: %d", len(uuidsArray), len(m))
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, clientDone)
+ mock.Stop()
+}
+
+// Test that NewStreamManager can accept a Component.
+//
+// This validates that Component conforms to StreamClient interface.
+func TestStreamManager(t *testing.T) {
+ NewStreamManager(&Component{}, nil)
+}
+
+// Tests that the decoder is properly initialized when connecting a component to a server.
+// The decoder is expected to be built after a valid connection
+// Based on the xmpp_component example.
+func TestDecoder(t *testing.T) {
+ c, _ := mockComponentConnection(t, testDecoderPort, handlerForComponentHandshakeDefaultID)
+ if c.transport.GetDecoder() == nil {
+ t.Errorf("Failed to initialize decoder. Decoder is nil.")
+ }
+}
+
+// Tests sending an IQ to the server, and getting the response
+func TestSendIq(t *testing.T) {
+ serverDone := make(chan struct{})
+ clientDone := make(chan struct{})
+ h := func(t *testing.T, sc *ServerConn) {
+ handlerForComponentIQSend(t, sc)
+ serverDone <- struct{}{}
+ }
+
+ //Connecting to a mock server, initialized with given port and handler function
+ c, m := mockComponentConnection(t, testSendIqPort, h)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"})
+ if err != nil {
+ t.Fatalf("failed to create IQ request: %v", err)
+ }
+ disco := iqReq.DiscoInfo()
+ iqReq.Payload = disco
+
+ // Handle a possible error
+ errChan := make(chan error)
+ errorHandler := func(err error) {
+ errChan <- err
+ }
+ c.ErrorHandler = errorHandler
+
+ go func() {
+ var res chan stanza.IQ
+ res, _ = c.SendIQ(ctx, iqReq)
+
+ select {
+ case <-res:
+ case err := <-errChan:
+ t.Fatal(err)
+ }
+ clientDone <- struct{}{}
+ }()
+
+ waitForEntity(t, clientDone)
+ waitForEntity(t, serverDone)
+
+ cancel()
+ m.Stop()
+}
+
+// Checking that error handling is done properly client side when an invalid IQ is sent and the server responds in kind.
+func TestSendIqFail(t *testing.T) {
+ done := make(chan struct{})
+ h := func(t *testing.T, sc *ServerConn) {
+ handlerForComponentIQSend(t, sc)
+ done <- struct{}{}
+ }
+ //Connecting to a mock server, initialized with given port and handler function
+ c, m := mockComponentConnection(t, testSendIqFailPort, h)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ iqReq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "test1@localhost/mremond-mbp", To: defaultServerName, Id: defaultStreamID, Lang: "en"})
+ if err != nil {
+ t.Fatalf("failed to create IQ request: %v", err)
+ }
+
+ // Removing the id to make the stanza invalid. The IQ constructor makes a random one if none is specified
+ // so we need to overwrite it.
+ iqReq.Id = ""
+ disco := iqReq.DiscoInfo()
+ iqReq.Payload = disco
+
+ errChan := make(chan error)
+ errorHandler := func(err error) {
+ errChan <- err
+ }
+ c.ErrorHandler = errorHandler
+
+ var res chan stanza.IQ
+ res, _ = c.SendIQ(ctx, iqReq)
+
+ select {
+ case r := <-res: // Do we get an IQ response from the server ?
+ t.Errorf("We should not be getting an IQ response here : this should fail !")
+ fmt.Println(r)
+ case <-errChan: // Do we get a stream error from the server ?
+ // If we get an error from the server, the test passes.
+ case <-time.After(defaultChannelTimeout): // Timeout ?
+ t.Errorf("Failed to receive response, to sent IQ, from mock server")
+ }
+
+ select {
+ case <-done:
+ m.Stop()
+ case <-time.After(defaultChannelTimeout):
+ t.Errorf("The mock server failed to finish its job !")
+ }
+ cancel()
+}
+
+// Tests sending raw xml to the mock server.
+// Right now, the server response is not checked and an err is passed in a channel if the test is supposed to err.
+// In this test, we use IQs
+func TestSendRaw(t *testing.T) {
+ done := make(chan struct{})
+ // Handler for the mock server
+ h := func(t *testing.T, sc *ServerConn) {
+ // Completes the connection by exchanging handshakes
+ handlerForComponentHandshakeDefaultID(t, sc)
+ respondToIQ(t, sc)
+ done <- struct{}{}
+ }
+
+ type testCase struct {
+ req string
+ shouldErr bool
+ port int
+ }
+ testRequests := make(map[string]testCase)
+ // Sending a correct IQ of type get. Not supposed to err
+ testRequests["Correct IQ"] = testCase{
+ req: ``,
+ shouldErr: false,
+ port: testSendRawPort + 100,
+ }
+ // Sending an IQ with a missing ID. Should err
+ testRequests["IQ with missing ID"] = testCase{
+ req: ``,
+ shouldErr: true,
+ port: testSendRawPort + 200,
+ }
+
+ // A handler for the component.
+ // In the failing test, the server returns a stream error, which triggers this handler, component side.
+ errChan := make(chan error)
+ errHandler := func(err error) {
+ errChan <- err
+ }
+
+ // Tests for all the IQs
+ for name, tcase := range testRequests {
+ t.Run(name, func(st *testing.T) {
+ //Connecting to a mock server, initialized with given port and handler function
+ c, m := mockComponentConnection(t, tcase.port, h)
+ c.ErrorHandler = errHandler
+ // Sending raw xml from test case
+ err := c.SendRaw(tcase.req)
+ if err != nil {
+ t.Errorf("Error sending Raw string")
+ }
+ // Just wait a little so the message has time to arrive
+ select {
+ // We don't use the default "long" timeout here because waiting it out means passing the test.
+ case <-time.After(200 * time.Millisecond):
+ case err = <-errChan:
+ if err == nil && tcase.shouldErr {
+ t.Errorf("Failed to get closing stream err")
+ } else if err != nil && !tcase.shouldErr {
+ t.Errorf("This test is not supposed to err ! => %s", err.Error())
+ }
+ }
+ c.transport.Close()
+ select {
+ case <-done:
+ m.Stop()
+ case <-time.After(defaultChannelTimeout):
+ t.Errorf("The mock server failed to finish its job !")
+ }
+ })
+ }
+}
+
+// Tests the Disconnect method for Components
+func TestDisconnect(t *testing.T) {
+ c, m := mockComponentConnection(t, testDisconnectPort, handlerForComponentHandshakeDefaultID)
+ err := c.transport.Ping()
+ if err != nil {
+ t.Errorf("Could not ping but not disconnected yet")
+ }
+ c.Disconnect()
+ err = c.transport.Ping()
+ if err == nil {
+ t.Errorf("Did not disconnect properly")
+ }
+ m.Stop()
+}
+
+// Tests that a streamManager successfully disconnects when a handshake fails between the component and the server.
+func TestStreamManagerDisconnect(t *testing.T) {
+ // Init mock server
+ testComponentAddress := fmt.Sprintf("%s:%d", testComponentDomain, testSManDisconnectPort)
+ mock := ServerMock{}
+ // Handler fails the handshake, which is currently the only option to disconnect completely when using a streamManager
+ // a failed handshake being a permanent error, except for a "conflict"
+ mock.Start(t, testComponentAddress, handlerComponentFailedHandshakeDefaultID)
+
+ //==================================
+ // Create Component to connect to it
+ c := makeBasicComponent(defaultComponentName, testComponentAddress, t)
+
+ //========================================
+ // Connect the new Component to the server
+ cm := NewStreamManager(c, nil)
+ errChan := make(chan error)
+ runSMan := func(errChan chan error) {
+ errChan <- cm.Run()
+ }
+
+ go runSMan(errChan)
+ select {
+ case <-errChan:
+ case <-time.After(100 * time.Millisecond):
+ t.Errorf("The component and server seem to still be connected while they should not.")
+ }
+ mock.Stop()
+}
+
+//=============================================================================
+// Basic XMPP Server Mock Handlers.
+
+// ===============================
+// Init mock server and connection
+// Creating a mock server and connecting a Component to it. Initialized with given port and handler function
+// The Component and mock are both returned
+func mockComponentConnection(t *testing.T, port int, handler func(t *testing.T, sc *ServerConn)) (*Component, *ServerMock) {
+ // Init mock server
+ testComponentAddress := fmt.Sprintf("%s:%d", testComponentDomain, port)
+ mock := &ServerMock{}
+ mock.Start(t, testComponentAddress, handler)
+
+ //==================================
+ // Create Component to connect to it
+ c := makeBasicComponent(defaultComponentName, testComponentAddress, t)
+
+ //========================================
+ // Connect the new Component to the server
+ err := c.Connect()
+ if err != nil {
+ t.Errorf("%+v", err)
+ }
+
+ // Now that the Component is connected, let's set the xml.Decoder for the server
+
+ return c, mock
+}
+
+func makeBasicComponent(name string, mockServerAddr string, t *testing.T) *Component {
+ opts := ComponentOptions{
+ TransportConfiguration: TransportConfiguration{
+ Address: mockServerAddr,
+ Domain: "localhost",
+ },
+ Domain: testComponentDomain,
+ Secret: "mypass",
+ Name: name,
+ Category: "gateway",
+ Type: "service",
+ }
+ router := NewRouter()
+ c, err := NewComponent(opts, router, componentDefaultErrorHandler)
+ if err != nil {
+ t.Errorf("%+v", err)
+ }
+ c.transport, err = NewComponentTransport(c.ComponentOptions.TransportConfiguration)
+ if err != nil {
+ t.Errorf("%+v", err)
+ }
+ return c
+}
+
+// This really should not be used as is.
+// It's just meant to be a placeholder when error handling is not needed at this level
+func componentDefaultErrorHandler(err error) {
+
+}
+
+// Sends IQ response to Component request.
+// No parsing of the request here. We just check that it's valid, and send the default response.
+func handlerForComponentIQSend(t *testing.T, sc *ServerConn) {
+ // Completes the connection by exchanging handshakes
+ handlerForComponentHandshakeDefaultID(t, sc)
+ respondToIQ(t, sc)
+}
+
+// Used for ID and handshake related tests
+func checkOpenStreamHandshakeID(t *testing.T, sc *ServerConn, streamID string) {
+ err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout))
+ if err != nil {
+ t.Fatalf("failed to set deadline: %v", err)
+ }
+ defer sc.connection.SetDeadline(time.Time{})
+
+ for { // TODO clean up. That for loop is not elegant and I prefer bounded recursion.
+ token, err := sc.decoder.Token()
+ if err != nil {
+ t.Errorf("cannot read next token: %s", err)
+ }
+
+ switch elem := token.(type) {
+ // Wait for first startElement
+ case xml.StartElement:
+ if elem.Name.Space != stanza.NSStream || elem.Name.Local != "stream" {
+ err = errors.New("xmpp: expected but got <" + elem.Name.Local + "> in " + elem.Name.Space)
+ return
+ }
+ if _, err := fmt.Fprintf(sc.connection, serverStreamOpen, "localhost", streamID, stanza.NSComponent, stanza.NSStream); err != nil {
+ t.Errorf("cannot write server stream open: %s", err)
+ }
+ return
+ }
+ }
+}
+
+func checkOpenStreamHandshakeDefaultID(t *testing.T, sc *ServerConn) {
+ checkOpenStreamHandshakeID(t, sc, defaultStreamID)
+}
+
+// Performs a Component connection with a handshake. It uses a default ID defined in this file as a constant.
+// This handler is supposed to fail by sending a "message" stanza instead of a stanza to finalize the handshake.
+func handlerComponentFailedHandshakeDefaultID(t *testing.T, sc *ServerConn) {
+ checkOpenStreamHandshakeDefaultID(t, sc)
+ readHandshakeComponent(t, sc.decoder)
+
+ // Send a message, instead of a "" tag, to fail the handshake process dans disconnect the client.
+ me := stanza.Message{
+ Attrs: stanza.Attrs{Type: stanza.MessageTypeChat, From: defaultServerName, To: defaultComponentName, Lang: "en"},
+ Body: "Fail my handshake.",
+ }
+ s, _ := xml.Marshal(me)
+ _, err := sc.connection.Write(s)
+ if err != nil {
+ t.Fatalf("could not write message: %v", err)
+ }
+
+ return
+}
+
+// Reads from the connection with the Component. Expects a handshake request, and returns the tag.
+func readHandshakeComponent(t *testing.T, decoder *xml.Decoder) {
+ se, err := stanza.NextStart(decoder)
+ if err != nil {
+ t.Errorf("cannot read auth: %s", err)
+ return
+ }
+ nv := &stanza.Handshake{}
+ // Decode element into pointer storage
+ if err = decoder.DecodeElement(nv, &se); err != nil {
+ t.Errorf("cannot decode handshake: %s", err)
+ return
+ }
+ if len(strings.TrimSpace(nv.Value)) == 0 {
+ t.Errorf("did not receive handshake ID")
+ }
+}
+
+// Performs a Component connection with a handshake. It uses a default ID defined in this file as a constant.
+// Used in the mock server as a Handler
+func handlerForComponentHandshakeDefaultID(t *testing.T, sc *ServerConn) {
+ checkOpenStreamHandshakeDefaultID(t, sc)
+ readHandshakeComponent(t, sc.decoder)
+ sc.connection.Write([]byte("")) // That's all the server needs to return (see xep-0114)
+ return
+}
diff --git a/go-xmpp/config.go b/go-xmpp/config.go
new file mode 100644
index 0000000..4609a0a
--- /dev/null
+++ b/go-xmpp/config.go
@@ -0,0 +1,35 @@
+package xmpp
+
+import (
+ "gosrc.io/xmpp/stanza"
+ "os"
+ "time"
+)
+
+// Config & TransportConfiguration must not be modified after having been passed to NewClient. Any
+// changes made after connecting are ignored.
+type Config struct {
+ TransportConfiguration
+
+ Jid string
+ parsedJid *stanza.Jid // For easier manipulation
+ Credential Credential
+ StreamLogger *os.File // Used for debugging
+ Lang string // TODO: should default to 'en'
+ KeepaliveInterval time.Duration // Interval between keepalive packets
+ ConnectTimeout int // Client timeout in seconds. Default to 15
+ // Insecure can be set to true to allow to open a session without TLS. If TLS
+ // is supported on the server, we will still try to use it.
+ Insecure bool
+
+ // Activate stream management process during session
+ StreamManagementEnable bool
+ // Enable stream management resume capability
+ streamManagementResume bool
+}
+
+// IsStreamResumable tells if a stream session is resumable by reading the "config" part of a client.
+// It checks if stream management is enabled, and if stream resumption was set and accepted by the server.
+func IsStreamResumable(c *Client) bool {
+ return c.config.StreamManagementEnable && c.config.streamManagementResume
+}
diff --git a/go-xmpp/conn_error.go b/go-xmpp/conn_error.go
new file mode 100644
index 0000000..2b1501c
--- /dev/null
+++ b/go-xmpp/conn_error.go
@@ -0,0 +1,33 @@
+package xmpp
+
+import (
+ "fmt"
+
+ "golang.org/x/xerrors"
+)
+
+type ConnError struct {
+ frame xerrors.Frame
+ err error
+ // Permanent will be true if error is not recoverable
+ Permanent bool
+}
+
+func NewConnError(err error, permanent bool) ConnError {
+ return ConnError{err: err, frame: xerrors.Caller(1), Permanent: permanent}
+}
+
+func (e ConnError) Format(s fmt.State, verb rune) {
+ xerrors.FormatError(e, s, verb)
+}
+
+func (e ConnError) FormatError(p xerrors.Printer) error {
+ e.frame.Format(p)
+ return e.err
+}
+
+func (e ConnError) Error() string {
+ return fmt.Sprint(e)
+}
+
+func (e ConnError) Unwrap() error { return e.err }
diff --git a/go-xmpp/doc.go b/go-xmpp/doc.go
new file mode 100644
index 0000000..f29bbf6
--- /dev/null
+++ b/go-xmpp/doc.go
@@ -0,0 +1,42 @@
+/*
+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 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
+
+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
+
+Fluux XMPP can be use to create fully interactive XMPP clients (for
+example console-based), but it is more commonly used to build automated
+clients (connected devices, automation scripts, chatbots, etc.).
+
+Components
+
+XMPP components can typically be used to extends the features of an XMPP
+server, in a portable way, using component protocol over persistent TCP
+serverConnections.
+
+Component protocol is defined in XEP-114 (https://xmpp.org/extensions/xep-0114.html).
+
+Compliance
+
+Fluux XMPP has been primarily tested with ejabberd (https://www.ejabberd.im)
+but it should work with any XMPP compliant server.
+
+*/
+package xmpp
diff --git a/go-xmpp/go.mod b/go-xmpp/go.mod
new file mode 100644
index 0000000..0d6e57b
--- /dev/null
+++ b/go-xmpp/go.mod
@@ -0,0 +1,10 @@
+module gosrc.io/xmpp
+
+go 1.25
+
+require (
+ github.com/google/go-cmp v0.7.0
+ github.com/google/uuid v1.6.0
+ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
+ nhooyr.io/websocket v1.8.17
+)
diff --git a/go-xmpp/go.sum b/go-xmpp/go.sum
new file mode 100644
index 0000000..a345e2f
--- /dev/null
+++ b/go-xmpp/go.sum
@@ -0,0 +1,8 @@
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
+nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/go-xmpp/network.go b/go-xmpp/network.go
new file mode 100644
index 0000000..8b03f3f
--- /dev/null
+++ b/go-xmpp/network.go
@@ -0,0 +1,32 @@
+package xmpp
+
+import (
+ "strconv"
+ "strings"
+)
+
+// ensurePort adds a port to an address if none are provided.
+// It handles both IPV4 and IPV6 addresses.
+func ensurePort(addr string, port int) string {
+ // This is an IPV6 address literal
+ if strings.HasPrefix(addr, "[") {
+ // if address has no port (behind his ipv6 address) - add default port
+ if strings.LastIndex(addr, ":") <= strings.LastIndex(addr, "]") {
+ return addr + ":" + strconv.Itoa(port)
+ }
+ return addr
+ }
+
+ // This is either an IPV6 address without bracket or an IPV4 address
+ switch strings.Count(addr, ":") {
+ case 0:
+ // This is IPV4 without port
+ return addr + ":" + strconv.Itoa(port)
+ case 1:
+ // This is IPV6 with port
+ return addr
+ default:
+ // This is IPV6 without port, as you need to use bracket with port in IPV6
+ return "[" + addr + "]:" + strconv.Itoa(port)
+ }
+}
diff --git a/go-xmpp/network_test.go b/go-xmpp/network_test.go
new file mode 100644
index 0000000..470f150
--- /dev/null
+++ b/go-xmpp/network_test.go
@@ -0,0 +1,66 @@
+package xmpp
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseAddr(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {name: "ipv4-no-port-1", input: "localhost", want: "localhost:5222"},
+ {name: "ipv4-with-port-1", input: "localhost:5555", want: "localhost:5555"},
+ {name: "ipv4-no-port-2", input: "127.0.0.1", want: "127.0.0.1:5222"},
+ {name: "ipv4-with-port-2", input: "127.0.0.1:5555", want: "127.0.0.1:5555"},
+ {name: "ipv6-no-port-1", input: "::1", want: "[::1]:5222"},
+ {name: "ipv6-no-port-2", input: "[::1]", want: "[::1]:5222"},
+ {name: "ipv6-no-port-3", input: "2001::7334", want: "[2001::7334]:5222"},
+ {name: "ipv6-no-port-4", input: "2001:db8:85a3:0:0:8a2e:370:7334", want: "[2001:db8:85a3:0:0:8a2e:370:7334]:5222"},
+ {name: "ipv6-with-port-1", input: "[::1]:5555", want: "[::1]:5555"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(st *testing.T) {
+ addr := ensurePort(tc.input, 5222)
+
+ if addr != tc.want {
+ st.Errorf("incorrect Result: %v (!= %v)", addr, tc.want)
+ }
+ })
+ }
+}
+
+func TestEnsurePort(t *testing.T) {
+ testAddresses := []string{
+ "1ca3:6c07:ee3a:89ca:e065:9a70:71d:daad",
+ "1ca3:6c07:ee3a:89ca:e065:9a70:71d:daad:5252",
+ "[::1]",
+ "127.0.0.1:5555",
+ "127.0.0.1",
+ "[::1]:5555",
+ }
+
+ for _, oldAddr := range testAddresses {
+ t.Run(oldAddr, func(st *testing.T) {
+ newAddr := ensurePort(oldAddr, 5222)
+
+ if len(newAddr) < len(oldAddr) {
+ st.Errorf("incorrect Result: transformed address is shorter than input : %v (old) > %v (new)", newAddr, oldAddr)
+ }
+ // If IPv6, the new address needs brackets to specify a port, like so : [2001:db8:85a3:0:0:8a2e:370:7334]:5222
+ if strings.Count(newAddr, "[") < strings.Count(oldAddr, "[") ||
+ strings.Count(newAddr, "]") < strings.Count(oldAddr, "]") {
+
+ st.Errorf("incorrect Result. Transformed address seems to not have correct brakets : %v => %v", oldAddr, newAddr)
+ }
+
+ // Check if we messed up the colons, or didn't properly add a port
+ if strings.Count(newAddr, ":") < strings.Count(oldAddr, ":") {
+ st.Errorf("incorrect Result: transformed address doesn't seem to have a port %v (=> %v, no port ?)", oldAddr, newAddr)
+ }
+ })
+ }
+
+}
diff --git a/go-xmpp/router.go b/go-xmpp/router.go
new file mode 100644
index 0000000..7bba8b9
--- /dev/null
+++ b/go-xmpp/router.go
@@ -0,0 +1,374 @@
+package xmpp
+
+import (
+ "context"
+ "encoding/xml"
+ "strings"
+ "sync"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+/*
+The XMPP router helps client and component developers select which XMPP they would like to process,
+and associate processing code depending on the router configuration.
+
+Here are important rules to keep in mind while setting your routes and matchers:
+- Routes are evaluated in the order they are set.
+- When a route matches, it is executed and all others routes are ignored. For each packet, only a single
+ route is executed.
+- An empty route will match everything. Adding an empty route as the last route in your router will
+ allow you to get all stanzas that did not match any previous route. You can for example use this to
+ log all unexpected stanza received by your client or component.
+
+TODO: Automatically reply to IQ that do not match any route, to comply to XMPP standard.
+*/
+
+type Router struct {
+ // Routes to be matched, in order.
+ routes []*Route
+
+ IQResultRoutes map[string]*IQResultRoute
+ IQResultRouteLock sync.RWMutex
+}
+
+// NewRouter returns a new router instance.
+func NewRouter() *Router {
+ return &Router{
+ IQResultRoutes: make(map[string]*IQResultRoute),
+ }
+}
+
+// route is called by the XMPP client to dispatch stanza received using the set up routes.
+// It is also used by test, but is not supposed to be used directly by users of the library.
+func (r *Router) route(s Sender, p stanza.Packet) {
+ a, isA := p.(stanza.SMAnswer)
+ if isA {
+ switch tt := s.(type) {
+ case *Client:
+ lastAcked := a.H
+ SendMissingStz(int(lastAcked), s, tt.Session.SMState.UnAckQueue)
+ case *Component:
+ // TODO
+ default:
+ }
+ }
+ iq, isIq := p.(*stanza.IQ)
+ if isIq {
+ r.IQResultRouteLock.RLock()
+ route, ok := r.IQResultRoutes[iq.Id]
+ r.IQResultRouteLock.RUnlock()
+ if ok {
+ r.IQResultRouteLock.Lock()
+ delete(r.IQResultRoutes, iq.Id)
+ r.IQResultRouteLock.Unlock()
+ route.result <- *iq
+ close(route.result)
+ return
+ }
+ }
+
+ var match RouteMatch
+ if r.Match(p, &match) {
+ // If we match, route the packet
+ match.Handler.HandlePacket(s, p)
+ return
+ }
+
+ // If there is no match and we receive an iq set or get, we need to send a reply
+ if isIq && (iq.Type == stanza.IQTypeGet || iq.Type == stanza.IQTypeSet) {
+ iqNotImplemented(s, iq)
+ }
+}
+
+// SendMissingStz sends all stanzas that did not reach the server, according to the response to an ack request (see XEP-0198, acks)
+func SendMissingStz(lastSent int, s Sender, uaq *stanza.UnAckQueue) error {
+ uaq.RWMutex.Lock()
+ if len(uaq.Uslice) <= 0 {
+ uaq.RWMutex.Unlock()
+ return nil
+ }
+ last := uaq.Uslice[len(uaq.Uslice)-1]
+ if last.Id > lastSent {
+ // Remove sent stanzas from the queue
+ uaq.PopN(lastSent - last.Id)
+ // Re-send non acknowledged stanzas
+ for _, elt := range uaq.PopN(len(uaq.Uslice)) {
+ eltStz := elt.(*stanza.UnAckedStz)
+ err := s.SendRaw(eltStz.Stz)
+ if err != nil {
+ return err
+ }
+
+ }
+ // Ask for updates on stanzas we just sent to the entity. Not sure I should leave this. Maybe let users call ack again by themselves ?
+ s.Send(stanza.SMRequest{})
+ }
+ uaq.RWMutex.Unlock()
+ return nil
+}
+
+func iqNotImplemented(s Sender, iq *stanza.IQ) {
+ err := stanza.Err{
+ XMLName: xml.Name{Local: "error"},
+ Code: 501,
+ Type: "cancel",
+ Reason: "feature-not-implemented",
+ }
+ reply := iq.MakeError(err)
+ _ = s.Send(reply)
+}
+
+// NewRoute registers an empty routes
+func (r *Router) NewRoute() *Route {
+ route := &Route{}
+ r.routes = append(r.routes, route)
+ return route
+}
+
+// NewIQResultRoute register a route that will catch an IQ result stanza with
+// the given Id. The route will only match ones, after which it will automatically
+// be unregistered
+func (r *Router) NewIQResultRoute(ctx context.Context, id string) chan stanza.IQ {
+ route := NewIQResultRoute(ctx)
+ r.IQResultRouteLock.Lock()
+ r.IQResultRoutes[id] = route
+ r.IQResultRouteLock.Unlock()
+
+ // Start a go function to make sure the route is unregistered when the context
+ // is done.
+ go func() {
+ <-route.context.Done()
+ r.IQResultRouteLock.Lock()
+ delete(r.IQResultRoutes, id)
+ r.IQResultRouteLock.Unlock()
+ }()
+
+ return route.result
+}
+
+func (r *Router) Match(p stanza.Packet, match *RouteMatch) bool {
+ for _, route := range r.routes {
+ if route.Match(p, match) {
+ return true
+ }
+ }
+ return false
+}
+
+// Handle registers a new route with a matcher for a given packet name (iq, message, presence)
+// See Route.Packet() and Route.Handler().
+func (r *Router) Handle(name string, handler Handler) *Route {
+ return r.NewRoute().Packet(name).Handler(handler)
+}
+
+// HandleFunc registers a new route with a matcher for for a given packet name (iq, message, presence)
+// See Route.Path() and Route.HandlerFunc().
+func (r *Router) HandleFunc(name string, f func(s Sender, p stanza.Packet)) *Route {
+ return r.NewRoute().Packet(name).HandlerFunc(f)
+}
+
+// ============================================================================
+
+// TimeoutHandlerFunc is a function type for handling IQ result timeouts.
+type TimeoutHandlerFunc func(err error)
+
+// IQResultRoute is a temporary route to match IQ result stanzas
+type IQResultRoute struct {
+ context context.Context
+ result chan stanza.IQ
+}
+
+// NewIQResultRoute creates a new IQResultRoute instance
+func NewIQResultRoute(ctx context.Context) *IQResultRoute {
+ return &IQResultRoute{
+ context: ctx,
+ result: make(chan stanza.IQ),
+ }
+}
+
+// ============================================================================
+// IQ result handler
+
+// IQResultHandler is a utility interface for IQ result handlers
+type IQResultHandler interface {
+ HandleIQ(ctx context.Context, s Sender, iq stanza.IQ)
+}
+
+// IQResultHandlerFunc is an adapter to allow using functions as IQ result handlers.
+type IQResultHandlerFunc func(ctx context.Context, s Sender, iq stanza.IQ)
+
+// HandleIQ is a proxy function to implement IQResultHandler using a function.
+func (f IQResultHandlerFunc) HandleIQ(ctx context.Context, s Sender, iq stanza.IQ) {
+ f(ctx, s, iq)
+}
+
+// ============================================================================
+// Route
+
+type Handler interface {
+ HandlePacket(s Sender, p stanza.Packet)
+}
+
+type Route struct {
+ handler Handler
+ // Matchers are used to "specialize" routes and focus on specific packet features
+ matchers []Matcher
+}
+
+func (r *Route) Handler(handler Handler) *Route {
+ r.handler = handler
+ return r
+}
+
+// The HandlerFunc type is an adapter to allow the use of
+// ordinary functions as XMPP handlers. If f is a function
+// with the appropriate signature, HandlerFunc(f) is a
+// Handler that calls f.
+type HandlerFunc func(s Sender, p stanza.Packet)
+
+// HandlePacket calls f(s, p)
+func (f HandlerFunc) HandlePacket(s Sender, p stanza.Packet) {
+ f(s, p)
+}
+
+// HandlerFunc sets a handler function for the route
+func (r *Route) HandlerFunc(f HandlerFunc) *Route {
+ return r.Handler(f)
+}
+
+// AddMatcher adds a matcher to the route
+func (r *Route) AddMatcher(m Matcher) *Route {
+ r.matchers = append(r.matchers, m)
+ return r
+}
+
+func (r *Route) Match(p stanza.Packet, match *RouteMatch) bool {
+ for _, m := range r.matchers {
+ if matched := m.Match(p, match); !matched {
+ return false
+ }
+ }
+
+ // We have a match, let's pass info route match info
+ match.Route = r
+ match.Handler = r.handler
+ return true
+}
+
+// --------------------
+// Match on packet name
+
+type nameMatcher string
+
+func (n nameMatcher) Match(p stanza.Packet, match *RouteMatch) bool {
+ var name string
+ // TODO: To avoid type switch everywhere in matching, I think we will need to have
+ // to move to a concrete type for packets, to make matching and comparison more natural.
+ // Current code structure is probably too rigid.
+ // Maybe packet types should even be from an enum.
+ switch p.(type) {
+ case stanza.Message:
+ name = "message"
+ case *stanza.IQ:
+ name = "iq"
+ case stanza.Presence:
+ name = "presence"
+ }
+ if name == string(n) {
+ return true
+ }
+ return false
+}
+
+// Packet matches on a packet name (iq, message, presence, ...)
+// It matches on the Local part of the xml.Name
+func (r *Route) Packet(name string) *Route {
+ name = strings.ToLower(name)
+ return r.AddMatcher(nameMatcher(name))
+}
+
+// -------------------------
+// Match on stanza type
+
+// nsTypeMather matches on a list of IQ payload namespaces
+type nsTypeMatcher []string
+
+func (m nsTypeMatcher) Match(p stanza.Packet, match *RouteMatch) bool {
+ var stanzaType stanza.StanzaType
+ switch packet := p.(type) {
+ case *stanza.IQ:
+ stanzaType = packet.Type
+ case stanza.Presence:
+ stanzaType = packet.Type
+ case stanza.Message:
+ if packet.Type == "" {
+ // optional on message, normal is the default type
+ stanzaType = "normal"
+ } else {
+ stanzaType = packet.Type
+ }
+ default:
+ return false
+ }
+ return matchInArray(m, string(stanzaType))
+}
+
+// IQNamespaces adds an IQ matcher, expecting both an IQ and a
+func (r *Route) StanzaType(types ...string) *Route {
+ for k, v := range types {
+ types[k] = strings.ToLower(v)
+ }
+ return r.AddMatcher(nsTypeMatcher(types))
+}
+
+// -------------------------
+// Match on IQ and namespace
+
+// nsIqMather matches on a list of IQ payload namespaces
+type nsIQMatcher []string
+
+func (m nsIQMatcher) Match(p stanza.Packet, match *RouteMatch) bool {
+ iq, ok := p.(*stanza.IQ)
+ if !ok {
+ return false
+ }
+ if iq.Payload == nil {
+ return false
+ }
+ return matchInArray(m, iq.Payload.Namespace())
+}
+
+// IQNamespaces adds an IQ matcher, expecting both an IQ and a
+func (r *Route) IQNamespaces(namespaces ...string) *Route {
+ for k, v := range namespaces {
+ namespaces[k] = strings.ToLower(v)
+ }
+ return r.AddMatcher(nsIQMatcher(namespaces))
+}
+
+// ============================================================================
+// Matchers
+
+// Matchers are used to "specialize" routes and focus on specific packet features.
+// You can register attach them to a route via the AddMatcher method.
+type Matcher interface {
+ Match(stanza.Packet, *RouteMatch) bool
+}
+
+// RouteMatch extracts and gather match information
+type RouteMatch struct {
+ Route *Route
+ Handler Handler
+}
+
+// matchInArray is a generic matching function to check if a string is a list
+// of specific function
+func matchInArray(arr []string, value string) bool {
+ for _, str := range arr {
+ if str == value {
+ return true
+ }
+ }
+ return false
+}
diff --git a/go-xmpp/router_test.go b/go-xmpp/router_test.go
new file mode 100644
index 0000000..677ad0e
--- /dev/null
+++ b/go-xmpp/router_test.go
@@ -0,0 +1,332 @@
+package xmpp
+
+import (
+ "bytes"
+ "context"
+ "encoding/xml"
+ "testing"
+ "time"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// ============================================================================
+// Test route & matchers
+
+func TestIQResultRoutes(t *testing.T) {
+ t.Parallel()
+ router := NewRouter()
+ conn := NewSenderMock()
+
+ if router.IQResultRoutes == nil {
+ t.Fatal("NewRouter does not initialize isResultRoutes")
+ }
+
+ // Check if the IQ handler was called
+ ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
+ defer cancel()
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, Id: "1234"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ res := router.NewIQResultRoute(ctx, "1234")
+ go router.route(conn, iq)
+ select {
+ case <-ctx.Done():
+ t.Fatal("IQ result was not matched")
+ case <-res:
+ // Success
+ }
+
+ // The match must only happen once, so the id should no longer be in IQResultRoutes
+ if _, ok := router.IQResultRoutes[iq.Attrs.Id]; ok {
+ t.Fatal("IQ ID was not removed from the route map")
+ }
+
+ // Check other IQ does not matcah
+ ctx, cancel = context.WithTimeout(context.Background(), time.Millisecond*100)
+ defer cancel()
+ iq.Attrs.Id = "4321"
+ res = router.NewIQResultRoute(ctx, "1234")
+ go router.route(conn, iq)
+ select {
+ case <-ctx.Done():
+ // Success
+ case <-res:
+ t.Fatal("IQ result with wrong ID was matched")
+ }
+}
+
+func TestNameMatcher(t *testing.T) {
+ router := NewRouter()
+ router.HandleFunc("message", func(s Sender, p stanza.Packet) {
+ _ = s.SendRaw(successFlag)
+ })
+
+ // Check that a message packet is properly matched
+ conn := NewSenderMock()
+ msg := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, To: "test@localhost", Id: "1"})
+ msg.Body = "Hello"
+ router.route(conn, msg)
+ if conn.String() != successFlag {
+ t.Error("Message was not matched and routed properly")
+ }
+
+ // Check that an IQ packet is not matched
+ conn = NewSenderMock()
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ iq.Payload = &stanza.DiscoInfo{}
+ router.route(conn, iq)
+ if conn.String() == successFlag {
+ t.Error("IQ should not have been matched and routed")
+ }
+}
+
+func TestIQNSMatcher(t *testing.T) {
+ router := NewRouter()
+ router.NewRoute().
+ IQNamespaces(stanza.NSDiscoInfo, stanza.NSDiscoItems).
+ HandlerFunc(func(s Sender, p stanza.Packet) {
+ _ = s.SendRaw(successFlag)
+ })
+
+ // Check that an IQ with proper namespace does match
+ conn := NewSenderMock()
+ iqDisco, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create iqDisco: %v", err)
+ }
+ // TODO: Add a function to generate payload with proper namespace initialisation
+ iqDisco.Payload = &stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: stanza.NSDiscoInfo,
+ Local: "query",
+ }}
+ router.route(conn, iqDisco)
+ if conn.String() != successFlag {
+ t.Errorf("IQ should have been matched and routed: %v", iqDisco)
+ }
+
+ // Check that another namespace is not matched
+ conn = NewSenderMock()
+ iqVersion, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, To: "localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create iqVersion: %v", err)
+ }
+ // TODO: Add a function to generate payload with proper namespace initialisation
+ iqVersion.Payload = &stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: "jabber:iq:version",
+ Local: "query",
+ }}
+ router.route(conn, iqVersion)
+ if conn.String() == successFlag {
+ t.Errorf("IQ should not have been matched and routed: %v", iqVersion)
+ }
+}
+
+func TestTypeMatcher(t *testing.T) {
+ router := NewRouter()
+ router.NewRoute().
+ StanzaType("normal").
+ HandlerFunc(func(s Sender, p stanza.Packet) {
+ _ = s.SendRaw(successFlag)
+ })
+
+ // Check that a packet with the proper type matches
+ conn := NewSenderMock()
+ message := stanza.NewMessage(stanza.Attrs{Type: "normal", To: "test@localhost", Id: "1"})
+ message.Body = "hello"
+ router.route(conn, message)
+
+ if conn.String() != successFlag {
+ t.Errorf("'normal' message should have been matched and routed: %v", message)
+ }
+
+ // We should match on default type 'normal' for message without a type
+ conn = NewSenderMock()
+ message = stanza.NewMessage(stanza.Attrs{To: "test@localhost", Id: "1"})
+ message.Body = "hello"
+ router.route(conn, message)
+
+ if conn.String() != successFlag {
+ t.Errorf("message should have been matched and routed: %v", message)
+ }
+
+ // We do not match on other types
+ conn = NewSenderMock()
+ iqVersion, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create iqVersion: %v", err)
+ }
+ iqVersion.Payload = &stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: "jabber:iq:version",
+ Local: "query",
+ }}
+ router.route(conn, iqVersion)
+
+ if conn.String() == successFlag {
+ t.Errorf("iq get should not have been matched and routed: %v", iqVersion)
+ }
+}
+
+func TestCompositeMatcher(t *testing.T) {
+ router := NewRouter()
+ router.NewRoute().
+ IQNamespaces("jabber:iq:version").
+ StanzaType(string(stanza.IQTypeGet)).
+ HandlerFunc(func(s Sender, p stanza.Packet) {
+ _ = s.SendRaw(successFlag)
+ })
+
+ // Data set
+ getVersionIq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create getVersionIq: %v", err)
+ }
+ getVersionIq.Payload = &stanza.Version{
+ XMLName: xml.Name{
+ Space: "jabber:iq:version",
+ Local: "query",
+ }}
+
+ setVersionIq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeSet, From: "service.localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create setVersionIq: %v", err)
+ }
+ setVersionIq.Payload = &stanza.Version{
+ XMLName: xml.Name{
+ Space: "jabber:iq:version",
+ Local: "query",
+ }}
+
+ getDiscoIq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create getDiscoIq: %v", err)
+ }
+ getDiscoIq.Payload = &stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: "http://jabber.org/protocol/disco#info",
+ Local: "query",
+ }}
+
+ message := stanza.NewMessage(stanza.Attrs{Type: "normal", To: "test@localhost", Id: "1"})
+ message.Body = "hello"
+
+ tests := []struct {
+ name string
+ input stanza.Packet
+ want bool
+ }{
+ {name: "match get version iq", input: getVersionIq, want: true},
+ {name: "ignore set version iq", input: setVersionIq, want: false},
+ {name: "ignore get discoinfo iq", input: getDiscoIq, want: false},
+ {name: "ignore message", input: message, want: false},
+ }
+
+ //
+ for _, tc := range tests {
+ t.Run(tc.name, func(st *testing.T) {
+ conn := NewSenderMock()
+ router.route(conn, tc.input)
+
+ res := conn.String() == successFlag
+ if tc.want != res {
+ st.Errorf("incorrect result for %#v\nMatch = %#v, expecting %#v", tc.input, res, tc.want)
+ }
+ })
+ }
+}
+
+// A blank route with empty matcher will always match
+// It can be use to receive all packets that do not match any of the previous route.
+func TestCatchallMatcher(t *testing.T) {
+ router := NewRouter()
+ router.NewRoute().
+ HandlerFunc(func(s Sender, p stanza.Packet) {
+ _ = s.SendRaw(successFlag)
+ })
+
+ // Check that we match on several packets
+ conn := NewSenderMock()
+ message := stanza.NewMessage(stanza.Attrs{Type: "chat", To: "test@localhost", Id: "1"})
+ message.Body = "hello"
+ router.route(conn, message)
+
+ if conn.String() != successFlag {
+ t.Errorf("chat message should have been matched and routed: %v", message)
+ }
+
+ conn = NewSenderMock()
+ iqVersion, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeGet, From: "service.localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create iqVersion: %v", err)
+ }
+ iqVersion.Payload = &stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: "jabber:iq:version",
+ Local: "query",
+ }}
+ router.route(conn, iqVersion)
+
+ if conn.String() != successFlag {
+ t.Errorf("iq get should have been matched and routed: %v", iqVersion)
+ }
+}
+
+// ============================================================================
+// SenderMock
+
+const successFlag = "matched"
+const cancelledFlag = "cancelled"
+
+type SenderMock struct {
+ buffer *bytes.Buffer
+}
+
+func NewSenderMock() SenderMock {
+ return SenderMock{buffer: new(bytes.Buffer)}
+}
+
+func (s SenderMock) Send(packet stanza.Packet) error {
+ out, err := xml.Marshal(packet)
+ if err != nil {
+ return err
+ }
+ s.buffer.Write(out)
+ return nil
+}
+
+func (s SenderMock) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
+ out, err := xml.Marshal(iq)
+ if err != nil {
+ return nil, err
+ }
+ s.buffer.Write(out)
+ return nil, nil
+}
+
+func (s SenderMock) SendRaw(str string) error {
+ s.buffer.WriteString(str)
+ return nil
+}
+
+func (s SenderMock) String() string {
+ return s.buffer.String()
+}
+
+func TestSenderMock(t *testing.T) {
+ conn := NewSenderMock()
+ msg := stanza.NewMessage(stanza.Attrs{To: "test@localhost", Id: "1"})
+ msg.Body = "Hello"
+ if err := conn.Send(msg); err != nil {
+ t.Error("Could not send message")
+ }
+ if conn.String() != "Hello" {
+ t.Errorf("Incorrect packet sent: %s", conn.String())
+ }
+}
diff --git a/go-xmpp/session.go b/go-xmpp/session.go
new file mode 100644
index 0000000..05bdce3
--- /dev/null
+++ b/go-xmpp/session.go
@@ -0,0 +1,342 @@
+package xmpp
+
+import (
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "gosrc.io/xmpp/stanza"
+ "strconv"
+)
+
+type Session struct {
+ // Session info
+ BindJid string // Jabber ID as provided by XMPP server
+ StreamId string
+ SMState SMState
+ Features stanza.StreamFeatures
+ TlsEnabled bool
+ lastPacketId int
+
+ // read / write
+ transport Transport
+
+ // error management
+ err error
+}
+
+func NewSession(c *Client, state SMState) (*Session, error) {
+ var s *Session
+ if c.Session == nil {
+ s = new(Session)
+ s.transport = c.transport
+ s.SMState = state
+ s.init()
+ } else {
+ s = c.Session
+ // We keep information about the previously set session, like the session ID, but we read server provided
+ // info again in case it changed between session break and resume, such as features.
+ s.init()
+ }
+
+ if s.err != nil {
+ return nil, NewConnError(s.err, true)
+ }
+
+ if !c.transport.IsSecure() {
+ s.startTlsIfSupported(c.config)
+ }
+
+ if !c.transport.IsSecure() && !c.config.Insecure {
+ err := fmt.Errorf("failed to negotiate TLS session : %s", s.err)
+ return nil, NewConnError(err, true)
+ }
+
+ if s.TlsEnabled {
+ s.reset()
+ }
+
+ // auth
+ s.auth(c.config)
+ if s.err != nil {
+ return s, s.err
+ }
+ s.reset()
+ if s.err != nil {
+ return s, s.err
+ }
+
+ // attempt resumption
+ if s.resume(c.config) {
+ return s, s.err
+ }
+
+ // otherwise, bind resource and 'start' XMPP session
+ s.bind(c.config)
+ if s.err != nil {
+ return s, s.err
+ }
+ s.rfc3921Session()
+ if s.err != nil {
+ return s, s.err
+ }
+
+ // Enable stream management if supported
+ s.EnableStreamManagement(c.config)
+ if s.err != nil {
+ return s, s.err
+ }
+
+ return s, s.err
+}
+
+func (s *Session) PacketId() string {
+ s.lastPacketId++
+ return fmt.Sprintf("%x", s.lastPacketId)
+}
+
+// init gathers information on the session such as stream features from the server.
+func (s *Session) init() {
+ s.Features = s.extractStreamFeatures()
+}
+
+func (s *Session) reset() {
+ if s.StreamId, s.err = s.transport.StartStream(); s.err != nil {
+ return
+ }
+
+ s.Features = s.extractStreamFeatures()
+}
+
+func (s *Session) extractStreamFeatures() (f stanza.StreamFeatures) {
+ // extract stream features
+ if s.err = s.transport.GetDecoder().Decode(&f); s.err != nil {
+ s.err = errors.New("stream open decode features: " + s.err.Error())
+ }
+ return
+}
+
+func (s *Session) startTlsIfSupported(o *Config) {
+ if s.err != nil {
+ return
+ }
+
+ if !s.transport.DoesStartTLS() {
+ if !o.Insecure {
+ s.err = errors.New("transport does not support starttls")
+ }
+ return
+ }
+
+ if _, ok := s.Features.DoesStartTLS(); ok {
+ fmt.Fprintf(s.transport, "")
+
+ var k stanza.TLSProceed
+ if s.err = s.transport.GetDecoder().DecodeElement(&k, nil); s.err != nil {
+ s.err = errors.New("expecting starttls proceed: " + s.err.Error())
+ return
+ }
+
+ s.err = s.transport.StartTLS()
+
+ if s.err == nil {
+ s.TlsEnabled = true
+ }
+ return
+ }
+
+ // If we do not allow cleartext serverConnections, make it explicit that server do not support starttls
+ if !o.Insecure {
+ s.err = errors.New("XMPP server does not advertise support for starttls")
+ }
+}
+
+func (s *Session) auth(o *Config) {
+ if s.err != nil {
+ return
+ }
+
+ s.err = authSASL(s.transport, s.transport.GetDecoder(), s.Features, o.parsedJid.Node, o.Credential)
+}
+
+// Attempt to resume session using stream management
+func (s *Session) resume(o *Config) bool {
+ if !s.Features.DoesStreamManagement() {
+ return false
+ }
+ if s.SMState.Id == "" {
+ return false
+ }
+
+ rsm := stanza.SMResume{
+ PrevId: s.SMState.Id,
+ H: &s.SMState.Inbound,
+ }
+ data, err := xml.Marshal(rsm)
+
+ _, err = s.transport.Write(data)
+ if err != nil {
+ return false
+ }
+ var packet stanza.Packet
+ packet, s.err = stanza.NextPacket(s.transport.GetDecoder())
+ if s.err == nil {
+ switch p := packet.(type) {
+ case stanza.SMResumed:
+ if p.PrevId != s.SMState.Id {
+ s.err = errors.New("session resumption: mismatched id")
+ s.SMState = SMState{}
+ return false
+ }
+ return true
+ case stanza.SMFailed:
+ default:
+ s.err = errors.New("unexpected reply to SM resume")
+ }
+ }
+ s.SMState = SMState{}
+ return false
+}
+
+func (s *Session) bind(o *Config) {
+ if s.err != nil {
+ return
+ }
+
+ // Send IQ message asking to bind to the local user name.
+ var resource = o.parsedJid.Resource
+ iqB, err := stanza.NewIQ(stanza.Attrs{
+ Type: stanza.IQTypeSet,
+ Id: s.PacketId(),
+ })
+ if err != nil {
+ s.err = err
+ return
+ }
+
+ // Check if we already have a resource name, and include it in the request if so
+ if resource != "" {
+ iqB.Payload = &stanza.Bind{
+ Resource: resource,
+ }
+ } else {
+ iqB.Payload = &stanza.Bind{}
+
+ }
+
+ // Send the bind request IQ
+ data, err := xml.Marshal(iqB)
+ if err != nil {
+ s.err = err
+ return
+ }
+ n, err := s.transport.Write(data)
+ if err != nil {
+ s.err = err
+ return
+ } else if n == 0 {
+ s.err = errors.New("failed to write bind iq stanza to the server : wrote 0 bytes")
+ return
+ }
+
+ // Check the server response
+ var iq stanza.IQ
+ if s.err = s.transport.GetDecoder().Decode(&iq); s.err != nil {
+ s.err = errors.New("error decoding iq bind result: " + s.err.Error())
+ return
+ }
+
+ // TODO Check all elements
+ switch payload := iq.Payload.(type) {
+ case *stanza.Bind:
+ s.BindJid = payload.Jid // our local id (with possibly randomly generated resource
+ default:
+ s.err = errors.New("iq bind result missing")
+ }
+
+ return
+}
+
+// After the bind, if the session is not optional (as per old RFC 3921), we send the session open iq.
+func (s *Session) rfc3921Session() {
+ if s.err != nil {
+ return
+ }
+
+ var iq stanza.IQ
+ // We only negotiate session binding if it is mandatory, we skip it when optional.
+ if !s.Features.Session.IsOptional() {
+ se, err := stanza.NewIQ(stanza.Attrs{
+ Type: stanza.IQTypeSet,
+ Id: s.PacketId(),
+ })
+ if err != nil {
+ s.err = err
+ return
+ }
+ se.Payload = &stanza.StreamSession{}
+ data, err := xml.Marshal(se)
+ if err != nil {
+ s.err = err
+ return
+ }
+ n, err := s.transport.Write(data)
+ if err != nil {
+ s.err = err
+ return
+ } else if n == 0 {
+ s.err = errors.New("there was a problem marshaling the session IQ : wrote 0 bytes to server")
+ return
+ }
+
+ if s.err = s.transport.GetDecoder().Decode(&iq); s.err != nil {
+ s.err = errors.New("expecting iq result after session open: " + s.err.Error())
+ return
+ }
+ }
+}
+
+// Enable stream management, with session resumption, if supported.
+func (s *Session) EnableStreamManagement(o *Config) {
+ if s.err != nil {
+ return
+ }
+ if !s.Features.DoesStreamManagement() || !o.StreamManagementEnable {
+ return
+ }
+ q := stanza.NewUnAckQueue()
+ ebleNonza := stanza.SMEnable{Resume: &o.streamManagementResume}
+ pktStr, err := xml.Marshal(ebleNonza)
+ if err != nil {
+ s.err = err
+ return
+ }
+ _, err = s.transport.Write(pktStr)
+ if err != nil {
+ s.err = err
+ return
+ }
+
+ var packet stanza.Packet
+ packet, s.err = stanza.NextPacket(s.transport.GetDecoder())
+ if s.err == nil {
+ switch p := packet.(type) {
+ case stanza.SMEnabled:
+ // Server allows resumption or not using SMEnabled attribute "resume". We must read the server response
+ // and update config accordingly
+ b, err := strconv.ParseBool(p.Resume)
+ if err != nil || !b {
+ o.StreamManagementEnable = false
+ }
+ s.SMState = SMState{Id: p.Id, preferredReconAddr: p.Location}
+ s.SMState.UnAckQueue = q
+ case stanza.SMFailed:
+ // TODO: Store error in SMState, for later inspection
+ s.SMState = SMState{StreamErrorGroup: p.StreamErrorGroup}
+ s.SMState.UnAckQueue = q
+ s.err = errors.New("failed to establish session : " + s.SMState.StreamErrorGroup.GroupErrorName())
+ default:
+ s.err = errors.New("unexpected reply to SM enable")
+ }
+ }
+ return
+}
diff --git a/go-xmpp/stanza/README.md b/go-xmpp/stanza/README.md
new file mode 100644
index 0000000..6ffb822
--- /dev/null
+++ b/go-xmpp/stanza/README.md
@@ -0,0 +1,142 @@
+# XMPP Stanza
+
+XMPP `stanza` package is used to parse, marshal and unmarshal XMPP stanzas and nonzas.
+
+## Stanza creation
+
+When creating stanzas, you can use two approaches:
+
+1. You can create IQ, Presence or Message structs, set the fields and manually prepare extensions struct to add to the
+stanza.
+2. You can use `stanza` build helper to be guided when creating the stanza, and have more controls performed on the
+final stanza.
+
+The methods are equivalent and you can use whatever suits you best. The helpers will finally generate the same type of
+struct that you can build by hand.
+
+### Composing stanzas manually with structs
+
+Here is for example how you would generate an IQ discovery result:
+
+ iqResp := stanza.NewIQ(stanza.Attrs{Type: "result", From: iq.To, To: iq.From, Id: iq.Id})
+ identity := stanza.Identity{
+ Name: opts.Name,
+ Category: opts.Category,
+ Type: opts.Type,
+ }
+ payload := stanza.DiscoInfo{
+ XMLName: xml.Name{
+ Space: stanza.NSDiscoInfo,
+ Local: "query",
+ },
+ Identity: []stanza.Identity{identity},
+ Features: []stanza.Feature{
+ {Var: stanza.NSDiscoInfo},
+ {Var: stanza.NSDiscoItems},
+ {Var: "jabber:iq:version"},
+ {Var: "urn:xmpp:delegation:1"},
+ },
+ }
+ iqResp.Payload = &payload
+
+### Using helpers
+
+Here is for example how you would generate an IQ discovery result using Builder:
+
+ iq := stanza.NewIQ(stanza.Attrs{Type: "get", To: "service.localhost", Id: "disco-get-1"})
+ disco := iq.DiscoInfo()
+ disco.AddIdentity("Test Component", "gateway", "service")
+ disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1")
+
+## Payload and extensions
+
+### Message
+
+Here is the list of implemented message extensions:
+
+- `Delegation`
+
+- `Markable`
+- `MarkAcknowledged`
+- `MarkDisplayed`
+- `MarkReceived`
+
+- `StateActive`
+- `StateComposing`
+- `StateGone`
+- `StateInactive`
+- `StatePaused`
+
+- `HTML`
+
+- `OOB`
+
+- `ReceiptReceived`
+- `ReceiptRequest`
+
+- `Mood`
+
+### Presence
+
+Here is the list of implemented presence extensions:
+
+- `MucPresence`
+
+### IQ
+
+IQ (Information Queries) contain a payload associated with the request and possibly an error. The main difference with
+Message and Presence extension is that you can only have one payload per IQ. The XMPP specification does not support
+having multiple payloads.
+
+Here is the list of structs implementing IQPayloads:
+
+- `ControlSet`
+- `ControlSetResponse`
+- `Delegation`
+- `DiscoInfo`
+- `DiscoItems`
+- `Pubsub`
+- `Version`
+- `Node`
+
+Finally, when the payload of the parsed stanza is unknown, the parser will provide the unknown payload as a generic
+`Node` element. You can also use the Node struct to add custom information on stanza generation. However, in both cases,
+you may also consider [adding your own custom extensions on stanzas]().
+
+
+## Adding your own custom extensions on stanzas
+
+Extensions are registered on launch using the `Registry`. It can be used to register you own custom payload. You may
+want to do so to support extensions we did not yet implement, or to add your own custom extensions to your XMPP stanzas.
+
+To create an extension you need:
+1. to create a struct for that extension. It need to have XMLName for consistency and to tagged at the struct level with
+`xml` info.
+2. It need to implement one or several extensions interface: stanza.IQPayload, stanza.MsgExtension and / or
+stanza.PresExtension
+3. Add that custom extension to the stanza.TypeRegistry during the file init.
+
+Here an example code showing how to create a custom IQPayload.
+
+```go
+package myclient
+
+import (
+ "encoding/xml"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+type CustomPayload struct {
+ XMLName xml.Name `xml:"my:custom:payload query"`
+ Node string `xml:"node,attr,omitempty"`
+}
+
+func (c CustomPayload) Namespace() string {
+ return c.XMLName.Space
+}
+
+func init() {
+ stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{"my:custom:payload", "query"}, CustomPayload{})
+}
+```
\ No newline at end of file
diff --git a/go-xmpp/stanza/commands.go b/go-xmpp/stanza/commands.go
new file mode 100644
index 0000000..5ce8b14
--- /dev/null
+++ b/go-xmpp/stanza/commands.go
@@ -0,0 +1,157 @@
+package stanza
+
+import "encoding/xml"
+
+// Implements the XEP-0050 extension
+
+const (
+ CommandActionCancel = "cancel"
+ CommandActionComplete = "complete"
+ CommandActionExecute = "execute"
+ CommandActionNext = "next"
+ CommandActionPrevious = "prev"
+
+ CommandStatusCancelled = "canceled"
+ CommandStatusCompleted = "completed"
+ CommandStatusExecuting = "executing"
+
+ CommandNoteTypeErr = "error"
+ CommandNoteTypeInfo = "info"
+ CommandNoteTypeWarn = "warn"
+)
+
+type Command struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/commands command"`
+
+ CommandElements []CommandElement
+
+ BadAction *struct{} `xml:"bad-action,omitempty"`
+ BadLocale *struct{} `xml:"bad-locale,omitempty"`
+ BadPayload *struct{} `xml:"bad-payload,omitempty"`
+ BadSessionId *struct{} `xml:"bad-sessionid,omitempty"`
+ MalformedAction *struct{} `xml:"malformed-action,omitempty"`
+ SessionExpired *struct{} `xml:"session-expired,omitempty"`
+
+ // Attributes
+ Action string `xml:"action,attr,omitempty"`
+ Node string `xml:"node,attr"`
+ SessionId string `xml:"sessionid,attr,omitempty"`
+ Status string `xml:"status,attr,omitempty"`
+ Lang string `xml:"lang,attr,omitempty"`
+
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (c *Command) Namespace() string {
+ return c.XMLName.Space
+}
+
+func (c *Command) GetSet() *ResultSet {
+ return c.ResultSet
+}
+
+type CommandElement interface {
+ Ref() string
+}
+
+type Actions struct {
+ XMLName xml.Name `xml:"actions"`
+
+ Prev *struct{} `xml:"prev,omitempty"`
+ Next *struct{} `xml:"next,omitempty"`
+ Complete *struct{} `xml:"complete,omitempty"`
+
+ Execute string `xml:"execute,attr,omitempty"`
+}
+
+func (a *Actions) Ref() string {
+ return "actions"
+}
+
+type Note struct {
+ XMLName xml.Name `xml:"note"`
+
+ Text string `xml:",cdata"`
+ Type string `xml:"type,attr,omitempty"`
+}
+
+func (n *Note) Ref() string {
+ return "note"
+}
+func (f *Form) Ref() string { return "form" }
+
+func (n *Node) Ref() string {
+ return "node"
+}
+
+func (c *Command) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ c.XMLName = start.Name
+
+ // Extract packet attributes
+ for _, attr := range start.Attr {
+ if attr.Name.Local == "action" {
+ c.Action = attr.Value
+ }
+ if attr.Name.Local == "node" {
+ c.Node = attr.Value
+ }
+ if attr.Name.Local == "sessionid" {
+ c.SessionId = attr.Value
+ }
+ if attr.Name.Local == "status" {
+ c.Status = attr.Value
+ }
+ if attr.Name.Local == "lang" {
+ c.Lang = attr.Value
+ }
+ }
+
+ // decode inner elements
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+
+ case xml.StartElement:
+ // Decode sub-elements
+ var err error
+ switch tt.Name.Local {
+
+ case "actions":
+ a := Actions{}
+ err = d.DecodeElement(&a, &tt)
+ c.CommandElements = append(c.CommandElements, &a)
+ case "note":
+ nt := Note{}
+ err = d.DecodeElement(&nt, &tt)
+ c.CommandElements = append(c.CommandElements, &nt)
+ case "x":
+ f := Form{}
+ err = d.DecodeElement(&f, &tt)
+ c.CommandElements = append(c.CommandElements, &f)
+ default:
+ n := Node{}
+ err = d.DecodeElement(&n, &tt)
+ c.CommandElements = append(c.CommandElements, &n)
+ if err != nil {
+ return err
+ }
+ }
+ if err != nil {
+ return err
+ }
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "http://jabber.org/protocol/commands", Local: "command"}, Command{})
+}
diff --git a/go-xmpp/stanza/commands_test.go b/go-xmpp/stanza/commands_test.go
new file mode 100644
index 0000000..04ed7cf
--- /dev/null
+++ b/go-xmpp/stanza/commands_test.go
@@ -0,0 +1,41 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+func TestMarshalCommands(t *testing.T) {
+ input := "Available Services" +
+ "" +
+ "- httpd" +
+ "offoff" +
+ "onon
" +
+ "- postgresql" +
+ "offoff" +
+ "onon
" +
+ "- jabberd" +
+ "offoff" +
+ "onon
"
+ var c stanza.Command
+ err := xml.Unmarshal([]byte(input), &c)
+
+ if err != nil {
+ t.Fatalf("failed to unmarshal initial input")
+ }
+
+ data, err := xml.Marshal(c)
+ if err != nil {
+ t.Fatalf("failed to marshal unmarshalled input")
+ }
+
+ if err := compareMarshal(input, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/go-xmpp/stanza/component.go b/go-xmpp/stanza/component.go
new file mode 100644
index 0000000..ba3b81e
--- /dev/null
+++ b/go-xmpp/stanza/component.go
@@ -0,0 +1,96 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// ============================================================================
+// Handshake Stanza
+
+// Handshake is a stanza used by XMPP components to authenticate on XMPP
+// component port.
+type Handshake struct {
+ XMLName xml.Name `xml:"jabber:component:accept handshake"`
+ // TODO Add handshake value with test for proper serialization
+ Value string `xml:",innerxml"`
+}
+
+func (Handshake) Name() string {
+ return "component:handshake"
+}
+
+// Handshake decoding wrapper
+
+type handshakeDecoder struct{}
+
+var handshake handshakeDecoder
+
+func (handshakeDecoder) decode(p *xml.Decoder, se xml.StartElement) (Handshake, error) {
+ var packet Handshake
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+// ============================================================================
+// Component delegation
+// XEP-0355
+
+// Delegation can be used both on message (for delegated) and IQ (for Forwarded),
+// depending on the context.
+type Delegation struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:delegation:1 delegation"`
+ Forwarded *Forwarded // This is used in iq to wrap delegated iqs
+ Delegated *Delegated // This is used in a message to confirm delegated namespace
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (d *Delegation) Namespace() string {
+ return d.XMLName.Space
+}
+func (d *Delegation) GetSet() *ResultSet {
+ return d.ResultSet
+}
+
+// Forwarded is used to wrapped forwarded stanzas.
+// TODO: Move it in another file, as it is not limited to components.
+type Forwarded struct {
+ XMLName xml.Name `xml:"urn:xmpp:forward:0 forwarded"`
+ Stanza Packet
+}
+
+// UnmarshalXML is a custom unmarshal function used by xml.Unmarshal to
+// transform generic XML content into hierarchical Node structure.
+func (f *Forwarded) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ // Check subelements to extract required field as boolean
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+
+ case xml.StartElement:
+ if packet, err := decodeClient(d, tt); err == nil {
+ f.Stanza = packet
+ }
+
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+type Delegated struct {
+ XMLName xml.Name `xml:"delegated"`
+ Namespace string `xml:"namespace,attr,omitempty"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:delegation:1", Local: "delegation"}, Delegation{})
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:xmpp:delegation:1", Local: "delegation"}, Delegation{})
+}
diff --git a/go-xmpp/stanza/component_test.go b/go-xmpp/stanza/component_test.go
new file mode 100644
index 0000000..2fb1672
--- /dev/null
+++ b/go-xmpp/stanza/component_test.go
@@ -0,0 +1,79 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+// We should be able to properly parse delegation confirmation messages
+func TestParsingDelegationMessage(t *testing.T) {
+ packetStr := `
+
+
+
+`
+ var msg Message
+ data := []byte(packetStr)
+ if err := xml.Unmarshal(data, &msg); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", data)
+ }
+
+ // Check that we have extracted the delegation info as MsgExtension
+ var nsDelegated string
+ for _, ext := range msg.Extensions {
+ if delegation, ok := ext.(*Delegation); ok {
+ nsDelegated = delegation.Delegated.Namespace
+ }
+ }
+ if nsDelegated != "http://jabber.org/protocol/pubsub" {
+ t.Errorf("Could not find delegated namespace in delegation: %#v\n", msg)
+ }
+}
+
+// Check that we can parse a delegation IQ.
+// The most important thing is to be able to
+func TestParsingDelegationIQ(t *testing.T) {
+ packetStr := `
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+`
+ var iq IQ
+ data := []byte(packetStr)
+ if err := xml.Unmarshal(data, &iq); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", data)
+ }
+
+ // Check that we have extracted the delegation info as IQPayload
+ var node string
+ if iq.Payload != nil {
+ if delegation, ok := iq.Payload.(*Delegation); ok {
+ packet := delegation.Forwarded.Stanza
+ forwardedIQ, ok := packet.(*IQ)
+ if !ok {
+ t.Errorf("Could not extract packet IQ")
+ return
+ }
+ if forwardedIQ.Payload != nil {
+ if pubsub, ok := forwardedIQ.Payload.(*PubSubGeneric); ok {
+ node = pubsub.Publish.Node
+ }
+ }
+ }
+ }
+ if node != "http://jabber.org/protocol/mood" {
+ t.Errorf("Could not find mood node name on delegated publish: %#v\n", iq)
+ }
+}
diff --git a/go-xmpp/stanza/datetime_profiles.go b/go-xmpp/stanza/datetime_profiles.go
new file mode 100644
index 0000000..7a2ef74
--- /dev/null
+++ b/go-xmpp/stanza/datetime_profiles.go
@@ -0,0 +1,70 @@
+package stanza
+
+import (
+ "errors"
+ "strings"
+ "time"
+)
+
+// Helper structures and functions to manage dates and timestamps as defined in
+// XEP-0082: XMPP Date and Time Profiles (https://xmpp.org/extensions/xep-0082.html)
+
+const dateLayoutXEP0082 = "2006-01-02"
+const timeLayoutXEP0082 = "15:04:05+00:00"
+
+var InvalidDateInput = errors.New("could not parse date. Input might not be in a supported format")
+var InvalidDateOutput = errors.New("could not format date as desired")
+
+type JabberDate struct {
+ value time.Time
+}
+
+func (d JabberDate) DateToString() string {
+ return d.value.Format(dateLayoutXEP0082)
+}
+
+func (d JabberDate) DateTimeToString(nanos bool) string {
+ if nanos {
+ return d.value.Format(time.RFC3339Nano)
+ }
+ return d.value.Format(time.RFC3339)
+}
+
+func (d JabberDate) TimeToString(nanos bool) (string, error) {
+ if nanos {
+ spl := strings.Split(d.value.Format(time.RFC3339Nano), "T")
+ if len(spl) != 2 {
+ return "", InvalidDateOutput
+ }
+ return spl[1], nil
+ }
+ spl := strings.Split(d.value.Format(time.RFC3339), "T")
+ if len(spl) != 2 {
+ return "", InvalidDateOutput
+ }
+ return spl[1], nil
+}
+
+func NewJabberDateFromString(strDate string) (JabberDate, error) {
+ t, err := time.Parse(time.RFC3339, strDate)
+ if err == nil {
+ return JabberDate{value: t}, nil
+ }
+
+ t, err = time.Parse(time.RFC3339Nano, strDate)
+ if err == nil {
+ return JabberDate{value: t}, nil
+ }
+
+ t, err = time.Parse(dateLayoutXEP0082, strDate)
+ if err == nil {
+ return JabberDate{value: t}, nil
+ }
+
+ t, err = time.Parse(timeLayoutXEP0082, strDate)
+ if err == nil {
+ return JabberDate{value: t}, nil
+ }
+
+ return JabberDate{}, InvalidDateInput
+}
diff --git a/go-xmpp/stanza/datetime_profiles_test.go b/go-xmpp/stanza/datetime_profiles_test.go
new file mode 100644
index 0000000..5355b7d
--- /dev/null
+++ b/go-xmpp/stanza/datetime_profiles_test.go
@@ -0,0 +1,191 @@
+package stanza
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDateToString(t *testing.T) {
+ t1 := JabberDate{value: time.Now()}
+ t2 := JabberDate{value: time.Now().Add(24 * time.Hour)}
+
+ t1Str := t1.DateToString()
+ t2Str := t2.DateToString()
+
+ if t1Str == t2Str {
+ t.Fatalf("time representations should not be identical")
+ }
+}
+
+func TestDateToStringOracle(t *testing.T) {
+ expected := "2009-11-10"
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)}
+
+ t1Str := t1.DateToString()
+ if t1Str != expected {
+ t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str)
+ }
+}
+
+func TestDateTimeToString(t *testing.T) {
+ t1 := JabberDate{value: time.Now()}
+ t2 := JabberDate{value: time.Now().Add(10 * time.Second)}
+
+ t1Str := t1.DateTimeToString(false)
+ t2Str := t2.DateTimeToString(false)
+
+ if t1Str == t2Str {
+ t.Fatalf("time representations should not be identical")
+ }
+}
+
+func TestDateTimeToStringOracle(t *testing.T) {
+ expected := "2009-11-10T23:03:22+08:00"
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)}
+
+ t1Str := t1.DateTimeToString(false)
+ if t1Str != expected {
+ t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str)
+ }
+}
+
+func TestDateTimeToStringNanos(t *testing.T) {
+ t1 := JabberDate{value: time.Now()}
+ time.After(10 * time.Millisecond)
+ t2 := JabberDate{value: time.Now()}
+
+ t1Str := t1.DateTimeToString(true)
+ t2Str := t2.DateTimeToString(true)
+
+ if t1Str == t2Str {
+ t.Fatalf("time representations should not be identical")
+ }
+}
+
+func TestDateTimeToStringNanosOracle(t *testing.T) {
+ expected := "2009-11-10T23:03:22.000000089+08:00"
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)}
+
+ t1Str := t1.DateTimeToString(true)
+ if t1Str != expected {
+ t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str)
+ }
+}
+
+func TestTimeToString(t *testing.T) {
+ t1 := JabberDate{value: time.Now()}
+ t2 := JabberDate{value: time.Now().Add(10 * time.Second)}
+
+ t1Str, err := t1.TimeToString(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t2Str, err := t2.TimeToString(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if t1Str == t2Str {
+ t.Fatalf("time representations should not be identical")
+ }
+}
+
+func TestTimeToStringOracle(t *testing.T) {
+ expected := "23:03:22+08:00"
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)}
+
+ t1Str, err := t1.TimeToString(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if t1Str != expected {
+ t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str)
+ }
+}
+
+func TestTimeToStringNanos(t *testing.T) {
+ t1 := JabberDate{value: time.Now()}
+ time.Sleep(10 * time.Millisecond)
+ t2 := JabberDate{value: time.Now()}
+
+ t1Str, err := t1.TimeToString(true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t2Str, err := t2.TimeToString(true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if t1Str == t2Str {
+ t.Fatalf("time representations should not be identical")
+ }
+}
+func TestTimeToStringNanosOracle(t *testing.T) {
+ expected := "23:03:22.000000089+08:00"
+ loc, err := time.LoadLocation("Asia/Shanghai")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t1 := JabberDate{value: time.Date(2009, time.November, 10, 23, 3, 22, 89, loc)}
+
+ t1Str, err := t1.TimeToString(true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if t1Str != expected {
+ t.Fatalf("time is different than expected. Expected: %s, Actual: %s", expected, t1Str)
+ }
+}
+
+func TestJabberDateParsing(t *testing.T) {
+ date := "2009-11-10"
+ _, err := NewJabberDateFromString(date)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dateTime := "2009-11-10T23:03:22+08:00"
+ _, err = NewJabberDateFromString(dateTime)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dateTimeNanos := "2009-11-10T23:03:22.000000089+08:00"
+ _, err = NewJabberDateFromString(dateTimeNanos)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // TODO : fix these. Parsing a time with an offset doesn't work
+ //time := "23:03:22+08:00"
+ //_, err = NewJabberDateFromString(time)
+ //if err != nil {
+ // t.Fatalf(err.Error())
+ //}
+
+ //timeNanos := "23:03:22.000000089+08:00"
+ //_, err = NewJabberDateFromString(timeNanos)
+ //if err != nil {
+ // t.Fatalf(err.Error())
+ //}
+
+}
diff --git a/go-xmpp/stanza/doc.go b/go-xmpp/stanza/doc.go
new file mode 100644
index 0000000..fee61b7
--- /dev/null
+++ b/go-xmpp/stanza/doc.go
@@ -0,0 +1,4 @@
+/*
+XMPP stanza package is used to parse, marshal and unmarshal XMPP stanzas and nonzas.
+*/
+package stanza
diff --git a/go-xmpp/stanza/error.go b/go-xmpp/stanza/error.go
new file mode 100644
index 0000000..5f42018
--- /dev/null
+++ b/go-xmpp/stanza/error.go
@@ -0,0 +1,136 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "strconv"
+ "strings"
+)
+
+// ============================================================================
+// 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"`
+}
+
+// UnmarshalXML implements custom parsing for XMPP errors
+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"}
+ // TODO : change the pubsub handling ? It kind of dilutes the information
+ // Handles : 6.1.3.11 Node Has Moved for XEP-0060 (PubSubGeneric)
+ goneName := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "gone"}
+ if elt.XMLName == textName || // Regular error text
+ elt.XMLName == goneName { // Gone text for pubsub
+ x.Text = elt.Content
+ } else if elt.XMLName.Space == "urn:ietf:params:xml:ns:xmpp-stanzas" ||
+ elt.XMLName.Space == "http://jabber.org/protocol/pubsub#errors" {
+ if strings.TrimSpace(x.Reason) != "" {
+ x.Reason = strings.Join([]string{elt.XMLName.Local}, ":")
+ } else {
+ 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}
+ err = e.EncodeToken(xml.StartElement{Name: reason})
+ if err != nil {
+ return err
+ }
+ err = e.EncodeToken(xml.EndElement{Name: reason})
+ if err != nil {
+ return err
+ }
+
+ }
+
+ // Text
+ if x.Text != "" {
+ text := xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-stanzas", Local: "text"}
+ err = e.EncodeToken(xml.StartElement{Name: text})
+ if err != nil {
+ return err
+ }
+ err = e.EncodeToken(xml.CharData(x.Text))
+ if err != nil {
+ return err
+ }
+ err = e.EncodeToken(xml.EndElement{Name: text})
+ if err != nil {
+ return err
+ }
+ }
+
+ return e.EncodeToken(xml.EndElement{Name: start.Name})
+}
diff --git a/go-xmpp/stanza/error_enum.go b/go-xmpp/stanza/error_enum.go
new file mode 100644
index 0000000..710de0e
--- /dev/null
+++ b/go-xmpp/stanza/error_enum.go
@@ -0,0 +1,13 @@
+package stanza
+
+// 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 = "modify"
+ ErrorTypeWait ErrorType = "wait"
+)
diff --git a/go-xmpp/stanza/error_test.go b/go-xmpp/stanza/error_test.go
new file mode 100644
index 0000000..5d933a5
--- /dev/null
+++ b/go-xmpp/stanza/error_test.go
@@ -0,0 +1,31 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+func TestErr_UnmarshalXML(t *testing.T) {
+ packet := `
+
+
+
+ System overloaded, please retry
+
+ `
+
+ parsedIQ := IQ{}
+ data := []byte(packet)
+ if err := xml.Unmarshal(data, &parsedIQ); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", data)
+ }
+
+ xmppError := parsedIQ.Error
+ if xmppError.Text != "System overloaded, please retry" {
+ t.Errorf("Could not extract error text: '%s'", xmppError.Text)
+ }
+}
diff --git a/go-xmpp/stanza/fifo_queue.go b/go-xmpp/stanza/fifo_queue.go
new file mode 100644
index 0000000..ca28810
--- /dev/null
+++ b/go-xmpp/stanza/fifo_queue.go
@@ -0,0 +1,34 @@
+package stanza
+
+// FIFO queue for string contents
+// Implementations have no guarantee regarding thread safety !
+type FifoQueue interface {
+ // Pop returns the first inserted element still in queue and deletes it from queue. If queue is empty, returns nil
+ // No guarantee regarding thread safety !
+ Pop() Queueable
+
+ // PopN returns the N first inserted elements still in queue and deletes them from queue. If queue is empty or i<=0, returns nil
+ // If number to pop is greater than queue length, returns all queue elements
+ // No guarantee regarding thread safety !
+ PopN(i int) []Queueable
+
+ // Peek returns a copy of the first inserted element in queue without deleting it. If queue is empty, returns nil
+ // No guarantee regarding thread safety !
+ Peek() Queueable
+
+ // Peek returns a copy of the first inserted element in queue without deleting it. If queue is empty or i<=0, returns nil.
+ // If number to peek is greater than queue length, returns all queue elements
+ // No guarantee regarding thread safety !
+ PeekN() []Queueable
+ // Push adds an element to the queue
+ // No guarantee regarding thread safety !
+ Push(s Queueable) error
+
+ // Empty returns true if queue is empty
+ // No guarantee regarding thread safety !
+ Empty() bool
+}
+
+type Queueable interface {
+ QueueableName() string
+}
diff --git a/go-xmpp/stanza/form.go b/go-xmpp/stanza/form.go
new file mode 100644
index 0000000..f758f74
--- /dev/null
+++ b/go-xmpp/stanza/form.go
@@ -0,0 +1,68 @@
+package stanza
+
+import "encoding/xml"
+
+type FormType string
+
+const (
+ FormTypeCancel = "cancel"
+ FormTypeForm = "form"
+ FormTypeResult = "result"
+ FormTypeSubmit = "submit"
+)
+
+// See XEP-0004 and XEP-0068
+// Pointer semantics
+type Form struct {
+ XMLName xml.Name `xml:"jabber:x:data x"`
+ Instructions []string `xml:"instructions"`
+ Title string `xml:"title,omitempty"`
+ Fields []*Field `xml:"field,omitempty"`
+ Reported *FormItem `xml:"reported"`
+ Items []FormItem `xml:"item,omitempty"`
+ Type string `xml:"type,attr"`
+}
+
+type FormItem struct {
+ XMLName xml.Name
+ Fields []Field `xml:"field,omitempty"`
+}
+
+type Field struct {
+ XMLName xml.Name `xml:"field"`
+ Description string `xml:"desc,omitempty"`
+ Required *string `xml:"required"`
+ ValuesList []string `xml:"value"`
+ Options []Option `xml:"option,omitempty"`
+ Var string `xml:"var,attr,omitempty"`
+ Type string `xml:"type,attr,omitempty"`
+ Label string `xml:"label,attr,omitempty"`
+}
+
+func NewForm(fields []*Field, formType string) *Form {
+ return &Form{
+ Type: formType,
+ Fields: fields,
+ }
+}
+
+type FieldType string
+
+const (
+ FieldTypeBool = "boolean"
+ FieldTypeFixed = "fixed"
+ FieldTypeHidden = "hidden"
+ FieldTypeJidMulti = "jid-multi"
+ FieldTypeJidSingle = "jid-single"
+ FieldTypeListMulti = "list-multi"
+ FieldTypeListSingle = "list-single"
+ FieldTypeTextMulti = "text-multi"
+ FieldTypeTextPrivate = "text-private"
+ FieldTypeTextSingle = "text-Single"
+)
+
+type Option struct {
+ XMLName xml.Name `xml:"option"`
+ Label string `xml:"label,attr,omitempty"`
+ ValuesList []string `xml:"value"`
+}
diff --git a/go-xmpp/stanza/form_test.go b/go-xmpp/stanza/form_test.go
new file mode 100644
index 0000000..ea6c613
--- /dev/null
+++ b/go-xmpp/stanza/form_test.go
@@ -0,0 +1,110 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "strings"
+ "testing"
+)
+
+const (
+ formSubmit = "" +
+ "" +
+ "" +
+ "" +
+ "http://jabber.org/protocol/pubsub#node_config" +
+ "" +
+ "" +
+ "Princely Musings (Atom)" +
+ "" +
+ "" +
+ "1" +
+ "" +
+ "" +
+ "roster" +
+ "" +
+ "" +
+ "friends" +
+ "servants" +
+ "courtiers" +
+ "" +
+ "" +
+ "http://www.w3.org/2005/Atom" +
+ "" +
+ "" +
+ "headline" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ ""
+
+ clientJid = "hamlet@denmark.lit/elsinore"
+ serviceJid = "pubsub.shakespeare.lit"
+ iqId = "config1"
+ serviceNode = "princely_musings"
+)
+
+func TestMarshalFormSubmit(t *testing.T) {
+ formIQ, err := NewIQ(Attrs{From: clientJid, To: serviceJid, Id: iqId, Type: IQTypeSet})
+ if err != nil {
+ t.Fatalf("failed to create formIQ: %v", err)
+ }
+ formIQ.Payload = &PubSubOwner{
+ OwnerUseCase: &ConfigureOwner{
+ Node: serviceNode,
+ Form: &Form{
+ Type: FormTypeSubmit,
+ Fields: []*Field{
+ {Var: "FORM_TYPE", Type: FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}},
+ {Var: "pubsub#title", ValuesList: []string{"Princely Musings (Atom)"}},
+ {Var: "pubsub#deliver_notifications", ValuesList: []string{"1"}},
+ {Var: "pubsub#access_model", ValuesList: []string{"roster"}},
+ {Var: "pubsub#roster_groups_allowed", ValuesList: []string{"friends", "servants", "courtiers"}},
+ {Var: "pubsub#type", ValuesList: []string{"http://www.w3.org/2005/Atom"}},
+ {
+ Var: "pubsub#notification_type",
+ Type: "list-single",
+ Label: "Specify the delivery style for event notifications",
+ ValuesList: []string{"headline"},
+ Options: []Option{
+ {ValuesList: []string{"normal"}},
+ {ValuesList: []string{"headline"}},
+ },
+ },
+ },
+ },
+ },
+ }
+ b, err := xml.Marshal(formIQ.Payload)
+ if err != nil {
+ t.Fatalf("Could not marshal formIQ : %v", err)
+ }
+
+ if strings.ReplaceAll(string(b), " ", "") != strings.ReplaceAll(formSubmit, " ", "") {
+ t.Fatalf("Expected formIQ and marshalled one are different.\nExepected : %s\nMarshalled : %s", formSubmit, string(b))
+ }
+
+}
+
+func TestUnmarshalFormSubmit(t *testing.T) {
+ var f PubSubOwner
+ mErr := xml.Unmarshal([]byte(formSubmit), &f)
+ if mErr != nil {
+ t.Fatalf("failed to unmarshal formSubmit ! %s", mErr)
+ }
+
+ data, err := xml.Marshal(&f)
+ if err != nil {
+ t.Fatalf("failed to marshal formSubmit")
+ }
+
+ if strings.ReplaceAll(string(data), " ", "") != strings.ReplaceAll(formSubmit, " ", "") {
+ t.Fatalf("failed unmarshal/marshal for formSubmit : %s\n%s", string(data), formSubmit)
+ }
+}
diff --git a/go-xmpp/stanza/iot.go b/go-xmpp/stanza/iot.go
new file mode 100644
index 0000000..b6952e0
--- /dev/null
+++ b/go-xmpp/stanza/iot.go
@@ -0,0 +1,48 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+type ControlSet struct {
+ XMLName xml.Name `xml:"urn:xmpp:iot:control set"`
+ Fields []ControlField `xml:",any"`
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (c *ControlSet) Namespace() string {
+ return c.XMLName.Space
+}
+
+func (c *ControlSet) GetSet() *ResultSet {
+ return c.ResultSet
+}
+
+type ControlGetForm struct {
+ XMLName xml.Name `xml:"urn:xmpp:iot:control getForm"`
+}
+
+type ControlField struct {
+ XMLName xml.Name
+ Name string `xml:"name,attr,omitempty"`
+ Value string `xml:"value,attr,omitempty"`
+}
+
+type ControlSetResponse struct {
+ XMLName xml.Name `xml:"urn:xmpp:iot:control setResponse"`
+}
+
+func (c *ControlSetResponse) Namespace() string {
+ return c.XMLName.Space
+}
+func (c *ControlSetResponse) GetSet() *ResultSet {
+ return nil
+}
+
+// ============================================================================
+// Registry init
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:xmpp:iot:control", Local: "set"}, ControlSet{})
+}
diff --git a/go-xmpp/stanza/iot_test.go b/go-xmpp/stanza/iot_test.go
new file mode 100644
index 0000000..c0953e9
--- /dev/null
+++ b/go-xmpp/stanza/iot_test.go
@@ -0,0 +1,26 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+func TestControlSet(t *testing.T) {
+ packet := `
+
+
+
+
+
+`
+
+ parsedIQ := IQ{}
+ data := []byte(packet)
+ if err := xml.Unmarshal(data, &parsedIQ); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", data)
+ }
+
+ if cs, ok := parsedIQ.Payload.(*ControlSet); !ok {
+ t.Errorf("Payload is not an iot control set: %v", cs)
+ }
+}
diff --git a/go-xmpp/stanza/iq.go b/go-xmpp/stanza/iq.go
new file mode 100644
index 0000000..a8cd62a
--- /dev/null
+++ b/go-xmpp/stanza/iq.go
@@ -0,0 +1,192 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "errors"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+/*
+TODO support ability to put Raw payload inside IQ
+*/
+
+// ============================================================================
+// IQ Packet
+
+// IQ implements RFC 6120 - A.5 Client Namespace (a part)
+type IQ struct { // Info/Query
+ XMLName xml.Name `xml:"iq"`
+ // 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
+ // request."
+ Payload IQPayload `xml:",omitempty"`
+ Error *Err `xml:"error,omitempty"`
+ // Any is used to decode unknown payload as a generic structure
+ Any *Node `xml:",any"`
+}
+
+type IQPayload interface {
+ Namespace() string
+ GetSet() *ResultSet
+}
+
+func NewIQ(a Attrs) (*IQ, error) {
+ if a.Id == "" {
+ if id, err := uuid.NewRandom(); err == nil {
+ a.Id = id.String()
+ }
+ }
+
+ iq := IQ{
+ XMLName: xml.Name{Local: "iq"},
+ Attrs: a,
+ }
+
+ if iq.Type.IsEmpty() {
+ return nil, IqTypeUnset
+ }
+ return &iq, nil
+}
+
+func (iq *IQ) MakeError(xerror Err) *IQ {
+ from := iq.From
+ to := iq.To
+
+ iq.Type = "error"
+ iq.From = to
+ iq.To = from
+ iq.Error = &xerror
+
+ return iq
+}
+
+func (*IQ) Name() string {
+ return "iq"
+}
+
+// NoOp to implement BiDirIteratorElt
+func (*IQ) NoOp() {
+
+}
+
+type iqDecoder struct{}
+
+var iq iqDecoder
+
+func (iqDecoder) decode(p *xml.Decoder, se xml.StartElement) (*IQ, error) {
+ var packet IQ
+ err := p.DecodeElement(&packet, &se)
+ return &packet, err
+}
+
+// UnmarshalXML implements custom parsing for IQs
+func (iq *IQ) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ iq.XMLName = start.Name
+
+ // Extract IQ attributes
+ for _, attr := range start.Attr {
+ if attr.Name.Local == "id" {
+ iq.Id = attr.Value
+ }
+ if attr.Name.Local == "type" {
+ iq.Type = StanzaType(attr.Value)
+ }
+ if attr.Name.Local == "to" {
+ iq.To = attr.Value
+ }
+ if attr.Name.Local == "from" {
+ iq.From = attr.Value
+ }
+ }
+
+ // decode inner elements
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+ case xml.StartElement:
+ if tt.Name.Local == "error" {
+ var xmppError Err
+ err = d.DecodeElement(&xmppError, &tt)
+ if err != nil {
+ return err
+ }
+ iq.Error = &xmppError
+ continue
+ }
+ if iqExt := TypeRegistry.GetIQExtension(tt.Name); iqExt != nil {
+ // Decode payload extension
+ err = d.DecodeElement(iqExt, &tt)
+ if err != nil {
+ return err
+ }
+ iq.Payload = iqExt
+ continue
+ }
+ // TODO: If unknown decode as generic node
+ node := new(Node)
+ err = d.DecodeElement(node, &tt)
+ if err != nil {
+ return err
+ }
+ iq.Any = node
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+var (
+ IqTypeUnset = errors.New("iq type is not set but is mandatory")
+ IqIDUnset = errors.New("iq stanza ID is not set but is mandatory")
+ IqSGetNoPl = errors.New("iq is of type get or set but has no payload")
+ IqResNoPl = errors.New("iq is of type result but has no payload")
+ IqErrNoErrPl = errors.New("iq is of type error but has no error payload")
+)
+
+// IsValid checks if the IQ is valid. If not, return an error with the reason as a message
+// Following RFC-3920 for IQs
+func (iq *IQ) IsValid() (bool, error) {
+ // ID is required
+ if len(strings.TrimSpace(iq.Id)) == 0 {
+ return false, IqIDUnset
+ }
+
+ // Type is required
+ if iq.Type.IsEmpty() {
+ return false, IqTypeUnset
+ }
+
+ // Type get and set must contain one and only one child element that specifies the semantics
+ if iq.Type == IQTypeGet || iq.Type == IQTypeSet {
+ if iq.Payload == nil && iq.Any == nil {
+ return false, IqSGetNoPl
+ }
+ }
+
+ // A result must include zero or one child element
+ if iq.Type == IQTypeResult {
+ if iq.Payload != nil && iq.Any != nil {
+ return false, IqResNoPl
+ }
+ }
+
+ //Error type must contain an "error" child element
+ if iq.Type == IQTypeError {
+ if iq.Error == nil {
+ return false, IqErrNoErrPl
+ }
+ }
+
+ return true, nil
+}
diff --git a/go-xmpp/stanza/iq_disco.go b/go-xmpp/stanza/iq_disco.go
new file mode 100644
index 0000000..8e50f90
--- /dev/null
+++ b/go-xmpp/stanza/iq_disco.go
@@ -0,0 +1,163 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// ============================================================================
+// Disco Info
+
+const (
+ // NSDiscoInfo defines the namespace for disco IQ stanzas
+ NSDiscoInfo = "http://jabber.org/protocol/disco#info"
+)
+
+// ----------
+// Namespaces
+
+type DiscoInfo struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/disco#info query"`
+ Node string `xml:"node,attr,omitempty"`
+ Identity []Identity `xml:"identity"`
+ Features []Feature `xml:"feature"`
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+// Namespace lets DiscoInfo implement the IQPayload interface
+func (d *DiscoInfo) Namespace() string {
+ return d.XMLName.Space
+}
+
+func (d *DiscoInfo) GetSet() *ResultSet {
+ return d.ResultSet
+}
+
+// ---------------
+// Builder helpers
+
+// DiscoInfo builds a default DiscoInfo payload
+func (iq *IQ) DiscoInfo() *DiscoInfo {
+ d := DiscoInfo{
+ XMLName: xml.Name{
+ Space: NSDiscoInfo,
+ Local: "query",
+ },
+ }
+ iq.Payload = &d
+ return &d
+}
+
+func (d *DiscoInfo) AddIdentity(name, category, typ string) {
+ identity := Identity{
+ XMLName: xml.Name{Local: "identity"},
+ Name: name,
+ Category: category,
+ Type: typ,
+ }
+ d.Identity = append(d.Identity, identity)
+}
+
+func (d *DiscoInfo) AddFeatures(namespace ...string) {
+ for _, ns := range namespace {
+ d.Features = append(d.Features, Feature{Var: ns})
+ }
+}
+
+func (d *DiscoInfo) SetNode(node string) *DiscoInfo {
+ d.Node = node
+ return d
+}
+
+func (d *DiscoInfo) SetIdentities(ident ...Identity) *DiscoInfo {
+ d.Identity = ident
+ return d
+}
+
+func (d *DiscoInfo) SetFeatures(namespace ...string) *DiscoInfo {
+ d.Features = []Feature{}
+ for _, ns := range namespace {
+ d.Features = append(d.Features, Feature{Var: ns})
+ }
+ return d
+}
+
+// -----------
+// SubElements
+
+type Identity struct {
+ XMLName xml.Name `xml:"identity,omitempty"`
+ Name string `xml:"name,attr,omitempty"`
+ Category string `xml:"category,attr,omitempty"`
+ Type string `xml:"type,attr,omitempty"`
+}
+
+type Feature struct {
+ XMLName xml.Name `xml:"feature"`
+ Var string `xml:"var,attr"`
+}
+
+// ============================================================================
+// Disco Info
+
+const (
+ NSDiscoItems = "http://jabber.org/protocol/disco#items"
+)
+
+type DiscoItems struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/disco#items query"`
+ Node string `xml:"node,attr,omitempty"`
+ Items []DiscoItem `xml:"item"`
+
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (d *DiscoItems) Namespace() string {
+ return d.XMLName.Space
+}
+
+func (d *DiscoItems) GetSet() *ResultSet {
+ return d.ResultSet
+}
+
+// ---------------
+// Builder helpers
+
+// DiscoItems builds a default DiscoItems payload
+func (iq *IQ) DiscoItems() *DiscoItems {
+ d := DiscoItems{
+ XMLName: xml.Name{Space: NSDiscoItems, Local: "query"},
+ }
+ iq.Payload = &d
+ return &d
+}
+
+func (d *DiscoItems) SetNode(node string) *DiscoItems {
+ d.Node = node
+ return d
+}
+
+func (d *DiscoItems) AddItem(jid, node, name string) *DiscoItems {
+ item := DiscoItem{
+ JID: jid,
+ Node: node,
+ Name: name,
+ }
+ d.Items = append(d.Items, item)
+ return d
+}
+
+type DiscoItem struct {
+ XMLName xml.Name `xml:"item"`
+ JID string `xml:"jid,attr,omitempty"`
+ Node string `xml:"node,attr,omitempty"`
+ Name string `xml:"name,attr,omitempty"`
+}
+
+// ============================================================================
+// Registry init
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSDiscoInfo, Local: "query"}, DiscoInfo{})
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSDiscoItems, Local: "query"}, DiscoItems{})
+}
diff --git a/go-xmpp/stanza/iq_disco_test.go b/go-xmpp/stanza/iq_disco_test.go
new file mode 100644
index 0000000..87b7001
--- /dev/null
+++ b/go-xmpp/stanza/iq_disco_test.go
@@ -0,0 +1,96 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// Test DiscoInfo Builder with several features
+func TestDiscoInfo_Builder(t *testing.T) {
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: "get", To: "service.localhost", Id: "disco-get-1"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ disco := iq.DiscoInfo()
+ disco.AddIdentity("Test Component", "gateway", "service")
+ disco.AddFeatures(stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1")
+
+ parsedIQ, err := checkMarshalling(t, iq)
+ if err != nil {
+ return
+ }
+
+ // Check result
+ pp, ok := parsedIQ.Payload.(*stanza.DiscoInfo)
+ if !ok {
+ t.Errorf("Parsed stanza does not contain correct IQ payload")
+ }
+
+ // Check features
+ features := []string{stanza.NSDiscoInfo, stanza.NSDiscoItems, "jabber:iq:version", "urn:xmpp:delegation:1"}
+ if len(pp.Features) != len(features) {
+ t.Errorf("Features length mismatch: %#v", pp.Features)
+ } else {
+ for i, f := range pp.Features {
+ if f.Var != features[i] {
+ t.Errorf("Missing feature: %s", features[i])
+ }
+ }
+ }
+
+ // Check identity
+ if len(pp.Identity) != 1 {
+ t.Errorf("Identity length mismatch: %#v", pp.Identity)
+ } else {
+ if pp.Identity[0].Name != "Test Component" {
+ t.Errorf("Incorrect identity name: %#v", pp.Identity[0].Name)
+ }
+ }
+}
+
+// Implements XEP-0030 example 17
+// https://xmpp.org/extensions/xep-0030.html#example-17
+func TestDiscoItems_Builder(t *testing.T) {
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "catalog.shakespeare.lit",
+ To: "romeo@montague.net/orchard", Id: "items-2"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ iq.DiscoItems().
+ AddItem("catalog.shakespeare.lit", "books", "Books by and about Shakespeare").
+ AddItem("catalog.shakespeare.lit", "clothing", "Wear your literary taste with pride").
+ AddItem("catalog.shakespeare.lit", "music", "Music from the time of Shakespeare")
+
+ parsedIQ, err := checkMarshalling(t, iq)
+ if err != nil {
+ return
+ }
+
+ // Check result
+ pp, ok := parsedIQ.Payload.(*stanza.DiscoItems)
+ if !ok {
+ t.Errorf("Parsed stanza does not contain correct IQ payload")
+ }
+
+ // Check items
+ items := []stanza.DiscoItem{{xml.Name{}, "catalog.shakespeare.lit", "books", "Books by and about Shakespeare"},
+ {xml.Name{}, "catalog.shakespeare.lit", "clothing", "Wear your literary taste with pride"},
+ {xml.Name{}, "catalog.shakespeare.lit", "music", "Music from the time of Shakespeare"}}
+ if len(pp.Items) != len(items) {
+ t.Errorf("List length mismatch: %#v", pp.Items)
+ } else {
+ for i, item := range pp.Items {
+ if item.JID != items[i].JID {
+ t.Errorf("Jid Mismatch (expected: %s): %s", items[i].JID, item.JID)
+ }
+ if item.Node != items[i].Node {
+ t.Errorf("Node Mismatch (expected: %s): %s", items[i].JID, item.JID)
+ }
+ if item.Name != items[i].Name {
+ t.Errorf("Name Mismatch (expected: %s): %s", items[i].JID, item.JID)
+ }
+ }
+ }
+}
diff --git a/go-xmpp/stanza/iq_roster.go b/go-xmpp/stanza/iq_roster.go
new file mode 100644
index 0000000..34ab5f0
--- /dev/null
+++ b/go-xmpp/stanza/iq_roster.go
@@ -0,0 +1,126 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// ============================================================================
+// Roster
+
+const (
+ // NSRoster is the Roster IQ namespace
+ NSRoster = "jabber:iq:roster"
+ // SubscriptionNone indicates the user does not have a subscription to
+ // the contact's presence, and the contact does not have a subscription
+ // to the user's presence; this is the default value, so if the subscription
+ // attribute is not included then the state is to be understood as "none"
+ SubscriptionNone = "none"
+
+ // SubscriptionTo indicates the user has a subscription to the contact's
+ // presence, but the contact does not have a subscription to the user's presence.
+ SubscriptionTo = "to"
+
+ // SubscriptionFrom indicates the contact has a subscription to the user's
+ // presence, but the user does not have a subscription to the contact's presence
+ SubscriptionFrom = "from"
+
+ // SubscriptionBoth indicates the user and the contact have subscriptions to each
+ // other's presence (also called a "mutual subscription")
+ SubscriptionBoth = "both"
+)
+
+// ----------
+// Namespaces
+
+// Roster struct represents Roster IQs
+type Roster struct {
+ XMLName xml.Name `xml:"jabber:iq:roster query"`
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+// Namespace defines the namespace for the RosterIQ
+func (r *Roster) Namespace() string {
+ return r.XMLName.Space
+}
+func (r *Roster) GetSet() *ResultSet {
+ return r.ResultSet
+}
+
+// ---------------
+// Builder helpers
+
+// RosterIQ builds a default Roster payload
+func (iq *IQ) RosterIQ() *Roster {
+ r := Roster{
+ XMLName: xml.Name{
+ Space: NSRoster,
+ Local: "query",
+ },
+ }
+ iq.Payload = &r
+ return &r
+}
+
+// -----------
+// SubElements
+
+// RosterItems represents the list of items in a roster IQ
+type RosterItems struct {
+ XMLName xml.Name `xml:"jabber:iq:roster query"`
+ Items []RosterItem `xml:"item"`
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+// Namespace lets RosterItems implement the IQPayload interface
+func (r *RosterItems) Namespace() string {
+ return r.XMLName.Space
+}
+
+func (r *RosterItems) GetSet() *ResultSet {
+ return r.ResultSet
+}
+
+// RosterItem represents an item in the roster iq
+type RosterItem struct {
+ XMLName xml.Name `xml:"jabber:iq:roster item"`
+ Jid string `xml:"jid,attr"`
+ Ask string `xml:"ask,attr,omitempty"`
+ Name string `xml:"name,attr,omitempty"`
+ Subscription string `xml:"subscription,attr,omitempty"`
+ Groups []string `xml:"group"`
+}
+
+// ---------------
+// Builder helpers
+
+// RosterItems builds a default RosterItems payload
+func (iq *IQ) RosterItems() *RosterItems {
+ ri := RosterItems{
+ XMLName: xml.Name{Space: "jabber:iq:roster", Local: "query"},
+ }
+ iq.Payload = &ri
+ return &ri
+}
+
+// AddItem builds an item and ads it to the roster IQ
+func (r *RosterItems) AddItem(jid, subscription, ask, name string, groups []string) *RosterItems {
+ item := RosterItem{
+ Jid: jid,
+ Name: name,
+ Groups: groups,
+ Subscription: subscription,
+ Ask: ask,
+ }
+ r.Items = append(r.Items, item)
+ return r
+}
+
+// ============================================================================
+// Registry init
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSRoster, Local: "query"}, Roster{})
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: NSRoster, Local: "query"}, RosterItems{})
+}
diff --git a/go-xmpp/stanza/iq_roster_test.go b/go-xmpp/stanza/iq_roster_test.go
new file mode 100644
index 0000000..8eee77f
--- /dev/null
+++ b/go-xmpp/stanza/iq_roster_test.go
@@ -0,0 +1,112 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "reflect"
+ "testing"
+)
+
+func TestRosterBuilder(t *testing.T) {
+ iq, err := NewIQ(Attrs{Type: IQTypeResult, From: "romeo@montague.net/orchard"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ var noGroup []string
+
+ iq.RosterItems().AddItem("xl8ceawrfu8zdneomw1h6h28d@crypho.com",
+ SubscriptionBoth,
+ "",
+ "xl8ceaw",
+ []string{"0flucpm8i2jtrjhxw01uf1nd2",
+ "bm2bajg9ex4e1swiuju9i9nu5",
+ "rvjpanomi4ejpx42fpmffoac0"}).
+ AddItem("9aynsym60zbu78jbdvpho7s68@crypho.com",
+ SubscriptionBoth,
+ "",
+ "9aynsym60",
+ []string{"mzaoy73i6ra5k502182zi1t97"}).
+ AddItem("admin@crypho.com",
+ SubscriptionBoth,
+ "",
+ "admin",
+ noGroup)
+
+ parsedIQ, err := checkMarshalling(t, iq)
+ if err != nil {
+ return
+ }
+
+ // Check result
+ pp, ok := parsedIQ.Payload.(*RosterItems)
+ if !ok {
+ t.Errorf("Parsed stanza does not contain correct IQ payload")
+ }
+
+ // Check items
+ items := []RosterItem{
+ {
+ XMLName: xml.Name{},
+ Name: "xl8ceaw",
+ Ask: "",
+ Jid: "xl8ceawrfu8zdneomw1h6h28d@crypho.com",
+ Subscription: SubscriptionBoth,
+ Groups: []string{"0flucpm8i2jtrjhxw01uf1nd2",
+ "bm2bajg9ex4e1swiuju9i9nu5",
+ "rvjpanomi4ejpx42fpmffoac0"},
+ },
+ {
+ XMLName: xml.Name{},
+ Name: "9aynsym60",
+ Ask: "",
+ Jid: "9aynsym60zbu78jbdvpho7s68@crypho.com",
+ Subscription: SubscriptionBoth,
+ Groups: []string{"mzaoy73i6ra5k502182zi1t97"},
+ },
+ {
+ XMLName: xml.Name{},
+ Name: "admin",
+ Ask: "",
+ Jid: "admin@crypho.com",
+ Subscription: SubscriptionBoth,
+ Groups: noGroup,
+ },
+ }
+ if len(pp.Items) != len(items) {
+ t.Errorf("List length mismatch: %#v", pp.Items)
+ } else {
+ for i, item := range pp.Items {
+ if item.Jid != items[i].Jid {
+ t.Errorf("Jid Mismatch (expected: %s): %s", items[i].Jid, item.Jid)
+ }
+ if !reflect.DeepEqual(item.Groups, items[i].Groups) {
+ t.Errorf("Node Mismatch (expected: %s): %s", items[i].Jid, item.Jid)
+ }
+ if item.Name != items[i].Name {
+ t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid)
+ }
+ if item.Ask != items[i].Ask {
+ t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid)
+ }
+ if item.Subscription != items[i].Subscription {
+ t.Errorf("Name Mismatch (expected: %s): %s", items[i].Jid, item.Jid)
+ }
+ }
+ }
+}
+
+func checkMarshalling(t *testing.T, iq *IQ) (*IQ, error) {
+ // Marshall
+ data, err := xml.Marshal(iq)
+ if err != nil {
+ t.Errorf("cannot marshal iq: %s\n%#v", err, iq)
+ return nil, err
+ }
+
+ // Unmarshall
+ var parsedIQ IQ
+ err = xml.Unmarshal(data, &parsedIQ)
+ if err != nil {
+ t.Errorf("Unmarshal returned error: %s\n%s", err, data)
+ }
+ return &parsedIQ, err
+}
diff --git a/go-xmpp/stanza/iq_test.go b/go-xmpp/stanza/iq_test.go
new file mode 100644
index 0000000..e5af6de
--- /dev/null
+++ b/go-xmpp/stanza/iq_test.go
@@ -0,0 +1,240 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func TestUnmarshalIqs(t *testing.T) {
+ //var cs1 = new(iot.ControlSet)
+ var tests = []struct {
+ iqString string
+ parsedIQ stanza.IQ
+ }{
+ {"",
+ stanza.IQ{XMLName: xml.Name{Local: "iq"}, Attrs: stanza.Attrs{Type: stanza.IQTypeSet, To: "test@localhost", Id: "1"}}},
+ //{"", IQ{XMLName: xml.Name{Space: "jabber:client", Local: "iq"}, PacketAttrs: PacketAttrs{To: "test@localhost", From: "server", Type: "set", Id: "2"}, Payload: cs1}},
+ }
+
+ for _, test := range tests {
+ parsedIQ := stanza.IQ{}
+ err := xml.Unmarshal([]byte(test.iqString), &parsedIQ)
+ if err != nil {
+ t.Errorf("Unmarshal(%s) returned error", test.iqString)
+ }
+
+ if !xmlEqual(parsedIQ, test.parsedIQ) {
+ t.Errorf("non matching items\n%s", cmp.Diff(parsedIQ, test.parsedIQ))
+ }
+
+ }
+}
+
+func TestGenerateIqId(t *testing.T) {
+ t.Parallel()
+ iq, err := stanza.NewIQ(stanza.Attrs{Id: "1", Type: "dummy type"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ if iq.Id != "1" {
+ t.Errorf("NewIQ replaced id with %s", iq.Id)
+ }
+
+ iq, err = stanza.NewIQ(stanza.Attrs{Type: "dummy type"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ if iq.Id == "" {
+ t.Error("NewIQ did not generate an Id")
+ }
+
+ otherIq, err := stanza.NewIQ(stanza.Attrs{Type: "dummy type"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ if iq.Id == otherIq.Id {
+ t.Errorf("NewIQ generated two identical ids: %s", iq.Id)
+ }
+}
+
+func TestGenerateIq(t *testing.T) {
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "admin@localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ payload := stanza.DiscoInfo{
+ Identity: []stanza.Identity{
+ {Name: "Test Gateway",
+ Category: "gateway",
+ Type: "mqtt",
+ }},
+ Features: []stanza.Feature{
+ {Var: stanza.NSDiscoInfo},
+ {Var: stanza.NSDiscoItems},
+ },
+ }
+ iq.Payload = &payload
+
+ data, err := xml.Marshal(iq)
+ if err != nil {
+ t.Errorf("cannot marshal xml structure")
+ }
+
+ if strings.Contains(string(data), "
+
+
+
+ Not subscribed
+
+`
+
+ parsedIQ := stanza.IQ{}
+ err := xml.Unmarshal([]byte(iq), &parsedIQ)
+ if err != nil {
+ t.Errorf("Unmarshal error: %s", iq)
+ return
+ }
+
+ if parsedIQ.Error.Reason != "subscription-required" {
+ t.Errorf("incorrect error value: '%s'", parsedIQ.Error.Reason)
+ }
+}
+
+func TestUnknownPayload(t *testing.T) {
+ iq := `
+
+`
+ parsedIQ := stanza.IQ{}
+ err := xml.Unmarshal([]byte(iq), &parsedIQ)
+ if err != nil {
+ t.Errorf("Unmarshal error: %#v (%s)", err, iq)
+ return
+ }
+
+ if parsedIQ.Any.XMLName.Space != "unknown:ns" {
+ t.Errorf("could not extract namespace: '%s'", parsedIQ.Any.XMLName.Space)
+ }
+}
+
+func TestIsValid(t *testing.T) {
+ type testCase struct {
+ iq string
+ shouldErr bool
+ }
+ testIQs := make(map[string]testCase)
+ testIQs["Valid IQ"] = testCase{
+ `
+
+ `,
+ false,
+ }
+ testIQs["Invalid IQ"] = testCase{
+ `
+
+ `,
+ true,
+ }
+
+ for name, tcase := range testIQs {
+ t.Run(name, func(st *testing.T) {
+ parsedIQ := stanza.IQ{}
+ err := xml.Unmarshal([]byte(tcase.iq), &parsedIQ)
+ if err != nil {
+ t.Errorf("Unmarshal error: %#v (%s)", err, tcase.iq)
+ return
+ }
+ isValid, err := parsedIQ.IsValid()
+ if !isValid && !tcase.shouldErr {
+ t.Errorf("failed validation for iq because: %s\nin test case : %s", err, tcase.iq)
+ }
+ })
+ }
+
+}
diff --git a/go-xmpp/stanza/iq_version.go b/go-xmpp/stanza/iq_version.go
new file mode 100644
index 0000000..4661dba
--- /dev/null
+++ b/go-xmpp/stanza/iq_version.go
@@ -0,0 +1,51 @@
+package stanza
+
+import "encoding/xml"
+
+// ============================================================================
+// Software Version (XEP-0092)
+
+// Version
+type Version struct {
+ XMLName xml.Name `xml:"jabber:iq:version query"`
+ Name string `xml:"name,omitempty"`
+ Version string `xml:"version,omitempty"`
+ OS string `xml:"os,omitempty"`
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (v *Version) Namespace() string {
+ return v.XMLName.Space
+}
+
+func (v *Version) GetSet() *ResultSet {
+ return v.ResultSet
+}
+
+// ---------------
+// Builder helpers
+
+// Version builds a default software version payload
+func (iq *IQ) Version() *Version {
+ d := Version{
+ XMLName: xml.Name{Space: "jabber:iq:version", Local: "query"},
+ }
+ iq.Payload = &d
+ return &d
+}
+
+// Set all software version info
+func (v *Version) SetInfo(name, version, os string) *Version {
+ v.Name = name
+ v.Version = version
+ v.OS = os
+ return v
+}
+
+// ============================================================================
+// Registry init
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "jabber:iq:version", Local: "query"}, Version{})
+}
diff --git a/go-xmpp/stanza/iq_version_test.go b/go-xmpp/stanza/iq_version_test.go
new file mode 100644
index 0000000..bdd621e
--- /dev/null
+++ b/go-xmpp/stanza/iq_version_test.go
@@ -0,0 +1,43 @@
+package stanza_test
+
+import (
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// Build a Software Version reply
+// https://xmpp.org/extensions/xep-0092.html#example-2
+func TestVersion_Builder(t *testing.T) {
+ name := "Exodus"
+ version := "0.7.0.4"
+ os := "Windows-XP 5.01.2600"
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: "result", From: "romeo@montague.net/orchard",
+ To: "juliet@capulet.com/balcony", Id: "version_1"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ iq.Version().SetInfo(name, version, os)
+
+ parsedIQ, err := checkMarshalling(t, iq)
+ if err != nil {
+ return
+ }
+
+ // Check result
+ pp, ok := parsedIQ.Payload.(*stanza.Version)
+ if !ok {
+ t.Errorf("Parsed stanza does not contain correct IQ payload")
+ }
+
+ // Check version info
+ if pp.Name != name {
+ t.Errorf("Name Mismatch (expected: %s): %s", name, pp.Name)
+ }
+ if pp.Version != version {
+ t.Errorf("Version Mismatch (expected: %s): %s", version, pp.Version)
+ }
+ if pp.OS != os {
+ t.Errorf("OS Mismatch (expected: %s): %s", os, pp.OS)
+ }
+}
diff --git a/go-xmpp/stanza/jid.go b/go-xmpp/stanza/jid.go
new file mode 100644
index 0000000..c3677a9
--- /dev/null
+++ b/go-xmpp/stanza/jid.go
@@ -0,0 +1,101 @@
+package stanza
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+type Jid struct {
+ Node string
+ Domain string
+ Resource string
+}
+
+func NewJid(sjid string) (*Jid, error) {
+ jid := new(Jid)
+
+ if sjid == "" {
+ return jid, fmt.Errorf("jid cannot be empty")
+ }
+
+ s1 := strings.SplitN(sjid, "@", 2)
+ if len(s1) == 1 { // This is a server or component Jid
+ jid.Domain = s1[0]
+ } else { // Jid has a local username part
+ if s1[0] == "" {
+ return jid, fmt.Errorf("invalid jid '%s", sjid)
+ }
+ jid.Node = s1[0]
+ if s1[1] == "" {
+ return jid, fmt.Errorf("domain cannot be empty")
+ }
+ jid.Domain = s1[1]
+ }
+
+ // Extract resource from domain field
+ s2 := strings.SplitN(jid.Domain, "/", 2)
+ if len(s2) == 2 { // If len = 1, domain is already correct, and resource is already empty
+ jid.Domain = s2[0]
+ jid.Resource = s2[1]
+ }
+
+ if !isUsernameValid(jid.Node) {
+ return jid, fmt.Errorf("invalid Node in Jid '%s'", sjid)
+ }
+ if !isDomainValid(jid.Domain) {
+ return jid, fmt.Errorf("invalid domain in Jid '%s'", sjid)
+ }
+
+ return jid, nil
+}
+
+func (j *Jid) Full() string {
+ if j.Resource == "" {
+ return j.Bare()
+ } else if j.Node == "" {
+ return j.Node + "/" + j.Resource
+ } else {
+ return j.Node + "@" + j.Domain + "/" + j.Resource
+ }
+}
+
+func (j *Jid) Bare() string {
+ if j.Node == "" {
+ return j.Domain
+ } else {
+ return j.Node + "@" + j.Domain
+ }
+}
+
+// ============================================================================
+// Helpers, for parsing / validation
+
+func isUsernameValid(username string) bool {
+ invalidRunes := []rune{'@', '/', '\'', '"', ':', '<', '>'}
+ return strings.IndexFunc(username, isInvalid(invalidRunes)) < 0
+}
+
+func isDomainValid(domain string) bool {
+ if len(domain) == 0 {
+ return false
+ }
+
+ invalidRunes := []rune{'@', '/'}
+ return strings.IndexFunc(domain, isInvalid(invalidRunes)) < 0
+}
+
+func isInvalid(invalidRunes []rune) func(c rune) bool {
+ isInvalid := func(c rune) bool {
+ if unicode.IsSpace(c) {
+ return true
+ }
+ for _, r := range invalidRunes {
+ if c == r {
+ return true
+ }
+ }
+ return false
+ }
+ return isInvalid
+}
diff --git a/go-xmpp/stanza/jid_test.go b/go-xmpp/stanza/jid_test.go
new file mode 100644
index 0000000..781221d
--- /dev/null
+++ b/go-xmpp/stanza/jid_test.go
@@ -0,0 +1,101 @@
+package stanza
+
+import (
+ "testing"
+)
+
+func TestValidJids(t *testing.T) {
+ tests := []struct {
+ jidstr string
+ expected Jid
+ }{
+ {jidstr: "test@domain.com", expected: Jid{"test", "domain.com", ""}},
+ {jidstr: "test@domain.com/resource", expected: Jid{"test", "domain.com", "resource"}},
+ // resource can contain '/' or '@'
+ {jidstr: "test@domain.com/a/b", expected: Jid{"test", "domain.com", "a/b"}},
+ {jidstr: "test@domain.com/a@b", expected: Jid{"test", "domain.com", "a@b"}},
+ {jidstr: "domain.com", expected: Jid{"", "domain.com", ""}},
+ }
+
+ for _, tt := range tests {
+ jid, err := NewJid(tt.jidstr)
+ if err != nil {
+ t.Errorf("could not parse correct jid: %s", tt.jidstr)
+ continue
+ }
+
+ if jid == nil {
+ t.Error("jid should not be nil")
+ }
+
+ if jid.Node != tt.expected.Node {
+ t.Errorf("incorrect jid Node (%s): %s", tt.expected.Node, jid.Node)
+ }
+
+ if jid.Node != tt.expected.Node {
+ t.Errorf("incorrect jid domain (%s): %s", tt.expected.Domain, jid.Domain)
+ }
+
+ if jid.Resource != tt.expected.Resource {
+ t.Errorf("incorrect jid resource (%s): %s", tt.expected.Resource, jid.Resource)
+ }
+ }
+}
+
+func TestIncorrectJids(t *testing.T) {
+ badJids := []string{
+ "",
+ "user@",
+ "@domain.com",
+ "user:name@domain.com",
+ "user
+
+
+
+`
+
+ parsedMessage := stanza.Message{}
+ if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil {
+ t.Errorf("message error stanza unmarshall error: %v", err)
+ return
+ }
+ if parsedMessage.Error.Type != "cancel" {
+ t.Errorf("incorrect error type: %s", parsedMessage.Error.Type)
+ }
+}
+
+func TestGetOOB(t *testing.T) {
+ image := "https://localhost/image.png"
+ msg := stanza.NewMessage(stanza.Attrs{To: "test@localhost"})
+ ext := stanza.OOB{
+ XMLName: xml.Name{Space: "jabber:x:oob", Local: "x"},
+ URL: image,
+ }
+ msg.Extensions = append(msg.Extensions, &ext)
+
+ // OOB can properly be found
+ var oob stanza.OOB
+ // Try to find and
+ if ok := msg.Get(&oob); !ok {
+ t.Error("could not find oob extension")
+ return
+ }
+ if oob.URL != image {
+ t.Errorf("OOB URL was not properly extracted: ''%s", oob.URL)
+ }
+
+ // Markable is not found
+ var m stanza.Markable
+ if ok := msg.Get(&m); ok {
+ t.Error("we should not have found markable extension")
+ }
+}
diff --git a/go-xmpp/stanza/msg_chat_markers.go b/go-xmpp/stanza/msg_chat_markers.go
new file mode 100644
index 0000000..66276f9
--- /dev/null
+++ b/go-xmpp/stanza/msg_chat_markers.go
@@ -0,0 +1,42 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+/*
+Support for:
+- XEP-0333 - Chat Markers: https://xmpp.org/extensions/xep-0333.html
+*/
+
+const NSMsgChatMarkers = "urn:xmpp:chat-markers:0"
+
+type Markable struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 markable"`
+}
+
+type MarkReceived struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 received"`
+ ID string `xml:"id,attr"`
+}
+
+type MarkDisplayed struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 displayed"`
+ ID string `xml:"id,attr"`
+}
+
+type MarkAcknowledged struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:chat-markers:0 acknowledged"`
+ ID string `xml:"id,attr"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "markable"}, Markable{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "received"}, MarkReceived{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "displayed"}, MarkDisplayed{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatMarkers, Local: "acknowledged"}, MarkAcknowledged{})
+}
diff --git a/go-xmpp/stanza/msg_chat_state.go b/go-xmpp/stanza/msg_chat_state.go
new file mode 100644
index 0000000..553a314
--- /dev/null
+++ b/go-xmpp/stanza/msg_chat_state.go
@@ -0,0 +1,45 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+/*
+Support for:
+- XEP-0085 - Chat State Notifications: https://xmpp.org/extensions/xep-0085.html
+*/
+
+const NSMsgChatStateNotifications = "http://jabber.org/protocol/chatstates"
+
+type StateActive struct {
+ MsgExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates active"`
+}
+
+type StateComposing struct {
+ MsgExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates composing"`
+}
+
+type StateGone struct {
+ MsgExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates gone"`
+}
+
+type StateInactive struct {
+ MsgExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates inactive"`
+}
+
+type StatePaused struct {
+ MsgExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/chatstates paused"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "active"}, StateActive{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "composing"}, StateComposing{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "gone"}, StateGone{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "inactive"}, StateInactive{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgChatStateNotifications, Local: "paused"}, StatePaused{})
+}
diff --git a/go-xmpp/stanza/msg_hint.go b/go-xmpp/stanza/msg_hint.go
new file mode 100644
index 0000000..205d0ef
--- /dev/null
+++ b/go-xmpp/stanza/msg_hint.go
@@ -0,0 +1,36 @@
+package stanza
+
+import "encoding/xml"
+
+/*
+Support for:
+- XEP-0334: Message Processing Hints: https://xmpp.org/extensions/xep-0334.html
+Pointers should be used to keep consistent with unmarshal. Eg :
+msg.Extensions = append(msg.Extensions, &stanza.HintNoCopy{}, &stanza.HintStore{})
+*/
+
+type HintNoPermanentStore struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:hints no-permanent-store"`
+}
+
+type HintNoStore struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:hints no-store"`
+}
+
+type HintNoCopy struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:hints no-copy"`
+}
+type HintStore struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:hints store"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "no-permanent-store"}, HintNoPermanentStore{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "no-store"}, HintNoStore{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "no-copy"}, HintNoCopy{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "urn:xmpp:hints", Local: "store"}, HintStore{})
+}
diff --git a/go-xmpp/stanza/msg_hint_test.go b/go-xmpp/stanza/msg_hint_test.go
new file mode 100644
index 0000000..b86d2f9
--- /dev/null
+++ b/go-xmpp/stanza/msg_hint_test.go
@@ -0,0 +1,72 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "gosrc.io/xmpp/stanza"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+const msg_const = `
+
+ V unir avtugf pybnx gb uvqr zr sebz gurve fvtug
+
+
+
+
+`
+
+func TestSerializationHint(t *testing.T) {
+ msg := stanza.NewMessage(stanza.Attrs{To: "juliet@capulet.lit/laptop", From: "romeo@montague.lit/laptop"})
+ msg.Body = "V unir avtugf pybnx gb uvqr zr sebz gurve fvtug"
+ msg.Extensions = append(msg.Extensions, stanza.HintNoCopy{}, stanza.HintNoPermanentStore{}, stanza.HintNoStore{}, stanza.HintStore{})
+ data, _ := xml.Marshal(msg)
+ if strings.ReplaceAll(strings.Join(strings.Fields(msg_const), ""), "\n", "") != strings.Join(strings.Fields(string(data)), "") {
+ t.Fatalf("marshalled message does not match expected message")
+ }
+}
+
+func TestUnmarshalHints(t *testing.T) {
+ // Init message as in the const value
+ msgConst := stanza.NewMessage(stanza.Attrs{To: "juliet@capulet.lit/laptop", From: "romeo@montague.lit/laptop"})
+ msgConst.Body = "V unir avtugf pybnx gb uvqr zr sebz gurve fvtug"
+ msgConst.Extensions = append(msgConst.Extensions, &stanza.HintNoCopy{}, &stanza.HintNoPermanentStore{}, &stanza.HintNoStore{}, &stanza.HintStore{})
+
+ // Compare message with the const value
+ msg := stanza.Message{}
+ err := xml.Unmarshal([]byte(msg_const), &msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if msgConst.XMLName.Local != msg.XMLName.Local {
+ t.Fatalf("message tags do not match. Expected: %s, Actual: %s", msgConst.XMLName.Local, msg.XMLName.Local)
+ }
+ if msgConst.Body != msg.Body {
+ t.Fatalf("message bodies do not match. Expected: %s, Actual: %s", msgConst.Body, msg.Body)
+ }
+
+ if !reflect.DeepEqual(msgConst.Attrs, msg.Attrs) {
+ t.Fatalf("attributes do not match")
+ }
+
+ if !reflect.DeepEqual(msgConst.Error, msg.Error) {
+ t.Fatalf("attributes do not match")
+ }
+ var found bool
+ for _, ext := range msgConst.Extensions {
+ for _, strExt := range msg.Extensions {
+ if reflect.TypeOf(ext) == reflect.TypeOf(strExt) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("extensions do not match")
+ }
+ found = false
+ }
+}
diff --git a/go-xmpp/stanza/msg_html.go b/go-xmpp/stanza/msg_html.go
new file mode 100644
index 0000000..f8fbba6
--- /dev/null
+++ b/go-xmpp/stanza/msg_html.go
@@ -0,0 +1,22 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+type HTML struct {
+ MsgExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/xhtml-im html"`
+ Body HTMLBody
+ Lang string `xml:"xml:lang,attr,omitempty"`
+}
+
+type HTMLBody struct {
+ XMLName xml.Name `xml:"http://www.w3.org/1999/xhtml body"`
+ // InnerXML MUST be valid xhtml. We do not check if it is valid when generating the XMPP stanza.
+ InnerXML string `xml:",innerxml"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "http://jabber.org/protocol/xhtml-im", Local: "html"}, HTML{})
+}
diff --git a/go-xmpp/stanza/msg_html_test.go b/go-xmpp/stanza/msg_html_test.go
new file mode 100644
index 0000000..b37e9d6
--- /dev/null
+++ b/go-xmpp/stanza/msg_html_test.go
@@ -0,0 +1,44 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+func TestHTMLGen(t *testing.T) {
+ htmlBody := "Hello World
"
+ msg := stanza.NewMessage(stanza.Attrs{To: "test@localhost"})
+ msg.Body = "Hello World"
+ body := stanza.HTMLBody{
+ InnerXML: htmlBody,
+ }
+ html := stanza.HTML{Body: body}
+ msg.Extensions = append(msg.Extensions, html)
+
+ result := msg.XMPPFormat()
+ str := `Hello WorldHello World
`
+ if result != str {
+ t.Errorf("incorrect serialize message:\n%s", result)
+ }
+
+ parsedMessage := stanza.Message{}
+ if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil {
+ t.Errorf("message HTML unmarshall error: %v", err)
+ return
+ }
+
+ if parsedMessage.Body != msg.Body {
+ t.Errorf("incorrect parsed body: '%s'", parsedMessage.Body)
+ }
+
+ var h stanza.HTML
+ if ok := parsedMessage.Get(&h); !ok {
+ t.Error("could not extract HTML body")
+ }
+
+ if h.Body.InnerXML != htmlBody {
+ t.Errorf("could not extract html body: '%s'", h.Body.InnerXML)
+ }
+}
diff --git a/go-xmpp/stanza/msg_oob.go b/go-xmpp/stanza/msg_oob.go
new file mode 100644
index 0000000..92ccadf
--- /dev/null
+++ b/go-xmpp/stanza/msg_oob.go
@@ -0,0 +1,21 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+/*
+Support for:
+- XEP-0066 - Out of Band Data: https://xmpp.org/extensions/xep-0066.html
+*/
+
+type OOB struct {
+ MsgExtension
+ XMLName xml.Name `xml:"jabber:x:oob x"`
+ URL string `xml:"url"`
+ Desc string `xml:"desc,omitempty"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "jabber:x:oob", Local: "x"}, OOB{})
+}
diff --git a/go-xmpp/stanza/msg_pubsub_event.go b/go-xmpp/stanza/msg_pubsub_event.go
new file mode 100644
index 0000000..70db228
--- /dev/null
+++ b/go-xmpp/stanza/msg_pubsub_event.go
@@ -0,0 +1,213 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// Implementation of the http://jabber.org/protocol/pubsub#event namespace
+type PubSubEvent struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub#event event"`
+ MsgExtension
+ EventElement EventElement
+ //List ItemsEvent
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: "http://jabber.org/protocol/pubsub#event", Local: "event"}, PubSubEvent{})
+}
+
+type EventElement interface {
+ Name() string
+}
+
+// *********************
+// Collection
+// *********************
+
+const PubSubCollectionEventName = "Collection"
+
+type CollectionEvent struct {
+ AssocDisassoc AssocDisassoc
+ Node string `xml:"node,attr,omitempty"`
+}
+
+func (c CollectionEvent) Name() string {
+ return PubSubCollectionEventName
+}
+
+// *********************
+// Associate/Disassociate
+// *********************
+type AssocDisassoc interface {
+ GetAssocDisassoc() string
+}
+
+// *********************
+// Associate
+// *********************
+const Assoc = "Associate"
+
+type AssociateEvent struct {
+ XMLName xml.Name `xml:"associate"`
+ Node string `xml:"node,attr"`
+}
+
+func (a *AssociateEvent) GetAssocDisassoc() string {
+ return Assoc
+}
+
+// *********************
+// Disassociate
+// *********************
+const Disassoc = "Disassociate"
+
+type DisassociateEvent struct {
+ XMLName xml.Name `xml:"disassociate"`
+ Node string `xml:"node,attr"`
+}
+
+func (e *DisassociateEvent) GetAssocDisassoc() string {
+ return Disassoc
+}
+
+// *********************
+// Configuration
+// *********************
+
+const PubSubConfigEventName = "Configuration"
+
+type ConfigurationEvent struct {
+ Node string `xml:"node,attr,omitempty"`
+ Form *Form
+}
+
+func (c ConfigurationEvent) Name() string {
+ return PubSubConfigEventName
+}
+
+// *********************
+// Delete
+// *********************
+const PubSubDeleteEventName = "Delete"
+
+type DeleteEvent struct {
+ Node string `xml:"node,attr"`
+ Redirect *RedirectEvent `xml:"redirect"`
+}
+
+func (c DeleteEvent) Name() string {
+ return PubSubConfigEventName
+}
+
+// *********************
+// Redirect
+// *********************
+type RedirectEvent struct {
+ URI string `xml:"uri,attr"`
+}
+
+// *********************
+// List
+// *********************
+
+const PubSubItemsEventName = "List"
+
+type ItemsEvent struct {
+ XMLName xml.Name `xml:"items"`
+ Items []ItemEvent `xml:"item,omitempty"`
+ Node string `xml:"node,attr"`
+ Retract *RetractEvent `xml:"retract"`
+}
+
+type ItemEvent struct {
+ XMLName xml.Name `xml:"item"`
+ Id string `xml:"id,attr,omitempty"`
+ Publisher string `xml:"publisher,attr,omitempty"`
+ Any *Node `xml:",any"`
+}
+
+func (i ItemsEvent) Name() string {
+ return PubSubItemsEventName
+}
+
+// *********************
+// List
+// *********************
+
+type RetractEvent struct {
+ XMLName xml.Name `xml:"retract"`
+ ID string `xml:"node,attr"`
+}
+
+// *********************
+// Purge
+// *********************
+const PubSubPurgeEventName = "Purge"
+
+type PurgeEvent struct {
+ XMLName xml.Name `xml:"purge"`
+ Node string `xml:"node,attr"`
+}
+
+func (p PurgeEvent) Name() string {
+ return PubSubPurgeEventName
+}
+
+// *********************
+// Subscription
+// *********************
+const PubSubSubscriptionEventName = "Subscription"
+
+type SubscriptionEvent struct {
+ SubStatus string `xml:"subscription,attr,omitempty"`
+ Expiry string `xml:"expiry,attr,omitempty"`
+ SubInfo `xml:",omitempty"`
+}
+
+func (s SubscriptionEvent) Name() string {
+ return PubSubSubscriptionEventName
+}
+
+func (pse *PubSubEvent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ pse.XMLName = start.Name
+ // decode inner elements
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+ var ee EventElement
+ switch tt := t.(type) {
+ case xml.StartElement:
+ switch tt.Name.Local {
+ case "collection":
+ ee = &CollectionEvent{}
+ case "configuration":
+ ee = &ConfigurationEvent{}
+ case "delete":
+ ee = &DeleteEvent{}
+ case "items":
+ ee = &ItemsEvent{}
+ case "purge":
+ ee = &PurgeEvent{}
+ case "subscription":
+ ee = &SubscriptionEvent{}
+ default:
+ ee = nil
+ }
+ // known child element found, decode it
+ if ee != nil {
+ err = d.DecodeElement(ee, &tt)
+ if err != nil {
+ return err
+ }
+ pse.EventElement = ee
+ }
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+
+ }
+}
diff --git a/go-xmpp/stanza/msg_pubsub_event_test.go b/go-xmpp/stanza/msg_pubsub_event_test.go
new file mode 100644
index 0000000..29a795d
--- /dev/null
+++ b/go-xmpp/stanza/msg_pubsub_event_test.go
@@ -0,0 +1,162 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "gosrc.io/xmpp/stanza"
+ "strings"
+ "testing"
+)
+
+func TestDecodeMsgEvent(t *testing.T) {
+ str := `
+
+
+ -
+
+ Soliloquy
+
+ To be, or not to be: that is the question:
+ Whether 'tis nobler in the mind to suffer
+ The slings and arrows of outrageous fortune,
+ Or to take arms against a sea of troubles,
+ And by opposing end them?
+
+
+ tag:denmark.lit,2003:entry-32397
+ 2003-12-13T18:30:02Z
+ 2003-12-13T18:30:02Z
+
+
+
+
+
+ `
+ parsedMessage := stanza.Message{}
+ if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil {
+ t.Errorf("message receipt unmarshall error: %v", err)
+ return
+ }
+
+ if parsedMessage.Body != "" {
+ t.Errorf("Unexpected body: '%s'", parsedMessage.Body)
+ }
+
+ if len(parsedMessage.Extensions) < 1 {
+ t.Errorf("no extension found on parsed message")
+ return
+ }
+
+ switch ext := parsedMessage.Extensions[0].(type) {
+ case *stanza.PubSubEvent:
+ if ext.XMLName.Local != "event" {
+ t.Fatalf("unexpected extension: %s:%s", ext.XMLName.Space, ext.XMLName.Local)
+ }
+ tmp, ok := parsedMessage.Extensions[0].(*stanza.PubSubEvent)
+ if !ok {
+ t.Fatalf("unexpected extension element: %s:%s", ext.XMLName.Space, ext.XMLName.Local)
+ }
+ ie, ok := tmp.EventElement.(*stanza.ItemsEvent)
+ if !ok {
+ t.Fatalf("unexpected extension element: %s:%s", ext.XMLName.Space, ext.XMLName.Local)
+ }
+ if ie.Items[0].Any.Nodes[0].Content != "Soliloquy" {
+ t.Fatalf("could not read title ! Read this : %s", ie.Items[0].Any.Nodes[0].Content)
+ }
+
+ if len(ie.Items[0].Any.Nodes) != 6 {
+ t.Fatalf("some nodes were not correctly parsed")
+ }
+ default:
+ t.Fatalf("could not find pubsub event extension")
+ }
+
+}
+
+func TestEncodeEvent(t *testing.T) {
+ expected := "" +
+ "- " +
+ "My pub item title" +
+ "My pub item content summary" +
+ "My pub item content ID2003-12-13T18:30:02Z" +
+ "2003-12-13T18:30:02Z
"
+ message := stanza.Message{
+ Extensions: []stanza.MsgExtension{
+ stanza.PubSubEvent{
+ EventElement: stanza.ItemsEvent{
+ Items: []stanza.ItemEvent{
+ {
+ Id: "ae890ac52d0df67ed7cfdf51b644e901",
+ Any: &stanza.Node{
+ XMLName: xml.Name{
+ Space: "http://www.w3.org/2005/Atom",
+ Local: "entry",
+ },
+ Attrs: nil,
+ Content: "",
+ Nodes: []stanza.Node{
+ {
+ XMLName: xml.Name{Space: "", Local: "title"},
+ Attrs: nil,
+ Content: "My pub item title",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "summary"},
+ Attrs: nil,
+ Content: "My pub item content summary",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "link"},
+ Attrs: []xml.Attr{
+ {
+ Name: xml.Name{Space: "", Local: "rel"},
+ Value: "alternate",
+ },
+ {
+ Name: xml.Name{Space: "", Local: "type"},
+ Value: "text/html",
+ },
+ {
+ Name: xml.Name{Space: "", Local: "href"},
+ Value: "http://denmark.lit/2003/12/13/atom03",
+ },
+ },
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "id"},
+ Attrs: nil,
+ Content: "My pub item content ID",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "published"},
+ Attrs: nil,
+ Content: "2003-12-13T18:30:02Z",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "updated"},
+ Attrs: nil,
+ Content: "2003-12-13T18:30:02Z",
+ Nodes: nil,
+ },
+ },
+ },
+ },
+ },
+ Node: "princely_musings",
+ Retract: nil,
+ },
+ },
+ },
+ }
+
+ data, _ := xml.Marshal(message)
+ if strings.TrimSpace(string(data)) != strings.TrimSpace(expected) {
+ t.Errorf("event was not encoded properly : \nexpected:%s \ngot: %s", expected, string(data))
+ }
+
+}
diff --git a/go-xmpp/stanza/msg_receipts.go b/go-xmpp/stanza/msg_receipts.go
new file mode 100644
index 0000000..71fdd52
--- /dev/null
+++ b/go-xmpp/stanza/msg_receipts.go
@@ -0,0 +1,29 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+/*
+Support for:
+- XEP-0184 - Message Delivery Receipts: https://xmpp.org/extensions/xep-0184.html
+*/
+
+const NSMsgReceipts = "urn:xmpp:receipts"
+
+// Used on outgoing message, to tell the recipient that you are requesting a message receipt / ack.
+type ReceiptRequest struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:receipts request"`
+}
+
+type ReceiptReceived struct {
+ MsgExtension
+ XMLName xml.Name `xml:"urn:xmpp:receipts received"`
+ ID string `xml:"id,attr"`
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgReceipts, Local: "request"}, ReceiptRequest{})
+ TypeRegistry.MapExtension(PKTMessage, xml.Name{Space: NSMsgReceipts, Local: "received"}, ReceiptReceived{})
+}
diff --git a/go-xmpp/stanza/msg_receipts_test.go b/go-xmpp/stanza/msg_receipts_test.go
new file mode 100644
index 0000000..bf379a1
--- /dev/null
+++ b/go-xmpp/stanza/msg_receipts_test.go
@@ -0,0 +1,42 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+func TestDecodeRequest(t *testing.T) {
+ str := `
+ My lord, dispatch; read o'er these articles.
+
+`
+ parsedMessage := stanza.Message{}
+ if err := xml.Unmarshal([]byte(str), &parsedMessage); err != nil {
+ t.Errorf("message receipt unmarshall error: %v", err)
+ return
+ }
+
+ if parsedMessage.Body != "My lord, dispatch; read o'er these articles." {
+ t.Errorf("Unexpected body: '%s'", parsedMessage.Body)
+ }
+
+ if len(parsedMessage.Extensions) < 1 {
+ t.Errorf("no extension found on parsed message")
+ return
+ }
+
+ switch ext := parsedMessage.Extensions[0].(type) {
+ case *stanza.ReceiptRequest:
+ if ext.XMLName.Local != "request" {
+ t.Errorf("unexpected extension: %s:%s", ext.XMLName.Space, ext.XMLName.Local)
+ }
+ default:
+ t.Errorf("could not find receipts extension")
+ }
+
+}
diff --git a/go-xmpp/stanza/node.go b/go-xmpp/stanza/node.go
new file mode 100644
index 0000000..308729c
--- /dev/null
+++ b/go-xmpp/stanza/node.go
@@ -0,0 +1,63 @@
+package stanza
+
+import "encoding/xml"
+
+// ============================================================================
+// Generic / unknown content
+
+// Node is a generic structure to represent XML data. It is used to parse
+// unreferenced or custom stanza payload.
+type Node struct {
+ XMLName xml.Name
+ Attrs []xml.Attr `xml:"-"`
+ Content string `xml:",cdata"`
+ Nodes []Node `xml:",any"`
+}
+
+func (n *Node) Namespace() string {
+ return n.XMLName.Space
+}
+
+// Attr represents generic XML attributes, as used on the generic XML Node
+// representation.
+type Attr struct {
+ K string
+ V string
+}
+
+// UnmarshalXML is a custom unmarshal function used by xml.Unmarshal to
+// transform generic XML content into hierarchical Node structure.
+func (n *Node) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ // Assign "n.Attrs = start.Attr", without repeating xmlns in attributes:
+ for _, attr := range start.Attr {
+ // Do not repeat xmlns, it is already in XMLName
+ if attr.Name.Local != "xmlns" {
+ n.Attrs = append(n.Attrs, attr)
+ }
+ }
+ type node Node
+ return d.DecodeElement((*node)(n), &start)
+}
+
+// MarshalXML is a custom XML serializer used by xml.Marshal to serialize a
+// Node structure to XML.
+func (n Node) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
+ start.Attr = n.Attrs
+ start.Name = n.XMLName
+
+ err = e.EncodeToken(start)
+ if err != nil {
+ return err
+ }
+ err = e.EncodeElement(n.Nodes, xml.StartElement{Name: n.XMLName})
+ if err != nil {
+ return err
+ }
+ if n.Content != "" {
+ err = e.EncodeToken(xml.CharData(n.Content))
+ if err != nil {
+ return err
+ }
+ }
+ return e.EncodeToken(xml.EndElement{Name: start.Name})
+}
diff --git a/go-xmpp/stanza/node_test.go b/go-xmpp/stanza/node_test.go
new file mode 100644
index 0000000..33649d9
--- /dev/null
+++ b/go-xmpp/stanza/node_test.go
@@ -0,0 +1,33 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+func TestNode_Marshal(t *testing.T) {
+ jsonData := []byte("{\"key\":\"value\"}")
+
+ iqResp, err := NewIQ(Attrs{Type: "result", From: "admin@localhost", To: "test@localhost", Id: "1"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ iqResp.Any = &Node{
+ XMLName: xml.Name{Space: "myNS", Local: "space"},
+ Content: string(jsonData),
+ }
+
+ bytes, err := xml.Marshal(iqResp)
+ if err != nil {
+ t.Errorf("Could not marshal XML: %v", err)
+ }
+
+ parsedIQ := IQ{}
+ if err := xml.Unmarshal(bytes, &parsedIQ); err != nil {
+ t.Errorf("Unmarshal returned error: %v", err)
+ }
+
+ if parsedIQ.Any.Content != string(jsonData) {
+ t.Errorf("Cannot find generic any payload in parsedIQ: '%s'", parsedIQ.Any.Content)
+ }
+}
diff --git a/go-xmpp/stanza/ns.go b/go-xmpp/stanza/ns.go
new file mode 100644
index 0000000..f1be9b1
--- /dev/null
+++ b/go-xmpp/stanza/ns.go
@@ -0,0 +1,12 @@
+package stanza
+
+const (
+ NSStream = "http://etherx.jabber.org/streams"
+ nsTLS = "urn:ietf:params:xml:ns:xmpp-tls"
+ NSSASL = "urn:ietf:params:xml:ns:xmpp-sasl"
+ NSBind = "urn:ietf:params:xml:ns:xmpp-bind"
+ NSSession = "urn:ietf:params:xml:ns:xmpp-session"
+ NSFraming = "urn:ietf:params:xml:ns:xmpp-framing"
+ NSClient = "jabber:client"
+ NSComponent = "jabber:component:accept"
+)
diff --git a/go-xmpp/stanza/open.go b/go-xmpp/stanza/open.go
new file mode 100644
index 0000000..32ece17
--- /dev/null
+++ b/go-xmpp/stanza/open.go
@@ -0,0 +1,13 @@
+package stanza
+
+import "encoding/xml"
+
+// Open Packet
+// Reference: WebSocket connections must start with this element
+// https://tools.ietf.org/html/rfc7395#section-3.4
+type WebsocketOpen struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-framing open"`
+ From string `xml:"from,attr"`
+ Id string `xml:"id,attr"`
+ Version string `xml:"version,attr"`
+}
diff --git a/go-xmpp/stanza/packet.go b/go-xmpp/stanza/packet.go
new file mode 100644
index 0000000..567796d
--- /dev/null
+++ b/go-xmpp/stanza/packet.go
@@ -0,0 +1,18 @@
+package stanza
+
+type Packet interface {
+ Name() string
+}
+
+// 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"`
+ Lang string `xml:"lang,attr,omitempty"`
+}
+
+type packetFormatter interface {
+ XMPPFormat() string
+}
diff --git a/go-xmpp/stanza/packet_enum.go b/go-xmpp/stanza/packet_enum.go
new file mode 100644
index 0000000..84dd476
--- /dev/null
+++ b/go-xmpp/stanza/packet_enum.go
@@ -0,0 +1,31 @@
+package stanza
+
+import "strings"
+
+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"
+)
+
+func (s StanzaType) IsEmpty() bool {
+ return len(strings.TrimSpace(string(s))) == 0
+}
diff --git a/go-xmpp/stanza/parser.go b/go-xmpp/stanza/parser.go
new file mode 100644
index 0000000..b5f11cf
--- /dev/null
+++ b/go-xmpp/stanza/parser.go
@@ -0,0 +1,199 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+)
+
+// Reads and checks the opening XMPP stream element.
+// TODO It returns a stream structure containing:
+// - Host: You can check the host against the host you were expecting to connect to
+// - Id: the Stream ID is a temporary shared secret used for some hash calculation. It is also used by ProcessOne
+// 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 ( or )
+func InitStream(p *xml.Decoder) (sessionID string, err error) {
+ for {
+ var t xml.Token
+ t, err = p.Token()
+ if err != nil {
+ return sessionID, err
+ }
+
+ switch elem := t.(type) {
+ case xml.StartElement:
+ isStreamOpen := elem.Name.Space == NSStream && elem.Name.Local == "stream"
+ isFrameOpen := elem.Name.Space == NSFraming && elem.Name.Local == "open"
+ if !isStreamOpen && !isFrameOpen {
+ err = errors.New("xmpp: expected or but got <" + elem.Name.Local + "> in " + elem.Name.Space)
+ return sessionID, err
+ }
+
+ // Parse XMPP stream attributes
+ for _, attrs := range elem.Attr {
+ switch attrs.Name.Local {
+ case "id":
+ sessionID = attrs.Value
+ }
+ }
+ return sessionID, err
+ }
+ }
+}
+
+// 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
+// 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
+ t, err := NextXmppToken(p)
+ if err != nil {
+ return nil, err
+ }
+
+ if ee, ok := t.(xml.EndElement); ok {
+ return decodeStream(p, ee)
+ }
+
+ // If not an end element, then must be a start
+ se, ok := t.(xml.StartElement)
+ if !ok {
+ return nil, errors.New("unknown token ")
+ }
+ // Decode one of the top level XMPP namespace
+ switch se.Name.Space {
+ case NSStream:
+ return decodeStream(p, se)
+ case NSSASL:
+ return decodeSASL(p, se)
+ case NSClient:
+ return decodeClient(p, se)
+ case NSComponent:
+ return decodeComponent(p, se)
+ case NSStreamManagement:
+ return sm.decode(p, se)
+ default:
+ return nil, errors.New("unknown namespace " +
+ se.Name.Space + " <" + se.Name.Local + "/>")
+ }
+}
+
+// NextXmppToken scans XML token stream to find next StartElement or stream EndElement.
+// We need the EndElement scan, because we must register stream close tags
+func NextXmppToken(p *xml.Decoder) (xml.Token, error) {
+ for {
+ t, err := p.Token()
+ if err == io.EOF {
+ return xml.StartElement{}, errors.New("connection closed")
+ }
+ if err != nil {
+ return xml.StartElement{}, fmt.Errorf("NextStart %s", err)
+ }
+ switch t := t.(type) {
+ case xml.StartElement:
+ return t, nil
+ case xml.EndElement:
+ if t.Name.Space == NSStream && t.Name.Local == "stream" {
+ return t, nil
+ }
+ }
+ }
+}
+
+// NextStart scans XML token stream to find next StartElement.
+func NextStart(p *xml.Decoder) (xml.StartElement, error) {
+ for {
+ t, err := p.Token()
+ if err == io.EOF {
+ return xml.StartElement{}, errors.New("connection closed")
+ }
+ if err != nil {
+ return xml.StartElement{}, fmt.Errorf("NextStart %s", err)
+ }
+ switch t := t.(type) {
+ case xml.StartElement:
+ return t, nil
+ }
+ }
+}
+
+/*
+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, t xml.Token) (Packet, error) {
+ if se, ok := t.(xml.StartElement); ok {
+ switch se.Name.Local {
+ case "error":
+ return streamError.decode(p, se)
+ case "features":
+ return streamFeatures.decode(p, se)
+ default:
+ return nil, errors.New("unexpected XMPP packet " +
+ se.Name.Space + " <" + se.Name.Local + "/>")
+ }
+ }
+
+ if ee, ok := t.(xml.EndElement); ok {
+ if ee.Name.Local == "stream" {
+ return streamClose.decode(ee), nil
+ }
+ return nil, errors.New("unexpected XMPP packet " +
+ ee.Name.Space + " <" + ee.Name.Local + "/>")
+ }
+
+ // Should not happen
+ return nil, errors.New("unexpected XML token ")
+}
+
+// decodeSASL decodes a packet related to SASL authentication.
+func decodeSASL(p *xml.Decoder, se xml.StartElement) (Packet, error) {
+ switch se.Name.Local {
+ case "success":
+ return saslSuccess.decode(p, se)
+ case "failure":
+ return saslFailure.decode(p, se)
+ default:
+ return nil, errors.New("unexpected XMPP packet " +
+ se.Name.Space + " <" + se.Name.Local + "/>")
+ }
+}
+
+// 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":
+ return message.decode(p, se)
+ case "presence":
+ return presence.decode(p, se)
+ case "iq":
+ return iq.decode(p, se)
+ default:
+ return nil, errors.New("unexpected XMPP packet " +
+ se.Name.Space + " <" + se.Name.Local + "/>")
+ }
+}
+
+// decodeComponent decodes all known packets in the component namespace.
+func decodeComponent(p *xml.Decoder, se xml.StartElement) (Packet, error) {
+ switch se.Name.Local {
+ case "handshake": // handshake is used to authenticate components
+ return handshake.decode(p, se)
+ case "message":
+ return message.decode(p, se)
+ case "presence":
+ return presence.decode(p, se)
+ case "iq":
+ return iq.decode(p, se)
+ default:
+ return nil, errors.New("unexpected XMPP packet " +
+ se.Name.Space + " <" + se.Name.Local + "/>")
+ }
+}
diff --git a/go-xmpp/stanza/pep.go b/go-xmpp/stanza/pep.go
new file mode 100644
index 0000000..cfd50c1
--- /dev/null
+++ b/go-xmpp/stanza/pep.go
@@ -0,0 +1,27 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+type Tune struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/tune tune"`
+ Artist string `xml:"artist,omitempty"`
+ Length int `xml:"length,omitempty"`
+ Rating int `xml:"rating,omitempty"`
+ Source string `xml:"source,omitempty"`
+ Title string `xml:"title,omitempty"`
+ Track string `xml:"track,omitempty"`
+ Uri string `xml:"uri,omitempty"`
+}
+
+// Mood defines data model for XEP-0107 - User Mood
+// See: https://xmpp.org/extensions/xep-0107.html
+type Mood struct {
+ MsgExtension // Mood can be added as a message extension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/mood mood"`
+ // TODO: Custom parsing to extract mood type from tag name.
+ // Note: the list is predefined.
+ // Mood type
+ Text string `xml:"text,omitempty"`
+}
diff --git a/go-xmpp/stanza/pres_muc.go b/go-xmpp/stanza/pres_muc.go
new file mode 100644
index 0000000..e103a48
--- /dev/null
+++ b/go-xmpp/stanza/pres_muc.go
@@ -0,0 +1,148 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "strconv"
+ "time"
+)
+
+// ============================================================================
+// MUC Presence extension
+
+// MucPresence implements XEP-0045: Multi-User Chat - 19.1
+type MucPresence struct {
+ PresExtension
+ XMLName xml.Name `xml:"http://jabber.org/protocol/muc x"`
+ Password string `xml:"password,omitempty"`
+ History History `xml:"history,omitempty"`
+}
+
+const timeLayout = "2006-01-02T15:04:05Z"
+
+// History implements XEP-0045: Multi-User Chat - 19.1
+type History struct {
+ XMLName xml.Name
+ MaxChars NullableInt `xml:"maxchars,attr,omitempty"`
+ MaxStanzas NullableInt `xml:"maxstanzas,attr,omitempty"`
+ Seconds NullableInt `xml:"seconds,attr,omitempty"`
+ Since time.Time `xml:"since,attr,omitempty"`
+}
+
+type NullableInt struct {
+ Value int
+ isSet bool
+}
+
+func NewNullableInt(val int) NullableInt {
+ return NullableInt{val, true}
+}
+
+func (n NullableInt) Get() (v int, ok bool) {
+ return n.Value, n.isSet
+}
+
+// UnmarshalXML implements custom parsing for history element
+func (h *History) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ h.XMLName = start.Name
+
+ // Extract attributes
+ for _, attr := range start.Attr {
+ switch attr.Name.Local {
+ case "maxchars":
+ v, err := strconv.Atoi(attr.Value)
+ if err != nil {
+ return err
+ }
+ h.MaxChars = NewNullableInt(v)
+ case "maxstanzas":
+ v, err := strconv.Atoi(attr.Value)
+ if err != nil {
+ return err
+ }
+ h.MaxStanzas = NewNullableInt(v)
+ case "seconds":
+ v, err := strconv.Atoi(attr.Value)
+ if err != nil {
+ return err
+ }
+ h.Seconds = NewNullableInt(v)
+ case "since":
+ t, err := time.Parse(timeLayout, attr.Value)
+ if err != nil {
+ return err
+ }
+ h.Since = t
+ }
+ }
+
+ // Consume remaining data until element end
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+func (h History) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) {
+ mc, isMcSet := h.MaxChars.Get()
+ ms, isMsSet := h.MaxStanzas.Get()
+ s, isSSet := h.Seconds.Get()
+
+ // We do not have any value, ignore history element
+ if h.Since.IsZero() && !isMcSet && !isMsSet && !isSSet {
+ return nil
+ }
+
+ // Encode start element and attributes
+ start.Name = xml.Name{Local: "history"}
+
+ if isMcSet {
+ attr := xml.Attr{
+ Name: xml.Name{Local: "maxchars"},
+ Value: strconv.Itoa(mc),
+ }
+ start.Attr = append(start.Attr, attr)
+ }
+
+ if isMsSet {
+ attr := xml.Attr{
+ Name: xml.Name{Local: "maxstanzas"},
+ Value: strconv.Itoa(ms),
+ }
+ start.Attr = append(start.Attr, attr)
+ }
+
+ if isSSet {
+ attr := xml.Attr{
+ Name: xml.Name{Local: "seconds"},
+ Value: strconv.Itoa(s),
+ }
+ start.Attr = append(start.Attr, attr)
+ }
+
+ if !h.Since.IsZero() {
+ attr := xml.Attr{
+ Name: xml.Name{Local: "since"},
+ Value: h.Since.Format(timeLayout),
+ }
+ start.Attr = append(start.Attr, attr)
+ }
+ if err := e.EncodeToken(start); err != nil {
+ return err
+ }
+
+ return e.EncodeToken(xml.EndElement{Name: start.Name})
+
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTPresence, xml.Name{Space: "http://jabber.org/protocol/muc", Local: "x"}, MucPresence{})
+}
diff --git a/go-xmpp/stanza/pres_muc_test.go b/go-xmpp/stanza/pres_muc_test.go
new file mode 100644
index 0000000..209dc93
--- /dev/null
+++ b/go-xmpp/stanza/pres_muc_test.go
@@ -0,0 +1,97 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// https://xmpp.org/extensions/xep-0045.html#example-27
+func TestMucPassword(t *testing.T) {
+ str := `
+
+ cauldronburn
+
+`
+
+ var parsedPresence stanza.Presence
+ if err := xml.Unmarshal([]byte(str), &parsedPresence); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", str)
+ }
+
+ var muc stanza.MucPresence
+ if ok := parsedPresence.Get(&muc); !ok {
+ t.Error("muc presence extension was not found")
+ }
+
+ if muc.Password != "cauldronburn" {
+ t.Errorf("incorrect password: '%s'", muc.Password)
+ }
+}
+
+// https://xmpp.org/extensions/xep-0045.html#example-37
+func TestMucHistory(t *testing.T) {
+ str := `
+
+
+
+`
+
+ var parsedPresence stanza.Presence
+ if err := xml.Unmarshal([]byte(str), &parsedPresence); err != nil {
+ t.Errorf("Unmarshal(%s) returned error: %s", str, err)
+ return
+ }
+
+ var muc stanza.MucPresence
+ if ok := parsedPresence.Get(&muc); !ok {
+ t.Error("muc presence extension was not found")
+ return
+ }
+
+ if v, ok := muc.History.MaxStanzas.Get(); !ok || v != 20 {
+ t.Errorf("incorrect MaxStanzas: '%#v'", muc.History.MaxStanzas)
+ }
+}
+
+// https://xmpp.org/extensions/xep-0045.html#example-37
+func TestMucNoHistory(t *testing.T) {
+ str := "" +
+ "" +
+ "" +
+ "" +
+ ""
+
+ maxstanzas := 0
+
+ pres := stanza.Presence{Attrs: stanza.Attrs{
+ From: "hag66@shakespeare.lit/pda",
+ Id: "n13mt3l",
+ To: "coven@chat.shakespeare.lit/thirdwitch",
+ },
+ Extensions: []stanza.PresExtension{
+ stanza.MucPresence{
+ History: stanza.History{MaxStanzas: stanza.NewNullableInt(maxstanzas)},
+ },
+ },
+ }
+ data, err := xml.Marshal(&pres)
+ if err != nil {
+ t.Error("error on encode:", err)
+ return
+ }
+
+ if string(data) != str {
+ t.Errorf("incorrect stanza: \n%s\n%s", str, data)
+ }
+}
diff --git a/go-xmpp/stanza/presence.go b/go-xmpp/stanza/presence.go
new file mode 100644
index 0000000..d3a8d1a
--- /dev/null
+++ b/go-xmpp/stanza/presence.go
@@ -0,0 +1,139 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "reflect"
+)
+
+// ============================================================================
+// Presence Packet
+
+// Presence implements RFC 6120 - A.5 Client Namespace (a part)
+type Presence struct {
+ XMLName xml.Name `xml:"presence"`
+ Attrs
+ Show PresenceShow `xml:"show,omitempty"`
+ Status string `xml:"status,omitempty"`
+ Priority int8 `xml:"priority,omitempty"` // default: 0
+ Error Err `xml:"error,omitempty"`
+ Extensions []PresExtension `xml:",omitempty"`
+}
+
+func (Presence) Name() string {
+ return "presence"
+}
+
+func NewPresence(a Attrs) Presence {
+ return Presence{
+ XMLName: xml.Name{Local: "presence"},
+ Attrs: a,
+ }
+}
+
+// Get search and extracts a specific extension on a presence stanza.
+// It receives a pointer to an PresExtension. It will panic if the caller
+// does not pass a pointer.
+// It will return true if the passed extension is found and set the pointer
+// to the extension passed as parameter to the found extension.
+// It will return false if the extension is not found on the presence.
+//
+// Example usage:
+// var muc xmpp.MucPresence
+// if ok := msg.Get(&muc); ok {
+// // muc presence extension has been found
+// }
+func (pres *Presence) Get(ext PresExtension) bool {
+ target := reflect.ValueOf(ext)
+ if target.Kind() != reflect.Ptr {
+ panic("you must pass a pointer to the message Get method")
+ }
+
+ for _, e := range pres.Extensions {
+ if reflect.TypeOf(e) == target.Type() {
+ source := reflect.ValueOf(e)
+ if source.Kind() != reflect.Ptr {
+ source = source.Elem()
+ }
+ target.Elem().Set(source.Elem())
+ return true
+ }
+ }
+ return false
+}
+
+type presenceDecoder struct{}
+
+var presence presenceDecoder
+
+func (presenceDecoder) decode(p *xml.Decoder, se xml.StartElement) (Presence, error) {
+ var packet Presence
+ err := p.DecodeElement(&packet, &se)
+ // TODO Add default presence type (when omitted)
+ return packet, err
+}
+
+// UnmarshalXML implements custom parsing for presence stanza
+func (pres *Presence) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ pres.XMLName = start.Name
+
+ // Extract packet attributes
+ for _, attr := range start.Attr {
+ if attr.Name.Local == "id" {
+ pres.Id = attr.Value
+ }
+ if attr.Name.Local == "type" {
+ pres.Type = StanzaType(attr.Value)
+ }
+ if attr.Name.Local == "to" {
+ pres.To = attr.Value
+ }
+ if attr.Name.Local == "from" {
+ pres.From = attr.Value
+ }
+ if attr.Name.Local == "lang" {
+ pres.Lang = attr.Value
+ }
+ }
+
+ // decode inner elements
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+
+ case xml.StartElement:
+ if presExt := TypeRegistry.GetPresExtension(tt.Name); presExt != nil {
+ // Decode message extension
+ err = d.DecodeElement(presExt, &tt)
+ if err != nil {
+ return err
+ }
+ pres.Extensions = append(pres.Extensions, presExt)
+ } else {
+ // Decode standard message sub-elements
+ var err error
+ switch tt.Name.Local {
+ case "show":
+ err = d.DecodeElement(&pres.Show, &tt)
+ case "status":
+ err = d.DecodeElement(&pres.Status, &tt)
+ case "priority":
+ err = d.DecodeElement(&pres.Priority, &tt)
+ case "error":
+ err = d.DecodeElement(&pres.Error, &tt)
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
diff --git a/go-xmpp/stanza/presence_enum.go b/go-xmpp/stanza/presence_enum.go
new file mode 100644
index 0000000..c92691f
--- /dev/null
+++ b/go-xmpp/stanza/presence_enum.go
@@ -0,0 +1,12 @@
+package stanza
+
+// 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"
+)
diff --git a/go-xmpp/stanza/presence_test.go b/go-xmpp/stanza/presence_test.go
new file mode 100644
index 0000000..94431d8
--- /dev/null
+++ b/go-xmpp/stanza/presence_test.go
@@ -0,0 +1,63 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "gosrc.io/xmpp/stanza"
+)
+
+func TestGeneratePresence(t *testing.T) {
+ presence := stanza.NewPresence(stanza.Attrs{From: "admin@localhost", To: "test@localhost", Id: "1"})
+ presence.Show = stanza.PresenceShowChat
+
+ data, err := xml.Marshal(presence)
+ if err != nil {
+ t.Errorf("cannot marshal xml structure")
+ }
+
+ var parsedPresence stanza.Presence
+ if err = xml.Unmarshal(data, &parsedPresence); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", data)
+ }
+
+ if !xmlEqual(parsedPresence, presence) {
+ t.Errorf("non matching items\n%s", cmp.Diff(parsedPresence, presence))
+ }
+}
+
+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 stanza.PresenceShow `xml:"show"`
+ Status string `xml:"status"`
+ Priority int8 `xml:"priority"`
+ }
+
+ presence := stanza.NewPresence(stanza.Attrs{From: "admin@localhost", To: "test@localhost", Id: "1"})
+ presence.Show = stanza.PresenceShowXA
+ presence.Status = "Coding"
+ presence.Priority = 10
+
+ data, err := xml.Marshal(presence)
+ if err != nil {
+ t.Errorf("cannot marshal xml structure")
+ }
+
+ var parsedPresence pres
+ if err = xml.Unmarshal(data, &parsedPresence); err != nil {
+ t.Errorf("Unmarshal(%s) returned error", data)
+ }
+
+ if parsedPresence.Show != presence.Show {
+ t.Errorf("cannot read 'show' as presence subelement (%s)", parsedPresence.Show)
+ }
+ if parsedPresence.Status != presence.Status {
+ t.Errorf("cannot read 'status' as presence subelement (%s)", parsedPresence.Status)
+ }
+ if parsedPresence.Priority != presence.Priority {
+ t.Errorf("cannot read 'priority' as presence subelement (%d)", parsedPresence.Priority)
+ }
+}
diff --git a/go-xmpp/stanza/pubsub.go b/go-xmpp/stanza/pubsub.go
new file mode 100644
index 0000000..f0aa1ce
--- /dev/null
+++ b/go-xmpp/stanza/pubsub.go
@@ -0,0 +1,433 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "errors"
+ "strings"
+)
+
+type PubSubGeneric struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub pubsub"`
+
+ Create *Create `xml:"create,omitempty"`
+ Configure *Configure `xml:"configure,omitempty"`
+
+ Subscribe *SubInfo `xml:"subscribe,omitempty"`
+ SubOptions *SubOptions `xml:"options,omitempty"`
+
+ Publish *Publish `xml:"publish,omitempty"`
+ PublishOptions *PublishOptions `xml:"publish-options"`
+
+ Affiliations *Affiliations `xml:"affiliations,omitempty"`
+ Default *Default `xml:"default,omitempty"`
+
+ Items *Items `xml:"items,omitempty"`
+ Retract *Retract `xml:"retract,omitempty"`
+ Subscription *Subscription `xml:"subscription,omitempty"`
+
+ Subscriptions *Subscriptions `xml:"subscriptions,omitempty"`
+ // To use in responses to sub/unsub for instance
+ // Subscription options
+ Unsubscribe *SubInfo `xml:"unsubscribe,omitempty"`
+
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (p *PubSubGeneric) Namespace() string {
+ return p.XMLName.Space
+}
+
+func (p *PubSubGeneric) GetSet() *ResultSet {
+ return p.ResultSet
+}
+
+type Affiliations struct {
+ List []Affiliation `xml:"affiliation"`
+ Node string `xml:"node,attr,omitempty"`
+}
+
+type Affiliation struct {
+ AffiliationStatus string `xml:"affiliation"`
+ Node string `xml:"node,attr"`
+}
+
+type Create struct {
+ Node string `xml:"node,attr,omitempty"`
+}
+
+type SubOptions struct {
+ SubInfo
+ Form *Form `xml:"x"`
+}
+
+type Configure struct {
+ Form *Form `xml:"x"`
+}
+type Default struct {
+ Node string `xml:"node,attr,omitempty"`
+ Type string `xml:"type,attr,omitempty"`
+ Form *Form `xml:"x"`
+}
+
+type Subscribe struct {
+ XMLName xml.Name `xml:"subscribe"`
+ SubInfo
+}
+type Unsubscribe struct {
+ XMLName xml.Name `xml:"unsubscribe"`
+ SubInfo
+}
+
+// SubInfo represents information about a subscription
+// Node is the node related to the subscription
+// Jid is the subscription JID of the subscribed entity
+// SubID is the subscription ID
+type SubInfo struct {
+ Node string `xml:"node,attr,omitempty"`
+ Jid string `xml:"jid,attr,omitempty"`
+ // Sub ID is optional
+ SubId *string `xml:"subid,attr,omitempty"`
+}
+
+// validate checks if a node and a jid are present in the sub info, and if this jid is valid.
+func (si *SubInfo) validate() error {
+ // Requests MUST contain a valid JID
+ if _, err := NewJid(si.Jid); err != nil {
+ return err
+ }
+ // SubInfo must contain both a valid JID and a node. See XEP-0060
+ if strings.TrimSpace(si.Node) == "" {
+ return errors.New("SubInfo must contain the node AND the subscriber JID in subscription config options requests")
+ }
+ return nil
+}
+
+// Handles the "5.6 Retrieve Subscriptions" of XEP-0060
+type Subscriptions struct {
+ XMLName xml.Name `xml:"subscriptions"`
+ List []Subscription `xml:"subscription,omitempty"`
+}
+
+// Handles the "5.6 Retrieve Subscriptions" and the 6.1 Subscribe to a Node and so on of XEP-0060
+type Subscription struct {
+ SubStatus string `xml:"subscription,attr,omitempty"`
+ SubInfo `xml:",omitempty"`
+ // Seems like we can't marshal a self-closing tag for now : https://github.com/golang/go/issues/21399
+ // subscribe-options should be like this as per XEP-0060:
+ //
+ //
+ //
+ // Used to indicate if configuration options is required.
+ Required *struct{}
+}
+
+type PublishOptions struct {
+ XMLName xml.Name `xml:"publish-options"`
+ Form *Form
+}
+
+type Publish struct {
+ XMLName xml.Name `xml:"publish"`
+ Node string `xml:"node,attr"`
+ Items []Item `xml:"item,omitempty"` // xsd says there can be many. See also 12.10 Batch Processing of XEP-0060
+}
+
+type Items struct {
+ List []Item `xml:"item,omitempty"`
+ MaxItems int `xml:"max_items,attr,omitempty"`
+ Node string `xml:"node,attr"`
+ SubId string `xml:"subid,attr,omitempty"`
+}
+
+type Item struct {
+ XMLName xml.Name `xml:"item"`
+ Id string `xml:"id,attr,omitempty"`
+ Publisher string `xml:"publisher,attr,omitempty"`
+ Any *Node `xml:",any"`
+}
+
+type Retract struct {
+ XMLName xml.Name `xml:"retract"`
+ Node string `xml:"node,attr"`
+ Notify *bool `xml:"notify,attr,omitempty"`
+ Items []Item `xml:"item"`
+}
+
+type PubSubOption struct {
+ XMLName xml.Name `xml:"jabber:x:data options"`
+ Form `xml:"x"`
+}
+
+// NewSubRq builds a subscription request to a node at the given service.
+// It's a Set type IQ.
+// Information about the subscription and the requester are separated. subInfo contains information about the subscription.
+// 6.1 Subscribe to a Node
+func NewSubRq(serviceId string, subInfo SubInfo) (*IQ, error) {
+ if e := subInfo.validate(); e != nil {
+ return nil, e
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Subscribe: &subInfo,
+ }
+ return iq, nil
+}
+
+// NewUnsubRq builds an unsub request to a node at the given service.
+// It's a Set type IQ
+// Information about the subscription and the requester are separated. subInfo contains information about the subscription.
+// 6.2 Unsubscribe from a Node
+func NewUnsubRq(serviceId string, subInfo SubInfo) (*IQ, error) {
+ if e := subInfo.validate(); e != nil {
+ return nil, e
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Unsubscribe: &subInfo,
+ }
+ return iq, nil
+}
+
+// NewSubOptsRq builds a request for the subscription options.
+// It's a Get type IQ
+// Information about the subscription and the requester are separated. subInfo contains information about the subscription.
+// 6.3 Configure Subscription Options
+func NewSubOptsRq(serviceId string, subInfo SubInfo) (*IQ, error) {
+ if e := subInfo.validate(); e != nil {
+ return nil, e
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ SubOptions: &SubOptions{
+ SubInfo: subInfo,
+ },
+ }
+ return iq, nil
+}
+
+// NewFormSubmission builds a form submission pubsub IQ
+// Information about the subscription and the requester are separated. subInfo contains information about the subscription.
+// 6.3.5 Form Submission
+func NewFormSubmission(serviceId string, subInfo SubInfo, form *Form) (*IQ, error) {
+ if e := subInfo.validate(); e != nil {
+ return nil, e
+ }
+ if form.Type != FormTypeSubmit {
+ return nil, errors.New("form type was expected to be submit but was : " + form.Type)
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ SubOptions: &SubOptions{
+ SubInfo: subInfo,
+ Form: form,
+ },
+ }
+ return iq, nil
+}
+
+// NewSubAndConfig builds a subscribe request that contains configuration options for the service
+// From XEP-0060 : The element MUST follow the element and
+// MUST NOT possess a 'node' attribute or 'jid' attribute,
+// since the value of the element's 'node' attribute specifies the desired NodeID and
+// the value of the element's 'jid' attribute specifies the subscriber's JID
+// 6.3.7 Subscribe and Configure
+func NewSubAndConfig(serviceId string, subInfo SubInfo, form *Form) (*IQ, error) {
+ if e := subInfo.validate(); e != nil {
+ return nil, e
+ }
+ if form.Type != FormTypeSubmit {
+ return nil, errors.New("form type was expected to be submit but was : " + form.Type)
+ }
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Subscribe: &subInfo,
+ SubOptions: &SubOptions{
+ SubInfo: SubInfo{SubId: subInfo.SubId},
+ Form: form,
+ },
+ }
+ return iq, nil
+
+}
+
+// NewItemsRequest creates a request to query existing items from a node.
+// Specify a "maxItems" value to request only the last maxItems items. If 0, requests all items.
+// 6.5.2 Requesting All List AND 6.5.7 Requesting the Most Recent List
+func NewItemsRequest(serviceId string, node string, maxItems int) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Items: &Items{Node: node},
+ }
+
+ if maxItems != 0 {
+ ps, _ := iq.Payload.(*PubSubGeneric)
+ ps.Items.MaxItems = maxItems
+ }
+ return iq, nil
+}
+
+// NewItemsRequest creates a request to get a specific item from a node.
+// 6.5.8 Requesting a Particular Item
+func NewSpecificItemRequest(serviceId, node, itemId string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Items: &Items{Node: node,
+ List: []Item{
+ {
+ Id: itemId,
+ },
+ },
+ },
+ }
+ return iq, nil
+}
+
+// NewPublishItemRq creates a request to publish a single item to a node identified by its provided ID
+func NewPublishItemRq(serviceId, nodeID, pubItemID string, item Item) (*IQ, error) {
+ // "The element MUST possess a 'node' attribute, specifying the NodeID of the node."
+ if strings.TrimSpace(nodeID) == "" {
+ return nil, errors.New("cannot publish without a target node ID")
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Publish: &Publish{Node: nodeID, Items: []Item{item}},
+ }
+
+ // "The element provided by the publisher MAY possess an 'id' attribute,
+ // specifying a unique ItemID for the item.
+ // If an ItemID is not provided in the publish request,
+ // the pubsub service MUST generate one and MUST ensure that it is unique for that node."
+ if strings.TrimSpace(pubItemID) != "" {
+ ps, _ := iq.Payload.(*PubSubGeneric)
+ ps.Publish.Items[0].Id = pubItemID
+ }
+ return iq, nil
+}
+
+// NewPublishItemOptsRq creates a request to publish items to a node identified by its provided ID, along with configuration options
+// A pubsub service MAY support the ability to specify options along with a publish request
+//(if so, it MUST advertise support for the "http://jabber.org/protocol/pubsub#publish-options" feature).
+func NewPublishItemOptsRq(serviceId, nodeID string, items []Item, options *PublishOptions) (*IQ, error) {
+ // "The element MUST possess a 'node' attribute, specifying the NodeID of the node."
+ if strings.TrimSpace(nodeID) == "" {
+ return nil, errors.New("cannot publish without a target node ID")
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Publish: &Publish{Node: nodeID, Items: items},
+ PublishOptions: options,
+ }
+
+ return iq, nil
+}
+
+// NewDelItemFromNode creates a request to delete and item from a node, given its id.
+// To delete an item, the publisher sends a retract request.
+// This helper function follows 7.2 Delete an Item from a Node
+func NewDelItemFromNode(serviceId, nodeID, itemId string, notify *bool) (*IQ, error) {
+ // "The element MUST possess a 'node' attribute, specifying the NodeID of the node."
+ if strings.TrimSpace(nodeID) == "" {
+ return nil, errors.New("cannot delete item without a target node ID")
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Retract: &Retract{Node: nodeID, Items: []Item{{Id: itemId}}, Notify: notify},
+ }
+ return iq, nil
+}
+
+// NewCreateAndConfigNode makes a request for node creation that has the desired node configuration.
+// See 8.1.3 Create and Configure a Node
+func NewCreateAndConfigNode(serviceId, nodeID string, confForm *Form) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Create: &Create{Node: nodeID},
+ Configure: &Configure{Form: confForm},
+ }
+ return iq, nil
+}
+
+// NewCreateNode builds a request to create a node on the service referenced by "serviceId"
+// See 8.1 Create a Node
+func NewCreateNode(serviceId, nodeName string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Create: &Create{Node: nodeName},
+ }
+ return iq, nil
+}
+
+// NewRetrieveAllSubsRequest builds a request to retrieve all subscriptions from all nodes
+// In order to make the request, the requesting entity MUST send an IQ-get whose
+// child contains an empty element with no attributes.
+func NewRetrieveAllSubsRequest(serviceId string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Subscriptions: &Subscriptions{},
+ }
+ return iq, nil
+}
+
+// NewRetrieveAllAffilsRequest builds a request to retrieve all affiliations from all nodes
+// In order to make the request of the service, the requesting entity includes an empty element with no attributes.
+func NewRetrieveAllAffilsRequest(serviceId string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubGeneric{
+ Affiliations: &Affiliations{},
+ }
+ return iq, nil
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "http://jabber.org/protocol/pubsub", Local: "pubsub"}, PubSubGeneric{})
+}
diff --git a/go-xmpp/stanza/pubsub_owner.go b/go-xmpp/stanza/pubsub_owner.go
new file mode 100644
index 0000000..d4c9843
--- /dev/null
+++ b/go-xmpp/stanza/pubsub_owner.go
@@ -0,0 +1,458 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "errors"
+ "strings"
+)
+
+type PubSubOwner struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/pubsub#owner pubsub"`
+ OwnerUseCase OwnerUseCase
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (pso *PubSubOwner) Namespace() string {
+ return pso.XMLName.Space
+}
+
+func (pso *PubSubOwner) GetSet() *ResultSet {
+ return pso.ResultSet
+}
+
+type OwnerUseCase interface {
+ UseCase() string
+}
+
+type AffiliationsOwner struct {
+ XMLName xml.Name `xml:"affiliations"`
+ Affiliations []AffiliationOwner `xml:"affiliation,omitempty"`
+ Node string `xml:"node,attr"`
+}
+
+func (AffiliationsOwner) UseCase() string {
+ return "affiliations"
+}
+
+type AffiliationOwner struct {
+ XMLName xml.Name `xml:"affiliation"`
+ AffiliationStatus string `xml:"affiliation,attr"`
+ Jid string `xml:"jid,attr"`
+}
+
+const (
+ AffiliationStatusMember = "member"
+ AffiliationStatusNone = "none"
+ AffiliationStatusOutcast = "outcast"
+ AffiliationStatusOwner = "owner"
+ AffiliationStatusPublisher = "publisher"
+ AffiliationStatusPublishOnly = "publish-only"
+)
+
+type ConfigureOwner struct {
+ XMLName xml.Name `xml:"configure"`
+ Node string `xml:"node,attr,omitempty"`
+ Form *Form `xml:"x,omitempty"`
+}
+
+func (*ConfigureOwner) UseCase() string {
+ return "configure"
+}
+
+type DefaultOwner struct {
+ XMLName xml.Name `xml:"default"`
+ Form *Form `xml:"x,omitempty"`
+}
+
+func (*DefaultOwner) UseCase() string {
+ return "default"
+}
+
+type DeleteOwner struct {
+ XMLName xml.Name `xml:"delete"`
+ RedirectOwner *RedirectOwner `xml:"redirect,omitempty"`
+ Node string `xml:"node,attr,omitempty"`
+}
+
+func (*DeleteOwner) UseCase() string {
+ return "delete"
+}
+
+type RedirectOwner struct {
+ XMLName xml.Name `xml:"redirect"`
+ URI string `xml:"uri,attr"`
+}
+
+type PurgeOwner struct {
+ XMLName xml.Name `xml:"purge"`
+ Node string `xml:"node,attr"`
+}
+
+func (*PurgeOwner) UseCase() string {
+ return "purge"
+}
+
+type SubscriptionsOwner struct {
+ XMLName xml.Name `xml:"subscriptions"`
+ Subscriptions []SubscriptionOwner `xml:"subscription"`
+ Node string `xml:"node,attr"`
+}
+
+func (*SubscriptionsOwner) UseCase() string {
+ return "subscriptions"
+}
+
+type SubscriptionOwner struct {
+ SubscriptionStatus string `xml:"subscription"`
+ Jid string `xml:"jid,attr"`
+}
+
+const (
+ SubscriptionStatusNone = "none"
+ SubscriptionStatusPending = "pending"
+ SubscriptionStatusSubscribed = "subscribed"
+ SubscriptionStatusUnconfigured = "unconfigured"
+)
+
+// NewConfigureNode creates a request to configure a node on the given service.
+// A form will be returned by the service, to which the user must respond using for instance the NewFormSubmission function.
+// See 8.2 Configure a Node
+func NewConfigureNode(serviceId, nodeName string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &ConfigureOwner{Node: nodeName},
+ }
+ return iq, nil
+}
+
+// NewDelNode creates a request to delete node "nodeID" from the "serviceId" service
+// See 8.4 Delete a Node
+func NewDelNode(serviceId, nodeID string) (*IQ, error) {
+ if strings.TrimSpace(nodeID) == "" {
+ return nil, errors.New("cannot delete a node without a target node ID")
+ }
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &DeleteOwner{Node: nodeID},
+ }
+ return iq, nil
+}
+
+// NewPurgeAllItems creates a new purge request for the "nodeId" node, at "serviceId" service
+// See 8.5 Purge All Node Items
+func NewPurgeAllItems(serviceId, nodeId string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &PurgeOwner{Node: nodeId},
+ }
+ return iq, nil
+}
+
+// NewRequestDefaultConfig build a request to ask the service for the default config of its nodes
+// See 8.3 Request Default Node Configuration Options
+func NewRequestDefaultConfig(serviceId string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &DefaultOwner{},
+ }
+ return iq, nil
+}
+
+// NewApproveSubRequest creates a new sub approval response to a request from the service to the owner of the node
+// In order to approve the request, the owner shall submit the form and set the "pubsub#allow" field to a value of "1" or "true"
+// For tracking purposes the message MUST reflect the 'id' attribute originally provided in the request.
+// See 8.6 Manage Subscription Requests
+func NewApproveSubRequest(serviceId, reqID string, apprForm *Form) (Message, error) {
+ if serviceId == "" {
+ return Message{}, errors.New("need a target service serviceId send approval serviceId")
+ }
+ if reqID == "" {
+ return Message{}, errors.New("the request ID is empty but must be used for the approval")
+ }
+ if apprForm == nil {
+ return Message{}, errors.New("approval form is nil")
+ }
+ apprMess := NewMessage(Attrs{To: serviceId})
+ apprMess.Extensions = []MsgExtension{apprForm}
+ apprMess.Id = reqID
+
+ return apprMess, nil
+}
+
+// NewGetPendingSubRequests creates a new request for all pending subscriptions to all their nodes at a service
+// This feature MUST be implemented using the Ad-Hoc Commands (XEP-0050) protocol
+// 8.7 Process Pending Subscription Requests
+func NewGetPendingSubRequests(serviceId string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &Command{
+ // the command name ('node' attribute of the command element) MUST have a value of "http://jabber.org/protocol/pubsub#get-pending"
+ Node: "http://jabber.org/protocol/pubsub#get-pending",
+ Action: CommandActionExecute,
+ }
+ return iq, nil
+}
+
+// NewGetPendingSubRequests creates a new request for all pending subscriptions to be approved on a given node
+// Upon receiving the data form for managing subscription requests, the owner then MAY request pending subscription
+// approval requests for a given node.
+// See 8.7.4 Per-Node Request
+func NewApprovePendingSubRequest(serviceId, sessionId, nodeId string) (*IQ, error) {
+ if sessionId == "" {
+ return nil, errors.New("the sessionId must be maintained for the command")
+ }
+
+ form := &Form{
+ Type: FormTypeSubmit,
+ Fields: []*Field{{Var: "pubsub#node", ValuesList: []string{nodeId}}},
+ }
+ data, err := xml.Marshal(form)
+ if err != nil {
+ return nil, err
+ }
+ var n Node
+ err = xml.Unmarshal(data, &n)
+ if err != nil {
+ return nil, err
+ }
+
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &Command{
+ // the command name ('node' attribute of the command element) MUST have a value of "http://jabber.org/protocol/pubsub#get-pending"
+ Node: "http://jabber.org/protocol/pubsub#get-pending",
+ Action: CommandActionExecute,
+ SessionId: sessionId,
+ CommandElements: []CommandElement{&n},
+ }
+ return iq, nil
+}
+
+// NewSubListRequest creates a request to list subscriptions of the client, for all nodes at the service.
+// It's a Get type IQ
+// 8.8.1 Retrieve Subscriptions
+func NewSubListRqPl(serviceId, nodeID string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &SubscriptionsOwner{Node: nodeID},
+ }
+ return iq, nil
+}
+
+func NewSubsForEntitiesRequest(serviceId, nodeID string, subs []SubscriptionOwner) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &SubscriptionsOwner{Node: nodeID, Subscriptions: subs},
+ }
+ return iq, nil
+}
+
+// NewModifAffiliationRequest creates a request to either modify one or more affiliations, or delete one or more affiliations
+// 8.9.2 Modify Affiliation & 8.9.2.4 Multiple Simultaneous Modifications & 8.9.3 Delete an Entity (just set the status to "none")
+func NewModifAffiliationRequest(serviceId, nodeID string, newAffils []AffiliationOwner) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &AffiliationsOwner{
+ Node: nodeID,
+ Affiliations: newAffils,
+ },
+ }
+ return iq, nil
+}
+
+// NewAffiliationListRequest creates a request to list all affiliated entities
+// See 8.9.1 Retrieve List List
+func NewAffiliationListRequest(serviceId, nodeID string) (*IQ, error) {
+ iq, err := NewIQ(Attrs{Type: IQTypeGet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ iq.Payload = &PubSubOwner{
+ OwnerUseCase: &AffiliationsOwner{
+ Node: nodeID,
+ },
+ }
+ return iq, nil
+}
+
+// NewFormSubmission builds a form submission pubsub IQ, in the Owner namespace
+// This is typically used to respond to a form issued by the server when configuring a node.
+// See 8.2.4 Form Submission
+func NewFormSubmissionOwner(serviceId, nodeName string, fields []*Field) (*IQ, error) {
+ if serviceId == "" || nodeName == "" {
+ return nil, errors.New("serviceId and nodeName must be filled for this request to be valid")
+ }
+
+ submitConf, err := NewIQ(Attrs{Type: IQTypeSet, To: serviceId})
+ if err != nil {
+ return nil, err
+ }
+ submitConf.Payload = &PubSubOwner{
+ OwnerUseCase: &ConfigureOwner{
+ Node: nodeName,
+ Form: NewForm(fields,
+ FormTypeSubmit)},
+ }
+
+ return submitConf, nil
+}
+
+// GetFormFields gets the fields from a form in a IQ stanza of type result, as a map.
+// Key is the "var" attribute of the field, and field is the value.
+// The user can then select and modify the fields they want to alter, and submit a new form to the service using the
+// NewFormSubmission function to build the IQ.
+// TODO : remove restriction on IQ type ?
+func (iq *IQ) GetFormFields() (map[string]*Field, error) {
+ if iq.Type != IQTypeResult {
+ return nil, errors.New("this IQ is not a result type IQ. Cannot extract the form from it")
+ }
+ switch payload := iq.Payload.(type) {
+ // We support IOT Control IQ
+ case *PubSubGeneric:
+ fieldMap := make(map[string]*Field)
+ for _, elt := range payload.Configure.Form.Fields {
+ fieldMap[elt.Var] = elt
+ }
+ return fieldMap, nil
+ case *PubSubOwner:
+ fieldMap := make(map[string]*Field)
+ co, ok := payload.OwnerUseCase.(*ConfigureOwner)
+ if !ok {
+ return nil, errors.New("this IQ does not contain a PubSub payload with a configure tag for the owner namespace")
+ }
+ for _, elt := range co.Form.Fields {
+ fieldMap[elt.Var] = elt
+ }
+ return fieldMap, nil
+
+ case *Command:
+ fieldMap := make(map[string]*Field)
+ var form *Form
+ for _, ce := range payload.CommandElements {
+ fo, ok := ce.(*Form)
+ if ok {
+ form = fo
+ break
+ }
+ }
+ if form == nil {
+ return nil, errors.New("this IQ does not contain a command payload with a form")
+ }
+ for _, elt := range form.Fields {
+ fieldMap[elt.Var] = elt
+ }
+ return fieldMap, nil
+ default:
+ if iq.Any != nil {
+ fieldMap := make(map[string]*Field)
+ if iq.Any.XMLName.Local != "command" {
+ return nil, errors.New("this IQ does not contain a form")
+ }
+
+ for _, nde := range iq.Any.Nodes {
+ if nde.XMLName.Local == "x" {
+ for _, n := range nde.Nodes {
+ if n.XMLName.Local == "field" {
+ f := Field{}
+ data, err := xml.Marshal(n)
+ if err != nil {
+ continue
+ }
+ err = xml.Unmarshal(data, &f)
+ if err == nil {
+ fieldMap[f.Var] = &f
+ }
+ }
+ }
+ }
+ }
+ return fieldMap, nil
+ }
+ return nil, errors.New("this IQ does not contain a form")
+ }
+}
+
+func (pso *PubSubOwner) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ pso.XMLName = start.Name
+ // decode inner elements
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+
+ case xml.StartElement:
+ // Decode sub-elements
+ var err error
+ switch tt.Name.Local {
+
+ case "affiliations":
+ aff := AffiliationsOwner{}
+ err = d.DecodeElement(&aff, &tt)
+ pso.OwnerUseCase = &aff
+ case "configure":
+ co := ConfigureOwner{}
+ err = d.DecodeElement(&co, &tt)
+ pso.OwnerUseCase = &co
+ case "default":
+ def := DefaultOwner{}
+ err = d.DecodeElement(&def, &tt)
+ pso.OwnerUseCase = &def
+ case "delete":
+ del := DeleteOwner{}
+ err = d.DecodeElement(&del, &tt)
+ pso.OwnerUseCase = &del
+ case "purge":
+ pu := PurgeOwner{}
+ err = d.DecodeElement(&pu, &tt)
+ pso.OwnerUseCase = &pu
+ case "subscriptions":
+ subs := SubscriptionsOwner{}
+ err = d.DecodeElement(&subs, &tt)
+ pso.OwnerUseCase = &subs
+ if err != nil {
+ return err
+ }
+ }
+ if err != nil {
+ return err
+ }
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "http://jabber.org/protocol/pubsub#owner", Local: "pubsub"}, PubSubOwner{})
+}
diff --git a/go-xmpp/stanza/pubsub_owner_test.go b/go-xmpp/stanza/pubsub_owner_test.go
new file mode 100644
index 0000000..8ebd16b
--- /dev/null
+++ b/go-xmpp/stanza/pubsub_owner_test.go
@@ -0,0 +1,886 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "errors"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// ******************************
+// * 8.2 Configure a Node
+// ******************************
+func TestNewConfigureNode(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subR, err := stanza.NewConfigureNode("pubsub.shakespeare.lit", "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create a configure node request: %v", err)
+ }
+ subR.Id = "config1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a configure tag")
+ }
+
+ if ownrUsecase.Node == "" {
+ t.Fatalf("could not parse node from config tag")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewConfigureNodeResp(t *testing.T) {
+ response := `
+
+
+
+
+
+ http://jabber.org/protocol/pubsub#node_config
+
+
+ 0
+
+
+ 1028
+
+
+
+
+
+ never
+
+
+ 0
+
+
+
+
+ headline
+
+
+ http://www.w3.org/2005/Atom
+
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubOwnerPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a configure tag")
+ }
+
+ if ownrUsecase.Form == nil {
+ t.Fatalf("form is nil in the parsed config tag")
+ }
+
+ if len(ownrUsecase.Form.Fields) != 8 {
+ t.Fatalf("one or more fields in the response form could not be parsed correctly")
+ }
+}
+
+// *************************************************
+// * 8.3 Request Default Node Configuration Options
+// *************************************************
+
+func TestNewRequestDefaultConfig(t *testing.T) {
+ expectedReq := " " +
+ " "
+
+ subR, err := stanza.NewRequestDefaultConfig("pubsub.shakespeare.lit")
+ if err != nil {
+ t.Fatalf("failed to create a default config request: %v", err)
+ }
+ subR.Id = "def1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ _, ok = pubsub.OwnerUseCase.(*stanza.DefaultOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a default tag")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewRequestDefaultConfigResp(t *testing.T) {
+ response := `
+
+
+
+
+
+ http://jabber.org/protocol/pubsub#node_config
+
+
+ 0
+
+
+ 1028
+
+
+
+
+
+ never
+
+
+ 0
+
+
+
+
+ headline
+
+
+ http://www.w3.org/2005/Atom
+
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubOwnerPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a configure tag")
+ }
+
+ if ownrUsecase.Form == nil {
+ t.Fatalf("form is nil in the parsed config tag")
+ }
+
+ if len(ownrUsecase.Form.Fields) != 8 {
+ t.Fatalf("one or more fields in the response form could not be parsed correctly")
+ }
+}
+
+// ***********************
+// * 8.4 Delete a Node
+// ***********************
+
+func TestNewDelNode(t *testing.T) {
+ expectedReq := "" +
+ " " +
+ " "
+
+ subR, err := stanza.NewDelNode("pubsub.shakespeare.lit", "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create a node delete request: %v", err)
+ }
+ subR.Id = "delete1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ _, ok = pubsub.OwnerUseCase.(*stanza.DeleteOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a delete tag")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewDelNodeResp(t *testing.T) {
+ response := `
+
+
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubOwnerPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ ownrUsecase, ok := pubsub.OwnerUseCase.(*stanza.DeleteOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a configure tag")
+ }
+
+ if ownrUsecase.RedirectOwner == nil {
+ t.Fatalf("redirect is nil in the delete tag")
+ }
+
+ if ownrUsecase.RedirectOwner.URI == "" {
+ t.Fatalf("could not parse redirect uri")
+ }
+}
+
+// ****************************
+// * 8.5 Purge All Node Items
+// ****************************
+
+func TestNewPurgeAllItems(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subR, err := stanza.NewPurgeAllItems("pubsub.shakespeare.lit", "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create a purge all items request: %v", err)
+ }
+ subR.Id = "purge1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+
+ if pubsub.OwnerUseCase == nil {
+ t.Fatalf("owner use case is nil")
+ }
+
+ purge, ok := pubsub.OwnerUseCase.(*stanza.PurgeOwner)
+ if !ok {
+ t.Fatalf("owner use case is not a delete tag")
+ }
+
+ if purge.Node == "" {
+ t.Fatalf("could not parse purge targer node")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ************************************
+// * 8.6 Manage Subscription Requests
+// ************************************
+func TestNewApproveSubRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ "http://jabber.org/protocol/pubsub#subscribe_authorization " +
+ " 123-abc princely_musings " +
+ " horatio@denmark.lit " +
+ "true "
+
+ apprForm := &stanza.Form{
+ Type: stanza.FormTypeSubmit,
+ Fields: []*stanza.Field{
+ {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#subscribe_authorization"}},
+ {Var: "pubsub#subid", ValuesList: []string{"123-abc"}},
+ {Var: "pubsub#node", ValuesList: []string{"princely_musings"}},
+ {Var: "pubsub#subscriber_jid", ValuesList: []string{"horatio@denmark.lit"}},
+ {Var: "pubsub#allow", ValuesList: []string{"true"}},
+ },
+ }
+
+ subR, err := stanza.NewApproveSubRequest("pubsub.shakespeare.lit", "approve1", apprForm)
+ if err != nil {
+ t.Fatalf("failed to create a sub approval request: %v", err)
+ }
+ subR.Id = "approve1"
+
+ frm, ok := subR.Extensions[0].(*stanza.Form)
+ if !ok {
+ t.Fatalf("extension is not a from !")
+ }
+
+ var allowField *stanza.Field
+
+ for _, f := range frm.Fields {
+ if f.Var == "pubsub#allow" {
+ allowField = f
+ }
+ }
+ if allowField == nil || allowField.ValuesList[0] != "true" {
+ t.Fatalf("could not correctly parse the allow field in the response from")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ********************************************
+// * 8.7 Process Pending Subscription Requests
+// ********************************************
+
+func TestNewGetPendingSubRequests(t *testing.T) {
+ expectedReq := " " +
+ "" +
+ " "
+
+ subR, err := stanza.NewGetPendingSubRequests("pubsub.shakespeare.lit")
+ if err != nil {
+ t.Fatalf("failed to create a get pending subs request: %v", err)
+ }
+ subR.Id = "pending1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ command, ok := subR.Payload.(*stanza.Command)
+ if !ok {
+ t.Fatalf("payload is not a command !")
+ }
+
+ if command.Action != stanza.CommandActionExecute {
+ t.Fatalf("command should be execute !")
+ }
+
+ if command.Node != "http://jabber.org/protocol/pubsub#get-pending" {
+ t.Fatalf("command node should be http://jabber.org/protocol/pubsub#get-pending !")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewGetPendingSubRequestsResp(t *testing.T) {
+ response := `
+
+
+
+
+ http://jabber.org/protocol/pubsub#subscribe_authorization
+
+
+
+
+
+
+
+
+`
+
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+ if err != nil {
+ t.Fatalf("could not parse iq")
+ }
+
+ _, ok := respIQ.Payload.(*stanza.Command)
+ if !ok {
+ t.Fatal("this iq payload is not a command")
+ }
+
+ fMap, err := respIQ.GetFormFields()
+ if err != nil || len(fMap) != 2 {
+ t.Fatal("could not parse command form fields")
+ }
+
+}
+
+// ********************************************
+// * 8.7 Process Pending Subscription Requests
+// ********************************************
+
+func TestNewApprovePendingSubRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " " +
+ "princely_musings "
+
+ subR, err := stanza.NewApprovePendingSubRequest("pubsub.shakespeare.lit",
+ "pubsub-get-pending:20031021T150901Z-600",
+ "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create a approve pending sub request: %v", err)
+ }
+ subR.Id = "pending2"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ command, ok := subR.Payload.(*stanza.Command)
+ if !ok {
+ t.Fatalf("payload is not a command !")
+ }
+
+ if command.Action != stanza.CommandActionExecute {
+ t.Fatalf("command should be execute !")
+ }
+
+ //if command.Node != "http://jabber.org/protocol/pubsub#get-pending"{
+ // t.Fatalf("command node should be http://jabber.org/protocol/pubsub#get-pending !")
+ //}
+ //
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ********************************************
+// * 8.8.1 Retrieve Subscriptions List
+// ********************************************
+
+func TestNewSubListRqPl(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subR, err := stanza.NewSubListRqPl("pubsub.shakespeare.lit", "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create a sub list request: %v", err)
+ }
+ subR.Id = "subman1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub in namespace owner !")
+ }
+
+ subs, ok := pubsub.OwnerUseCase.(*stanza.SubscriptionsOwner)
+ if !ok {
+ t.Fatalf("pubsub doesn not contain a subscriptions node !")
+ }
+
+ if subs.Node != "princely_musings" {
+ t.Fatalf("subs node attribute should be princely_musings. Found %s", subs.Node)
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewSubListRqPlResp(t *testing.T) {
+ response := `
+
+
+
+
+
+
+
+
+
+
+`
+
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+ if err != nil {
+ t.Fatalf("could not parse iq")
+ }
+
+ pubsub, ok := respIQ.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatal("this iq payload is not a command")
+ }
+
+ subs, ok := pubsub.OwnerUseCase.(*stanza.SubscriptionsOwner)
+ if !ok {
+ t.Fatalf("pubsub doesn not contain a subscriptions node !")
+ }
+
+ if len(subs.Subscriptions) != 4 {
+ t.Fatalf("expected to find 4 subscriptions but got %d", len(subs.Subscriptions))
+ }
+
+}
+
+// ********************************************
+// * 8.9.1 Retrieve Affiliations List
+// ********************************************
+
+func TestNewAffiliationListRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subR, err := stanza.NewAffiliationListRequest("pubsub.shakespeare.lit", "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create an affiliations list request: %v", err)
+ }
+ subR.Id = "ent1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub in namespace owner !")
+ }
+
+ affils, ok := pubsub.OwnerUseCase.(*stanza.AffiliationsOwner)
+ if !ok {
+ t.Fatalf("pubsub doesn not contain an affiliations node !")
+ }
+
+ if affils.Node != "princely_musings" {
+ t.Fatalf("affils node attribute should be princely_musings. Found %s", affils.Node)
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewAffiliationListRequestResp(t *testing.T) {
+ response := `
+
+
+
+
+
+
+
+
+`
+
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+ if err != nil {
+ t.Fatalf("could not parse iq")
+ }
+
+ pubsub, ok := respIQ.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatal("this iq payload is not a command")
+ }
+
+ affils, ok := pubsub.OwnerUseCase.(*stanza.AffiliationsOwner)
+ if !ok {
+ t.Fatalf("pubsub doesn not contain an affiliations node !")
+ }
+
+ if len(affils.Affiliations) != 2 {
+ t.Fatalf("expected to find 2 subscriptions but got %d", len(affils.Affiliations))
+ }
+
+}
+
+// ********************************************
+// * 8.9.2 Modify Affiliation
+// ********************************************
+
+func TestNewModifAffiliationRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " " +
+ " " +
+ " " +
+ ""
+
+ affils := []stanza.AffiliationOwner{
+ {
+ AffiliationStatus: stanza.AffiliationStatusNone,
+ Jid: "hamlet@denmark.lit",
+ },
+ {
+ AffiliationStatus: stanza.AffiliationStatusNone,
+ Jid: "polonius@denmark.lit",
+ },
+ {
+ AffiliationStatus: stanza.AffiliationStatusPublisher,
+ Jid: "bard@shakespeare.lit",
+ },
+ }
+
+ subR, err := stanza.NewModifAffiliationRequest("pubsub.shakespeare.lit", "princely_musings", affils)
+ if err != nil {
+ t.Fatalf("failed to create a modif affiliation request: %v", err)
+ }
+ subR.Id = "ent3"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub in namespace owner !")
+ }
+
+ as, ok := pubsub.OwnerUseCase.(*stanza.AffiliationsOwner)
+ if !ok {
+ t.Fatalf("pubsub doesn not contain an affiliations node !")
+ }
+
+ if as.Node != "princely_musings" {
+ t.Fatalf("affils node attribute should be princely_musings. Found %s", as.Node)
+ }
+ if len(as.Affiliations) != 3 {
+ t.Fatalf("expected 3 affiliations, found %d", len(as.Affiliations))
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetFormFields(t *testing.T) {
+ response := `
+
+
+
+
+
+ http://jabber.org/protocol/pubsub#node_config
+
+
+ 0
+
+
+ 1028
+
+
+
+
+
+ never
+
+
+ 0
+
+
+
+
+ headline
+
+
+ http://www.w3.org/2005/Atom
+
+
+
+
+
+
+`
+ var iq stanza.IQ
+ err := xml.Unmarshal([]byte(response), &iq)
+ if err != nil {
+ t.Fatalf("could not parse IQ")
+ }
+
+ fields, err := iq.GetFormFields()
+ if len(fields) != 8 {
+ t.Fatalf("could not correctly parse fields. Expected 8, found : %v", len(fields))
+ }
+
+}
+
+func TestGetFormFieldsCmd(t *testing.T) {
+ response := `
+
+
+
+
+ http://jabber.org/protocol/pubsub#subscribe_authorization
+
+
+
+
+
+
+
+
+`
+ var iq stanza.IQ
+ err := xml.Unmarshal([]byte(response), &iq)
+ if err != nil {
+ t.Fatalf("could not parse IQ")
+ }
+
+ fields, err := iq.GetFormFields()
+ if len(fields) != 2 {
+ t.Fatalf("could not correctly parse fields. Expected 2, found : %v", len(fields))
+ }
+
+}
+
+func TestNewFormSubmissionOwner(t *testing.T) {
+ expectedReq := "" +
+ " " +
+ " " +
+ "http://jabber.org/protocol/pubsub#node_config " +
+ "604800 roster " +
+ " friends servants " +
+ "courtiers "
+
+ subR, err := stanza.NewFormSubmissionOwner("pubsub.shakespeare.lit",
+ "princely_musings",
+ []*stanza.Field{
+ {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}},
+ {Var: "pubsub#item_expire", ValuesList: []string{"604800"}},
+ {Var: "pubsub#access_model", ValuesList: []string{"roster"}},
+ {Var: "pubsub#roster_groups_allowed", ValuesList: []string{"friends", "servants", "courtiers"}},
+ })
+ if err != nil {
+ t.Fatalf("failed to create a form submission request: %v", err)
+ }
+ subR.Id = "config2"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ t.Fatalf("payload is not a pubsub in namespace owner !")
+ }
+
+ conf, ok := pubsub.OwnerUseCase.(*stanza.ConfigureOwner)
+ if !ok {
+ t.Fatalf("pubsub does not contain a configure node !")
+ }
+
+ if conf.Form == nil {
+ t.Fatalf("the form is absent from the configuration submission !")
+ }
+ if len(conf.Form.Fields) != 4 {
+ t.Fatalf("expected 4 fields, found %d", len(conf.Form.Fields))
+ }
+ if len(conf.Form.Fields[3].ValuesList) != 3 {
+ t.Fatalf("expected 3 values in fourth field, found %d", len(conf.Form.Fields[3].ValuesList))
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func getPubSubOwnerPayload(response string) (*stanza.PubSubOwner, error) {
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+
+ if err != nil {
+ return &stanza.PubSubOwner{}, err
+ }
+
+ pubsub, ok := respIQ.Payload.(*stanza.PubSubOwner)
+ if !ok {
+ return nil, errors.New("this iq payload is not a pubsub of the owner namespace")
+ }
+
+ return pubsub, nil
+}
diff --git a/go-xmpp/stanza/pubsub_test.go b/go-xmpp/stanza/pubsub_test.go
new file mode 100644
index 0000000..ed3299a
--- /dev/null
+++ b/go-xmpp/stanza/pubsub_test.go
@@ -0,0 +1,923 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "errors"
+ "strings"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+var submitFormExample = stanza.NewForm([]*stanza.Field{
+ {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}},
+ {Var: "pubsub#title", ValuesList: []string{"Princely Musings (Atom)"}},
+ {Var: "pubsub#deliver_notifications", ValuesList: []string{"1"}},
+ {Var: "pubsub#access_model", ValuesList: []string{"roster"}},
+ {Var: "pubsub#roster_groups_allowed", ValuesList: []string{"friends", "servants", "courtiers"}},
+ {Var: "pubsub#type", ValuesList: []string{"http://www.w3.org/2005/Atom"}},
+ {
+ Var: "pubsub#notification_type",
+ Type: "list-single",
+ Label: "Specify the delivery style for event notifications",
+ ValuesList: []string{"headline"},
+ Options: []stanza.Option{
+ {ValuesList: []string{"normal"}},
+ {ValuesList: []string{"headline"}},
+ },
+ },
+}, stanza.FormTypeSubmit)
+
+// ***********************************
+// * 6.1 Subscribe to a Node
+// ***********************************
+
+func TestNewSubRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subInfo := stanza.SubInfo{
+ Node: "princely_musings", Jid: "francisco@denmark.lit",
+ }
+ subR, err := stanza.NewSubRq("pubsub.shakespeare.lit", subInfo)
+ if err != nil {
+ t.Fatalf("failed to create a sub request: %v", err)
+ }
+ subR.Id = "sub1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+
+}
+
+func TestNewSubResp(t *testing.T) {
+ response := `
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubGenericPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if pubsub.Subscription == nil {
+ t.Fatalf("subscription node is nil")
+ }
+ if pubsub.Subscription.Node == "" ||
+ pubsub.Subscription.Jid == "" ||
+ pubsub.Subscription.SubId == nil ||
+ pubsub.Subscription.SubStatus == "" {
+ t.Fatalf("one or more of the subscription attributes was not successfully decoded")
+ }
+
+}
+
+// ***********************************
+// * 6.2 Unsubscribe from a Node
+// ***********************************
+
+func TestNewUnSubRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subInfo := stanza.SubInfo{
+ Node: "princely_musings", Jid: "francisco@denmark.lit",
+ }
+ subR, err := stanza.NewUnsubRq("pubsub.shakespeare.lit", subInfo)
+ if err != nil {
+ t.Fatalf("failed to create an unsub request: %v", err)
+ }
+ subR.Id = "unsub1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Unsubscribe == nil {
+ t.Fatalf("Unsubscribe tag should be present in sub config options request")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewUnsubResp(t *testing.T) {
+ response := `
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubGenericPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if pubsub.Subscription == nil {
+ t.Fatalf("subscription node is nil")
+ }
+ if pubsub.Subscription.Node == "" ||
+ pubsub.Subscription.Jid == "" ||
+ pubsub.Subscription.SubId == nil ||
+ pubsub.Subscription.SubStatus == "" {
+ t.Fatalf("one or more of the subscription attributes was not successfully decoded")
+ }
+
+}
+
+// ***************************************
+// * 6.3 Configure Subscription Options
+// ***************************************
+func TestNewSubOptsRq(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subInfo := stanza.SubInfo{
+ Node: "princely_musings", Jid: "francisco@denmark.lit",
+ }
+ subR, err := stanza.NewSubOptsRq("pubsub.shakespeare.lit", subInfo)
+ if err != nil {
+ t.Fatalf("failed to create a sub options request: %v", err)
+ }
+ subR.Id = "options1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.SubOptions == nil {
+ t.Fatalf("Options tag should be present in sub config options request")
+ }
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewNewConfOptsRsp(t *testing.T) {
+ response := `
+
+
+
+
+
+ http://jabber.org/protocol/pubsub#subscribe_options
+
+
+ 1
+
+
+ 0
+
+
+ false
+
+
+
+
+
+
+
+ chat
+ online
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubGenericPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if pubsub.SubOptions == nil {
+ t.Fatalf("sub options node is nil")
+ }
+ if pubsub.SubOptions.Form == nil {
+ t.Fatalf("the response form is nil")
+ }
+
+ if len(pubsub.SubOptions.Form.Fields) != 5 {
+ t.Fatalf("one or more fields in the response form could not be parsed correctly")
+ }
+}
+
+// ***************************************
+// * 6.3.5 Form Submission
+// ***************************************
+func TestNewFormSubmission(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " " +
+ " http://jabber.org/protocol/pubsub#node_config " +
+ "Princely Musings (Atom) " +
+ "1 roster " +
+ " friends servants" +
+ " courtiers http://www.w3.org/2005/Atom " +
+ " " +
+ "headline " +
+ " "
+
+ subInfo := stanza.SubInfo{
+ Node: "princely_musings", Jid: "francisco@denmark.lit",
+ }
+
+ subR, err := stanza.NewFormSubmission("pubsub.shakespeare.lit", subInfo, submitFormExample)
+ if err != nil {
+ t.Fatalf("failed to create a form submission request: %v", err)
+ }
+ subR.Id = "options2"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.SubOptions == nil {
+ t.Fatalf("Options tag should be present in sub config options request")
+ }
+ if pubsub.SubOptions.Form == nil {
+ t.Fatalf("No form in form submit request !")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ***************************************
+// * 6.3.7 Subscribe and Configure
+// ***************************************
+
+func TestNewSubAndConfig(t *testing.T) {
+ expectedReq := "" +
+ " " +
+ "" +
+ " " +
+ " http://jabber.org/protocol/pubsub#node_config " +
+ "Princely Musings (Atom) " +
+ "1 roster " +
+ " friends servants" +
+ " courtiers http://www.w3.org/2005/Atom " +
+ " " +
+ "headline " +
+ " "
+
+ subInfo := stanza.SubInfo{
+ Node: "princely_musings", Jid: "francisco@denmark.lit",
+ }
+
+ subR, err := stanza.NewSubAndConfig("pubsub.shakespeare.lit", subInfo, submitFormExample)
+ if err != nil {
+ t.Fatalf("failed to create a sub and config request: %v", err)
+ }
+ subR.Id = "sub1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.SubOptions == nil {
+ t.Fatalf("Options tag should be present in sub config options request")
+ }
+ if pubsub.SubOptions.Form == nil {
+ t.Fatalf("No form in form submit request !")
+ }
+
+ // The element MUST NOT possess a 'node' attribute or 'jid' attribute
+ // See XEP-0060
+ if pubsub.SubOptions.SubInfo.Node != "" || pubsub.SubOptions.SubInfo.Jid != "" {
+ t.Fatalf("SubInfo node and jid should be empty for the options tag !")
+ }
+ if pubsub.Subscribe.Node == "" || pubsub.Subscribe.Jid == "" {
+ t.Fatalf("SubInfo node and jid should NOT be empty for the subscribe tag !")
+ }
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewSubAndConfigResp(t *testing.T) {
+ response := `
+
+
+
+
+
+
+ http://jabber.org/protocol/pubsub#subscribe_options
+
+
+ 1
+
+
+ 0
+
+
+ false
+
+
+ chat
+ online
+ away
+
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubGenericPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pubsub.Subscription == nil {
+ t.Fatalf("sub node is nil")
+ }
+
+ if pubsub.SubOptions == nil {
+ t.Fatalf("sub options node is nil")
+ }
+ if pubsub.SubOptions.Form == nil {
+ t.Fatalf("the response form is nil")
+ }
+
+ if len(pubsub.SubOptions.Form.Fields) != 5 {
+ t.Fatalf("one or more fields in the response form could not be parsed correctly")
+ }
+}
+
+// ***************************************
+// * 6.5.2 Requesting All List
+// ***************************************
+func TestNewItemsRequest(t *testing.T) {
+ subR, err := stanza.NewItemsRequest("pubsub.shakespeare.lit", "princely_musings", 0)
+ if err != nil {
+ t.Fatalf("Could not create an items request : %s", err)
+ }
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Items == nil {
+ t.Fatalf("List tag should be present to request items from a service")
+ }
+ if len(pubsub.Items.List) != 0 {
+ t.Fatalf("There should be no items in the tag to request all items from a service")
+ }
+}
+func TestNewItemsResp(t *testing.T) {
+ response := `
+
+
+
+ -
+
+ Alone
+ Now I am alone. O, what a rogue and peasant slave am I!
+
+ tag:denmark.lit,2003:entry-32396
+ 2003-12-13T11:09:53Z
+ 2003-12-13T11:09:53Z
+
+
+ -
+
+ Soliloquy
+ To be, or not to be: that is the question: Whether 'tis nobler in the
+ mind to suffer The slings and arrows of outrageous fortune, Or to take arms
+ against a sea of troubles, And by opposing end them?
+
+ tag:denmark.lit,2003:entry-32397
+ 2003-12-13T18:30:02Z
+ 2003-12-13T18:30:02Z
+
+
+
+
+
+`
+
+ pubsub, err := getPubSubGenericPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pubsub.Items == nil {
+ t.Fatalf("sub options node is nil")
+ }
+ if pubsub.Items.List == nil {
+ t.Fatalf("the response form is nil")
+ }
+
+ if len(pubsub.Items.List) != 2 {
+ t.Fatalf("one or more items in the response could not be parsed correctly")
+ }
+}
+
+// ***************************************
+// * 6.5.8 Requesting a Particular Item
+// ***************************************
+func TestNewSpecificItemRequest(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subR, err := stanza.NewSpecificItemRequest("pubsub.shakespeare.lit", "princely_musings", "ae890ac52d0df67ed7cfdf51b644e901")
+ if err != nil {
+ t.Fatalf("failed to create a specific item request: %v", err)
+ }
+ subR.Id = "items3"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Items == nil {
+ t.Fatalf("List tag should be present to request items from a service")
+ }
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ***************************************
+// * 7.1 Publish an Item to a Node
+// ***************************************
+func TestNewPublishItemRq(t *testing.T) {
+ item := stanza.Item{
+ XMLName: xml.Name{},
+ Id: "",
+ Publisher: "",
+ Any: &stanza.Node{
+ XMLName: xml.Name{
+ Space: "http://www.w3.org/2005/Atom",
+ Local: "entry",
+ },
+ Attrs: nil,
+ Content: "",
+ Nodes: []stanza.Node{
+ {
+ XMLName: xml.Name{Space: "", Local: "title"},
+ Attrs: nil,
+ Content: "My pub item title",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "summary"},
+ Attrs: nil,
+ Content: "My pub item content summary",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "link"},
+ Attrs: []xml.Attr{
+ {
+ Name: xml.Name{Space: "", Local: "rel"},
+ Value: "alternate",
+ },
+ {
+ Name: xml.Name{Space: "", Local: "type"},
+ Value: "text/html",
+ },
+ {
+ Name: xml.Name{Space: "", Local: "href"},
+ Value: "http://denmark.lit/2003/12/13/atom03",
+ },
+ },
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "id"},
+ Attrs: nil,
+ Content: "My pub item content ID",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "published"},
+ Attrs: nil,
+ Content: "2003-12-13T18:30:02Z",
+ Nodes: nil,
+ },
+ {
+ XMLName: xml.Name{Space: "", Local: "updated"},
+ Attrs: nil,
+ Content: "2003-12-13T18:30:02Z",
+ Nodes: nil,
+ },
+ },
+ },
+ }
+
+ subR, err := stanza.NewPublishItemRq("pubsub.shakespeare.lit", "princely_musings", "bnd81g37d61f49fgn581", item)
+ if err != nil {
+ t.Fatalf("Could not create an item pub request : %s", err)
+ }
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated sub request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+
+ if strings.TrimSpace(pubsub.Publish.Node) == "" {
+ t.Fatalf("the element MUST possess a 'node' attribute, specifying the NodeID of the node.")
+ }
+ if pubsub.Publish.Items[0].Id == "" {
+ t.Fatalf("an id was provided for the item and it should be used")
+ }
+}
+
+// ***************************************
+// * 7.1.5 Publishing Options
+// ***************************************
+
+func TestNewPublishItemOptsRq(t *testing.T) {
+ expectedReq := " " +
+ " - " +
+ " Soliloquy " +
+ " To be, or not to be: that is the question: Whether \"tis nobler in the mind to suffer The " +
+ "slings and arrows of outrageous fortune, Or to take arms against a sea of troubles, And by opposing end them? " +
+ " " +
+ "tag:denmark.lit,2003:entry-32397 2003-12-13T18:30:02Z " +
+ "2003-12-13T18:30:02Z
" +
+ " " +
+ "http://jabber.org/protocol/pubsub#publish-options " +
+ "presence "
+
+ var iq stanza.IQ
+ err := xml.Unmarshal([]byte(expectedReq), &iq)
+ if err != nil {
+ t.Fatalf("could not unmarshal example request : %s", err)
+ }
+
+ pubsub, ok := iq.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Publish == nil {
+ t.Fatalf("Publish tag is empty")
+ }
+ if len(pubsub.Publish.Items) != 1 {
+ t.Fatalf("could not parse item properly")
+ }
+}
+
+// ***************************************
+// * 7.2 Delete an Item from a Node
+// ***************************************
+
+func TestNewDelItemFromNode(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " "
+
+ subR, err := stanza.NewDelItemFromNode("pubsub.shakespeare.lit", "princely_musings", "ae890ac52d0df67ed7cfdf51b644e901", nil)
+ if err != nil {
+ t.Fatalf("failed to create a delete item from node request: %v", err)
+ }
+ subR.Id = "retract1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated del item request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Retract == nil {
+ t.Fatalf("Retract tag should be present to del an item from a service")
+ }
+
+ if strings.TrimSpace(pubsub.Retract.Items[0].Id) == "" {
+ t.Fatalf("Item id, for the item to delete, should be non empty")
+ }
+ if pubsub.Retract.Items[0].Any != nil {
+ t.Fatalf("Item node must be empty")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// ***************************************
+// * 8.1 Create a Node
+// ***************************************
+
+func TestNewCreateNode(t *testing.T) {
+ expectedReq := " " +
+ " "
+
+ subR, err := stanza.NewCreateNode("pubsub.shakespeare.lit", "princely_musings")
+ if err != nil {
+ t.Fatalf("failed to create a create node request: %v", err)
+ }
+ subR.Id = "create1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated del item request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Create == nil {
+ t.Fatalf("Create tag should be present to create a node on a service")
+ }
+
+ if strings.TrimSpace(pubsub.Create.Node) == "" {
+ t.Fatalf("Expected node name to be present")
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewCreateNodeResp(t *testing.T) {
+ response := `
+
+
+
+
+
+`
+ pubsub, err := getPubSubGenericPayload(response)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pubsub.Create == nil {
+ t.Fatalf("create segment is nil")
+ }
+ if pubsub.Create.Node == "" {
+ t.Fatalf("could not parse generated nodeId")
+ }
+
+}
+
+// ***************************************
+// * 8.1.3 Create and Configure a Node
+// ***************************************
+
+func TestNewCreateAndConfigNode(t *testing.T) {
+ expectedReq := " " +
+ " " +
+ " " +
+ "http://jabber.org/protocol/pubsub#node_config " +
+ "0 0 " +
+ " 1028 "
+
+ subR, err := stanza.NewCreateAndConfigNode("pubsub.shakespeare.lit",
+ "princely_musings",
+ &stanza.Form{
+ Type: stanza.FormTypeSubmit,
+ Fields: []*stanza.Field{
+ {Var: "FORM_TYPE", Type: stanza.FieldTypeHidden, ValuesList: []string{"http://jabber.org/protocol/pubsub#node_config"}},
+ {Var: "pubsub#notify_retract", ValuesList: []string{"0"}},
+ {Var: "pubsub#notify_sub", ValuesList: []string{"0"}},
+ {Var: "pubsub#max_payload_size", ValuesList: []string{"1028"}},
+ },
+ })
+
+ if err != nil {
+ t.Fatalf("failed to create a create and config node request: %v", err)
+ }
+ subR.Id = "create1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated del item request : %s", e)
+ }
+
+ pubsub, ok := subR.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("payload is not a pubsub !")
+ }
+ if pubsub.Create == nil {
+ t.Fatalf("Create tag should be present to create a node on a service")
+ }
+
+ if strings.TrimSpace(pubsub.Create.Node) == "" {
+ t.Fatalf("Expected node name to be present")
+ }
+
+ if pubsub.Configure == nil {
+ t.Fatalf("Configure tag should be present to configure a node during its creation on a service")
+ }
+
+ if pubsub.Configure.Form == nil {
+ t.Fatalf("Expected a form to be present, to configure the node")
+ }
+ if len(pubsub.Configure.Form.Fields) != 4 {
+ t.Fatalf("Expected 4 elements to be present in the config form but got : %v", len(pubsub.Configure.Form.Fields))
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expectedReq, string(data)); err != nil {
+ t.Fatal(err)
+ }
+
+}
+
+// ********************************
+// * 5.7 Retrieve Subscriptions
+// ********************************
+
+func TestNewRetrieveAllSubsRequest(t *testing.T) {
+ expected := " " +
+ " "
+
+ subR, err := stanza.NewRetrieveAllSubsRequest("pubsub.shakespeare.lit")
+ if err != nil {
+ t.Fatalf("failed to create a get all subs request: %v", err)
+ }
+ subR.Id = "subscriptions1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated del item request : %s", e)
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expected, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRetrieveAllSubsResp(t *testing.T) {
+ response := `
+
+
+
+
+
+
+
+
+
+
+
+`
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+
+ if err != nil {
+ t.Fatalf("could not unmarshal response: %s", err)
+ }
+
+ pubsub, ok := respIQ.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("umarshalled payload is not a pubsub")
+ }
+
+ if pubsub.Subscriptions == nil {
+ t.Fatalf("subscriptions node is nil")
+ }
+ if len(pubsub.Subscriptions.List) != 5 {
+ t.Fatalf("incorrect number of decoded subscriptions")
+ }
+}
+
+// ********************************
+// * 5.7 Retrieve Affiliations
+// ********************************
+
+func TestNewRetrieveAllAffilsRequest(t *testing.T) {
+ expected := " " +
+ " "
+
+ subR, err := stanza.NewRetrieveAllAffilsRequest("pubsub.shakespeare.lit")
+ if err != nil {
+ t.Fatalf("failed to create a get all affiliations request: %v", err)
+ }
+ subR.Id = "affil1"
+
+ if _, e := checkMarshalling(t, subR); e != nil {
+ t.Fatalf("Failed to check marshalling for generated retreive all affiliations request : %s", e)
+ }
+
+ data, err := xml.Marshal(subR)
+ if err := compareMarshal(expected, string(data)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRetrieveAllAffilsResp(t *testing.T) {
+ response := `
+
+
+
+
+
+
+
+
+
+
+`
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+
+ if err != nil {
+ t.Fatalf("could not unmarshal response: %s", err)
+ }
+
+ pubsub, ok := respIQ.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ t.Fatalf("umarshalled payload is not a pubsub")
+ }
+
+ if pubsub.Affiliations == nil {
+ t.Fatalf("subscriptions node is nil")
+ }
+ if len(pubsub.Affiliations.List) != 4 {
+ t.Fatalf("incorrect number of decoded subscriptions")
+ }
+}
+
+func getPubSubGenericPayload(response string) (*stanza.PubSubGeneric, error) {
+ var respIQ stanza.IQ
+ err := xml.Unmarshal([]byte(response), &respIQ)
+
+ if err != nil {
+ return &stanza.PubSubGeneric{}, err
+ }
+
+ pubsub, ok := respIQ.Payload.(*stanza.PubSubGeneric)
+ if !ok {
+ return nil, errors.New("this iq payload is not a pubsub")
+ }
+
+ return pubsub, nil
+}
diff --git a/go-xmpp/stanza/registry.go b/go-xmpp/stanza/registry.go
new file mode 100644
index 0000000..beb35df
--- /dev/null
+++ b/go-xmpp/stanza/registry.go
@@ -0,0 +1,119 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "reflect"
+ "sync"
+)
+
+type MsgExtension interface{}
+type PresExtension interface{}
+
+// The Registry for msg and IQ types is a global variable.
+// TODO: Move to the client init process to remove the dependency on a global variable.
+// That should make it possible to be able to share the decoder.
+// TODO: Ensure that a client can add its own custom namespace to the registry (or overload existing ones).
+
+type PacketType uint8
+
+const (
+ PKTPresence PacketType = iota
+ PKTMessage
+ PKTIQ
+)
+
+var TypeRegistry = newRegistry()
+
+// We store different registries per packet type and namespace.
+type registryKey struct {
+ packetType PacketType
+ namespace string
+}
+
+type registryForNamespace map[string]reflect.Type
+
+type registry struct {
+ // We store different registries per packet type and namespace.
+ msgTypes map[registryKey]registryForNamespace
+ // Handle concurrent access
+ msgTypesLock *sync.RWMutex
+}
+
+func newRegistry() *registry {
+ return ®istry{
+ msgTypes: make(map[registryKey]registryForNamespace),
+ msgTypesLock: &sync.RWMutex{},
+ }
+}
+
+// MapExtension stores extension type for packet payload.
+// The match is done per PacketType (iq, message, or presence) and XML tag name.
+// You can use the alias "*" as local XML name to be able to match all unknown tag name for that
+// packet type and namespace.
+func (r *registry) MapExtension(pktType PacketType, name xml.Name, extension MsgExtension) {
+ key := registryKey{pktType, name.Space}
+ r.msgTypesLock.RLock()
+ store := r.msgTypes[key]
+ r.msgTypesLock.RUnlock()
+
+ r.msgTypesLock.Lock()
+ defer r.msgTypesLock.Unlock()
+ if store == nil {
+ store = make(map[string]reflect.Type)
+ }
+ store[name.Local] = reflect.TypeOf(extension)
+ r.msgTypes[key] = store
+}
+
+// GetExtensionType returns extension type for packet payload, based on packet type and tag name.
+func (r *registry) GetExtensionType(pktType PacketType, name xml.Name) reflect.Type {
+ key := registryKey{pktType, name.Space}
+
+ r.msgTypesLock.RLock()
+ defer r.msgTypesLock.RUnlock()
+ store := r.msgTypes[key]
+ result := store[name.Local]
+ if result == nil && name.Local != "*" {
+ return store["*"]
+ }
+ return result
+}
+
+// GetPresExtension returns an instance of PresExtension, by matching packet type and XML
+// tag name against the registry.
+func (r *registry) GetPresExtension(name xml.Name) PresExtension {
+ if extensionType := r.GetExtensionType(PKTPresence, name); extensionType != nil {
+ val := reflect.New(extensionType)
+ elt := val.Interface()
+ if presExt, ok := elt.(PresExtension); ok {
+ return presExt
+ }
+ }
+ return nil
+}
+
+// GetMsgExtension returns an instance of MsgExtension, by matching packet type and XML
+// tag name against the registry.
+func (r *registry) GetMsgExtension(name xml.Name) MsgExtension {
+ if extensionType := r.GetExtensionType(PKTMessage, name); extensionType != nil {
+ val := reflect.New(extensionType)
+ elt := val.Interface()
+ if msgExt, ok := elt.(MsgExtension); ok {
+ return msgExt
+ }
+ }
+ return nil
+}
+
+// GetIQExtension returns an instance of IQPayload, by matching packet type and XML
+// tag name against the registry.
+func (r *registry) GetIQExtension(name xml.Name) IQPayload {
+ if extensionType := r.GetExtensionType(PKTIQ, name); extensionType != nil {
+ val := reflect.New(extensionType)
+ elt := val.Interface()
+ if iqExt, ok := elt.(IQPayload); ok {
+ return iqExt
+ }
+ }
+ return nil
+}
diff --git a/go-xmpp/stanza/registry_test.go b/go-xmpp/stanza/registry_test.go
new file mode 100644
index 0000000..02d321b
--- /dev/null
+++ b/go-xmpp/stanza/registry_test.go
@@ -0,0 +1,47 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "reflect"
+ "testing"
+)
+
+func TestRegistry_RegisterMsgExt(t *testing.T) {
+ // Setup registry
+ typeRegistry := newRegistry()
+
+ // Register an element
+ name := xml.Name{Space: "urn:xmpp:receipts", Local: "request"}
+ typeRegistry.MapExtension(PKTMessage, name, ReceiptRequest{})
+
+ // Match that element
+ receipt := typeRegistry.GetMsgExtension(name)
+ if receipt == nil {
+ t.Error("cannot read element type from registry")
+ return
+ }
+
+ switch r := receipt.(type) {
+ case *ReceiptRequest:
+ default:
+ t.Errorf("Registry did not return expected type ReceiptRequest: %v", reflect.TypeOf(r))
+ }
+}
+
+func BenchmarkRegistryGet(b *testing.B) {
+ // Setup registry
+ typeRegistry := newRegistry()
+
+ // Register an element
+ name := xml.Name{Space: "urn:xmpp:receipts", Local: "request"}
+ typeRegistry.MapExtension(PKTMessage, name, ReceiptRequest{})
+
+ for i := 0; i < b.N; i++ {
+ // Match that element
+ receipt := typeRegistry.GetExtensionType(PKTMessage, name)
+ if receipt == nil {
+ b.Error("cannot read element type from registry")
+ return
+ }
+ }
+}
diff --git a/go-xmpp/stanza/results_sets.go b/go-xmpp/stanza/results_sets.go
new file mode 100644
index 0000000..ecb0759
--- /dev/null
+++ b/go-xmpp/stanza/results_sets.go
@@ -0,0 +1,29 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// Support for XEP-0059
+// See https://xmpp.org/extensions/xep-0059
+const (
+ // Common but not only possible namespace for query blocks in a result set context
+ NSQuerySet = "jabber:iq:search"
+)
+
+type ResultSet struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/rsm set"`
+ After *string `xml:"after,omitempty"`
+ Before *string `xml:"before,omitempty"`
+ Count *int `xml:"count,omitempty"`
+ First *First `xml:"first,omitempty"`
+ Index *int `xml:"index,omitempty"`
+ Last *string `xml:"last,omitempty"`
+ Max *int `xml:"max,omitempty"`
+}
+
+type First struct {
+ XMLName xml.Name `xml:"first"`
+ Content string `xml:",chardata"`
+ Index *int `xml:"index,attr,omitempty"`
+}
diff --git a/go-xmpp/stanza/results_sets_test.go b/go-xmpp/stanza/results_sets_test.go
new file mode 100644
index 0000000..2ddd123
--- /dev/null
+++ b/go-xmpp/stanza/results_sets_test.go
@@ -0,0 +1,28 @@
+package stanza_test
+
+import (
+ "gosrc.io/xmpp/stanza"
+ "testing"
+)
+
+// Limiting the number of items
+func TestNewResultSetReq(t *testing.T) {
+ expectedRq := " " +
+ " " +
+ "urn:xmpp:mam:2 2010-08-07T00:00:00Z " +
+ " 10 "
+
+ maxVal := 10
+ rs := &stanza.ResultSet{
+ Max: &maxVal,
+ }
+
+ // TODO when Mam is implemented
+ _ = expectedRq
+ _ = rs
+}
+
+func TestUnmarshalResultSeqReq(t *testing.T) {
+ // TODO when Mam is implemented
+
+}
diff --git a/go-xmpp/stanza/sasl_auth.go b/go-xmpp/stanza/sasl_auth.go
new file mode 100644
index 0000000..2fb660e
--- /dev/null
+++ b/go-xmpp/stanza/sasl_auth.go
@@ -0,0 +1,124 @@
+package stanza
+
+import "encoding/xml"
+
+// ============================================================================
+
+// SASLAuth implements SASL Authentication initiation.
+// Reference: https://tools.ietf.org/html/rfc6120#section-6.4.2
+type SASLAuth struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl auth"`
+ Mechanism string `xml:"mechanism,attr"`
+ Value string `xml:",innerxml"`
+}
+
+// ============================================================================
+
+// SASLSuccess implements SASL Success nonza, sent by server as a result of the
+// SASL auth negotiation.
+// Reference: https://tools.ietf.org/html/rfc6120#section-6.4.6
+type SASLSuccess struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl success"`
+}
+
+func (SASLSuccess) Name() string {
+ return "sasl:success"
+}
+
+// SASLSuccess decoding
+type saslSuccessDecoder struct{}
+
+var saslSuccess saslSuccessDecoder
+
+func (saslSuccessDecoder) decode(p *xml.Decoder, se xml.StartElement) (SASLSuccess, error) {
+ var packet SASLSuccess
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+// ============================================================================
+
+// SASLFailure
+type SASLFailure struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl failure"`
+ Any xml.Name // error reason is a subelement
+}
+
+func (SASLFailure) Name() string {
+ return "sasl:failure"
+}
+
+// SASLFailure decoding
+type saslFailureDecoder struct{}
+
+var saslFailure saslFailureDecoder
+
+func (saslFailureDecoder) decode(p *xml.Decoder, se xml.StartElement) (SASLFailure, error) {
+ var packet SASLFailure
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+// ===========================================================================
+// Resource binding
+
+// Bind is an IQ payload used during session negotiation to bind user resource
+// to the current XMPP stream.
+// Reference: https://tools.ietf.org/html/rfc6120#section-7
+type Bind struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-bind bind"`
+ Resource string `xml:"resource,omitempty"`
+ Jid string `xml:"jid,omitempty"`
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (b *Bind) Namespace() string {
+ return b.XMLName.Space
+}
+
+func (b *Bind) GetSet() *ResultSet {
+ return b.ResultSet
+}
+
+// ============================================================================
+// Session (Obsolete)
+
+// Session is both a stream feature and an obsolete IQ Payload, used to bind a
+// resource to the current XMPP stream on RFC 3121 only XMPP servers.
+// Session is obsolete in RFC 6121. It is added to Fluux XMPP for compliance
+// with RFC 3121.
+// Reference: https://xmpp.org/rfcs/rfc3921.html#session
+//
+// This is the draft defining how to handle the transition:
+// https://tools.ietf.org/html/draft-cridland-xmpp-session-01
+type StreamSession struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-session session"`
+ Optional *struct{} // If element does exist, it mean we are not required to open session
+ // Result sets
+ ResultSet *ResultSet `xml:"set,omitempty"`
+}
+
+func (s *StreamSession) Namespace() string {
+ return s.XMLName.Space
+}
+
+func (s *StreamSession) GetSet() *ResultSet {
+ return s.ResultSet
+}
+
+func (s *StreamSession) IsOptional() bool {
+ if s.XMLName.Local == "session" {
+ return s.Optional != nil
+ }
+ // If session element is missing, then we should not use session
+ return true
+}
+
+// ============================================================================
+// Registry init
+
+func init() {
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-bind", Local: "bind"}, Bind{})
+ TypeRegistry.MapExtension(PKTIQ, xml.Name{Space: "urn:ietf:params:xml:ns:xmpp-session", Local: "session"}, StreamSession{})
+}
diff --git a/go-xmpp/stanza/sasl_auth_test.go b/go-xmpp/stanza/sasl_auth_test.go
new file mode 100644
index 0000000..600035c
--- /dev/null
+++ b/go-xmpp/stanza/sasl_auth_test.go
@@ -0,0 +1,60 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// Check that we can detect optional session from advertised stream features
+func TestSessionFeatures(t *testing.T) {
+ streamFeatures := stanza.StreamFeatures{Session: stanza.StreamSession{Optional: &struct{}{}}}
+
+ data, err := xml.Marshal(streamFeatures)
+ if err != nil {
+ t.Errorf("cannot marshal xml structure: %s", err)
+ }
+
+ parsedStream := stanza.StreamFeatures{}
+ if err = xml.Unmarshal(data, &parsedStream); err != nil {
+ t.Errorf("Unmarshal(%s) returned error: %s", data, err)
+ }
+
+ if !parsedStream.Session.IsOptional() {
+ t.Error("Session should be optional")
+ }
+}
+
+// Check that the Session tag can be used in IQ decoding
+func TestSessionIQ(t *testing.T) {
+ iq, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeSet, Id: "session"})
+ if err != nil {
+ t.Fatalf("failed to create IQ: %v", err)
+ }
+ iq.Payload = &stanza.StreamSession{XMLName: xml.Name{Local: "session"}, Optional: &struct{}{}}
+
+ data, err := xml.Marshal(iq)
+ if err != nil {
+ t.Errorf("cannot marshal xml structure: %s", err)
+ return
+ }
+
+ parsedIQ := stanza.IQ{}
+ if err = xml.Unmarshal(data, &parsedIQ); err != nil {
+ t.Errorf("Unmarshal(%s) returned error: %s", data, err)
+ return
+ }
+
+ session, ok := parsedIQ.Payload.(*stanza.StreamSession)
+ if !ok {
+ t.Error("Missing session payload")
+ return
+ }
+
+ if !session.IsOptional() {
+ t.Error("Session should be optional")
+ }
+}
+
+// TODO Test Sasl mechanism
diff --git a/go-xmpp/stanza/stanza_errors.go b/go-xmpp/stanza/stanza_errors.go
new file mode 100644
index 0000000..c66ea33
--- /dev/null
+++ b/go-xmpp/stanza/stanza_errors.go
@@ -0,0 +1,171 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+type StanzaErrorGroup interface {
+ GroupErrorName() string
+}
+
+type BadFormat struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas bad-format"`
+}
+
+func (e *BadFormat) GroupErrorName() string { return "bad-format" }
+
+type BadNamespacePrefix struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas bad-namespace-prefix"`
+}
+
+func (e *BadNamespacePrefix) GroupErrorName() string { return "bad-namespace-prefix" }
+
+type Conflict struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas conflict"`
+}
+
+func (e *Conflict) GroupErrorName() string { return "conflict" }
+
+type ConnectionTimeout struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas connection-timeout"`
+}
+
+func (e *ConnectionTimeout) GroupErrorName() string { return "connection-timeout" }
+
+type HostGone struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas host-gone"`
+}
+
+func (e *HostGone) GroupErrorName() string { return "host-gone" }
+
+type HostUnknown struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas host-unknown"`
+}
+
+func (e *HostUnknown) GroupErrorName() string { return "host-unknown" }
+
+type ImproperAddressing struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas improper-addressing"`
+}
+
+func (e *ImproperAddressing) GroupErrorName() string { return "improper-addressing" }
+
+type InternalServerError struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas internal-server-error"`
+}
+
+func (e *InternalServerError) GroupErrorName() string { return "internal-server-error" }
+
+type InvalidForm struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-from"`
+}
+
+func (e *InvalidForm) GroupErrorName() string { return "invalid-from" }
+
+type InvalidId struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-id"`
+}
+
+func (e *InvalidId) GroupErrorName() string { return "invalid-id" }
+
+type InvalidNamespace struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-namespace"`
+}
+
+func (e *InvalidNamespace) GroupErrorName() string { return "invalid-namespace" }
+
+type InvalidXML struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas invalid-xml"`
+}
+
+func (e *InvalidXML) GroupErrorName() string { return "invalid-xml" }
+
+type NotAuthorized struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas not-authorized"`
+}
+
+func (e *NotAuthorized) GroupErrorName() string { return "not-authorized" }
+
+type NotWellFormed struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas not-well-formed"`
+}
+
+func (e *NotWellFormed) GroupErrorName() string { return "not-well-formed" }
+
+type PolicyViolation struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas policy-violation"`
+}
+
+func (e *PolicyViolation) GroupErrorName() string { return "policy-violation" }
+
+type RemoteConnectionFailed struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas remote-connection-failed"`
+}
+
+func (e *RemoteConnectionFailed) GroupErrorName() string { return "remote-connection-failed" }
+
+type Reset struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas reset"`
+}
+
+func (e *Reset) GroupErrorName() string { return "reset" }
+
+type ResourceConstraint struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas resource-constraint"`
+}
+
+func (e *ResourceConstraint) GroupErrorName() string { return "resource-constraint" }
+
+type RestrictedXML struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas restricted-xml"`
+}
+
+func (e *RestrictedXML) GroupErrorName() string { return "restricted-xml" }
+
+type SeeOtherHost struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas see-other-host"`
+}
+
+func (e *SeeOtherHost) GroupErrorName() string { return "see-other-host" }
+
+type SystemShutdown struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas system-shutdown"`
+}
+
+func (e *SystemShutdown) GroupErrorName() string { return "system-shutdown" }
+
+type UndefinedCondition struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas undefined-condition"`
+}
+
+func (e *UndefinedCondition) GroupErrorName() string { return "undefined-condition" }
+
+type UnsupportedEncoding struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unsupported-encoding"`
+}
+
+type UnexpectedRequest struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unexpected-request"`
+}
+
+func (e *UnexpectedRequest) GroupErrorName() string { return "unexpected-request" }
+
+func (e *UnsupportedEncoding) GroupErrorName() string { return "unsupported-encoding" }
+
+type UnsupportedStanzaType struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unsupported-stanza-type"`
+}
+
+func (e *UnsupportedStanzaType) GroupErrorName() string { return "unsupported-stanza-type" }
+
+type UnsupportedVersion struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas unsupported-version"`
+}
+
+func (e *UnsupportedVersion) GroupErrorName() string { return "unsupported-version" }
+
+type XMLNotWellFormed struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-stanzas xml-not-well-formed"`
+}
+
+func (e *XMLNotWellFormed) GroupErrorName() string { return "xml-not-well-formed" }
diff --git a/go-xmpp/stanza/starttls.go b/go-xmpp/stanza/starttls.go
new file mode 100644
index 0000000..b8fce94
--- /dev/null
+++ b/go-xmpp/stanza/starttls.go
@@ -0,0 +1,14 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// Used during stream initiation / session establishment
+type TLSProceed struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls proceed"`
+}
+
+type tlsFailure struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls failure"`
+}
diff --git a/go-xmpp/stanza/stream.go b/go-xmpp/stanza/stream.go
new file mode 100644
index 0000000..6ab4bad
--- /dev/null
+++ b/go-xmpp/stanza/stream.go
@@ -0,0 +1,16 @@
+package stanza
+
+import "encoding/xml"
+
+// Start of stream
+// Reference: XMPP Core stream open
+// https://tools.ietf.org/html/rfc6120#section-4.2
+type Stream struct {
+ XMLName xml.Name `xml:"http://etherx.jabber.org/streams stream"`
+ From string `xml:"from,attr"`
+ To string `xml:"to,attr"`
+ Id string `xml:"id,attr"`
+ Version string `xml:"version,attr"`
+}
+
+const StreamClose = ""
diff --git a/go-xmpp/stanza/stream_features.go b/go-xmpp/stanza/stream_features.go
new file mode 100644
index 0000000..d1b6274
--- /dev/null
+++ b/go-xmpp/stanza/stream_features.go
@@ -0,0 +1,189 @@
+package stanza
+
+import (
+ "encoding/xml"
+)
+
+// ============================================================================
+// StreamFeatures Packet
+// Reference: The active stream features are published on
+// https://xmpp.org/registrar/stream-features.html
+// Note: That page misses draft and experimental XEP (i.e CSI, etc)
+
+type StreamFeatures struct {
+ XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"`
+ // Server capabilities hash
+ Caps Caps
+ // Stream features
+ StartTLS TlsStartTLS
+ Mechanisms saslMechanisms
+ Bind Bind
+ StreamManagement streamManagement
+ // Obsolete
+ Session StreamSession
+ // ProcessOne Stream Features
+ P1Push p1Push
+ P1Rebind p1Rebind
+ p1Ack p1Ack
+ Any []xml.Name `xml:",any"`
+}
+
+func (StreamFeatures) Name() string {
+ return "stream:features"
+}
+
+type streamFeatureDecoder struct{}
+
+var streamFeatures streamFeatureDecoder
+
+func (streamFeatureDecoder) decode(p *xml.Decoder, se xml.StartElement) (StreamFeatures, error) {
+ var packet StreamFeatures
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+// Capabilities
+// Reference: https://xmpp.org/extensions/xep-0115.html#stream
+// "A server MAY include its entity capabilities in a stream feature element so that connecting clients
+// and peer servers do not need to send service discovery requests each time they connect."
+// This is not a stream feature but a way to let client cache server disco info.
+type Caps struct {
+ XMLName xml.Name `xml:"http://jabber.org/protocol/caps c"`
+ Hash string `xml:"hash,attr"`
+ Node string `xml:"node,attr"`
+ Ver string `xml:"ver,attr"`
+ Ext string `xml:"ext,attr,omitempty"`
+}
+
+// ============================================================================
+// Supported Stream Features
+
+// StartTLS feature
+// Reference: RFC 6120 - https://tools.ietf.org/html/rfc6120#section-5.4
+type TlsStartTLS struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"`
+ Required bool
+}
+
+// UnmarshalXML implements custom parsing startTLS required flag
+func (stls *TlsStartTLS) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ stls.XMLName = start.Name
+
+ // Check subelements to extract required field as boolean
+ 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
+ }
+
+ if elt.XMLName.Local == "required" {
+ stls.Required = true
+ }
+
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+func (sf *StreamFeatures) DoesStartTLS() (feature TlsStartTLS, isSupported bool) {
+ if sf.StartTLS.XMLName.Space+" "+sf.StartTLS.XMLName.Local == nsTLS+" starttls" {
+ return sf.StartTLS, true
+ }
+ return feature, false
+}
+
+// Mechanisms
+// Reference: RFC 6120 - https://tools.ietf.org/html/rfc6120#section-6.4.1
+type saslMechanisms struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl mechanisms"`
+ Mechanism []string `xml:"mechanism"`
+}
+
+// StreamManagement
+// Reference: XEP-0198 - https://xmpp.org/extensions/xep-0198.html#feature
+type streamManagement struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 sm"`
+}
+
+func (streamManagement) Name() string {
+ return "streamManagement"
+}
+
+func (sf *StreamFeatures) DoesStreamManagement() (isSupported bool) {
+ if sf.StreamManagement.XMLName.Space+" "+sf.StreamManagement.XMLName.Local == "urn:xmpp:sm:3 sm" {
+ return true
+ }
+ return false
+}
+
+// P1 extensions
+// Reference: https://docs.ejabberd.im/developer/mobile/core-features/
+
+// p1:push support
+type p1Push struct {
+ XMLName xml.Name `xml:"p1:push push"`
+}
+
+// p1:rebind suppor
+type p1Rebind struct {
+ XMLName xml.Name `xml:"p1:rebind rebind"`
+}
+
+// p1:ack support
+type p1Ack struct {
+ XMLName xml.Name `xml:"p1:ack ack"`
+}
+
+// ============================================================================
+// StreamError Packet
+
+type StreamError struct {
+ XMLName xml.Name `xml:"http://etherx.jabber.org/streams error"`
+ Error xml.Name `xml:",any"`
+ Text string `xml:"urn:ietf:params:xml:ns:xmpp-streams text"`
+}
+
+func (StreamError) Name() string {
+ return "stream:error"
+}
+
+type streamErrorDecoder struct{}
+
+var streamError streamErrorDecoder
+
+func (streamErrorDecoder) decode(p *xml.Decoder, se xml.StartElement) (StreamError, error) {
+ var packet StreamError
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+// ============================================================================
+// StreamClose "Packet"
+
+// This is just a closing tag and hold no information
+type StreamClosePacket struct{}
+
+func (StreamClosePacket) Name() string {
+ return "stream:stream"
+}
+
+type streamCloseDecoder struct{}
+
+var streamClose streamCloseDecoder
+
+func (streamCloseDecoder) decode(_ xml.EndElement) StreamClosePacket {
+ return StreamClosePacket{}
+}
diff --git a/go-xmpp/stanza/stream_features_test.go b/go-xmpp/stanza/stream_features_test.go
new file mode 100644
index 0000000..621bd9c
--- /dev/null
+++ b/go-xmpp/stanza/stream_features_test.go
@@ -0,0 +1,64 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "testing"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+func TestNoStartTLS(t *testing.T) {
+ streamFeatures := `
+`
+
+ var parsedSF stanza.StreamFeatures
+ if err := xml.Unmarshal([]byte(streamFeatures), &parsedSF); err != nil {
+ t.Errorf("Unmarshal(%s) returned error: %v", streamFeatures, err)
+ }
+
+ startTLS, ok := parsedSF.DoesStartTLS()
+ if ok {
+ t.Error("StartTLS feature should not be enabled")
+ }
+ if startTLS.Required {
+ t.Error("StartTLS cannot be required as default")
+ }
+}
+
+func TestStartTLS(t *testing.T) {
+ streamFeatures := `
+
+
+
+`
+
+ var parsedSF stanza.StreamFeatures
+ if err := xml.Unmarshal([]byte(streamFeatures), &parsedSF); err != nil {
+ t.Errorf("Unmarshal(%s) returned error: %v", streamFeatures, err)
+ }
+
+ startTLS, ok := parsedSF.DoesStartTLS()
+ if !ok {
+ t.Error("StartTLS feature should be enabled")
+ }
+ if !startTLS.Required {
+ t.Error("StartTLS feature should be required")
+ }
+}
+
+// TODO: Ability to support / detect previous version of stream management feature
+func TestStreamManagement(t *testing.T) {
+ streamFeatures := `
+
+`
+
+ var parsedSF stanza.StreamFeatures
+ if err := xml.Unmarshal([]byte(streamFeatures), &parsedSF); err != nil {
+ t.Errorf("Unmarshal(%s) returned error: %v", streamFeatures, err)
+ }
+
+ ok := parsedSF.DoesStreamManagement()
+ if !ok {
+ t.Error("Stream Management feature should have been detected")
+ }
+}
diff --git a/go-xmpp/stanza/stream_management.go b/go-xmpp/stanza/stream_management.go
new file mode 100644
index 0000000..a2a4f0b
--- /dev/null
+++ b/go-xmpp/stanza/stream_management.go
@@ -0,0 +1,392 @@
+package stanza
+
+import (
+ "encoding/xml"
+ "errors"
+ "sync"
+)
+
+const (
+ NSStreamManagement = "urn:xmpp:sm:3"
+)
+
+type SMEnable struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 enable"`
+ Max *uint `xml:"max,attr,omitempty"`
+ Resume *bool `xml:"resume,attr,omitempty"`
+}
+
+// Enabled as defined in Stream Management spec
+// Reference: https://xmpp.org/extensions/xep-0198.html#enable
+type SMEnabled struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 enabled"`
+ Id string `xml:"id,attr,omitempty"`
+ Location string `xml:"location,attr,omitempty"`
+ Resume string `xml:"resume,attr,omitempty"`
+ Max uint `xml:"max,attr,omitempty"`
+}
+
+func (SMEnabled) Name() string {
+ return "Stream Management: enabled"
+}
+
+type UnAckQueue struct {
+ Uslice []*UnAckedStz
+ sync.RWMutex
+}
+type UnAckedStz struct {
+ Id int
+ Stz string
+}
+
+func NewUnAckQueue() *UnAckQueue {
+ return &UnAckQueue{
+ Uslice: make([]*UnAckedStz, 0, 10), // Capacity is 0 to comply with "Push" implementation (so that no reachable element is nil)
+ RWMutex: sync.RWMutex{},
+ }
+}
+
+func (u *UnAckedStz) QueueableName() string {
+ return "Un-acknowledged stanza"
+}
+
+func (uaq *UnAckQueue) PeekN(n int) []Queueable {
+ if uaq == nil {
+ return nil
+ }
+ if n <= 0 {
+ return nil
+ }
+ if len(uaq.Uslice) < n {
+ n = len(uaq.Uslice)
+ }
+
+ if len(uaq.Uslice) == 0 {
+ return nil
+ }
+ var r []Queueable
+ for i := 0; i < n; i++ {
+ r = append(r, uaq.Uslice[i])
+ }
+ return r
+}
+
+// No guarantee regarding thread safety !
+func (uaq *UnAckQueue) Pop() Queueable {
+ if uaq == nil {
+ return nil
+ }
+ r := uaq.Peek()
+ if r != nil {
+ uaq.Uslice = uaq.Uslice[1:]
+ }
+ return r
+}
+
+// No guarantee regarding thread safety !
+func (uaq *UnAckQueue) PopN(n int) []Queueable {
+ if uaq == nil {
+ return nil
+ }
+ r := uaq.PeekN(n)
+ uaq.Uslice = uaq.Uslice[len(r):]
+ return r
+}
+
+func (uaq *UnAckQueue) Peek() Queueable {
+ if uaq == nil {
+ return nil
+ }
+ if len(uaq.Uslice) == 0 {
+ return nil
+ }
+ r := uaq.Uslice[0]
+ return r
+}
+
+func (uaq *UnAckQueue) Push(s Queueable) error {
+ if uaq == nil {
+ return nil
+ }
+ pushIdx := 1
+ if len(uaq.Uslice) != 0 {
+ pushIdx = uaq.Uslice[len(uaq.Uslice)-1].Id + 1
+ }
+
+ sStz, ok := s.(*UnAckedStz)
+ if !ok {
+ return errors.New("element in not compatible with this queue. expected an UnAckedStz")
+ }
+
+ e := UnAckedStz{
+ Id: pushIdx,
+ Stz: sStz.Stz,
+ }
+
+ uaq.Uslice = append(uaq.Uslice, &e)
+
+ return nil
+}
+
+func (uaq *UnAckQueue) Empty() bool {
+ if uaq == nil {
+ return true
+ }
+ r := len(uaq.Uslice)
+ return r == 0
+}
+
+// Request as defined in Stream Management spec
+// Reference: https://xmpp.org/extensions/xep-0198.html#acking
+type SMRequest struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 r"`
+}
+
+func (SMRequest) Name() string {
+ return "Stream Management: request"
+}
+
+// Answer as defined in Stream Management spec
+// Reference: https://xmpp.org/extensions/xep-0198.html#acking
+type SMAnswer struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 a"`
+ H uint `xml:"h,attr"`
+}
+
+func (SMAnswer) Name() string {
+ return "Stream Management: answer"
+}
+
+// Resumed as defined in Stream Management spec
+// Reference: https://xmpp.org/extensions/xep-0198.html#acking
+type SMResumed struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 resumed"`
+ PrevId string `xml:"previd,attr,omitempty"`
+ H *uint `xml:"h,attr,omitempty"`
+}
+
+func (SMResumed) Name() string {
+ return "Stream Management: resumed"
+}
+
+// Resume as defined in Stream Management spec
+// Reference: https://xmpp.org/extensions/xep-0198.html#acking
+type SMResume struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 resume"`
+ PrevId string `xml:"previd,attr,omitempty"`
+ H *uint `xml:"h,attr,omitempty"`
+}
+
+func (SMResume) Name() string {
+ return "Stream Management: resume"
+}
+
+// Failed as defined in Stream Management spec
+// Reference: https://xmpp.org/extensions/xep-0198.html#acking
+type SMFailed struct {
+ XMLName xml.Name `xml:"urn:xmpp:sm:3 failed"`
+ H *uint `xml:"h,attr,omitempty"`
+
+ StreamErrorGroup StanzaErrorGroup
+}
+
+func (SMFailed) Name() string {
+ return "Stream Management: failed"
+}
+
+func (smf *SMFailed) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ smf.XMLName = start.Name
+
+ // According to https://xmpp.org/rfcs/rfc3920.html#def we should have no attributes aside from the namespace
+ // which we don't use internally
+
+ // decode inner elements
+ for {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+
+ switch tt := t.(type) {
+
+ case xml.StartElement:
+ // Decode sub-elements
+ var err error
+ switch tt.Name.Local {
+ case "bad-format":
+ bf := BadFormat{}
+ err = d.DecodeElement(&bf, &tt)
+ smf.StreamErrorGroup = &bf
+ case "bad-namespace-prefix":
+ bnp := BadNamespacePrefix{}
+ err = d.DecodeElement(&bnp, &tt)
+ smf.StreamErrorGroup = &bnp
+ case "conflict":
+ c := Conflict{}
+ err = d.DecodeElement(&c, &tt)
+ smf.StreamErrorGroup = &c
+ case "connection-timeout":
+ ct := ConnectionTimeout{}
+ err = d.DecodeElement(&ct, &tt)
+ smf.StreamErrorGroup = &ct
+ case "host-gone":
+ hg := HostGone{}
+ err = d.DecodeElement(&hg, &tt)
+ smf.StreamErrorGroup = &hg
+ case "host-unknown":
+ hu := HostUnknown{}
+ err = d.DecodeElement(&hu, &tt)
+ smf.StreamErrorGroup = &hu
+ case "improper-addressing":
+ ia := ImproperAddressing{}
+ err = d.DecodeElement(&ia, &tt)
+ smf.StreamErrorGroup = &ia
+ case "internal-server-error":
+ ise := InternalServerError{}
+ err = d.DecodeElement(&ise, &tt)
+ smf.StreamErrorGroup = &ise
+ case "invalid-from":
+ ifrm := InvalidForm{}
+ err = d.DecodeElement(&ifrm, &tt)
+ smf.StreamErrorGroup = &ifrm
+ case "invalid-id":
+ id := InvalidId{}
+ err = d.DecodeElement(&id, &tt)
+ smf.StreamErrorGroup = &id
+ case "invalid-namespace":
+ ins := InvalidNamespace{}
+ err = d.DecodeElement(&ins, &tt)
+ smf.StreamErrorGroup = &ins
+ case "invalid-xml":
+ ix := InvalidXML{}
+ err = d.DecodeElement(&ix, &tt)
+ smf.StreamErrorGroup = &ix
+ case "not-authorized":
+ na := NotAuthorized{}
+ err = d.DecodeElement(&na, &tt)
+ smf.StreamErrorGroup = &na
+ case "not-well-formed":
+ nwf := NotWellFormed{}
+ err = d.DecodeElement(&nwf, &tt)
+ smf.StreamErrorGroup = &nwf
+ case "policy-violation":
+ pv := PolicyViolation{}
+ err = d.DecodeElement(&pv, &tt)
+ smf.StreamErrorGroup = &pv
+ case "remote-connection-failed":
+ rcf := RemoteConnectionFailed{}
+ err = d.DecodeElement(&rcf, &tt)
+ smf.StreamErrorGroup = &rcf
+ case "resource-constraint":
+ rc := ResourceConstraint{}
+ err = d.DecodeElement(&rc, &tt)
+ smf.StreamErrorGroup = &rc
+ case "restricted-xml":
+ rx := RestrictedXML{}
+ err = d.DecodeElement(&rx, &tt)
+ smf.StreamErrorGroup = &rx
+ case "see-other-host":
+ soh := SeeOtherHost{}
+ err = d.DecodeElement(&soh, &tt)
+ smf.StreamErrorGroup = &soh
+ case "system-shutdown":
+ ss := SystemShutdown{}
+ err = d.DecodeElement(&ss, &tt)
+ smf.StreamErrorGroup = &ss
+ case "undefined-condition":
+ uc := UndefinedCondition{}
+ err = d.DecodeElement(&uc, &tt)
+ smf.StreamErrorGroup = &uc
+ case "unexpected-request":
+ ur := UnexpectedRequest{}
+ err = d.DecodeElement(&ur, &tt)
+ smf.StreamErrorGroup = &ur
+ case "unsupported-encoding":
+ ue := UnsupportedEncoding{}
+ err = d.DecodeElement(&ue, &tt)
+ smf.StreamErrorGroup = &ue
+ case "unsupported-stanza-type":
+ ust := UnsupportedStanzaType{}
+ err = d.DecodeElement(&ust, &tt)
+ smf.StreamErrorGroup = &ust
+ case "unsupported-version":
+ uv := UnsupportedVersion{}
+ err = d.DecodeElement(&uv, &tt)
+ smf.StreamErrorGroup = &uv
+ case "xml-not-well-formed":
+ xnwf := XMLNotWellFormed{}
+ err = d.DecodeElement(&xnwf, &tt)
+ smf.StreamErrorGroup = &xnwf
+ default:
+ return errors.New("error is unknown")
+ }
+ if err != nil {
+ return err
+ }
+ case xml.EndElement:
+ if tt == start.End() {
+ return nil
+ }
+ }
+ }
+}
+
+type smDecoder struct{}
+
+var sm smDecoder
+
+// decode decodes all known nonza in the stream management namespace.
+func (s smDecoder) decode(p *xml.Decoder, se xml.StartElement) (Packet, error) {
+ switch se.Name.Local {
+ case "enabled":
+ return s.decodeEnabled(p, se)
+ case "resumed":
+ return s.decodeResumed(p, se)
+ case "resume":
+ return s.decodeResume(p, se)
+ case "r":
+ return s.decodeRequest(p, se)
+ case "a":
+ return s.decodeAnswer(p, se)
+ case "failed":
+ return s.decodeFailed(p, se)
+ default:
+ return nil, errors.New("unexpected XMPP packet " +
+ se.Name.Space + " <" + se.Name.Local + "/>")
+ }
+}
+
+func (smDecoder) decodeEnabled(p *xml.Decoder, se xml.StartElement) (SMEnabled, error) {
+ var packet SMEnabled
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+func (smDecoder) decodeResumed(p *xml.Decoder, se xml.StartElement) (SMResumed, error) {
+ var packet SMResumed
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+func (smDecoder) decodeResume(p *xml.Decoder, se xml.StartElement) (SMResume, error) {
+ var packet SMResume
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+func (smDecoder) decodeRequest(p *xml.Decoder, se xml.StartElement) (SMRequest, error) {
+ var packet SMRequest
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+func (smDecoder) decodeAnswer(p *xml.Decoder, se xml.StartElement) (SMAnswer, error) {
+ var packet SMAnswer
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
+
+func (smDecoder) decodeFailed(p *xml.Decoder, se xml.StartElement) (SMFailed, error) {
+ var packet SMFailed
+ err := p.DecodeElement(&packet, &se)
+ return packet, err
+}
diff --git a/go-xmpp/stanza/stream_management_test.go b/go-xmpp/stanza/stream_management_test.go
new file mode 100644
index 0000000..1b3443e
--- /dev/null
+++ b/go-xmpp/stanza/stream_management_test.go
@@ -0,0 +1,226 @@
+package stanza_test
+
+import (
+ "gosrc.io/xmpp/stanza"
+ "math/rand"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestPopEmptyQueue(t *testing.T) {
+ var uaq stanza.UnAckQueue
+ popped := uaq.Pop()
+ if popped != nil {
+ t.Fatalf("queue is empty but something was popped !")
+ }
+}
+
+func TestPushUnack(t *testing.T) {
+ uaq := initUnAckQueue()
+ toPush := stanza.UnAckedStz{
+ Id: 3,
+ Stz: `
+
+ confucius
+ Qui
+ Kong
+
+`,
+ }
+
+ err := uaq.Push(&toPush)
+ if err != nil {
+ t.Fatalf("could not push element to the queue : %v", err)
+ }
+
+ if len(uaq.Uslice) != 4 {
+ t.Fatalf("push to the non-acked queue failed")
+ }
+ for i := 0; i < 4; i++ {
+ if uaq.Uslice[i].Id != i+1 {
+ t.Fatalf("indexes were not updated correctly. Expected %d got %d", i, uaq.Uslice[i].Id)
+ }
+ }
+
+ // Check that the queue is a fifo : popped element should not be the one we just pushed.
+ popped := uaq.Pop()
+ poppedElt, ok := popped.(*stanza.UnAckedStz)
+ if !ok {
+ t.Fatalf("popped element is not a *stanza.UnAckedStz")
+ }
+
+ if reflect.DeepEqual(*poppedElt, toPush) {
+ t.Fatalf("pushed element is at the top of the fifo queue when it should be at the bottom")
+ }
+
+}
+
+func TestPeekUnack(t *testing.T) {
+ uaq := initUnAckQueue()
+
+ expectedPeek := stanza.UnAckedStz{
+ Id: 1,
+ Stz: `
+
+ Capulet
+
+`,
+ }
+
+ if !reflect.DeepEqual(expectedPeek, *uaq.Uslice[0]) {
+ t.Fatalf("peek failed to return the correct stanza")
+ }
+
+}
+
+func TestPeekNUnack(t *testing.T) {
+ uaq := initUnAckQueue()
+ initLen := len(uaq.Uslice)
+ randPop := rand.Int31n(int32(initLen))
+
+ peeked := uaq.PeekN(int(randPop))
+
+ if len(uaq.Uslice) != initLen {
+ t.Fatalf("queue length changed whith peek n operation : had %d found %d after peek", initLen, len(uaq.Uslice))
+ }
+
+ if len(peeked) != int(randPop) {
+ t.Fatalf("did not peek the correct number of element from queue. Expected %d got %d", randPop, len(peeked))
+ }
+}
+
+func TestPeekNUnackTooLong(t *testing.T) {
+ uaq := initUnAckQueue()
+ initLen := len(uaq.Uslice)
+
+ // Have a random number of elements to peek that's greater than the queue size
+ randPop := rand.Int31n(int32(initLen)) + 1 + int32(initLen)
+
+ peeked := uaq.PeekN(int(randPop))
+
+ if len(uaq.Uslice) != initLen {
+ t.Fatalf("total length changed whith peek n operation : had %d found %d after pop", initLen, len(uaq.Uslice))
+ }
+
+ if len(peeked) != initLen {
+ t.Fatalf("did not peek the correct number of element from queue. Expected %d got %d", initLen, len(peeked))
+ }
+
+}
+
+func TestPopNUnack(t *testing.T) {
+ uaq := initUnAckQueue()
+ initLen := len(uaq.Uslice)
+ randPop := rand.Int31n(int32(initLen))
+
+ popped := uaq.PopN(int(randPop))
+
+ if len(uaq.Uslice)+len(popped) != initLen {
+ t.Fatalf("total length changed whith pop n operation : had %d found %d after pop", initLen, len(uaq.Uslice)+len(popped))
+ }
+
+ for _, elt := range popped {
+ for _, oldElt := range uaq.Uslice {
+ if reflect.DeepEqual(elt, oldElt) {
+ t.Fatalf("pop n operation duplicated some elements")
+ }
+ }
+ }
+}
+
+func TestPopNUnackTooLong(t *testing.T) {
+ uaq := initUnAckQueue()
+ initLen := len(uaq.Uslice)
+
+ // Have a random number of elements to pop that's greater than the queue size
+ randPop := rand.Int31n(int32(initLen)) + 1 + int32(initLen)
+
+ popped := uaq.PopN(int(randPop))
+
+ if len(uaq.Uslice)+len(popped) != initLen {
+ t.Fatalf("total length changed whith pop n operation : had %d found %d after pop", initLen, len(uaq.Uslice)+len(popped))
+ }
+
+ for _, elt := range popped {
+ for _, oldElt := range uaq.Uslice {
+ if reflect.DeepEqual(elt, oldElt) {
+ t.Fatalf("pop n operation duplicated some elements")
+ }
+ }
+ }
+}
+
+func TestPopUnack(t *testing.T) {
+ uaq := initUnAckQueue()
+ initLen := len(uaq.Uslice)
+
+ popped := uaq.Pop()
+
+ if len(uaq.Uslice)+1 != initLen {
+ t.Fatalf("total length changed whith pop operation : had %d found %d after pop", initLen, len(uaq.Uslice)+1)
+ }
+ for _, oldElt := range uaq.Uslice {
+ if reflect.DeepEqual(popped, oldElt) {
+ t.Fatalf("pop n operation duplicated some elements")
+ }
+ }
+
+}
+
+func initUnAckQueue() stanza.UnAckQueue {
+ q := []*stanza.UnAckedStz{
+ {
+ Id: 1,
+ Stz: `
+
+ Capulet
+
+`,
+ },
+ {Id: 2,
+ Stz: `
+
+`},
+ {Id: 3,
+ Stz: `
+
+
+
+ jabber:iq:search
+
+
+ male
+
+
+
+`},
+ }
+
+ return stanza.UnAckQueue{Uslice: q}
+
+}
+
+func init() {
+ rand.Seed(time.Now().UTC().UnixNano())
+}
diff --git a/go-xmpp/stanza/xmpp_test.go b/go-xmpp/stanza/xmpp_test.go
new file mode 100644
index 0000000..b39613b
--- /dev/null
+++ b/go-xmpp/stanza/xmpp_test.go
@@ -0,0 +1,81 @@
+package stanza_test
+
+import (
+ "encoding/xml"
+ "errors"
+ "regexp"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "gosrc.io/xmpp/stanza"
+)
+
+var reLeadcloseWhtsp = regexp.MustCompile(`^[\s\p{Zs}]+|[\s\p{Zs}]+$`)
+var reInsideWhtsp = regexp.MustCompile(`[\s\p{Zs}]`)
+
+// ============================================================================
+// Marshaller / unmarshaller test
+
+func checkMarshalling(t *testing.T, iq *stanza.IQ) (*stanza.IQ, error) {
+ // Marshall
+ data, err := xml.Marshal(iq)
+ if err != nil {
+ t.Errorf("cannot marshal iq: %s\n%#v", err, iq)
+ return nil, err
+ }
+
+ // Unmarshall
+ var parsedIQ stanza.IQ
+ err = xml.Unmarshal(data, &parsedIQ)
+ if err != nil {
+ t.Errorf("Unmarshal returned error: %s\n%s", err, data)
+ }
+ return &parsedIQ, err
+}
+
+// ============================================================================
+// XML structs comparison
+
+// Compare iq structure but ignore empty namespace as they are set properly on
+// marshal / unmarshal. There is no need to manage them on the manually
+// crafted structure.
+func xmlEqual(x, y interface{}) bool {
+ return cmp.Equal(x, y, xmlOpts())
+}
+
+// xmlDiff compares xml structures ignoring namespace preferences
+func xmlDiff(x, y interface{}) string {
+ return cmp.Diff(x, y, xmlOpts())
+}
+
+func xmlOpts() cmp.Options {
+ alwaysEqual := cmp.Comparer(func(_, _ interface{}) bool { return true })
+ opts := cmp.Options{
+ cmp.FilterValues(func(x, y interface{}) bool {
+ xx, xok := x.(xml.Name)
+ yy, yok := y.(xml.Name)
+ if xok && yok {
+ zero := xml.Name{}
+ if xx == zero || yy == zero {
+ return true
+ }
+ if xx.Space == "" || yy.Space == "" {
+ return true
+ }
+ }
+ return false
+ }, alwaysEqual),
+ }
+ return opts
+}
+
+func delSpaces(s string) string {
+ return reInsideWhtsp.ReplaceAllString(reLeadcloseWhtsp.ReplaceAllString(s, ""), "")
+}
+
+func compareMarshal(expected, data string) error {
+ if delSpaces(expected) != delSpaces(data) {
+ return errors.New("failed to verify unmarshal->marshal. Expected :" + expected + "\ngot: " + data)
+ }
+ return nil
+}
diff --git a/go-xmpp/stream_logger.go b/go-xmpp/stream_logger.go
new file mode 100644
index 0000000..0e6cc7b
--- /dev/null
+++ b/go-xmpp/stream_logger.go
@@ -0,0 +1,52 @@
+package xmpp
+
+import (
+ "io"
+)
+
+// Mediated Read / Write on socket
+// Used if logFile from Config is not nil
+type streamLogger struct {
+ socket io.ReadWriter // Actual connection
+ logFile io.Writer
+}
+
+func newStreamLogger(conn io.ReadWriter, logFile io.Writer) io.ReadWriter {
+ if logFile == nil {
+ return conn
+ } else {
+ return &streamLogger{conn, logFile}
+ }
+}
+
+func (sl *streamLogger) Read(p []byte) (n int, err error) {
+ n, err = sl.socket.Read(p)
+ if n > 0 {
+ sl.logFile.Write([]byte("RECV:\n")) // Prefix
+ if n, err := sl.logFile.Write(p[:n]); err != nil {
+ return n, err
+ }
+ sl.logFile.Write([]byte("\n\n")) // Separator
+ }
+ return
+}
+
+func (sl *streamLogger) Write(p []byte) (n int, err error) {
+ sl.logFile.Write([]byte("SEND:\n")) // Prefix
+ for _, w := range []io.Writer{sl.socket, sl.logFile} {
+ n, err = w.Write(p)
+ if err != nil {
+ return
+ }
+ if n != len(p) {
+ err = io.ErrShortWrite
+ return
+ }
+ }
+ sl.logFile.Write([]byte("\n\n")) // Separator
+ return len(p), nil
+}
+
+/*
+TODO: Make RECV, SEND prefixes +
+*/
diff --git a/go-xmpp/stream_manager.go b/go-xmpp/stream_manager.go
new file mode 100644
index 0000000..7bfb42c
--- /dev/null
+++ b/go-xmpp/stream_manager.go
@@ -0,0 +1,183 @@
+package xmpp
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "golang.org/x/xerrors"
+ "gosrc.io/xmpp/stanza"
+)
+
+// The Fluux XMPP lib can manage client or component XMPP streams.
+// The StreamManager handles the stream workflow handling the common
+// stream events and doing the right operations.
+//
+// It can handle:
+// - Client
+// - Stream establishment workflow
+// - Reconnection strategies, with exponential backoff. It also takes into account
+// permanent errors to avoid useless reconnection loops.
+// - Metrics processing
+
+// StreamClient is an interface used by StreamManager to control Client lifecycle,
+// set callback and trigger reconnection.
+type StreamClient interface {
+ Connect() error
+ Resume() error
+ Send(packet stanza.Packet) error
+ SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error)
+ SendRaw(packet string) error
+ Disconnect() error
+ SetHandler(handler EventHandler)
+}
+
+// Sender is an interface provided by Stream clients to allow sending XMPP data.
+// It is mostly use in callback to pass a limited subset of the stream client interface
+type Sender interface {
+ Send(packet stanza.Packet) error
+ SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error)
+ SendRaw(packet string) error
+}
+
+// StreamManager supervises an XMPP client connection. Its role is to handle connection events and
+// apply reconnection strategy.
+type StreamManager struct {
+ client StreamClient
+ PostConnect PostConnect
+
+ // Store low level metrics
+ Metrics *Metrics
+
+ wg sync.WaitGroup
+}
+
+type PostConnect func(c Sender)
+
+// NewStreamManager creates a new StreamManager structure, intended to support
+// handling XMPP client state event changes and auto-trigger reconnection
+// based on StreamManager configuration.
+// TODO: Move parameters to Start and remove factory method
+func NewStreamManager(client StreamClient, pc PostConnect) *StreamManager {
+ return &StreamManager{
+ client: client,
+ PostConnect: pc,
+ }
+}
+
+// Run launches the connection of the underlying client or component
+// and wait until Disconnect is called, or for the manager to terminate due
+// to an unrecoverable error.
+func (sm *StreamManager) Run() error {
+ if sm.client == nil {
+ return errors.New("missing stream client")
+ }
+
+ handler := func(e Event) error {
+ switch e.State.state {
+ case StateSessionEstablished:
+ sm.Metrics.setLoginTime()
+ case StateDisconnected:
+ // Reconnect on disconnection
+ return sm.resume()
+ case StateStreamError:
+ sm.client.Disconnect()
+ // Only try reconnecting if we have not been kicked by another session to avoid connection loop.
+ // TODO: Make this conflict exception a permanent error
+ if e.StreamError != "conflict" {
+ return sm.resume()
+ }
+ case StatePermanentError:
+ // Do not attempt to reconnect
+ }
+ return nil
+ }
+ sm.client.SetHandler(handler)
+
+ sm.wg.Add(1)
+ if err := sm.connect(); err != nil {
+ sm.wg.Done()
+ return err
+ }
+ sm.wg.Wait()
+ return nil
+}
+
+// Stop cancels pending operations and terminates existing XMPP client.
+func (sm *StreamManager) Stop() {
+ // Remove on disconnect handler to avoid triggering reconnect
+ sm.client.SetHandler(nil)
+ sm.client.Disconnect()
+ sm.wg.Done()
+}
+
+func (sm *StreamManager) connect() error {
+ if sm.client != nil {
+ if c, ok := sm.client.(*Client); ok {
+ if c.CurrentState.getState() == StateDisconnected {
+ sm.Metrics = initMetrics()
+ err := c.Connect()
+ if err != nil {
+ return err
+ }
+ if sm.PostConnect != nil {
+ sm.PostConnect(sm.client)
+ }
+ return nil
+ }
+ }
+ }
+ return errors.New("client is not disconnected")
+}
+
+// resume manages the reconnection loop and apply the define backoff to avoid overloading the server.
+func (sm *StreamManager) resume() error {
+ var backoff backoff // TODO: Group backoff calculation features with connection manager?
+
+ for {
+ var err error
+ // TODO: Make it possible to define logger to log disconnect and reconnection attempts
+ sm.Metrics = initMetrics()
+ if err = sm.client.Resume(); err != nil {
+ var actualErr ConnError
+ if xerrors.As(err, &actualErr) {
+ if actualErr.Permanent {
+ return xerrors.Errorf("unrecoverable connect error %#v", actualErr)
+ }
+ }
+ backoff.wait()
+ } else { // We are connected, we can leave the retry loop
+ break
+ }
+ }
+
+ if sm.PostConnect != nil {
+ sm.PostConnect(sm.client)
+ }
+ return nil
+}
+
+// Stream Metrics
+// ============================================================================
+
+type Metrics struct {
+ startTime time.Time
+ // LoginTime returns the between client initiation of the TCP/IP
+ // connection to the server and the return of the login result.
+ // This includes ConnectTime, but also XMPP level protocol negotiation
+ // like starttls.
+ LoginTime time.Duration
+}
+
+// initMetrics set metrics with default value and define the starting point
+// for duration calculation (connect time, login time, etc).
+func initMetrics() *Metrics {
+ return &Metrics{
+ startTime: time.Now(),
+ }
+}
+
+func (m *Metrics) setLoginTime() {
+ m.LoginTime = time.Since(m.startTime)
+}
diff --git a/go-xmpp/tcp_server_mock.go b/go-xmpp/tcp_server_mock.go
new file mode 100644
index 0000000..d189e3a
--- /dev/null
+++ b/go-xmpp/tcp_server_mock.go
@@ -0,0 +1,319 @@
+package xmpp
+
+import (
+ "encoding/xml"
+ "fmt"
+ "gosrc.io/xmpp/stanza"
+ "net"
+ "testing"
+ "time"
+)
+
+//=============================================================================
+// TCP Server Mock
+const (
+ defaultTimeout = 2 * time.Second
+ testComponentDomain = "localhost"
+ defaultServerName = "testServer"
+ defaultStreamID = "91bd0bba-012f-4d92-bb17-5fc41e6fe545"
+ defaultComponentName = "Test Component"
+ serverStreamOpen = ""
+
+ // Default port is not standard XMPP port to avoid interfering
+ // with local running XMPP server
+
+ // Component tests
+ testHandshakePort = iota + 15222
+ testDecoderPort
+ testSendIqPort
+ testSendIqFailPort
+ testSendRawPort
+ testDisconnectPort
+ testSManDisconnectPort
+
+ // Client tests
+ testClientBasePort
+ testClientRawPort
+ testClientIqPort
+ testClientIqFailPort
+ testClientPostConnectHook
+
+ // Client internal tests
+ testClientStreamManagement
+)
+
+// ClientHandler is passed by the test client to provide custom behaviour to
+// the TCP server mock. This allows customizing the server behaviour to allow
+// testing clients under various scenarii.
+type ClientHandler func(t *testing.T, serverConn *ServerConn)
+
+// ServerMock is a simple TCP server that can be use to mock basic server
+// behaviour to test clients.
+type ServerMock struct {
+ t *testing.T
+ handler ClientHandler
+ listener net.Listener
+ serverConnections []*ServerConn
+ done chan struct{}
+}
+
+type ServerConn struct {
+ connection net.Conn
+ decoder *xml.Decoder
+}
+
+// Start launches the mock TCP server, listening to an actual address / port.
+func (mock *ServerMock) Start(t *testing.T, addr string, handler ClientHandler) {
+ mock.t = t
+ mock.handler = handler
+ if err := mock.init(addr); err != nil {
+ return
+ }
+ go mock.loop()
+}
+
+func (mock *ServerMock) Stop() {
+ close(mock.done)
+ if mock.listener != nil {
+ mock.listener.Close()
+ }
+ // Close all existing serverConnections
+ for _, c := range mock.serverConnections {
+ c.connection.Close()
+ }
+}
+
+//=============================================================================
+// Mock Server internals
+
+// init starts listener on the provided address.
+func (mock *ServerMock) init(addr string) error {
+ mock.done = make(chan struct{})
+
+ l, err := net.Listen("tcp", addr)
+ if err != nil {
+ mock.t.Errorf("TCPServerMock cannot listen on address: %q", addr)
+ return err
+ }
+ mock.listener = l
+ return nil
+}
+
+// loop accepts serverConnections and creates a go routine per connection.
+// The go routine is running the client handler, that is used to provide the
+// real TCP server behaviour.
+func (mock *ServerMock) loop() {
+ listener := mock.listener
+ for {
+ conn, err := listener.Accept()
+ serverConn := &ServerConn{conn, xml.NewDecoder(conn)}
+ if err != nil {
+ select {
+ case <-mock.done:
+ return
+ default:
+ mock.t.Error("TCPServerMock accept error:", err.Error())
+ }
+ return
+ }
+ mock.serverConnections = append(mock.serverConnections, serverConn)
+
+ // TODO Create and pass a context to cancel the handler if they are still around = avoid possible leak on complex handlers
+ go mock.handler(mock.t, serverConn)
+ }
+}
+
+//======================================================================================================================
+// A few functions commonly used for tests. Trying to avoid duplicates in client and component test files.
+//======================================================================================================================
+
+func respondToIQ(t *testing.T, sc *ServerConn) {
+ // Decoder to parse the request
+ iqReq, err := receiveIq(sc)
+ if err != nil {
+ t.Fatalf("failed to receive IQ : %s", err.Error())
+ }
+
+ if vld, _ := iqReq.IsValid(); !vld {
+ mockIQError(sc.connection)
+ return
+ }
+
+ // Crafting response
+ iqResp, err := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: iqReq.To, To: iqReq.From, Id: iqReq.Id, Lang: "en"})
+ if err != nil {
+ t.Fatalf("failed to create iqResp: %v", err)
+ }
+ disco := iqResp.DiscoInfo()
+ disco.AddFeatures("vcard-temp",
+ `http://jabber.org/protocol/address`)
+
+ disco.AddIdentity("Multicast", "service", "multicast")
+ iqResp.Payload = disco
+
+ // Sending response to the Component
+ mResp, err := xml.Marshal(iqResp)
+ _, err = fmt.Fprintln(sc.connection, string(mResp))
+ if err != nil {
+ t.Errorf("Could not send response stanza : %s", err)
+ }
+ return
+}
+
+// When a presence stanza is automatically sent (right now it's the case in the client), we may want to discard it
+// and test further stanzas.
+func discardPresence(t *testing.T, sc *ServerConn) {
+ err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout))
+ if err != nil {
+ t.Fatalf("failed to set deadline: %v", err)
+ }
+ defer sc.connection.SetDeadline(time.Time{})
+ var presenceStz stanza.Presence
+
+ recvBuf := make([]byte, len(InitialPresence))
+ _, err = sc.connection.Read(recvBuf[:]) // recv data
+
+ if err != nil {
+ if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+ t.Errorf("read timeout: %s", err)
+ } else {
+ t.Errorf("read error: %s", err)
+ }
+ }
+ err = xml.Unmarshal(recvBuf, &presenceStz)
+
+ if err != nil {
+ t.Errorf("Expected presence but this happened : %s", err.Error())
+ }
+}
+
+// Reads next request coming from the Component. Expecting it to be an IQ request
+func receiveIq(sc *ServerConn) (*stanza.IQ, error) {
+ err := sc.connection.SetDeadline(time.Now().Add(defaultTimeout))
+ if err != nil {
+ return nil, err
+ }
+ defer sc.connection.SetDeadline(time.Time{})
+ var iqStz stanza.IQ
+ err = sc.decoder.Decode(&iqStz)
+ if err != nil {
+ return nil, err
+ }
+ return &iqStz, nil
+}
+
+// Should be used in server handlers when an IQ sent by a client or component is invalid.
+// This responds as expected from a "real" server, aside from the error message.
+func mockIQError(c net.Conn) {
+ s := stanza.StreamError{
+ XMLName: xml.Name{Local: "stream:error"},
+ Error: xml.Name{Local: "xml-not-well-formed"},
+ Text: `XML was not well-formed`,
+ }
+ raw, _ := xml.Marshal(s)
+ fmt.Fprintln(c, string(raw))
+ fmt.Fprintln(c, ``)
+}
+
+func sendStreamFeatures(t *testing.T, sc *ServerConn) {
+ // This is a basic server, supporting only 1 stream feature: SASL Plain Auth
+ features := `
+
+ PLAIN
+
+`
+ if _, err := fmt.Fprintln(sc.connection, features); err != nil {
+ t.Errorf("cannot send stream feature: %s", err)
+ }
+}
+
+// TODO return err in case of error reading the auth params
+func readAuth(t *testing.T, decoder *xml.Decoder) string {
+ se, err := stanza.NextStart(decoder)
+ if err != nil {
+ t.Errorf("cannot read auth: %s", err)
+ return ""
+ }
+
+ var nv interface{}
+ nv = &stanza.SASLAuth{}
+ // Decode element into pointer storage
+ if err = decoder.DecodeElement(nv, &se); err != nil {
+ t.Errorf("cannot decode auth: %s", err)
+ return ""
+ }
+
+ switch v := nv.(type) {
+ case *stanza.SASLAuth:
+ return v.Value
+ }
+ return ""
+}
+
+func sendBindFeature(t *testing.T, sc *ServerConn) {
+ // This is a basic server, supporting only 1 stream feature after auth: resource binding
+ features := `
+
+`
+ if _, err := fmt.Fprintln(sc.connection, features); err != nil {
+ t.Errorf("cannot send stream feature: %s", err)
+ }
+}
+
+func sendRFC3921Feature(t *testing.T, sc *ServerConn) {
+ // This is a basic server, supporting only 2 features after auth: resource & session binding
+ features := `
+
+
+`
+ if _, err := fmt.Fprintln(sc.connection, features); err != nil {
+ t.Errorf("cannot send stream feature: %s", err)
+ }
+}
+
+func bind(t *testing.T, sc *ServerConn) {
+ se, err := stanza.NextStart(sc.decoder)
+ if err != nil {
+ t.Errorf("cannot read bind: %s", err)
+ return
+ }
+
+ iq := &stanza.IQ{}
+ // Decode element into pointer storage
+ if err = sc.decoder.DecodeElement(&iq, &se); err != nil {
+ t.Errorf("cannot decode bind iq: %s", err)
+ return
+ }
+
+ // TODO Check all elements
+ switch iq.Payload.(type) {
+ case *stanza.Bind:
+ result := `
+
+ %s
+
+`
+ fmt.Fprintf(sc.connection, result, iq.Id, "test@localhost/test") // TODO use real Jid
+ }
+}
+
+func session(t *testing.T, sc *ServerConn) {
+ se, err := stanza.NextStart(sc.decoder)
+ if err != nil {
+ t.Errorf("cannot read session: %s", err)
+ return
+ }
+
+ iq := &stanza.IQ{}
+ // Decode element into pointer storage
+ if err = sc.decoder.DecodeElement(&iq, &se); err != nil {
+ t.Errorf("cannot decode session iq: %s", err)
+ return
+ }
+
+ switch iq.Payload.(type) {
+ case *stanza.StreamSession:
+ result := ``
+ fmt.Fprintf(sc.connection, result, iq.Id)
+ }
+}
diff --git a/go-xmpp/test.sh b/go-xmpp/test.sh
new file mode 100755
index 0000000..199c05e
--- /dev/null
+++ b/go-xmpp/test.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -e
+export GO111MODULE=on
+echo "" > coverage.txt
+
+for d in $(go list ./... | grep -v vendor); do
+ go test -race -coverprofile=profile.out -covermode=atomic "${d}"
+ if [ -f profile.out ]; then
+ cat profile.out >> coverage.txt
+ rm profile.out
+ fi
+done
diff --git a/go-xmpp/transport.go b/go-xmpp/transport.go
new file mode 100644
index 0000000..fa124ea
--- /dev/null
+++ b/go-xmpp/transport.go
@@ -0,0 +1,78 @@
+package xmpp
+
+import (
+ "crypto/tls"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+var ErrTransportProtocolNotSupported = errors.New("transport protocol not supported")
+var ErrTLSNotSupported = errors.New("transport does not support StartTLS")
+
+// TODO: rename to transport config?
+type TransportConfiguration struct {
+ // Address is the XMPP Host and port to connect to. Host is of
+ // the form 'serverhost:port' i.e "localhost:8888"
+ Address string
+ Domain string
+ ConnectTimeout int // Client timeout in seconds. Default to 15
+ // tls.Config must not be modified after having been passed to NewClient. Any
+ // changes made after connecting are ignored.
+ TLSConfig *tls.Config
+ CharsetReader func(charset string, input io.Reader) (io.Reader, error) // passed to xml decoder
+}
+
+type Transport interface {
+ Connect() (string, error)
+ DoesStartTLS() bool
+ StartTLS() error
+
+ LogTraffic(logFile io.Writer)
+
+ StartStream() (string, error)
+ GetDecoder() *xml.Decoder
+ IsSecure() bool
+
+ Ping() error
+ Read(p []byte) (n int, err error)
+ Write(p []byte) (n int, err error)
+ Close() error
+ // ReceivedStreamClose signals to the transport that a has been received and that the tcp connection
+ // should be closed.
+ ReceivedStreamClose()
+}
+
+// NewClientTransport creates a new Transport instance for clients.
+// The type of transport is determined by the address in the configuration:
+// - if the address is a URL with the `ws` or `wss` scheme WebsocketTransport is used
+// - in all other cases a XMPPTransport is used
+// For XMPPTransport it is mandatory for the address to have a port specified.
+func NewClientTransport(config TransportConfiguration) Transport {
+ if strings.HasPrefix(config.Address, "ws:") || strings.HasPrefix(config.Address, "wss:") {
+ return &WebsocketTransport{Config: config}
+ }
+
+ config.Address = ensurePort(config.Address, 5222)
+ return &XMPPTransport{
+ Config: config,
+ openStatement: clientStreamOpen,
+ }
+}
+
+// NewComponentTransport creates a new Transport instance for components.
+// Only XMPP transports are allowed. If you try to use any other protocol an error
+// will be returned.
+func NewComponentTransport(config TransportConfiguration) (Transport, error) {
+ if strings.HasPrefix(config.Address, "ws:") || strings.HasPrefix(config.Address, "wss:") {
+ return nil, fmt.Errorf("components only support XMPP transport: %w", ErrTransportProtocolNotSupported)
+ }
+
+ config.Address = ensurePort(config.Address, 5222)
+ return &XMPPTransport{
+ Config: config,
+ openStatement: componentStreamOpen,
+ }, nil
+}
diff --git a/go-xmpp/websocket_transport.go b/go-xmpp/websocket_transport.go
new file mode 100644
index 0000000..7631fc8
--- /dev/null
+++ b/go-xmpp/websocket_transport.go
@@ -0,0 +1,185 @@
+package xmpp
+
+import (
+ "bufio"
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "gosrc.io/xmpp/stanza"
+ "nhooyr.io/websocket"
+)
+
+const maxPacketSize = 32768
+
+const pingTimeout = time.Duration(5) * time.Second
+
+var ServerDoesNotSupportXmppOverWebsocket = errors.New("the websocket server does not support the xmpp subprotocol")
+
+// The decoder is expected to be initialized after connecting to a server.
+type WebsocketTransport struct {
+ Config TransportConfiguration
+ decoder *xml.Decoder
+ wsConn *websocket.Conn
+ queue chan []byte
+ logFile io.Writer
+
+ closeCtx context.Context
+ closeFunc context.CancelFunc
+}
+
+func (t *WebsocketTransport) Connect() (string, error) {
+ t.queue = make(chan []byte, 256)
+ t.closeCtx, t.closeFunc = context.WithCancel(context.Background())
+
+ var ctx context.Context
+ ctx = context.Background()
+ if t.Config.ConnectTimeout > 0 {
+ var cancelConnect context.CancelFunc
+ ctx, cancelConnect = context.WithTimeout(t.closeCtx, time.Duration(t.Config.ConnectTimeout)*time.Second)
+ defer cancelConnect()
+ }
+
+ wsConn, response, err := websocket.Dial(ctx, t.Config.Address, &websocket.DialOptions{
+ Subprotocols: []string{"xmpp"},
+ })
+
+ if err != nil {
+ return "", NewConnError(err, true)
+ }
+ if response.Header.Get("Sec-WebSocket-Protocol") != "xmpp" {
+ t.cleanup(websocket.StatusBadGateway)
+ return "", NewConnError(ServerDoesNotSupportXmppOverWebsocket, true)
+ }
+
+ wsConn.SetReadLimit(maxPacketSize)
+ t.wsConn = wsConn
+ t.startReader()
+
+ t.decoder = xml.NewDecoder(bufio.NewReaderSize(t, maxPacketSize))
+ t.decoder.CharsetReader = t.Config.CharsetReader
+
+ return t.StartStream()
+}
+
+func (t WebsocketTransport) StartStream() (string, error) {
+ if _, err := fmt.Fprintf(t, ``, t.Config.Domain); err != nil {
+ t.cleanup(websocket.StatusBadGateway)
+ return "", NewConnError(err, true)
+ }
+
+ sessionID, err := stanza.InitStream(t.GetDecoder())
+ if err != nil {
+ t.Close()
+ return "", NewConnError(err, false)
+ }
+ return sessionID, nil
+}
+
+// startReader runs a go function that keeps reading from the websocket. This
+// is required to allow Ping() to work: Ping requires a Reader to be running
+// to process incoming control frames.
+func (t WebsocketTransport) startReader() {
+ go func() {
+ buffer := make([]byte, maxPacketSize)
+ for {
+ _, reader, err := t.wsConn.Reader(t.closeCtx)
+ if err != nil {
+ return
+ }
+ n, err := reader.Read(buffer)
+ if err != nil && err != io.EOF {
+ return
+ }
+ if n > 0 {
+ // We need to make a copy, otherwise we will overwrite the slice content
+ // on the next iteration of the for loop.
+ tmp := make([]byte, n)
+ copy(tmp, buffer)
+ t.queue <- tmp
+ }
+ }
+ }()
+}
+
+func (t WebsocketTransport) StartTLS() error {
+ return ErrTLSNotSupported
+}
+
+func (t WebsocketTransport) DoesStartTLS() bool {
+ return false
+}
+
+func (t WebsocketTransport) GetDomain() string {
+ return t.Config.Domain
+}
+
+func (t WebsocketTransport) GetDecoder() *xml.Decoder {
+ return t.decoder
+}
+
+func (t WebsocketTransport) IsSecure() bool {
+ return strings.HasPrefix(t.Config.Address, "wss:")
+}
+
+func (t WebsocketTransport) Ping() error {
+ ctx, cancel := context.WithTimeout(t.closeCtx, pingTimeout)
+ defer cancel()
+ return t.wsConn.Ping(ctx)
+}
+
+func (t *WebsocketTransport) Read(p []byte) (int, error) {
+ select {
+ case <-t.closeCtx.Done():
+ return 0, t.closeCtx.Err()
+ case data := <-t.queue:
+ if t.logFile != nil && len(data) > 0 {
+ _, _ = fmt.Fprintf(t.logFile, "RECV:\n%s\n\n", data)
+ }
+ copy(p, data)
+ return len(data), nil
+ }
+}
+
+func (t WebsocketTransport) Write(p []byte) (int, error) {
+ if t.logFile != nil {
+ _, _ = fmt.Fprintf(t.logFile, "SEND:\n%s\n\n", p)
+ }
+ return len(p), t.wsConn.Write(t.closeCtx, websocket.MessageText, p)
+}
+
+func (t WebsocketTransport) Close() error {
+ t.Write([]byte(""))
+ return t.cleanup(websocket.StatusGoingAway)
+}
+
+func (t *WebsocketTransport) LogTraffic(logFile io.Writer) {
+ t.logFile = logFile
+}
+
+func (t *WebsocketTransport) cleanup(code websocket.StatusCode) error {
+ var err error
+ if t.queue != nil {
+ close(t.queue)
+ t.queue = nil
+ }
+ if t.wsConn != nil {
+ err = t.wsConn.Close(websocket.StatusGoingAway, "Done")
+ t.wsConn = nil
+ }
+ if t.closeFunc != nil {
+ t.closeFunc()
+ t.closeFunc = nil
+ t.closeCtx = nil
+ }
+ return err
+}
+
+// ReceivedStreamClose is not used for websockets for now
+func (t *WebsocketTransport) ReceivedStreamClose() {
+ return
+}
diff --git a/go-xmpp/xmpp_transport.go b/go-xmpp/xmpp_transport.go
new file mode 100644
index 0000000..c7d2022
--- /dev/null
+++ b/go-xmpp/xmpp_transport.go
@@ -0,0 +1,163 @@
+package xmpp
+
+import (
+ "bufio"
+ "crypto/tls"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "time"
+
+ "gosrc.io/xmpp/stanza"
+)
+
+// XMPPTransport implements the XMPP native TCP transport
+// The decoder is expected to be initialized after connecting to a server.
+type XMPPTransport struct {
+ openStatement string
+ Config TransportConfiguration
+ TLSConfig *tls.Config
+ decoder *xml.Decoder
+ conn net.Conn
+ readWriter io.ReadWriter
+ logFile io.Writer
+ isSecure bool
+ // Used to close TCP connection when a stream close message is received from the server
+ closeChan chan stanza.StreamClosePacket
+}
+
+var componentStreamOpen = fmt.Sprintf("", stanza.NSComponent, stanza.NSStream)
+
+var clientStreamOpen = fmt.Sprintf("", stanza.NSClient, stanza.NSStream)
+
+func (t *XMPPTransport) Connect() (string, error) {
+ var err error
+
+ // Since we're starting a new connection, reset the encryption status
+ t.isSecure = false
+
+ t.conn, err = net.DialTimeout("tcp", t.Config.Address, time.Duration(t.Config.ConnectTimeout)*time.Second)
+ if err != nil {
+ return "", NewConnError(err, true)
+ }
+
+ t.closeChan = make(chan stanza.StreamClosePacket, 1)
+ t.readWriter = newStreamLogger(t.conn, t.logFile)
+ t.decoder = xml.NewDecoder(bufio.NewReaderSize(t.readWriter, maxPacketSize))
+ t.decoder.CharsetReader = t.Config.CharsetReader
+ return t.StartStream()
+}
+
+func (t *XMPPTransport) StartStream() (string, error) {
+ if _, err := fmt.Fprintf(t, t.openStatement, t.Config.Domain); err != nil {
+ t.Close()
+ return "", NewConnError(err, true)
+ }
+
+ sessionID, err := stanza.InitStream(t.GetDecoder())
+ if err != nil {
+ t.Close()
+ return "", NewConnError(err, false)
+ }
+ return sessionID, nil
+}
+
+func (t *XMPPTransport) DoesStartTLS() bool {
+ return true
+}
+
+func (t *XMPPTransport) GetDomain() string {
+ return t.Config.Domain
+}
+
+func (t *XMPPTransport) GetDecoder() *xml.Decoder {
+ return t.decoder
+}
+
+func (t *XMPPTransport) IsSecure() bool {
+ return t.isSecure
+}
+
+func (t *XMPPTransport) StartTLS() error {
+ if t.Config.TLSConfig == nil {
+ t.TLSConfig = &tls.Config{}
+ } else {
+ t.TLSConfig = t.Config.TLSConfig.Clone()
+ }
+
+ if t.TLSConfig.ServerName == "" {
+ t.TLSConfig.ServerName = t.Config.Domain
+ }
+ tlsConn := tls.Client(t.conn, t.TLSConfig)
+ // We convert existing connection to TLS
+ if err := tlsConn.Handshake(); err != nil {
+ return err
+ }
+
+ t.isSecure = false
+ t.conn = tlsConn
+ t.readWriter = newStreamLogger(tlsConn, t.logFile)
+ t.decoder = xml.NewDecoder(bufio.NewReaderSize(t.readWriter, maxPacketSize))
+ t.decoder.CharsetReader = t.Config.CharsetReader
+
+ if !t.TLSConfig.InsecureSkipVerify {
+ if err := tlsConn.VerifyHostname(t.Config.Domain); err != nil {
+ return err
+ }
+ }
+
+ t.isSecure = true
+ return nil
+}
+
+func (t *XMPPTransport) Ping() error {
+ n, err := t.conn.Write([]byte("\n"))
+ if err != nil {
+ return err
+ }
+ if n != 1 {
+ return errors.New("could not write ping")
+ }
+ return nil
+}
+
+func (t *XMPPTransport) Read(p []byte) (n int, err error) {
+ if t.readWriter == nil {
+ return 0, errors.New("cannot read: not connected, no readwriter")
+ }
+ return t.readWriter.Read(p)
+}
+
+func (t *XMPPTransport) Write(p []byte) (n int, err error) {
+ if t.readWriter == nil {
+ return 0, errors.New("cannot write: not connected, no readwriter")
+ }
+ return t.readWriter.Write(p)
+}
+
+func (t *XMPPTransport) Close() error {
+ if t.readWriter != nil {
+ _, _ = t.readWriter.Write([]byte(stanza.StreamClose))
+ }
+
+ // Try to wait for the stream close tag from the server. After a timeout, disconnect anyway.
+ select {
+ case <-t.closeChan:
+ case <-time.After(time.Duration(t.Config.ConnectTimeout) * time.Second):
+ }
+
+ if t.conn != nil {
+ return t.conn.Close()
+ }
+ return nil
+}
+
+func (t *XMPPTransport) LogTraffic(logFile io.Writer) {
+ t.logFile = logFile
+}
+
+func (t *XMPPTransport) ReceivedStreamClose() {
+ t.closeChan <- stanza.StreamClosePacket{}
+}