From 40ef25767271bf7128e83af18b6ff923f71abc2d Mon Sep 17 00:00:00 2001 From: Martin/Geno Date: Tue, 6 Aug 2019 20:31:58 +0200 Subject: [PATCH] init --- .ci/check-gofmt | 8 +++++ .ci/check-testfiles | 25 ++++++++++++++++ .gitignore | 1 + .gitlab-ci.yml | 53 +++++++++++++++++++++++++++++++++ .test-coverage | 32 ++++++++++++++++++++ LICENSE.md | 21 +++++++++++++ README.md | 52 ++++++++++++++++++++++++++++++++ config.go | 19 ++++++++++++ config.toml | 11 +++++++ config_example.toml | 64 ++++++++++++++++++++++++++++++++++++++++ main.go | 70 ++++++++++++++++++++++++++++++++++++++++++++ runtime/muc.go | 41 ++++++++++++++++++++++++++ runtime/send.go | 70 ++++++++++++++++++++++++++++++++++++++++++++ runtime/xmpp_test.go | 1 + schalter/bot.go | 26 ++++++++++++++++ xmpp.go | 58 ++++++++++++++++++++++++++++++++++++ 16 files changed, 552 insertions(+) create mode 100755 .ci/check-gofmt create mode 100755 .ci/check-testfiles create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100755 .test-coverage create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 config.go create mode 100644 config.toml create mode 100644 config_example.toml create mode 100644 main.go create mode 100644 runtime/muc.go create mode 100644 runtime/send.go create mode 100644 runtime/xmpp_test.go create mode 100644 schalter/bot.go create mode 100644 xmpp.go diff --git a/.ci/check-gofmt b/.ci/check-gofmt new file mode 100755 index 0000000..4a1c0b2 --- /dev/null +++ b/.ci/check-gofmt @@ -0,0 +1,8 @@ +#!/bin/bash + +result="$(gofmt -s -l . | grep -v '^vendor/' )" +if [ -n "$result" ]; then + echo "Go code is not formatted, run 'gofmt -s -w .'" >&2 + echo "$result" + exit 1 +fi diff --git a/.ci/check-testfiles b/.ci/check-testfiles new file mode 100755 index 0000000..132ff73 --- /dev/null +++ b/.ci/check-testfiles @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# checks if every desired package has test files + +import os +import re +import sys + +source_re = re.compile(".*\.go") +test_re = re.compile(".*_test\.go") +missing = False + +for root, dirs, files in os.walk("."): + # ignore some paths + if root == "." or root.startswith("./vendor") or root.startswith("./."): + continue + + # source files but not test files? + if len(filter(source_re.match, files)) > 0 and len(filter(test_re.match, files)) == 0: + print("no test files for {}".format(root)) + missing = True + +if missing: + sys.exit(1) +else: + print("every package has test files") diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83274a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.conf diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..9854788 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,53 @@ +image: golang:latest +stages: + - build + - test + - deploy + +before_script: + - mkdir -p "/go/src/dev.sum7.eu/$CI_PROJECT_NAMESPACE/" + - cp -R "$CI_PROJECT_DIR" "/go/src/dev.sum7.eu/$CI_PROJECT_NAMESPACE/" + - cd "/go/src/dev.sum7.eu/$CI_PROJECT_PATH" + - go get -d -t ./... + +build-my-project: + stage: build + script: + - mkdir "$CI_PROJECT_DIR/bin/" + - go install "dev.sum7.eu/$CI_PROJECT_PATH" + - mv "/go/bin/$CI_PROJECT_NAME" "$CI_PROJECT_DIR/bin/$CI_PROJECT_NAME" + artifacts: + paths: + - "bin/$CI_PROJECT_NAME" + - config_example.toml + +test-my-project: + stage: test + script: + - go get github.com/client9/misspell/cmd/misspell + - misspell -error . + - ./.ci/check-gofmt + - ./.ci/check-testfiles + - go test $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt + - go tool cover -func=.testCoverage.txt + artifacts: + paths: + - .testCoverage.txt + +test-race-my-project: + stage: test + script: + - go test -race ./... + +deploy: + stage: deploy + only: + - master + script: + - go install "dev.sum7.eu/$CI_PROJECT_PATH" + - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null + - ssh -o StrictHostKeyChecking=no -p $SSH_PORT "$CI_PROJECT_NAME@$SSH_HOST" sudo /usr/bin/systemctl stop $CI_PROJECT_NAME + - scp -o StrictHostKeyChecking=no -P $SSH_PORT "/go/bin/$CI_PROJECT_NAME" "$CI_PROJECT_NAME@$SSH_HOST":/opt/$CI_PROJECT_NAME/bin + - ssh -o StrictHostKeyChecking=no -p $SSH_PORT "$CI_PROJECT_NAME@$SSH_HOST" sudo /usr/bin/systemctl start $CI_PROJECT_NAME diff --git a/.test-coverage b/.test-coverage new file mode 100755 index 0000000..cd46eda --- /dev/null +++ b/.test-coverage @@ -0,0 +1,32 @@ +#!/bin/bash +# Issue: https://github.com/mattn/goveralls/issues/20 +# Source: https://github.com/uber/go-torch/blob/63da5d33a225c195fea84610e2456d5f722f3963/.test-cover.sh +CI=$1 +echo "run for $CI" + +if [ "$CI" == "circle-ci" ]; then + cd ${GOPATH}/src/github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME} +fi + +echo "mode: count" > profile.cov +FAIL=0 + +# Standard go tooling behavior is to ignore dirs with leading underscors +for dir in $(find . -maxdepth 10 -not -path './vendor/*' -not -path './.git*' -not -path '*/_*' -type d); +do + if ls $dir/*.go &> /dev/null; then + go test -v -covermode=count -coverprofile=profile.tmp $dir || FAIL=$? + if [ -f profile.tmp ] + then + tail -n +2 < profile.tmp >> profile.cov + rm profile.tmp + fi + fi +done + +# Failures have incomplete results, so don't send +if [ "$FAIL" -eq 0 ]; then + goveralls -v -coverprofile=profile.cov -service=$CI -repotoken=$COVERALLS_REPO_TOKEN +fi + +exit $FAIL diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..05d9023 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 genofire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd1197f --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# hook2xmpp + + +[![pipeline status](https://dev.sum7.eu/genofire/hook2xmpp/badges/master/pipeline.svg)](https://dev.sum7.eu/genofire/hook2xmpp/pipelines) +[![coverage report](https://dev.sum7.eu/genofire/hook2xmpp/badges/master/coverage.svg)](https://dev.sum7.eu/genofire/hook2xmpp/pipelines) +[![Go Report Card](https://goreportcard.com/badge/dev.sum7.eu/genofire/hook2xmpp)](https://goreportcard.com/report/dev.sum7.eu/genofire/hook2xmpp) +[![GoDoc](https://godoc.org/dev.sum7.eu/genofire/hook2xmpp?status.svg)](https://godoc.org/dev.sum7.eu/genofire/hook2xmpp) + + +## Get hook2xmpp + +#### Download + +Latest Build binary from ci here: + +[Download All](https://dev.sum7.eu/genofire/hook2xmpp/-/jobs/artifacts/master/download/?job=build-my-project) (with config example) + +[Download Binary](https://dev.sum7.eu/genofire/hook2xmpp/-/jobs/artifacts/master/raw/bin/hook2xmpp?inline=false&job=build-my-project) + +#### Build + +```bash +go get -u dev.sum7.eu/genofire/hook2xmpp +``` + +## Configure + +see `config_example.toml` + +## Start / Boot + +_/lib/systemd/system/hook2xmpp.service_ : +``` +[Unit] +Description=hook2xmpp +After=network.target +# After=ejabberd.service +# After=prosody.service + +[Service] +Type=simple +# User=notRoot +ExecStart=/opt/go/bin/hook2xmpp --config /etc/hook2xmpp.conf +Restart=always +RestartSec=5sec + +[Install] +WantedBy=multi-user.target +``` + +Start: `systemctl start hook2xmpp` +Autostart: `systemctl enable hook2xmpp` diff --git a/config.go b/config.go new file mode 100644 index 0000000..213ab75 --- /dev/null +++ b/config.go @@ -0,0 +1,19 @@ +package main + +import ( + "github.com/bdlm/std/logger" + + "dev.sum7.eu/ccchb/ccchatbot/schalter" +) + +type Config struct { + LogLevel logger.Level `toml:"log_level"` + + XMPP struct { + Host string `toml:"host"` + JID string `toml:"jid"` + Password string `toml:"password"` + } `toml:"xmpp"` + + Schalter schalter.Schalter `toml:"schalter"` +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..cef33f5 --- /dev/null +++ b/config.toml @@ -0,0 +1,11 @@ +log_level = 50 + + +[xmpp] +jid = "fluxxmpp@chat.sum7.eu" +password = "test" + +[schalter] +url = "https://schalter.ccchb.de/spaceapi.json" + +muc = ["ffhb_events@conference.chat.sum7.eu","#ccchb@irc.hackint.org"] diff --git a/config_example.toml b/config_example.toml new file mode 100644 index 0000000..e2ddfba --- /dev/null +++ b/config_example.toml @@ -0,0 +1,64 @@ +log_level = 50 +webserver_bind = ":8080" + +startup_notify_user = ["user@fireorbit.de"] +startup_notify_muc = [] + +nickname = "logbot" + +[xmpp] +address = "fireorbit.de" +jid = "bot@fireorbit.de" +password = "example" + +# suported hooks are, which could be declared multiple times with different `secrets` (see [[hooks.grafana]]): +[[hooks.grafana]] +[[hooks.prometheus]] +[[hooks.git]] +[[hooks.gitlab]] +[[hooks.circleci]] + +# every hook could have following attributes: +secret = "" +notify_muc = [] +notify_user = [] + +# for handling webhooks from prometheus alertmanager + +[[hooks.prometheus]] + +# for handling webhooks from grafana +# at http://localhost:8080/grafana +# for image support you have to enable `external_image_storage` (e.g. `provider = local`) +# see more at http://docs.grafana.org/installation/configuration/#external-image-storage +[[hooks.grafana]] +secret = "dev.sum7.eu-aShared-Secret" +notify_muc = ["monitoring@conference.chat.sum7.eu"] + +[[hooks.grafana]] +secret = "dev.sum7.eu-aShared-Secret-for important messages" +notify_user = ["user@fireorbit.de"] + + +# for handling webhooks from git software (e.g. gitea, gogs, github) +# at http://localhost:8080/git +[[hooks.git]] +secret = "github-FreifunkBremen-yanic-aShared-Secret" +notify_muc = [] +notify_user = ["user@fireorbit.de"] + +# for handling webhooks from gitlab +# at http://localhost:8080/gitlab +[[hooks.gitlab]] +secret = "dev.sum7.eu-aShared-Secret" +notify_muc = [] +notify_user = ["user@fireorbit.de"] + +# for handling webhooks from circleci +# at http://localhost:8080/circleci +[[hooks.circleci]] +secret = "dev.sum7.eu-aShared-Secret" +notify_muc = [] +notify_user = ["user@fireorbit.de"] + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..6f3fdbf --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "flag" + "os" + "os/signal" + "syscall" + + "dev.sum7.eu/genofire/golang-lib/file" + "github.com/bdlm/log" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" + + "dev.sum7.eu/ccchb/ccchatbot/runtime" +) + +var config = Config{} + +func main() { + configFile := "config.toml" + flag.StringVar(&configFile, "config", configFile, "path of configuration file") + flag.Parse() + + if err := file.ReadTOML(configFile, &config); err != nil { + log.WithField("tip", "maybe call me with: ccchatbot --config /etc/ccchatbot.conf").Panicf("error on read config: %s", err) + } + + log.SetLevel(config.LogLevel) + + router := xmpp.NewRouter() + router.HandleFunc("presence", handlePresence) + router.HandleFunc("message", func(s xmpp.Sender, p stanza.Packet) { + msg, ok := p.(stanza.Message) + if !ok { + log.Errorf("ignoring wrong routed packet: %T", p) + return + } + if err := config.Schalter.HandleBotMessage(s, msg); err != nil { + log.Debugf("bot could not handle message: %s", err) + } + }) + + var err error + client, err := xmpp.NewClient(xmpp.Config{ + Address: config.XMPP.Host, + Jid: config.XMPP.JID, + Password: config.XMPP.Password, + }, router) + + if err != nil { + log.Panicf("error on startup xmpp client: %s", err) + } + + cm := xmpp.NewStreamManager(client, func(s xmpp.Sender) { + config.Schalter.Run(s) + }) + go func() { + err := cm.Run() + log.Panic("closed connection:", err) + }() + + // Wait for system signal + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigs + + runtime.LeaveAllMUCs(client) + + log.Infof("closed by receiving: %s", sig) +} diff --git a/runtime/muc.go b/runtime/muc.go new file mode 100644 index 0000000..95fec62 --- /dev/null +++ b/runtime/muc.go @@ -0,0 +1,41 @@ +package runtime + +import ( + "github.com/bdlm/log" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +var mucs []string + +func JoinMUC(c xmpp.Sender, to, nick string) error { + + toJID, err := xmpp.NewJid(to) + if err != nil { + return err + } + toJID.Resource = nick + jid := toJID.Full() + + mucs = append(mucs, jid) + + return c.Send(stanza.Presence{Attrs: stanza.Attrs{To: jid}, + Extensions: []stanza.PresExtension{ + stanza.MucPresence{ + History: stanza.History{MaxStanzas: stanza.NewNullableInt(0)}, + }}, + }) + +} + +func LeaveAllMUCs(c xmpp.Sender) { + + for _, muc := range mucs { + if err := c.Send(stanza.Presence{Attrs: stanza.Attrs{ + To: muc, + Type: stanza.PresenceTypeUnavailable, + }}); err != nil { + log.WithField("muc", muc).Errorf("error on leaving muc: %s", err) + } + } +} diff --git a/runtime/send.go b/runtime/send.go new file mode 100644 index 0000000..51c5695 --- /dev/null +++ b/runtime/send.go @@ -0,0 +1,70 @@ +package runtime + +import ( + "github.com/bdlm/log" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func SendImage(client xmpp.Sender, users, mucs []string, url, desc string) { + msg := stanza.Message{ + Attrs: stanza.Attrs{Type: stanza.MessageTypeGroupchat}, + Body: url, + Extensions: []stanza.MsgExtension{ + stanza.OOB{URL: url, Desc: desc}, + }, + } + + for _, muc := range mucs { + msg.To = muc + if err := client.Send(msg); err != nil { + log.WithFields(map[string]interface{}{ + "muc": muc, + "url": url, + }).Errorf("error on image notify: %s", err) + } + } + + msg.Type = stanza.MessageTypeChat + for _, user := range users { + msg.To = user + if err := client.Send(msg); err != nil { + log.WithFields(map[string]interface{}{ + "user": user, + "url": url, + }).Errorf("error on image notify: %s", err) + } + } +} + +func SendText(client xmpp.Sender, users, mucs []string, text, html string) { + msg := stanza.Message{ + Attrs: stanza.Attrs{Type: stanza.MessageTypeGroupchat}, + Body: text, + Extensions: []stanza.MsgExtension{ + stanza.HTML{Body: stanza.HTMLBody{InnerXML: html}}, + }, + } + + for _, muc := range mucs { + msg.To = muc + if err := client.Send(msg); err != nil { + log.WithFields(map[string]interface{}{ + "muc": muc, + "text": text, + }).Errorf("error on notify: %s", err) + } + } + + msg.Type = stanza.MessageTypeChat + for _, user := range users { + msg.To = user + if err := client.Send(msg); err != nil { + log.WithFields(map[string]interface{}{ + "user": user, + "text": text, + }).Errorf("error on notify: %s", err) + } + } +} diff --git a/runtime/xmpp_test.go b/runtime/xmpp_test.go new file mode 100644 index 0000000..7ccdf5f --- /dev/null +++ b/runtime/xmpp_test.go @@ -0,0 +1 @@ +package runtime diff --git a/schalter/bot.go b/schalter/bot.go new file mode 100644 index 0000000..754c72f --- /dev/null +++ b/schalter/bot.go @@ -0,0 +1,26 @@ +package schalter + +import ( + "fmt" + + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func (s *Schalter) HandleBotMessage(c xmpp.Sender, msg stanza.Message) error { + if msg.Body == ".status" { + jid, _ := xmpp.NewJid(msg.From) + reply := stanza.Message{ + Attrs: stanza.Attrs{Type: msg.Type, + To: msg.From, + }, + Body: s.StateString(), + } + if msg.Type == stanza.MessageTypeGroupchat { + reply.To = jid.Bare() + reply.Body = fmt.Sprintf("%s: %s", jid.Resource, reply.Body) + } + return c.Send(reply) + } + return fmt.Errorf("not handled by this bot: %v", msg) +} diff --git a/xmpp.go b/xmpp.go new file mode 100644 index 0000000..8b913cc --- /dev/null +++ b/xmpp.go @@ -0,0 +1,58 @@ +package main + +import ( + "github.com/bdlm/log" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func handlePresence(s xmpp.Sender, p stanza.Packet) { + pres, ok := p.(stanza.Presence) + if !ok { + log.Errorf("blame gosrc.io/xmpp for routing: %s", p) + return + } + from, err := xmpp.NewJid(pres.From) + if err != nil { + log.Errorf("blame gosrc.io/xmpp for jid encoding: %s", pres.From) + return + } + fromBare := from.Bare() + logPres := log.WithField("from", from) + + switch pres.Type { + case stanza.PresenceTypeSubscribe: + logPres.Debugf("recv presence subscribe") + if err := s.Send(stanza.Presence{Attrs: stanza.Attrs{ + Type: stanza.PresenceTypeSubscribed, + To: fromBare, + Id: pres.Id, + }}); err != nil { + logPres.WithField("user", pres.From).Errorf("answer of subscribe not send: %s", err) + return + } + logPres.Debugf("accept new subscribe") + + if err := s.Send(stanza.Presence{Attrs: stanza.Attrs{ + Type: stanza.PresenceTypeSubscribe, + To: fromBare, + }}); err != nil { + logPres.WithField("user", pres.From).Errorf("request of subscribe not send: %s", err) + return + } + logPres.Info("request also subscribe") + case stanza.PresenceTypeSubscribed: + logPres.Info("recv presence accepted subscribe") + case stanza.PresenceTypeUnsubscribe: + logPres.Info("recv presence remove subscribe") + case stanza.PresenceTypeUnsubscribed: + logPres.Info("recv presence removed subscribe") + case stanza.PresenceTypeUnavailable: + logPres.Debug("recv presence unavailable") + case "": + logPres.Debug("recv empty presence, maybe from joining muc") + return + default: + logPres.Warnf("recv presence unsupported: %s -> %v", pres.Type, pres) + } +}