Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546234eacf | ||
|
|
7a822e8eaf | ||
|
|
b07f92853d | ||
|
|
3bb74eb672 | ||
|
|
8cf0f84ddc | ||
|
|
006b43a590 | ||
|
|
120b0c2647 | ||
|
|
a3702a900c | ||
|
|
ede81d551c | ||
|
|
7c19abb6ae | ||
|
|
8f93dde120 | ||
|
|
ca7b82dab5 | ||
|
|
fce8df9e84 | ||
|
|
1597f99eef | ||
|
|
9ad48ff310 | ||
|
|
af0ec78eae | ||
|
|
5f95df147f | ||
|
|
98cd855349 | ||
|
|
1aa14f9fd5 | ||
|
|
39cdd5e6c1 | ||
|
|
9bbefbcea8 | ||
|
|
9593e7c041 | ||
|
|
82aa2abfbd | ||
|
|
58c7165ce5 | ||
|
|
5bcf0eda0b | ||
|
|
0bfb140dc7 | ||
|
|
7b63799f0b | ||
|
|
a97c42323c | ||
|
|
fc0ed5ac2c | ||
|
|
69994d9856 | ||
|
|
843687ff2b | ||
|
|
7416aa37e0 | ||
|
|
ce83000e0c | ||
|
|
359e8ed63e | ||
|
|
3b9df94cf6 | ||
|
|
634f451595 | ||
|
|
00d7945f70 | ||
|
|
60d6a287e5 | ||
|
|
d5981b7475 | ||
|
|
523722bae6 | ||
|
|
71e6a58fbd | ||
|
|
a7e90e4ae5 | ||
|
|
654ab8b618 | ||
|
|
7a201808e3 | ||
|
|
77e4e444d4 | ||
|
|
bf1685a382 | ||
|
|
0ac43946b1 | ||
|
|
15ff7a20db | ||
|
|
1dd3f09fed | ||
|
|
87bdbc440a | ||
|
|
62b5a9db72 | ||
|
|
39156af48a | ||
|
|
3f40d3da29 | ||
|
|
a7a49f7441 | ||
|
|
e026e777f6 | ||
|
|
09a809c102 | ||
|
|
29ef37e237 | ||
|
|
713cb24508 | ||
|
|
589101c292 | ||
|
|
f807565cb2 | ||
|
|
6626d35920 | ||
|
|
5c76729a6b | ||
|
|
c260b8b231 | ||
|
|
971147dcb8 |
@@ -0,0 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
As you might have gathered, this Gitea instance does not allow registrations. If you want to have an account on this instance in order to contribute to this project, you must contact me for an account. I am available on:
|
||||
|
||||
- XMPP: `x@sunglocto.net`
|
||||
- Matrix: `@x:sunglocto.net`
|
||||
- E-Mail: sunglocto <img width=16px height=16px src="https://sunglocto.net/icons/email.gif"/> protonmail.com
|
||||
|
||||
For actually submitting your patches, you can just fork this repo and submit a pull request. I currently have no contribution policy that you have to follow however if you just use common sense you should be good.
|
||||
|
||||
## AI contributions
|
||||
|
||||
Obviously vibe-coded or fully AI-generated contributions will be immediately rejected. This is in order to ensure the stability of the project and decrease the chance of significant bugs or vulnerabilities.
|
||||
|
||||
AI contributions that you have reviewed, edited and **ensured that they compile, run and work successfully** are acceptable. AI is a tool, and like any other tools, can be used for good if used in moderation.
|
||||
@@ -1,3 +1,7 @@
|
||||
# lambda
|
||||
|
||||
an XMPP client
|
||||
|
||||
icons are from Psi+ ([https://github.com/psi-im](https://github.com/psi-im))
|
||||
|
||||
additional icons are by Mark James's Silk Icon Set [https://github.com/markjames/famfamfam-silk-icons](https://github.com/markjames/famfamfam-silk-icons)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# TODO
|
||||
- XEP-0393: Message Styling 0%
|
||||
- XEP-0402: PEP Native Bookmarks 0%
|
||||
- XEP-0066: Out of Band Data partial
|
||||
- XEP-0461: Message Replies partial
|
||||
- XEP-0402: PEP Native Bookmarks 50% (often lags out client if enabled)
|
||||
- XEP-0461: Message Replies 0%
|
||||
- XEP-0444: Message Reactions partial
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"github.com/diamondburned/gotk4/pkg/gdk/v4"
|
||||
"github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed debug.png
|
||||
var defaultAvatarBytes []byte
|
||||
|
||||
//go:embed failed_load.png
|
||||
var failedBytes []byte
|
||||
|
||||
//go:embed assets/owner.png
|
||||
var ownerMedalBytes []byte
|
||||
|
||||
//go:embed assets/admin.png
|
||||
var adminMedalBytes []byte
|
||||
|
||||
//go:embed assets/member.png
|
||||
var memberMedalBytes []byte
|
||||
|
||||
//go:embed assets/noaffiliation.png
|
||||
var noneMedalBytes []byte
|
||||
|
||||
//go:embed assets/outcast.png
|
||||
var outcastMedalBytes []byte
|
||||
|
||||
//go:embed assets/cancel.png
|
||||
var cancelBytes []byte
|
||||
|
||||
//go:embed assets/status_away.png
|
||||
var sABytes []byte
|
||||
|
||||
//go:embed assets/status_busy.png
|
||||
var sBBytes []byte
|
||||
|
||||
//go:embed assets/status_chatty.png
|
||||
var sCBytes []byte
|
||||
|
||||
//go:embed assets/status_online.png
|
||||
var sOBytes []byte
|
||||
|
||||
//go:embed assets/status_xa.png
|
||||
var xaBytes []byte
|
||||
|
||||
//go:embed assets/tag.png
|
||||
var tagBytes []byte
|
||||
|
||||
//go:embed assets/lambda-disabled.png
|
||||
var logoDisabledBytes []byte
|
||||
|
||||
//go:embed assets/group.png
|
||||
var groupBytes []byte
|
||||
|
||||
//go:embed assets/door_in.png
|
||||
var doorInBytes []byte
|
||||
|
||||
//go:embed assets/door_out.png
|
||||
var doorOutBytes []byte
|
||||
|
||||
//go:embed assets/large_group.png
|
||||
var largeGroupBytes []byte
|
||||
|
||||
//go:embed assets/world.png
|
||||
var worldBytes []byte
|
||||
|
||||
//go:embed assets/disconnect.png
|
||||
var disconnectBytes []byte
|
||||
|
||||
//go:embed assets/chart_bar.png
|
||||
var barBytes []byte
|
||||
|
||||
//go:embed assets/chart_bar_laggy.png
|
||||
var barLaggyBytes []byte
|
||||
|
||||
//go:embed assets/ok.png
|
||||
var okBytes []byte
|
||||
|
||||
//go:embed assets/hourglass.png
|
||||
var hourglassBytes []byte
|
||||
|
||||
//go:embed assets/connect_tls.png
|
||||
var connectBytes []byte
|
||||
|
||||
//go:embed assets/comment.png
|
||||
var commentBytes []byte
|
||||
|
||||
//go:embed assets/information.png
|
||||
var informationBytes []byte
|
||||
|
||||
//go:embed assets/car.png
|
||||
var carBytes []byte
|
||||
|
||||
//go:embed assets/car_high.png
|
||||
var carHighBytes []byte
|
||||
|
||||
// muc icons
|
||||
|
||||
//go:embed assets/muc_open.png
|
||||
var mucOpenBytes []byte
|
||||
|
||||
//go:embed assets/muc_membersonly.png
|
||||
var mucMembersOnlyBytes []byte
|
||||
|
||||
//go:embed assets/muc_passwordprotected.png
|
||||
var mucPasswordProtectedBytes []byte
|
||||
|
||||
//go:embed assets/muc_unsecured.png
|
||||
var mucUnsecuredBytes []byte
|
||||
|
||||
//go:embed assets/muc_hidden.png
|
||||
var mucHiddenBytes []byte
|
||||
|
||||
//go:embed assets/muc_public.png
|
||||
var mucPublicBytes []byte
|
||||
|
||||
//go:embed assets/muc_unmoderated.png
|
||||
var mucUnmoderatedBytes []byte
|
||||
|
||||
//go:embed assets/muc_moderated.png
|
||||
var mucModeratedBytes []byte
|
||||
|
||||
//go:embed assets/muc_nonanonymous.png
|
||||
var mucNonAnonymousBytes []byte
|
||||
|
||||
//go:embed assets/muc_semianonymous.png
|
||||
var mucSemiAnonymousBytes []byte
|
||||
|
||||
//go:embed assets/muc_persistent.png
|
||||
var mucPersistentBytes []byte
|
||||
|
||||
//go:embed assets/muc_temporary.png
|
||||
var mucTemporaryBytes []byte
|
||||
|
||||
//go:embed assets/moderate.png
|
||||
var moderateBytes []byte
|
||||
|
||||
//go:embed assets/jabber.png
|
||||
var jabberBytes []byte
|
||||
|
||||
//go:embed assets/fail.png
|
||||
var failBytes []byte
|
||||
|
||||
//go:embed assets/please_wait.gif
|
||||
var pleaseWaitBytes []byte
|
||||
|
||||
//go:embed assets/update.png
|
||||
var updateBytes []byte
|
||||
|
||||
//go:embed assets/solar.png
|
||||
var solarBytes []byte
|
||||
|
||||
// var clientAssets map[string]gdk.Paintabler = make(map[string]gdk.Paintabler)
|
||||
var clientAssets sync.Map
|
||||
|
||||
func loadAsset(key string, data []byte) {
|
||||
loader := gdkpixbuf.NewPixbufLoader()
|
||||
loader.Write(data)
|
||||
loader.Close()
|
||||
clientAssets.Store(key, gdk.NewTextureForPixbuf(loader.Pixbuf()))
|
||||
}
|
||||
|
||||
func clientAssetsLoad(key string) gdk.Paintabler {
|
||||
dat, ok := clientAssets.Load(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
paintable, ok := dat.(gdk.Paintabler)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return paintable
|
||||
}
|
||||
|
||||
func init() {
|
||||
for key, data := range map[string][]byte{
|
||||
"DefaultAvatar": defaultAvatarBytes,
|
||||
"FailedAvatar": failedBytes,
|
||||
"owner": ownerMedalBytes,
|
||||
"admin": adminMedalBytes,
|
||||
"member": memberMedalBytes,
|
||||
"none": noneMedalBytes,
|
||||
"outcast": outcastMedalBytes,
|
||||
"cancel": cancelBytes,
|
||||
"tag": tagBytes,
|
||||
"disabled_logo": logoDisabledBytes,
|
||||
"group": groupBytes,
|
||||
"door_in": doorInBytes,
|
||||
"door_out": doorOutBytes,
|
||||
"large_group": largeGroupBytes,
|
||||
"world": worldBytes,
|
||||
"disconnect": disconnectBytes,
|
||||
"chart_bar": barBytes,
|
||||
"chart_bar_laggy": barLaggyBytes,
|
||||
"ok": okBytes,
|
||||
"hourglass": hourglassBytes,
|
||||
"connect": connectBytes,
|
||||
"comment": commentBytes,
|
||||
"information": informationBytes,
|
||||
"status_away": sABytes,
|
||||
"status_dnd": sBBytes,
|
||||
"status_chat": sCBytes,
|
||||
"status_xa": xaBytes,
|
||||
"status_": sOBytes,
|
||||
"car": carBytes,
|
||||
"car_high": carHighBytes,
|
||||
"muc_open": mucOpenBytes,
|
||||
"muc_membersonly": mucMembersOnlyBytes,
|
||||
"muc_passwordprotected": mucPasswordProtectedBytes,
|
||||
"muc_unsecured": mucUnsecuredBytes,
|
||||
"muc_hidden": mucHiddenBytes,
|
||||
"muc_public": mucPublicBytes,
|
||||
"muc_unmoderated": mucUnmoderatedBytes,
|
||||
"muc_moderated": mucModeratedBytes,
|
||||
"muc_nonanonymous": mucNonAnonymousBytes,
|
||||
"muc_semianonymous": mucSemiAnonymousBytes,
|
||||
"muc_persistent": mucPersistentBytes,
|
||||
"muc_temporary": mucTemporaryBytes,
|
||||
"moderate": moderateBytes,
|
||||
"jabber": jabberBytes,
|
||||
"fail": failBytes,
|
||||
"please_wait": pleaseWaitBytes,
|
||||
"update": updateBytes,
|
||||
"solar": solarBytes,
|
||||
} {
|
||||
loadAsset(key, data)
|
||||
}
|
||||
|
||||
}
|
||||
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 607 B |
|
After Width: | Height: | Size: 671 B |
|
After Width: | Height: | Size: 656 B |
|
After Width: | Height: | Size: 456 B |
|
After Width: | Height: | Size: 641 B |
|
After Width: | Height: | Size: 541 B |
|
After Width: | Height: | Size: 434 B |
@@ -0,0 +1 @@
|
||||
All client assets are owned by their respective owners
|
||||
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 796 B |
|
After Width: | Height: | Size: 693 B |
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 753 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 730 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 747 B |
|
After Width: | Height: | Size: 793 B |
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 935 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 970 B |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 612 B |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 622 B |
|
After Width: | Height: | Size: 924 B |
|
After Width: | Height: | Size: 882 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 460 B |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 543 B |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 910 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 923 B |
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/diamondburned/gotk4/pkg/gdk/v4"
|
||||
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||||
@@ -13,10 +14,17 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// global or app-level map/cache
|
||||
var textureCache = make(map[string]gdk.Paintabler)
|
||||
// var textureCache = make(map[string]gdk.Paintabler)
|
||||
var textureCache sync.Map
|
||||
|
||||
// Invalid images, if an image/avatar cannot be loaded on the system (e.g: incompatible format) it's put here
|
||||
// var invalidImages = make(map[string]bool)
|
||||
|
||||
var invalidImages sync.Map
|
||||
|
||||
func ensureCache() (string, error) {
|
||||
cachePath := configdir.LocalCache("lambda-im")
|
||||
@@ -28,49 +36,60 @@ func ensureCache() (string, error) {
|
||||
return cachePath, nil
|
||||
}
|
||||
|
||||
func getTexture(path string) gdk.Paintabler {
|
||||
if tex, exists := textureCache[path]; exists {
|
||||
return tex
|
||||
func getTexture(path string) (gdk.Paintabler, error) {
|
||||
tex, exists := textureCache.Load(path)
|
||||
if exists {
|
||||
return tex.(gdk.Paintabler), nil
|
||||
}
|
||||
|
||||
tex, err := gdk.NewTextureFromFilename(path) // load once
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
textureCache[path] = tex
|
||||
return tex
|
||||
textureCache.Store(path, tex)
|
||||
t, ok := tex.(gdk.Paintabler)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid type")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func newPictureFromPath(path string) *gtk.Picture {
|
||||
tex := getTexture(path)
|
||||
func newPictureFromPath(path string) (*gtk.Picture, error) {
|
||||
tex, err := getTexture(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := gtk.NewPictureForPaintable(tex)
|
||||
return img
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func newImageFromPath(path string) *gtk.Image {
|
||||
tex := getTexture(path)
|
||||
img := gtk.NewImageFromPaintable(tex)
|
||||
return img
|
||||
func newImageFromPath(path string) (*gtk.Image, error) {
|
||||
tex, err := getTexture(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gtk.NewImageFromPaintable(tex), nil
|
||||
}
|
||||
|
||||
func newPictureFromWeb(url string) *gtk.Picture {
|
||||
func newPictureFromWeb(url string) (*gtk.Picture, error) {
|
||||
pa, _ := ensureCache()
|
||||
// step 1: get a sha256 sum of the URL
|
||||
sum := fmt.Sprintf("%x", sha256.Sum256([]byte(url)))
|
||||
|
||||
p, ok := textureCache[sum]
|
||||
p, ok := textureCache.Load(sum)
|
||||
if ok {
|
||||
return gtk.NewPictureForPaintable(p)
|
||||
return gtk.NewPictureForPaintable(p.(gdk.Paintabler)), nil
|
||||
}
|
||||
|
||||
// step 2: download it
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullpath := filepath.Join(pa, sum)
|
||||
@@ -78,31 +97,31 @@ func newPictureFromWeb(url string) *gtk.Picture {
|
||||
// step 3: save it
|
||||
err = os.WriteFile(fullpath, b, 0644)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newPictureFromPath(fullpath)
|
||||
}
|
||||
|
||||
func newImageFromWeb(url string) *gtk.Image {
|
||||
func newImageFromWeb(url string) (*gtk.Image, error) {
|
||||
pa, _ := ensureCache()
|
||||
// step 1: get a sha256 sum of the URL
|
||||
sum := fmt.Sprintf("%x", sha256.Sum256([]byte(url)))
|
||||
|
||||
p, ok := textureCache[sum]
|
||||
p, ok := textureCache.Load(sum)
|
||||
if ok {
|
||||
return gtk.NewImageFromPaintable(p)
|
||||
return gtk.NewImageFromPaintable(p.(gdk.Paintabler)), nil
|
||||
}
|
||||
|
||||
// step 2: download it
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullpath := filepath.Join(pa, sum)
|
||||
@@ -110,7 +129,7 @@ func newImageFromWeb(url string) *gtk.Image {
|
||||
// step 3: save it
|
||||
err = os.WriteFile(fullpath, b, 0644)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newImageFromPath(fullpath)
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
## <a name="coc"></a> Code of Conduct
|
||||
|
||||
Help us keep our community open-minded and inclusive. Please read and follow our [Code of Conduct][coc].
|
||||
|
||||
## <a name="requests"></a> Questions, Bugs, Features
|
||||
|
||||
### <a name="question"></a> 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.
|
||||
|
||||
### <a name="issue"></a> 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.
|
||||
|
||||
### <a name="feature"></a> 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).
|
||||
|
||||
## <a name="submit"></a> 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.
|
||||
|
||||
## <a name="submit-pr"></a> 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!
|
||||
|
||||
## <a name="cla"></a> 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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)).
|
||||
@@ -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
|
||||
// <message to='service.localhost' from='localhost'><privilege xmlns='urn:xmpp:privilege:1'><perm type='outgoing' access='message'/><perm type='get' access='roster'/><perm type='managed_entity' access='presence'/></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
|
||||
}
|
||||
}
|
||||
@@ -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 => ./../
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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".
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
<iq id="1" to="MY_USERE@MY_DOMAIN/jukebox" type="set">
|
||||
<set xml:lang="en" xmlns="urn:xmpp:iot:control">
|
||||
<string name="url" value="https://soundcloud.com/UPDATE/ME"/>
|
||||
</set>
|
||||
</iq>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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, "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
||||
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
|
||||
}
|
||||
@@ -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 = "<presence/>"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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(`<iq id='ls72g593' type='get'>
|
||||
<query xmlns='jabber:iq:roster'/>
|
||||
</iq>
|
||||
`)
|
||||
|
||||
// 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 := `<stream:features>
|
||||
<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'/>
|
||||
<sm xmlns='urn:xmpp:sm:3'/>
|
||||
</stream: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 := `<stream:features>
|
||||
<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'/>
|
||||
</stream: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")
|
||||
}
|
||||
}
|
||||
@@ -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: `<iq type="get" id="91bd0bba-012f-4d92-bb17-5fc41e6fe545" from="test1@localhost/mremond-mbp" to="testServer" lang="en"><query xmlns="http://jabber.org/protocol/disco#info"></query></iq>`,
|
||||
shouldErr: false,
|
||||
port: testClientRawPort + 100,
|
||||
}
|
||||
// Sending an IQ with a missing ID. Should err
|
||||
testRequests["IQ with missing ID"] = testCase{
|
||||
req: `<iq type="get" from="test1@localhost/mremond-mbp" to="testServer" lang="en"><query xmlns="http://jabber.org/protocol/disco#info"></query></iq>`,
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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("<success xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\"/>"))
|
||||
|
||||
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 <stream> 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) {
|
||||
}
|
||||
@@ -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 <host[:port]> [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 <recipient,> [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;
|
||||
```
|
||||
@@ -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 ?)
|
||||
@@ -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 <host[:port]>",
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
|
||||
fluuxmpp: fluuxIO's xmpp comandline tool
|
||||
*/
|
||||
package main
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||