Repository: abahmed/kwatch
Pull request #168 (2022-11-29 15:08:50)

Add FeiShu alert notification
# Add chinese chat tool feishu notification integration.

FeiShu is a famous  chat tool in china, developed by ByteDance(who created tiktok).
FeiShu official site: https://www.feishu.cn/

Changes proposed in this pull request:
- Add feishu webhook integration.



Patch:
diff --git a/README.md b/README.md
index 7b33784..e63a63b 100644
--- a/README.md
+++ b/README.md
@@ -241,6 +241,19 @@ title
 | `alert.dingtalk.secret`             | Optional secret used to sign requests  |
 | `alert.dingtalk.title`              | Customized title in message            |
 
+#### FeiShu
+
+<p>
+	<img src="./assets/feishu.png" width="45%"/>
+</p>
+If you want to enable FeiShu, provide accessToken with optional secret and
+title
+
+| Parameter                | Description                 |
+|:-------------------------|:----------------------------|
+| `alert.feishu.webhook`   | FeiShu bot webhook URL      |
+| `alert.feishu.title`     | Customized title in message |
+
 
 ### Cleanup
 
diff --git a/alertmanager/alertmanager.go b/alertmanager/alertmanager.go
index d2b4870..c2c7e93 100644
--- a/alertmanager/alertmanager.go
+++ b/alertmanager/alertmanager.go
@@ -1,6 +1,7 @@
 package alertmanager
 
 import (
+	"github.com/abahmed/kwatch/alertmanager/feishu"
 	"reflect"
 	"strings"
 
@@ -59,6 +60,8 @@ func (a *AlertManager) Init(config map[string]map[string]string) {
 			pvdr = matrix.NewMatrix(v)
 		} else if lowerCaseKey == "dingtalk" {
 			pvdr = dingtalk.NewDingTalk(v)
+		} else if lowerCaseKey == "feishu" {
+			pvdr = feishu.NewFeiShu(v)
 		}
 
 		if !reflect.ValueOf(pvdr).IsNil() {
diff --git a/alertmanager/feishu/feishu.go b/alertmanager/feishu/feishu.go
new file mode 100644
index 0000000..dbcdae5
--- /dev/null
+++ b/alertmanager/feishu/feishu.go
@@ -0,0 +1,136 @@
+package feishu
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"github.com/abahmed/kwatch/constant"
+	"github.com/abahmed/kwatch/event"
+	"github.com/sirupsen/logrus"
+	"io"
+	"net/http"
+	"strings"
+)
+
+type FeiShu struct {
+	webhook string
+	title   string
+}
+
+type feiShuWebhookContent struct {
+	Tag     string `json:"tag"`
+	Content string `json:"content"`
+}
+
+// NewFeiShu returns new feishu web bot instance
+func NewFeiShu(config map[string]string) *FeiShu {
+	webhook, ok := config["webhook"]
+	if !ok || len(webhook) == 0 {
+		logrus.Warnf("initializing Fei Shu with empty webhook url")
+		return nil
+	}
+
+	logrus.Infof("initializing Fei Shu with webhook url: %s", webhook)
+
+	return &FeiShu{
+		webhook: webhook,
+		title:   config["title"],
+	}
+}
+
+// Name returns name of the provider
+func (r *FeiShu) Name() string {
+	return "Fei Shu"
+}
+
+// SendEvent sends event to the provider
+func (r *FeiShu) SendEvent(e *event.Event) error {
+	return r.sendByFeiShuApi(r.buildRequestBodyFeiShu(e, ""))
+}
+
+func (r *FeiShu) sendByFeiShuApi(reqBody string) error {
+	client := &http.Client{}
+	buffer := bytes.NewBuffer([]byte(reqBody))
+	request, err := http.NewRequest(http.MethodPost, r.webhook, buffer)
+	if err != nil {
+		return err
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+
+	response, err := client.Do(request)
+	if err != nil {
+		return err
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != 200 {
+		body, _ := io.ReadAll(response.Body)
+		return fmt.Errorf(
+			"call to rocket chat alert returned status code %d: %s",
+			response.StatusCode,
+			string(body))
+	}
+
+	return nil
+}
+
+// SendMessage sends text message to the provider
+func (r *FeiShu) SendMessage(msg string) error {
+	return r.sendByFeiShuApi(
+		r.buildRequestBodyFeiShu(new(event.Event), msg),
+	)
+}
+
+func (r *FeiShu) buildRequestBodyFeiShu(
+	e *event.Event,
+	customMsg string) string {
+	// add events part if it exists
+	eventsText := constant.DefaultEvents
+	events := strings.TrimSpace(e.Events)
+	if len(events) > 0 {
+		eventsText = e.Events
+	}
+
+	// add logs part if it exist
+	logsText := constant.DefaultLogs
+	logs := strings.TrimSpace(e.Logs)
+	if len(logs) > 0 {
+		logsText = e.Logs
+	}
+
+	// build text will be sent in the message use custom text if it's provided,
+	// otherwise use default
+	text := ""
+	if len(customMsg) <= 0 {
+		text = fmt.Sprintf(
+			"**Pod:** %s\n"+
+				"**Container:** %s\n"+
+				"**Namespace:** %s\n"+
+				"**Reason:** %s\n"+
+				"**Events:**\n```\n%s\n```\n"+
+				"**Logs:**\n```\n%s\n```",
+			e.Name,
+			e.Container,
+			e.Namespace,
+			e.Reason,
+			eventsText,
+			logsText,
+		)
+	} else {
+		text = customMsg
+	}
+	var content = []feiShuWebhookContent{
+		{
+			Tag:     "markdown",
+			Content: text,
+		},
+	}
+
+	jsonBytes, _ := json.Marshal(content)
+
+	body := "{\"msg_type\": \"interactive\",\"card\": {\"config\": {\"wide_screen_mode\": true},\"header\": {\"title\": {\"tag\": \"plain_text\",\"content\": \"" +
+		r.title +
+		"\"},\"template\": \"blue\"},\"elements\": " + string(jsonBytes) + "}}"
+	return body
+}


Test patch:
diff --git a/alertmanager/feishu/feishu_test.go b/alertmanager/feishu/feishu_test.go
new file mode 100644
index 0000000..586bfe2
--- /dev/null
+++ b/alertmanager/feishu/feishu_test.go
@@ -0,0 +1,141 @@
+package feishu
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/abahmed/kwatch/event"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestEmptyConfig(t *testing.T) {
+	assertions := assert.New(t)
+
+	c := NewFeiShu(map[string]string{})
+	assertions.Nil(c)
+}
+
+func TestRocketChat(t *testing.T) {
+	assertions := assert.New(t)
+
+	config := map[string]string{
+		"webhook": "testtest",
+	}
+	c := NewFeiShu(config)
+	assertions.NotNil(c)
+
+	assertions.Equal(c.Name(), "Fei Shu")
+}
+
+func TestBuildRequestBodyFeiShu(t *testing.T) {
+	assertions := assert.New(t)
+	s := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Write([]byte(`{"isOk": true}`))
+		}))
+
+	defer s.Close()
+
+	config := map[string]string{
+		"webhook": s.URL,
+	}
+	c := NewFeiShu(config)
+	assertions.NotNil(c)
+	ev := event.Event{
+		Name:      "test-pod",
+		Container: "test-container",
+		Namespace: "default",
+		Reason:    "OOMKILLED",
+		Logs:      "test\ntestlogs",
+		Events:    "test",
+	}
+	expectMessage := "{\"msg_type\": \"interactive\",\"card\": {\"config\": {\"wide_screen_mode\": true},\"header\": {\"title\": {\"tag\": \"plain_text\",\"content\": \"\"},\"template\": \"blue\"},\"elements\": [{\"tag\":\"markdown\",\"content\":\"**Pod:** test-pod\\n**Container:** test-container\\n**Namespace:** default\\n**Reason:** OOMKILLED\\n**Events:**\\n```\\ntest\\n```\\n**Logs:**\\n```\\ntest\\ntestlogs\\n```\"}]}}"
+	assertions.Equal(expectMessage, c.buildRequestBodyFeiShu(&ev, ""))
+}
+
+func TestSendMessage(t *testing.T) {
+	assertions := assert.New(t)
+
+	s := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Write([]byte(`{"isOk": true}`))
+		}))
+
+	defer s.Close()
+
+	config := map[string]string{
+		"webhook": s.URL,
+	}
+	c := NewFeiShu(config)
+	assertions.NotNil(c)
+
+	assertions.Nil(c.SendMessage("test"))
+}
+
+func TestSendMessageError(t *testing.T) {
+	assertions := assert.New(t)
+
+	s := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusBadGateway)
+		}))
+
+	defer s.Close()
+
+	config := map[string]string{
+		"webhook": s.URL,
+	}
+	c := NewFeiShu(config)
+	assertions.NotNil(c)
+
+	assertions.NotNil(c.SendMessage("test"))
+}
+
+func TestSendEvent(t *testing.T) {
+	assertions := assert.New(t)
+
+	s := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Write([]byte(`{"isOk": true}`))
+		}))
+
+	defer s.Close()
+
+	config := map[string]string{
+		"webhook": s.URL,
+	}
+	c := NewFeiShu(config)
+	assertions.NotNil(c)
+
+	ev := event.Event{
+		Name:      "test-pod",
+		Container: "test-container",
+		Namespace: "default",
+		Reason:    "OOMKILLED",
+		Logs:      "test\ntestlogs",
+		Events: "event1-event2-event3-event1-event2-event3-event1-event2-" +
+			"event3\nevent5\nevent6-event8-event11-event12",
+	}
+	assertions.Nil(c.SendEvent(&ev))
+}
+
+func TestInvalidHttpRequest(t *testing.T) {
+	assertions := assert.New(t)
+
+	config := map[string]string{
+		"webhook": "h ttp://localhost",
+	}
+	c := NewFeiShu(config)
+	assertions.NotNil(c)
+
+	assertions.NotNil(c.SendMessage("test"))
+
+	config = map[string]string{
+		"webhook": "http://localhost:132323",
+	}
+	c = NewFeiShu(config)
+	assertions.NotNil(c)
+
+	assertions.NotNil(c.SendMessage("test"))
+}