Add MS Teams support (#242)

##### ISSUE TYPE
<!--- Pick one below and delete the rest: -->
 - Feature Pull Request


##### SUMMARY

- Add support for Microsoft Teams
- Multicluster support not available yet for Teams

Integration tests will be addressed with a separate issue. Blocked by https://github.com/infracloudio/msbotbuilder-go/issues/46

Fixes #60
This commit is contained in:
Prasad Ghangal 2020-08-11 11:42:09 +05:30 committed by GitHub
parent 5e3ffc865b
commit c6db9526a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 663 additions and 33 deletions

View File

@ -18,7 +18,7 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# Development image
FROM golang:1.12-alpine3.10 AS BUILD-ENV
FROM golang:1.13-alpine3.10 AS BUILD-ENV
ARG GOOS_VAL
ARG GOARCH_VAL

View File

@ -55,6 +55,9 @@ func startController() error {
return fmt.Errorf("Error in loading configuration. Error:%s", err.Error())
}
// List notifiers
notifiers := notify.ListNotifiers(conf.Communications)
if conf.Communications.Slack.Enabled {
log.Info("Starting slack bot")
sb := bot.NewSlackBot(conf)
@ -67,8 +70,13 @@ func startController() error {
go mb.Start()
}
notifiers := notify.ListNotifiers(conf.Communications)
log.Infof("Notifier List: config=%#v list=%#v\n", conf.Communications, notifiers)
if conf.Communications.Teams.Enabled {
log.Info("Starting MS Teams bot")
tb := bot.NewTeamsBot(conf)
notifiers = append(notifiers, tb)
go tb.Start()
}
// Start upgrade notifier
if conf.Settings.UpgradeNotifier {
log.Info("Starting upgrade notifier")

View File

@ -15,6 +15,14 @@ communications:
team: 'MATTERMOST_TEAM' # Mattermost Team to configure with BotKube
channel: 'MATTERMOST_CHANNEL' # Mattermost Channel for receiving BotKube alerts
notiftype: short # Change notification type short/long you want to receive. notiftype is optional and Default notification type is short (if not specified)
# Settings for MS Teams
teams:
enabled: false
appID: 'APPLICATION_ID'
appPassword: 'APPLICATION_PASSWORD'
notiftype: short
port: 3978
# Settings for ELS
elasticsearch:

View File

@ -272,6 +272,14 @@ stringData:
type: botkube-event
shards: 1
replicas: 0
# Settings for MS Teams
teams:
enabled: false
appID: 'APPLICATION_ID'
appPassword: 'APPLICATION_PASSWORD'
notiftype: short
port: 3978
# Settings for Webhook
webhook:

View File

@ -272,7 +272,15 @@ stringData:
type: botkube-event
shards: 1
replicas: 0
# Settings for MS Teams
teams:
enabled: false
appID: 'APPLICATION_ID'
appPassword: 'APPLICATION_PASSWORD'
notiftype: short
port: 3978
# Settings for Webhook
webhook:
enabled: false

3
go.mod
View File

@ -19,6 +19,7 @@ require (
github.com/gorilla/websocket v1.4.1 // indirect
github.com/hashicorp/golang-lru v0.5.3 // indirect
github.com/imdario/mergo v0.3.7 // indirect
github.com/infracloudio/msbotbuilder-go v0.2.1
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/lib/pq v1.2.0 // indirect
github.com/mattermost/gorp v2.0.0+incompatible // indirect
@ -44,7 +45,7 @@ require (
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.2.4
gopkg.in/yaml.v2 v2.2.8
k8s.io/api v0.17.0
k8s.io/apimachinery v0.17.0
k8s.io/client-go v0.17.0

16
go.sum
View File

@ -1,14 +1,21 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Azure/go-autorest/autorest v0.9.0 h1:MRvx8gncNaXJqOoLmhNjUAKh33JJF8LyxPhomEtOsjs=
github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
github.com/Azure/go-autorest/autorest/adal v0.5.0 h1:q2gDruN08/guU9vAjuPWff0+QIrpH6ediguzdAzXAUU=
github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
github.com/Azure/go-autorest/autorest/date v0.1.0 h1:YGrhWfrgtFs84+h0o46rJrlmsZtyZRg470CqAXTZaGM=
github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
github.com/Azure/go-autorest/autorest/mocks v0.2.0 h1:Ww5g4zThfD/6cLb4z6xxgeyDa7QDkizMkJKe0ysZXp0=
github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
github.com/Azure/go-autorest/logger v0.1.0 h1:ruG4BSDXONFRrZZJ2GUXDiUyVpayPmb1GnWeHDdaNKY=
github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VYyQflFE619k=
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -52,6 +59,7 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0 h1:w3NnFcKR5241cfmQU5ZZAsf0xcpId6mWOupTvJlUX2U=
@ -152,6 +160,7 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
github.com/googleapis/gnostic v0.3.1 h1:WeAefnSUHlBb0iJKwxFDZdbfGwkd7xRNuV+IpXMJhYk=
github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU=
github.com/gophercloud/gophercloud v0.1.0 h1:P/nh25+rzXouhytV2pUHBb65fnds26Ghl8/391+sT5o=
github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
@ -170,6 +179,8 @@ github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI=
github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/infracloudio/msbotbuilder-go v0.2.1 h1:bNtsNHwgPXTdD57Uone7FirMPJ1krqudFapCvtIpL+4=
github.com/infracloudio/msbotbuilder-go v0.2.1/go.mod h1:zTFZH9V4x9YQMXrBw2CNsI6hO6blIQ8jHNvdnjbAqZM=
github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc=
github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
@ -198,6 +209,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lestrrat-go/jwx v0.9.0 h1:Fnd0EWzTm0kFrBPzE/PEPp9nzllES5buMkksPMjEKpM=
github.com/lestrrat-go/jwx v0.9.0/go.mod h1:iEoxlYfZjvoGpuWwxUz+eR5e6KTJGsaRcy/YNA/UnBk=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
@ -257,6 +270,7 @@ github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -432,6 +446,8 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -0,0 +1,33 @@
{{ if .Values.ingress.create }}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: {{ include "botkube.fullname" . }}
labels:
app.kubernetes.io/name: {{ include "botkube.name" . }}
helm.sh/chart: {{ include "botkube.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app: botkube
annotations:
{{- if .Values.ingress.annotations }}
{{ toYaml .Values.ingress.annotations | indent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- http:
paths:
- path: {{ .Values.ingress.urlPath }}
backend:
serviceName: {{ include "botkube.fullname" . }}
servicePort: {{ .Values.communications.teams.port }}
{{- if .Values.ingress.host }}
host: {{ .Values.ingress.host }}
{{- end }}
{{- end -}}

View File

@ -1,4 +1,4 @@
{{- if .Values.serviceMonitor.enabled }}
{{- if or .Values.serviceMonitor.enabled .Values.communications.teams.enabled }}
apiVersion: v1
kind: Service
metadata:
@ -12,9 +12,15 @@ metadata:
spec:
type: ClusterIP
ports:
{{- if .Values.serviceMonitor.enabled }}
- name: {{ .Values.service.name }}
port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
{{- end }}
{{- if .Values.communications.teams.enabled }}
- name: "teams"
port: {{ .Values.communications.teams.port }}
{{- end }}
selector:
app: botkube
{{- end }}

View File

@ -267,6 +267,14 @@ communications:
channel: 'MATTERMOST_CHANNEL' # Mattermost Channel for receiving BotKube alerts
notiftype: short # Change notification type short/long you want to receive. notiftype is optional and Default notification type is short (if not specified)
# Settings for MS Teams
teams:
enabled: false
appID: 'APPLICATION_ID'
appPassword: 'APPLICATION_PASSWORD'
notiftype: short
port: 3978
# Settings for ELS
elasticsearch:
enabled: false
@ -293,6 +301,18 @@ service:
name: metrics
port: 2112
targetPort: 2112
# Ingress settings to expose teams endpoint
ingress:
create: false
annotations:
kubernetes.io/ingress.class: nginx
host: 'HOST'
urlPath: "/"
tls:
enabled: false
secretName: ''
serviceMonitor:
## If true, a ServiceMonitor CR is created for a botkube

View File

@ -150,7 +150,7 @@ func (mm *mattermostMessage) handleMessage(b MMBot) {
mm.Request = strings.TrimPrefix(post.Message, "@"+BotName+" ")
e := execute.NewDefaultExecutor(mm.Request, b.AllowKubectl, b.RestrictAccess, b.DefaultNamespace,
b.ClusterName, b.ChannelName, mm.IsAuthChannel)
b.ClusterName, config.MattermostBot, b.ChannelName, mm.IsAuthChannel)
mm.Response = e.Execute()
mm.sendMessage()
}

View File

@ -156,12 +156,12 @@ func (sm *slackMessage) HandleMessage(b *SlackBot) {
}
e := execute.NewDefaultExecutor(sm.Request, b.AllowKubectl, b.RestrictAccess, b.DefaultNamespace,
b.ClusterName, b.ChannelName, sm.IsAuthChannel)
b.ClusterName, config.SlackBot, b.ChannelName, sm.IsAuthChannel)
sm.Response = e.Execute()
sm.Send()
}
func (sm slackMessage) Send() {
func (sm *slackMessage) Send() {
// Upload message as a file if too long
if len(sm.Response) >= 3990 {
params := slack.FileUploadParameters{

334
pkg/bot/teams.go Normal file
View File

@ -0,0 +1,334 @@
// Copyright (c) 2020 InfraCloud Technologies
//
// 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.
package bot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/infracloudio/botkube/pkg/config"
"github.com/infracloudio/botkube/pkg/events"
"github.com/infracloudio/botkube/pkg/execute"
"github.com/infracloudio/botkube/pkg/log"
"github.com/infracloudio/msbotbuilder-go/core"
coreActivity "github.com/infracloudio/msbotbuilder-go/core/activity"
"github.com/infracloudio/msbotbuilder-go/schema"
)
const (
defaultPort = "3978"
consentBufferSize = 100
longRespNotice = "Response is too long. Sending last few lines. Please send DM to BotKube to get complete response."
convTypePersonal = "personal"
channelSetCmd = "set default channel"
maxMessageSize = 15700
contentTypeCard = "application/vnd.microsoft.card.adaptive"
contentTypeFile = "application/vnd.microsoft.teams.card.file.consent"
responseFileName = "response.txt"
activityFileUpload = "fileUpload"
activityAccept = "accept"
activityUploadInfo = "uploadInfo"
)
var _ Bot = (*Teams)(nil)
// Teams contains credentials to start Teams backend server
type Teams struct {
AppID string
AppPassword string
MessagePath string
Port string
AllowKubectl bool
RestrictAccess bool
ClusterName string
NotifType config.NotifType
Adapter core.Adapter
DefaultNamespace string
ConversationRef *schema.ConversationReference
}
type consentContext struct {
Command string
}
// NewTeamsBot returns Teams instance
func NewTeamsBot(c *config.Config) *Teams {
// Set notifier off by default
config.Notify = false
port := c.Communications.Teams.Port
if port == "" {
port = defaultPort
}
msgPath := c.Communications.Teams.MessagePath
if msgPath == "" {
msgPath = "/"
}
return &Teams{
AppID: c.Communications.Teams.AppID,
AppPassword: c.Communications.Teams.AppPassword,
NotifType: c.Communications.Teams.NotifType,
MessagePath: msgPath,
Port: port,
AllowKubectl: c.Settings.Kubectl.Enabled,
RestrictAccess: c.Settings.Kubectl.RestrictAccess,
DefaultNamespace: c.Settings.Kubectl.DefaultNamespace,
ClusterName: c.Settings.ClusterName,
}
}
// Start MS Teams server to serve messages from Teams client
func (t *Teams) Start() {
var err error
setting := core.AdapterSetting{
AppID: t.AppID,
AppPassword: t.AppPassword,
}
t.Adapter, err = core.NewBotAdapter(setting)
if err != nil {
log.Errorf("Failed Start teams bot. %+v", err)
return
}
// Start consent cleanup
http.HandleFunc(t.MessagePath, t.processActivity)
log.Infof("Started MS Teams server on port %s", defaultPort)
log.Errorf("Error in MS Teams server. %v", http.ListenAndServe(fmt.Sprintf(":%s", t.Port), nil))
}
func (t *Teams) deleteConsent(ID string, convRef schema.ConversationReference) {
log.Debugf("Deleting activity %s\n", ID)
if err := t.Adapter.DeleteActivity(context.Background(), ID, convRef); err != nil {
log.Errorf("Failed to delete activity. %s", err.Error())
}
}
func (t *Teams) processActivity(w http.ResponseWriter, req *http.Request) {
ctx := context.Background()
log.Debugf("Received activity %v\n", req)
activity, err := t.Adapter.ParseRequest(ctx, req)
if err != nil {
log.Errorf("Failed to parse Teams request. %s", err.Error())
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = t.Adapter.ProcessActivity(ctx, activity, coreActivity.HandlerFuncs{
OnMessageFunc: func(turn *coreActivity.TurnContext) (schema.Activity, error) {
resp := t.processMessage(turn.Activity)
if len(resp) >= maxMessageSize {
if turn.Activity.Conversation.ConversationType == convTypePersonal {
// send file upload request
attachments := []schema.Attachment{
{
ContentType: contentTypeFile,
Name: responseFileName,
Content: map[string]interface{}{
"description": turn.Activity.Text,
"sizeInBytes": len(resp),
"acceptContext": map[string]interface{}{
"command": activity.Text,
},
},
},
}
return turn.SendActivity(coreActivity.MsgOptionAttachments(attachments))
}
resp = fmt.Sprintf("%s\n```\nCluster: %s\n%s", longRespNotice, t.ClusterName, resp[len(resp)-maxMessageSize:])
}
return turn.SendActivity(coreActivity.MsgOptionText(resp))
},
// handle invoke events
// https://developer.microsoft.com/en-us/microsoft-teams/blogs/working-with-files-in-your-microsoft-teams-bot/
OnInvokeFunc: func(turn *coreActivity.TurnContext) (schema.Activity, error) {
t.deleteConsent(turn.Activity.ReplyToID, coreActivity.GetCoversationReference(turn.Activity))
if err != nil {
return schema.Activity{}, fmt.Errorf("failed to read file: %s", err.Error())
}
if turn.Activity.Value["type"] != activityFileUpload {
return schema.Activity{}, nil
}
if turn.Activity.Value["action"] != activityAccept {
return schema.Activity{}, nil
}
if turn.Activity.Value["context"] == nil {
return schema.Activity{}, nil
}
// Parse upload info from invoke accept response
uploadInfo := schema.UploadInfo{}
infoJSON, err := json.Marshal(turn.Activity.Value[activityUploadInfo])
if err != nil {
return schema.Activity{}, err
}
if err := json.Unmarshal(infoJSON, &uploadInfo); err != nil {
return schema.Activity{}, err
}
// Parse context
consentCtx := consentContext{}
ctxJSON, err := json.Marshal(turn.Activity.Value["context"])
if err != nil {
return schema.Activity{}, err
}
if err := json.Unmarshal(ctxJSON, &consentCtx); err != nil {
return schema.Activity{}, err
}
msg := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(consentCtx.Command), "<at>BotKube</at>"))
e := execute.NewDefaultExecutor(msg, t.AllowKubectl, t.RestrictAccess, t.DefaultNamespace,
t.ClusterName, config.TeamsBot, "", true)
out := e.Execute()
actJSON, _ := json.MarshalIndent(turn.Activity, "", " ")
log.Debugf("Incoming MSTeams Activity: %s", actJSON)
// upload file
err = t.putRequest(uploadInfo.UploadURL, []byte(out))
if err != nil {
return schema.Activity{}, fmt.Errorf("failed to upload file: %s", err.Error())
}
// notify user about uploaded file
fileAttach := []schema.Attachment{
{
ContentType: contentTypeFile,
ContentURL: uploadInfo.ContentURL,
Name: uploadInfo.Name,
Content: map[string]interface{}{
"uniqueId": uploadInfo.UniqueID,
"fileType": uploadInfo.FileType,
},
},
}
return turn.SendActivity(coreActivity.MsgOptionAttachments(fileAttach))
},
})
if err != nil {
log.Errorf("Failed to process request. %s", err.Error())
}
}
func (t *Teams) processMessage(activity schema.Activity) string {
// Trim @BotKube prefix
msg := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(activity.Text), "<at>BotKube</at>"))
// User needs to execute "notifier start" cmd to enable notifications
// Parse "notifier" command and set conversation reference
args := strings.Fields(msg)
if activity.Conversation.ConversationType != convTypePersonal && len(args) > 0 && execute.ValidNotifierCommand[args[0]] {
if len(args) < 2 {
return execute.IncompleteCmdMsg
}
if execute.Start.String() == args[1] {
config.Notify = true
ref := coreActivity.GetCoversationReference(activity)
t.ConversationRef = &ref
// Remove messageID from the ChannelID
if ID, ok := activity.ChannelData["teamsChannelId"]; ok {
t.ConversationRef.ChannelID = ID.(string)
t.ConversationRef.Conversation.ID = ID.(string)
}
return fmt.Sprintf(execute.NotifierStartMsg, t.ClusterName)
}
}
// Multicluster is not supported for Teams
e := execute.NewDefaultExecutor(msg, t.AllowKubectl, t.RestrictAccess, t.DefaultNamespace,
t.ClusterName, config.TeamsBot, "", true)
return fmt.Sprintf("```\n%s\n```", e.Execute())
}
func (t *Teams) putRequest(u string, data []byte) error {
client := &http.Client{}
dec, err := url.QueryUnescape(u)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPut, dec, bytes.NewBuffer(data))
if err != nil {
return err
}
size := fmt.Sprintf("%d", len(data))
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Content-Length", size)
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(data)-1, len(data)))
resp, err := client.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 201 && resp.StatusCode != 200 {
return fmt.Errorf("failed to upload file with status %d", resp.StatusCode)
}
return nil
}
// SendEvent sends event message via Bot interface
func (t *Teams) SendEvent(event events.Event) error {
card := formatTeamsMessage(event, t.NotifType)
if err := t.sendProactiveMessage(card); err != nil {
log.Errorf("Failed to send notification. %s", err.Error())
}
log.Debugf("Event successfully sent to MS Teams >> %+v", event)
return nil
}
// SendMessage sends message to MsTeams
func (t *Teams) SendMessage(msg string) error {
if t.ConversationRef == nil {
log.Infof("Skipping SendMessage since conversation ref not set")
return nil
}
err := t.Adapter.ProactiveMessage(context.TODO(), *t.ConversationRef, coreActivity.HandlerFuncs{
OnMessageFunc: func(turn *coreActivity.TurnContext) (schema.Activity, error) {
return turn.SendActivity(coreActivity.MsgOptionText(msg))
},
})
if err != nil {
return err
}
log.Debug("Message successfully sent to MS Teams")
return nil
}
func (t *Teams) sendProactiveMessage(card map[string]interface{}) error {
if t.ConversationRef == nil {
log.Infof("Skipping SendMessage since conversation ref not set")
return nil
}
err := t.Adapter.ProactiveMessage(context.TODO(), *t.ConversationRef, coreActivity.HandlerFuncs{
OnMessageFunc: func(turn *coreActivity.TurnContext) (schema.Activity, error) {
attachments := []schema.Attachment{
{
ContentType: contentTypeCard,
Content: card,
},
}
return turn.SendActivity(coreActivity.MsgOptionAttachments(attachments))
},
})
return err
}

150
pkg/bot/teams_notif.go Normal file
View File

@ -0,0 +1,150 @@
package bot
import (
"strings"
"github.com/infracloudio/botkube/pkg/config"
"github.com/infracloudio/botkube/pkg/events"
"github.com/infracloudio/botkube/pkg/notify"
)
const (
// Constants for sending messageCards
messageType = "MessageCard"
schemaContext = "http://schema.org/extensions"
)
var themeColor = map[config.Level]string{
config.Info: "good",
config.Warn: "warning",
config.Debug: "good",
config.Error: "attention",
config.Critical: "attention",
}
type fact map[string]interface{}
func formatTeamsMessage(event events.Event, notifType config.NotifType) map[string]interface{} {
switch notifType {
case config.LongNotify:
return teamsLongNotification(event)
case config.ShortNotify:
fallthrough
default:
return teamsShortNotification(event)
}
}
func teamsShortNotification(event events.Event) map[string]interface{} {
return map[string]interface{}{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": []map[string]interface{}{
{
"type": "TextBlock",
"text": event.Title,
"size": "Large",
"color": themeColor[event.Level],
"wrap": true,
},
{
"type": "TextBlock",
"text": strings.ReplaceAll(notify.FormatShortMessage(event), "```", ""),
"wrap": true,
},
},
}
}
func teamsLongNotification(event events.Event) map[string]interface{} {
card := map[string]interface{}{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
}
sectionFacts := []fact{}
if event.Cluster != "" {
sectionFacts = append(sectionFacts, fact{
"title": "Cluster",
"value": event.Cluster,
})
}
sectionFacts = append(sectionFacts, fact{
"title": "Name",
"value": event.Name,
})
if event.Namespace != "" {
sectionFacts = append(sectionFacts, fact{
"title": "Namespace",
"value": event.Namespace,
})
}
if event.Reason != "" {
sectionFacts = append(sectionFacts, fact{
"title": "Reason",
"value": event.Reason,
})
}
if len(event.Messages) > 0 {
message := ""
for _, m := range event.Messages {
message = message + m + "\n"
}
sectionFacts = append(sectionFacts, fact{
"title": "Message",
"value": message,
})
}
if event.Action != "" {
sectionFacts = append(sectionFacts, fact{
"title": "Action",
"value": event.Action,
})
}
if len(event.Recommendations) > 0 {
rec := ""
for _, r := range event.Recommendations {
rec = rec + r + "\n"
}
sectionFacts = append(sectionFacts, fact{
"title": "Recommendations",
"value": rec,
})
}
if len(event.Warnings) > 0 {
warn := ""
for _, w := range event.Warnings {
warn = warn + w + "\n"
}
sectionFacts = append(sectionFacts, fact{
"title": "Warnings",
"value": warn,
})
}
card["body"] = []map[string]interface{}{
{
"type": "TextBlock",
"text": event.Title,
"size": "Large",
"color": themeColor[event.Level],
},
{
"type": "FactSet",
"facts": sectionFacts,
},
}
return card
}

View File

@ -59,6 +59,13 @@ const (
Error Level = "error"
// Critical level
Critical Level = "critical"
// SlackBot bot platform
SlackBot BotPlatform = "slack"
// MattermostBot bot platform
MattermostBot BotPlatform = "mattermost"
// TeamsBot bot platform
TeamsBot BotPlatform = "teams"
)
// EventType to watch
@ -67,6 +74,9 @@ type EventType string
// Level type to store event levels
type Level string
// BotPlatform supported by BotKube
type BotPlatform string
// ResourceConfigFileName is a name of botkube resource configuration file
var ResourceConfigFileName = "resource_config.yaml"
@ -120,9 +130,10 @@ type Namespaces struct {
// CommunicationsConfig channels to send events to
type CommunicationsConfig struct {
Slack Slack
ElasticSearch ElasticSearch
Mattermost Mattermost
Webhook Webhook
Teams Teams
ElasticSearch ElasticSearch
}
// Slack configuration to authentication and send notifications
@ -168,6 +179,17 @@ type Mattermost struct {
NotifType NotifType `yaml:",omitempty"`
}
// Teams creds for authentication with MS Teams
type Teams struct {
Enabled bool
AppID string `yaml:"appID,omitempty"`
AppPassword string `yaml:"appPassword,omitempty"`
Team string
Port string
MessagePath string
NotifType NotifType `yaml:",omitempty"`
}
// Webhook configuration to send notifications
type Webhook struct {
Enabled bool

View File

@ -45,7 +45,7 @@ import (
const (
controllerStartMsg = "...and now my watch begins for cluster '%s'! :crossed_swords:"
controllerStopMsg = "my watch has ended for cluster '%s'!"
controllerStopMsg = "My watch has ended for cluster '%s'!\nPlease send `@BotKube notifier start` to enable notification once BotKube comes online."
configUpdateMsg = "Looks like the configuration is updated for cluster '%s'. I shall halt my watch till I read it."
)
@ -109,10 +109,12 @@ func RegisterInformers(c *config.Config, notifiers []notify.Notifier) {
utils.KubeInformerFactory.Start(stopCh)
sigterm := make(chan os.Signal, 1)
signal.Notify(sigterm, syscall.SIGTERM)
signal.Notify(sigterm, syscall.SIGINT)
signal.Notify(sigterm, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL, syscall.SIGQUIT, syscall.SIGSTOP)
<-sigterm
sendMessage(c, notifiers, fmt.Sprintf(controllerStopMsg, c.Settings.ClusterName))
// Sleep for some time to send termination notification
time.Sleep(5 * time.Second)
}
func registerEventHandlers(c *config.Config, notifiers []notify.Notifier, resourceType string, events []config.EventType) (handlerFns cache.ResourceEventHandlerFuncs) {

View File

@ -37,7 +37,8 @@ import (
)
var (
validNotifierCommand = map[string]bool{
// ValidNotifierCommand is a map of valid notifier commands
ValidNotifierCommand = map[string]bool{
"notifier": true,
}
validPingCommand = map[string]bool{
@ -65,14 +66,21 @@ var (
)
const (
notifierStartMsg = "Brace yourselves, notifications are coming from cluster '%s'."
notifierStopMsg = "Sure! I won't send you notifications from cluster '%s' anymore."
unsupportedCmdMsg = "Command not supported. Please run /botkubehelp to see supported commands."
incompleteCmdMsg = "You missed to pass options for the command. Please run /botkubehelp to see command options."
kubectlDisabledMsg = "Sorry, the admin hasn't given me the permission to execute kubectl command on cluster '%s'."
filterNameMissing = "You forgot to pass filter name. Please pass one of the following valid filters:\n\n%s"
filterEnabled = "I have enabled '%s' filter on '%s' cluster."
filterDisabled = "Done. I won't run '%s' filter on '%s' cluster."
// NotifierStartMsg notifier enabled response message
NotifierStartMsg = "Brace yourselves, notifications are coming from cluster '%s'."
// IncompleteCmdMsg incomplete command response message
IncompleteCmdMsg = "You missed to pass options for the command. Please run /botkubehelp to see command options."
// Custom messages for teams platform
teamsUnsupportedCmdMsg = "Command not supported. Please visit botkube.io/usage to see supported commands."
teamsIncompleteCmdMsg = "You missed to pass options for the command. Please run /botkubehelp to see command options."
)
// Executor is an interface for processes to execute commands
@ -82,6 +90,7 @@ type Executor interface {
// DefaultExecutor is a default implementations of Executor
type DefaultExecutor struct {
Platform config.BotPlatform
Message string
AllowKubectl bool
RestrictAccess bool
@ -143,8 +152,9 @@ func (action FiltersAction) String() string {
// NewDefaultExecutor returns new Executor object
func NewDefaultExecutor(msg string, allowkubectl, restrictAccess bool, defaultNamespace,
clusterName, channelName string, isAuthChannel bool) Executor {
clusterName string, platform config.BotPlatform, channelName string, isAuthChannel bool) Executor {
return &DefaultExecutor{
Platform: platform,
Message: msg,
AllowKubectl: allowkubectl,
RestrictAccess: restrictAccess,
@ -178,8 +188,8 @@ func (e *DefaultExecutor) Execute() string {
return runKubectlCommand(args, e.ClusterName, e.DefaultNamespace, e.IsAuthChannel)
}
}
if validNotifierCommand[args[0]] {
return runNotifierCommand(args, e.ClusterName, e.IsAuthChannel)
if ValidNotifierCommand[args[0]] {
return e.runNotifierCommand(args, e.ClusterName, e.IsAuthChannel)
}
if validPingCommand[args[0]] {
res := runVersionCommand(args, e.ClusterName)
@ -193,15 +203,18 @@ func (e *DefaultExecutor) Execute() string {
}
// Check if filter command
if validFilterCommand[args[0]] {
return runFilterCommand(args, e.ClusterName, e.IsAuthChannel)
return e.runFilterCommand(args, e.ClusterName, e.IsAuthChannel)
}
if e.IsAuthChannel {
return unsupportedCmdMsg
return printDefaultMsg(e.Platform)
}
return ""
}
func printDefaultMsg() string {
func printDefaultMsg(p config.BotPlatform) string {
if p == config.TeamsBot {
return teamsUnsupportedCmdMsg
}
return unsupportedCmdMsg
}
@ -270,19 +283,19 @@ func runKubectlCommand(args []string, clusterName, defaultNamespace string, isAu
}
// TODO: Have a seperate cli which runs bot commands
func runNotifierCommand(args []string, clusterName string, isAuthChannel bool) string {
func (e *DefaultExecutor) runNotifierCommand(args []string, clusterName string, isAuthChannel bool) string {
if isAuthChannel == false {
return ""
}
if len(args) < 2 {
return incompleteCmdMsg
return IncompleteCmdMsg
}
switch args[1] {
case Start.String():
config.Notify = true
log.Info("Notifier enabled")
return fmt.Sprintf(notifierStartMsg, clusterName)
return fmt.Sprintf(NotifierStartMsg, clusterName)
case Stop.String():
config.Notify = false
log.Info("Notifier disabled")
@ -300,16 +313,16 @@ func runNotifierCommand(args []string, clusterName string, isAuthChannel bool) s
}
return fmt.Sprintf("Showing config for cluster '%s'\n\n%s", clusterName, out)
}
return printDefaultMsg()
return printDefaultMsg(e.Platform)
}
// runFilterCommand to list, enable or disable filters
func runFilterCommand(args []string, clusterName string, isAuthChannel bool) string {
func (e *DefaultExecutor) runFilterCommand(args []string, clusterName string, isAuthChannel bool) string {
if isAuthChannel == false {
return ""
}
if len(args) < 2 {
return incompleteCmdMsg
return IncompleteCmdMsg
}
switch args[1] {
@ -339,7 +352,7 @@ func runFilterCommand(args []string, clusterName string, isAuthChannel bool) str
}
return fmt.Sprintf(filterDisabled, args[2], clusterName)
}
return printDefaultMsg()
return printDefaultMsg(e.Platform)
}
// Use tabwriter to display string in tabular form

View File

@ -211,7 +211,7 @@ func mmLongNotification(event events.Event) []*model.SlackAttachmentField {
func mmShortNotification(event events.Event) []*model.SlackAttachmentField {
return []*model.SlackAttachmentField{
{
Value: formatShortMessage(event),
Value: FormatShortMessage(event),
},
}
}

View File

@ -210,14 +210,15 @@ func slackShortNotification(event events.Event) slack.Attachment {
Title: event.Title,
Fields: []slack.AttachmentField{
{
Value: formatShortMessage(event),
Value: FormatShortMessage(event),
},
},
Footer: "BotKube",
}
}
func formatShortMessage(event events.Event) (msg string) {
// FormatShortMessage prepares message in short event format
func FormatShortMessage(event events.Event) (msg string) {
additionalMsg := ""
if len(event.Messages) > 0 {
for _, m := range event.Messages {

View File

@ -86,7 +86,7 @@ func (w *Webhook) SendEvent(event events.Event) (err error) {
Error: event.Error,
Messages: event.Messages,
},
EventSummary: formatShortMessage(event),
EventSummary: FormatShortMessage(event),
TimeStamp: event.TimeStamp,
Recommendations: event.Recommendations,
Warnings: event.Warnings,