commit 6011b9f200e7f1c368353b867bf6de4524388b75 Author: Sergey Solodyagin Date: Thu Apr 4 00:02:34 2024 +0300 first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36f025c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 andoma + +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..cc24beb --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Telegram Logrus Hook + +This hook emits log messages (and corresponding fields) to the Telegram API for [logrus](https://git.company.lan/gopkg/logrus). + +## Installation + +Install the package with: + +``` +go get git.company.lan/gopkg/telegramhook +``` + +## Usage + +See the tests for working examples. Also: + +```go +import ( + "time" + + log "git.company.lan/gopkg/logrus" + "git.company.lan/gopkg/telegramhook" +) + +func main() { + hook, err := telegramhook.NewTelegramHook( + "MyCoolApp", + "MYTELEGRAMTOKEN", + "@mycoolusername", + telegramhook.WithAsync(true), + telegramhook.WithTimeout(30 * time.Second), + telegramhook.WithLevel(logrus.ErrorLevel), + ) + if err != nil { + log.Fatalf("Encountered error when creating Telegram hook: %s", err) + } + log.AddHook(hook) + + // Receive messages on failures + log.Errorf("Uh oh...") + ... + +} +``` + +Also you can set custom http.Client to use SOCKS5 proxy for example + +```go +import ( + "context" + "net" + "net/http" + "time" + + log "git.company.lan/gopkg/logrus" + "git.company.lan/gopkg/telegramhook" + "golang.org/x/net/proxy" +) + +func main() { + dialer, err := proxy.SOCKS5("tcp", "127.0.0.1:54321", nil, proxy.Direct) + dialContext := func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.Dial(network, address) + } + httpTransport := &http.Transport{ + DialContext: dialContext, + DisableKeepAlives: true, + } + httpClient := &http.Client{Transport: httpTransport} + + hook, err := telegramhook.NewTelegramHookWithClient( + "MyCoolApp", + "MYTELEGRAMTOKEN", + "@mycoolusername", + httpClient, + telegramhook.WithAsync(true), + telegramhook.WithTimeout(30 * time.Second), + ) + if err != nil { + log.Fatalf("Encountered error when creating Telegram hook: %s", err) + } + log.AddHook(hook) + + // Receive messages on failures + log.Errorf("Uh oh...") + ... + +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4c9122e --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.company.lan/gopkg/telegramhook + +go 1.21.5 + +require git.company.lan/gopkg/logrus v0.0.0-20240403185826-6e11474d0c90 + +require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fe45ff8 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +git.company.lan/gopkg/logrus v0.0.0-20240403185826-6e11474d0c90 h1:7lQC66WPJfhfy4VAVzxcY+vS7atQaHYSxAJSdGpV/1k= +git.company.lan/gopkg/logrus v0.0.0-20240403185826-6e11474d0c90/go.mod h1:ZkKoQvI1GMra/NL4xFGq/hfazVL5aWZAUUC5WvBXt+4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/telegramhook.go b/telegramhook.go new file mode 100644 index 0000000..0897cd7 --- /dev/null +++ b/telegramhook.go @@ -0,0 +1,321 @@ +package telegramhook + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "git.company.lan/gopkg/logrus" +) + +// TelegramHook to send logs via the Telegram API. +type TelegramHook struct { + client *http.Client + mu sync.RWMutex + appName string + authToken string + chatId string + threadId string + level logrus.Level + async bool +} + +// Option defines a method for additional configuration when instantiating TelegramHook +type Option func(*TelegramHook) + +// Async sets logging to telegram as asynchronous +func WithAsync(async bool) Option { + return func(h *TelegramHook) { + h.SetAsync(async) + } +} + +// Timeout sets http call timeout for telegram client +func WithTimeout(timeout time.Duration) Option { + return func(h *TelegramHook) { + if timeout > 0 { + h.client.Timeout = timeout + } + } +} + +// WithLevel set level +func WithLevel(level logrus.Level) Option { + return func(h *TelegramHook) { + h.SetLevel(level) + } +} + +// New creates a new instance of a hook targeting the Telegram API. +func NewTelegramHook(appName, authToken, chatId, threadId string, options ...Option) (*TelegramHook, error) { + client := &http.Client{} + return NewTelegramHookWithClient(appName, authToken, chatId, threadId, client, options...) +} + +// NewTelegramHookWithClient creates a new instance of a hook targeting the Telegram API with custom http.Client. +func NewTelegramHookWithClient(appName, authToken, chatId, threadId string, client *http.Client, options ...Option) (*TelegramHook, error) { + h := TelegramHook{ + client: client, + appName: appName, + authToken: authToken, + chatId: chatId, + threadId: threadId, + level: logrus.ErrorLevel, + async: false, + } + + for _, opt := range options { + opt(&h) + } + + // Verify the API token is valid and correct before continuing + if err := h.verifyToken(); err != nil { + return nil, err + } + + return &h, nil +} + +// apiRequest encapsulates the request structure we are sending to the Telegram API. +type apiRequest struct { + ChatId string `json:"chat_id"` + ThreadId string `json:"message_thread_id,omitempty"` + Text string `json:"text"` + ParseMode string `json:"parse_mode,omitempty"` +} + +// apiResponse encapsulates the response structure received from the Telegram API. +type apiResponse struct { + Ok bool `json:"ok"` + ErrorCode *int `json:"error_code,omitempty"` + Desc *string `json:"description,omitempty"` + Result *interface{} `json:"result,omitempty"` +} + +// verifyToken issues a test request to the Telegram API to ensure the provided token is correct and valid. +func (h *TelegramHook) verifyToken() error { + endpoint, _ := url.JoinPath(h.ApiEndpoint(), "getMe") + + res, err := h.client.Get(endpoint) + if err != nil { + return err + } + defer res.Body.Close() + + apiRes := apiResponse{} + if err := json.NewDecoder(res.Body).Decode(&apiRes); err != nil { + return err + } + + if !apiRes.Ok { + // Received an error from the Telegram API + msg := "Received error response from Telegram API" + + if apiRes.ErrorCode != nil { + msg = fmt.Sprintf("%s (error code %d)", msg, *apiRes.ErrorCode) + } + + if apiRes.Desc != nil { + msg = fmt.Sprintf("%s: %s", msg, *apiRes.Desc) + } + + j, _ := json.MarshalIndent(apiRes, "", "\t") + return fmt.Errorf("%s\n%s", msg, j) + } + + return nil +} + +// sendMessage issues the provided message to the Telegram API. +func (h *TelegramHook) sendMessage(msg string) error { + apiReq := apiRequest{ + ChatId: h.ChatId(), + ThreadId: h.ThreadId(), + Text: msg, + ParseMode: "HTML", + } + b, err := json.Marshal(apiReq) + if err != nil { + return err + } + + endpoint, _ := url.JoinPath(h.ApiEndpoint(), "sendMessage") + + res, err := h.client.Post(endpoint, "application/json", bytes.NewReader(b)) + if err != nil { + fmt.Fprintf(os.Stderr, "Encountered error when issuing request to Telegram API, %v", err) + return err + } + defer res.Body.Close() + + apiRes := apiResponse{} + if err := json.NewDecoder(res.Body).Decode(&apiRes); err != nil { + return err + } + + if !apiRes.Ok { + // Received an error from the Telegram API + msg := "Received error response from Telegram API" + + if apiRes.ErrorCode != nil { + msg = fmt.Sprintf("%s (error code %d)", msg, *apiRes.ErrorCode) + } + + if apiRes.Desc != nil { + msg = fmt.Sprintf("%s: %s", msg, *apiRes.Desc) + } + + return fmt.Errorf(msg) + } + + return nil +} + +// createMessage crafts an HTML-formatted message to send to the Telegram API. +func (h *TelegramHook) createMessage(entry *logrus.Entry) string { + var msg string + + switch entry.Level { + case logrus.PanicLevel: + msg = "PANIC" + case logrus.FatalLevel: + msg = "FATAL" + case logrus.ErrorLevel: + msg = "ERROR" + case logrus.WarnLevel: + msg = "WARNING" + case logrus.InfoLevel: + msg = "INFO" + case logrus.DebugLevel: + msg = "DEBUG" + } + + msg = strings.Join([]string{msg, h.AppName()}, "@") + msg = strings.Join([]string{msg, entry.Message}, " - ") + + if len(entry.Data) > 0 { + msg = strings.Join([]string{msg, "
"}, "\n")
+		for k, v := range entry.Data {
+			msg = strings.Join([]string{msg, html.EscapeString(fmt.Sprintf("\t%s: %+v", k, v))}, "\n")
+		}
+		msg = strings.Join([]string{msg, "
"}, "\n") + } + + return msg +} + +// Levels returns the log levels that the hook should be enabled for. +func (h *TelegramHook) Levels() []logrus.Level { + h.mu.RLock() + defer h.mu.RUnlock() + return logrus.AllLevels[:h.level+1] +} + +// Fire emits a log message to the Telegram API. +func (h *TelegramHook) Fire(entry *logrus.Entry) error { + msg := h.createMessage(entry) + + if h.Async() { + go h.sendMessage(msg) + return nil + } + + if err := h.sendMessage(msg); err != nil { + fmt.Fprintf(os.Stderr, "Unable to send message, %v", err) + return err + } + + return nil +} + +// ApiEndpoint +func (h *TelegramHook) ApiEndpoint() string { + h.mu.RLock() + defer h.mu.RUnlock() + return fmt.Sprintf("https://api.telegram.org/bot%s", h.authToken) +} + +// AppName +func (h *TelegramHook) AppName() string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.appName +} + +func (h *TelegramHook) SetAppName(appName string) { + h.mu.Lock() + defer h.mu.Unlock() + h.appName = appName +} + +// AuthToken +func (h *TelegramHook) AuthToken() string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.authToken +} + +func (h *TelegramHook) SetAuthToken(authToken string) { + h.mu.Lock() + defer h.mu.Unlock() + h.authToken = authToken +} + +// ChatId +func (h *TelegramHook) ChatId() string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.chatId +} + +func (h *TelegramHook) SetChatId(chatId string) { + h.mu.Lock() + defer h.mu.Unlock() + h.chatId = chatId +} + +// ThreadId +func (h *TelegramHook) ThreadId() string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.threadId +} + +func (h *TelegramHook) SetThreadId(threadId string) { + h.mu.Lock() + defer h.mu.Unlock() + h.threadId = threadId +} + +// Level +func (h *TelegramHook) Level() logrus.Level { + h.mu.RLock() + defer h.mu.RUnlock() + return h.level +} + +func (h *TelegramHook) SetLevel(level logrus.Level) { + h.mu.Lock() + defer h.mu.Unlock() + h.level = level +} + +// Async +func (h *TelegramHook) Async() bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.async +} + +func (h *TelegramHook) SetAsync(async bool) { + h.mu.Lock() + defer h.mu.Unlock() + h.async = async +} diff --git a/telegramhook_test.go b/telegramhook_test.go new file mode 100644 index 0000000..94d8b30 --- /dev/null +++ b/telegramhook_test.go @@ -0,0 +1,34 @@ +package telegramhook + +import ( + "errors" + "os" + "testing" + + log "git.company.lan/gopkg/logrus" +) + +func TestNewTelegramHook(t *testing.T) { + _, err := NewTelegramHook("", "", "", "") + if err == nil { + t.Errorf("No error on invalid Telegram API token.") + } + + _, err = NewTelegramHook("", os.Getenv("TELEGRAM_TOKEN"), "", "") + if err != nil { + t.Fatalf("Error on valid Telegram API token: %s", err) + } + + h, _ := NewTelegramHook("testing", os.Getenv("TELEGRAM_TOKEN"), os.Getenv("TELEGRAM_TARGET"), "") + if err != nil { + t.Fatalf("Error on valid Telegram API token and target: %s", err) + } + log.AddHook(h) + + log.WithError(errors.New("an error")).WithFields(log.Fields{ + "animal": "walrus", + "number": 1, + "size": 10, + "html": "bold", + }).Errorf("A walrus appears") +}