|
@@ -0,0 +1,218 @@
|
|
|
+// Copyright 2018 Unknwon
|
|
|
+//
|
|
|
+// Licensed under the Apache License, Version 2.0 (the "License"): you may
|
|
|
+// not use this file except in compliance with the License. You may obtain
|
|
|
+// a copy of the License at
|
|
|
+//
|
|
|
+// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
+//
|
|
|
+// Unless required by applicable law or agreed to in writing, software
|
|
|
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
|
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
|
+// License for the specific language governing permissions and limitations
|
|
|
+// under the License.
|
|
|
+
|
|
|
+package clog
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "encoding/json"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "io/ioutil"
|
|
|
+ "net/http"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+type (
|
|
|
+ discordEmbed struct {
|
|
|
+ Title string `json:"title"`
|
|
|
+ Description string `json:"description"`
|
|
|
+ Timestamp string `json:"timestamp"`
|
|
|
+ Color int `json:"color"`
|
|
|
+ }
|
|
|
+
|
|
|
+ discordPayload struct {
|
|
|
+ Username string `json:"username,omitempty"`
|
|
|
+ Embeds []*discordEmbed `json:"embeds"`
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+var (
|
|
|
+ discordTitles = []string{
|
|
|
+ "Tracing",
|
|
|
+ "Information",
|
|
|
+ "Warning",
|
|
|
+ "Error",
|
|
|
+ "Fatal",
|
|
|
+ }
|
|
|
+
|
|
|
+ discordColors = []int{
|
|
|
+ 0, // Trace
|
|
|
+ 3843043, // Info
|
|
|
+ 16761600, // Warn
|
|
|
+ 13041721, // Error
|
|
|
+ 9440319, // Fatal
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+type DiscordConfig struct {
|
|
|
+ // Minimum level of messages to be processed.
|
|
|
+ Level LEVEL
|
|
|
+ // Buffer size defines how many messages can be queued before hangs.
|
|
|
+ BufferSize int64
|
|
|
+ // Discord webhook URL.
|
|
|
+ URL string
|
|
|
+ // Username to be shown for the message.
|
|
|
+ // Leave empty to use default as set in the Discord.
|
|
|
+ Username string
|
|
|
+}
|
|
|
+
|
|
|
+type discord struct {
|
|
|
+ Adapter
|
|
|
+
|
|
|
+ url string
|
|
|
+ username string
|
|
|
+}
|
|
|
+
|
|
|
+func newDiscord() Logger {
|
|
|
+ return &discord{
|
|
|
+ Adapter: Adapter{
|
|
|
+ quitChan: make(chan struct{}),
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (d *discord) Level() LEVEL { return d.level }
|
|
|
+
|
|
|
+func (d *discord) Init(v interface{}) error {
|
|
|
+ cfg, ok := v.(DiscordConfig)
|
|
|
+ if !ok {
|
|
|
+ return ErrConfigObject{"DiscordConfig", v}
|
|
|
+ }
|
|
|
+
|
|
|
+ if !isValidLevel(cfg.Level) {
|
|
|
+ return ErrInvalidLevel{}
|
|
|
+ }
|
|
|
+ d.level = cfg.Level
|
|
|
+
|
|
|
+ if len(cfg.URL) == 0 {
|
|
|
+ return errors.New("URL cannot be empty")
|
|
|
+ }
|
|
|
+ d.url = cfg.URL
|
|
|
+ d.username = cfg.Username
|
|
|
+
|
|
|
+ d.msgChan = make(chan *Message, cfg.BufferSize)
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (d *discord) ExchangeChans(errorChan chan<- error) chan *Message {
|
|
|
+ d.errorChan = errorChan
|
|
|
+ return d.msgChan
|
|
|
+}
|
|
|
+
|
|
|
+func buildDiscordPayload(username string, msg *Message) (string, error) {
|
|
|
+ payload := discordPayload{
|
|
|
+ Username: username,
|
|
|
+ Embeds: []*discordEmbed{
|
|
|
+ {
|
|
|
+ Title: discordTitles[msg.Level],
|
|
|
+ Description: msg.Body[8:],
|
|
|
+ Timestamp: time.Now().Format(time.RFC3339),
|
|
|
+ Color: discordColors[msg.Level],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ p, err := json.Marshal(&payload)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ return string(p), nil
|
|
|
+}
|
|
|
+
|
|
|
+type rateLimitMsg struct {
|
|
|
+ RetryAfter int64 `json:"retry_after"`
|
|
|
+}
|
|
|
+
|
|
|
+func (d *discord) postMessage(r io.Reader) (int64, error) {
|
|
|
+ resp, err := http.Post(d.url, "application/json", r)
|
|
|
+ if err != nil {
|
|
|
+ return -1, fmt.Errorf("HTTP Post: %v", err)
|
|
|
+ }
|
|
|
+ defer resp.Body.Close()
|
|
|
+
|
|
|
+ if resp.StatusCode == 429 {
|
|
|
+ rlMsg := &rateLimitMsg{}
|
|
|
+ if err = json.NewDecoder(resp.Body).Decode(&rlMsg); err != nil {
|
|
|
+ return -1, fmt.Errorf("decode rate limit message: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return rlMsg.RetryAfter, nil
|
|
|
+ } else if resp.StatusCode/100 != 2 {
|
|
|
+ data, _ := ioutil.ReadAll(resp.Body)
|
|
|
+ return -1, fmt.Errorf("%s", data)
|
|
|
+ }
|
|
|
+
|
|
|
+ return -1, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (d *discord) write(msg *Message) {
|
|
|
+ payload, err := buildDiscordPayload(d.username, msg)
|
|
|
+ if err != nil {
|
|
|
+ d.errorChan <- fmt.Errorf("discord: builddiscordPayload: %v", err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const RETRY_TIMES = 3
|
|
|
+ // Due to discord limit, try at most x times with respect to "retry_after" parameter.
|
|
|
+ for i := 1; i <= 3; i++ {
|
|
|
+ retryAfter, err := d.postMessage(bytes.NewReader([]byte(payload)))
|
|
|
+ if err != nil {
|
|
|
+ d.errorChan <- fmt.Errorf("discord: postMessage: %v", err)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if retryAfter > 0 {
|
|
|
+ time.Sleep(time.Duration(retryAfter) * time.Millisecond)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ d.errorChan <- fmt.Errorf("discord: failed to send message after %d retries", RETRY_TIMES)
|
|
|
+}
|
|
|
+
|
|
|
+func (d *discord) Start() {
|
|
|
+LOOP:
|
|
|
+ for {
|
|
|
+ select {
|
|
|
+ case msg := <-d.msgChan:
|
|
|
+ d.write(msg)
|
|
|
+ case <-d.quitChan:
|
|
|
+ break LOOP
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for {
|
|
|
+ if len(d.msgChan) == 0 {
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ d.write(<-d.msgChan)
|
|
|
+ }
|
|
|
+ d.quitChan <- struct{}{} // Notify the cleanup is done.
|
|
|
+}
|
|
|
+
|
|
|
+func (d *discord) Destroy() {
|
|
|
+ d.quitChan <- struct{}{}
|
|
|
+ <-d.quitChan
|
|
|
+
|
|
|
+ close(d.msgChan)
|
|
|
+ close(d.quitChan)
|
|
|
+}
|
|
|
+
|
|
|
+func init() {
|
|
|
+ Register(DISCORD, newDiscord)
|
|
|
+}
|