Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
251 lines
6.8 KiB
Go
251 lines
6.8 KiB
Go
package telegram
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
notconfig "github.com/tech/sendico/notification/interface/services/notification/config"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/mlogger"
|
|
"github.com/tech/sendico/pkg/model"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const defaultAPIURL = "https://api.telegram.org"
|
|
|
|
type Client interface {
|
|
SendDemoRequest(ctx context.Context, request *model.DemoRequest) error
|
|
}
|
|
|
|
type client struct {
|
|
logger mlogger.Logger
|
|
httpClient *http.Client
|
|
apiURL string
|
|
botToken string
|
|
chatID string
|
|
threadID *int64
|
|
parseMode string
|
|
}
|
|
|
|
type sendMessagePayload struct {
|
|
ChatID string `json:"chat_id"`
|
|
Text string `json:"text"`
|
|
ParseMode string `json:"parse_mode,omitempty"`
|
|
ThreadID *int64 `json:"message_thread_id,omitempty"`
|
|
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
|
|
DisableNotify bool `json:"disable_notification,omitempty"`
|
|
ProtectContent bool `json:"protect_content,omitempty"`
|
|
}
|
|
|
|
func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) {
|
|
if cfg == nil {
|
|
return nil, merrors.InvalidArgument("telegram configuration is not provided", "config.notification.telegram")
|
|
}
|
|
token := strings.TrimSpace(os.Getenv(cfg.BotTokenEnv))
|
|
if token == "" {
|
|
return nil, merrors.InvalidArgument(fmt.Sprintf("telegram bot token env %s is empty", cfg.BotTokenEnv), cfg.BotTokenEnv)
|
|
}
|
|
chatID := strings.TrimSpace(os.Getenv(cfg.ChatIDEnv))
|
|
if chatID == "" {
|
|
return nil, merrors.InvalidArgument(fmt.Sprintf("telegram chat id env %s is empty", cfg.ChatIDEnv), cfg.ChatIDEnv)
|
|
}
|
|
|
|
var threadID *int64
|
|
if env := strings.TrimSpace(cfg.ThreadIDEnv); env != "" {
|
|
raw := strings.TrimSpace(os.Getenv(env))
|
|
if raw != "" {
|
|
val, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return nil, merrors.InvalidArgumentWrap(err, fmt.Sprintf("telegram thread id env %s is invalid", env), env)
|
|
}
|
|
threadID = &val
|
|
}
|
|
}
|
|
|
|
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
|
|
apiURL := strings.TrimSpace(cfg.APIURL)
|
|
if apiURL == "" {
|
|
apiURL = defaultAPIURL
|
|
}
|
|
parseMode := strings.TrimSpace(cfg.ParseMode)
|
|
if parseMode == "" {
|
|
parseMode = "Markdown"
|
|
}
|
|
|
|
return &client{
|
|
logger: logger.Named("telegram"),
|
|
httpClient: &http.Client{
|
|
Timeout: timeout,
|
|
},
|
|
apiURL: strings.TrimRight(apiURL, "/"),
|
|
botToken: token,
|
|
chatID: chatID,
|
|
threadID: threadID,
|
|
parseMode: parseMode,
|
|
}, nil
|
|
}
|
|
|
|
func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest) error {
|
|
if request == nil {
|
|
return merrors.InvalidArgument("demo request payload is nil", "request")
|
|
}
|
|
message := buildMessage(request, c.parseMode)
|
|
payload := sendMessagePayload{
|
|
ChatID: c.chatID,
|
|
Text: message,
|
|
ParseMode: c.parseMode,
|
|
ThreadID: c.threadID,
|
|
DisablePreview: true,
|
|
}
|
|
return c.sendMessage(ctx, payload)
|
|
}
|
|
|
|
func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) error {
|
|
body, err := json.Marshal(&payload)
|
|
if err != nil {
|
|
c.logger.Warn("Failed to marshal telegram payload", zap.Error(err))
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
|
|
if err != nil {
|
|
c.logger.Warn("Failed to create telegram request", zap.Error(err))
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
fields := []zap.Field{
|
|
zap.String("chat_id", payload.ChatID),
|
|
zap.Int("payload_size_bytes", len(body)),
|
|
zap.Bool("disable_preview", payload.DisablePreview),
|
|
zap.Bool("disable_notification", payload.DisableNotify),
|
|
zap.Bool("protect_content", payload.ProtectContent),
|
|
}
|
|
if payload.ThreadID != nil {
|
|
fields = append(fields, zap.Int64("thread_id", *payload.ThreadID))
|
|
}
|
|
c.logger.Debug("Sending Telegram message", fields...)
|
|
start := time.Now()
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
c.logger.Warn("Telegram request failed", zap.Error(err))
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
|
c.logger.Debug("Telegram message sent", zap.Int("status_code", resp.StatusCode), zap.Duration("latency", time.Since(start)))
|
|
return nil
|
|
}
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
|
c.logger.Warn("Telegram API returned non-success status",
|
|
zap.Int("status_code", resp.StatusCode),
|
|
zap.ByteString("response_body", respBody),
|
|
zap.String("chat_id", c.chatID))
|
|
return merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody)))
|
|
}
|
|
|
|
func (c *client) endpoint() string {
|
|
return fmt.Sprintf("%s/bot%s/sendMessage", c.apiURL, c.botToken)
|
|
}
|
|
|
|
func buildMessage(req *model.DemoRequest, parseMode string) string {
|
|
var builder strings.Builder
|
|
builder.WriteString("New demo request received\n")
|
|
builder.WriteString("-----------------------------\n")
|
|
|
|
formatter := selectValueFormatter(parseMode)
|
|
appendMessageField(&builder, "Name", req.Name, formatter)
|
|
appendMessageField(&builder, "Organization", req.OrganizationName, formatter)
|
|
appendMessageField(&builder, "Phone", req.Phone, formatter)
|
|
appendMessageField(&builder, "Work email", req.WorkEmail, formatter)
|
|
appendMessageField(&builder, "Payout volume", req.PayoutVolume, formatter)
|
|
if strings.TrimSpace(req.Comment) != "" {
|
|
appendMessageField(&builder, "Comment", req.Comment, formatter)
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
type valueFormatter func(string) string
|
|
|
|
func appendMessageField(builder *strings.Builder, label, value string, formatter valueFormatter) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
value = "—"
|
|
} else if formatter != nil {
|
|
value = formatter(value)
|
|
}
|
|
fmt.Fprintf(builder, "• %s: %s\n", label, value)
|
|
}
|
|
|
|
func selectValueFormatter(parseMode string) valueFormatter {
|
|
switch strings.ToLower(parseMode) {
|
|
case "markdown":
|
|
return func(value string) string {
|
|
return fmt.Sprintf("*%s*", escapeMarkdown(value))
|
|
}
|
|
case "markdownv2":
|
|
return func(value string) string {
|
|
return fmt.Sprintf("*%s*", escapeMarkdownV2(value))
|
|
}
|
|
case "html":
|
|
return func(value string) string {
|
|
return fmt.Sprintf("<b>%s</b>", html.EscapeString(value))
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var markdownEscaper = strings.NewReplacer(
|
|
"*", "\\*",
|
|
"_", "\\_",
|
|
"[", "\\[",
|
|
"]", "\\]",
|
|
"(", "\\(",
|
|
")", "\\)",
|
|
"`", "\\`",
|
|
)
|
|
|
|
var markdownV2Escaper = strings.NewReplacer(
|
|
"_", "\\_",
|
|
"*", "\\*",
|
|
"[", "\\[",
|
|
"]", "\\]",
|
|
"(", "\\(",
|
|
")", "\\)",
|
|
"~", "\\~",
|
|
"`", "\\`",
|
|
">", "\\>",
|
|
"#", "\\#",
|
|
"+", "\\+",
|
|
"-", "\\-",
|
|
"=", "\\=",
|
|
"|", "\\|",
|
|
"{", "\\{",
|
|
"}", "\\}",
|
|
".", "\\.",
|
|
"!", "\\!",
|
|
)
|
|
|
|
func escapeMarkdown(value string) string {
|
|
return markdownEscaper.Replace(value)
|
|
}
|
|
|
|
func escapeMarkdownV2(value string) string {
|
|
return markdownV2Escaper.Replace(value)
|
|
}
|