1
0
Fork 0
golang-github-nicholas-fedo.../pkg/services/lark/lark_service.go
Daniel Baumann bc0f764250
Merging upstream version 0.8.13.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-05-29 07:18:02 +02:00

237 lines
6 KiB
Go

package lark
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Constants for the Lark service configuration and limits.
const (
apiFormat = "https://%s/open-apis/bot/v2/hook/%s" // API endpoint format
maxLength = 4096 // Maximum message length in bytes
defaultTime = 30 * time.Second // Default HTTP client timeout
)
const (
larkHost = "open.larksuite.com"
feishuHost = "open.feishu.cn"
)
// Error variables for the Lark service.
var (
ErrInvalidHost = errors.New("invalid host, use 'open.larksuite.com' or 'open.feishu.cn'")
ErrNoPath = errors.New(
"no path, path like 'xxx' in 'https://open.larksuite.com/open-apis/bot/v2/hook/xxx'",
)
ErrLargeMessage = errors.New("message exceeds the max length")
ErrMissingHost = errors.New("host is required but not specified in the configuration")
ErrSendFailed = errors.New("failed to send notification to Lark")
ErrInvalidSignature = errors.New("failed to generate valid signature")
)
// httpClient is configured with a default timeout.
var httpClient = &http.Client{Timeout: defaultTime}
// Service sends notifications to Lark.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Lark.
func (service *Service) Send(message string, params *types.Params) error {
if len(message) > maxLength {
return ErrLargeMessage
}
config := *service.Config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return fmt.Errorf("updating params: %w", err)
}
if config.Host != larkHost && config.Host != feishuHost {
return ErrInvalidHost
}
if config.Path == "" {
return ErrNoPath
}
return service.doSend(config, message, params)
}
// Initialize configures the service with a URL and logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{}
service.pkr = format.NewPropKeyResolver(service.Config)
return service.Config.SetURL(configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// doSend sends the notification to Lark using the configured API URL.
func (service *Service) doSend(config Config, message string, params *types.Params) error {
if config.Host == "" {
return ErrMissingHost
}
postURL := fmt.Sprintf(apiFormat, config.Host, config.Path)
payload, err := service.preparePayload(message, config, params)
if err != nil {
return err
}
return service.sendRequest(postURL, payload)
}
// preparePayload constructs and marshals the request payload for the Lark API.
func (service *Service) preparePayload(
message string,
config Config,
params *types.Params,
) ([]byte, error) {
body := service.getRequestBody(message, config.Title, config.Secret, params)
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshaling payload to JSON: %w", err)
}
service.Logf("Lark Request Body: %s", string(data))
return data, nil
}
// sendRequest performs the HTTP POST request to the Lark API and handles the response.
func (service *Service) sendRequest(postURL string, payload []byte) error {
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
postURL,
bytes.NewReader(payload),
)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("%w: making HTTP request: %w", ErrSendFailed, err)
}
defer resp.Body.Close()
return service.handleResponse(resp)
}
// handleResponse processes the API response and checks for errors.
func (service *Service) handleResponse(resp *http.Response) error {
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%w: unexpected status %s", ErrSendFailed, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body: %w", err)
}
var response Response
if err := json.Unmarshal(data, &response); err != nil {
return fmt.Errorf("unmarshaling response: %w", err)
}
if response.Code != 0 {
return fmt.Errorf(
"%w: server returned code %d: %s",
ErrSendFailed,
response.Code,
response.Msg,
)
}
service.Logf(
"Notification sent successfully to %s/%s",
service.Config.Host,
service.Config.Path,
)
return nil
}
// genSign generates a signature for the request using the secret and timestamp.
func (service *Service) genSign(secret string, timestamp int64) (string, error) {
stringToSign := fmt.Sprintf("%v\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(stringToSign))
if _, err := h.Write([]byte{}); err != nil {
return "", fmt.Errorf("%w: computing HMAC: %w", ErrInvalidSignature, err)
}
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
// getRequestBody constructs the request body for the Lark API, supporting rich content via params.
func (service *Service) getRequestBody(
message, title, secret string,
params *types.Params,
) *RequestBody {
body := &RequestBody{}
if secret != "" {
ts := time.Now().Unix()
body.Timestamp = strconv.FormatInt(ts, 10)
sign, err := service.genSign(secret, ts)
if err != nil {
sign = "" // Fallback to empty string on error
}
body.Sign = sign
}
if title == "" {
body.MsgType = MsgTypeText
body.Content.Text = message
} else {
body.MsgType = MsgTypePost
content := [][]Item{{{Tag: TagValueText, Text: message}}}
if params != nil {
if link, ok := (*params)["link"]; ok && link != "" {
content = append(content, []Item{{Tag: TagValueLink, Text: "More Info", Link: link}})
}
}
body.Content.Post = &Post{
En: &Message{
Title: title,
Content: content,
},
}
}
return body
}