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("%s", 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) }