package telegram import ( "bytes" "context" "encoding/json" "fmt" "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 SendContactRequest(ctx context.Context, request *model.ContactRequest) error } type client struct { logger mlogger.Logger httpClient *http.Client apiURL string botToken string chatID string threadID *int64 parseMode parseMode } 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 } mode := normalizeParseMode(cfg.ParseMode) if mode == parseModeUnset { mode = parseModeMarkdown } return &client{ logger: logger.Named("telegram"), httpClient: &http.Client{ Timeout: timeout, }, apiURL: strings.TrimRight(apiURL, "/"), botToken: token, chatID: chatID, threadID: threadID, parseMode: mode, }, nil } func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest) error { if request == nil { return merrors.InvalidArgument("demo request payload is nil", "request") } return c.sendForm(ctx, newDemoRequestTemplate(request)) } 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 (c *client) SendContactRequest(ctx context.Context, request *model.ContactRequest) error { if request == nil { return merrors.InvalidArgument("contact request payload is nil", "request") } return c.sendForm(ctx, newContactRequestTemplate(request)) } func (c *client) sendForm(ctx context.Context, template messageTemplate) error { message := template.Format(c.parseMode) payload := sendMessagePayload{ ChatID: c.chatID, Text: message, ParseMode: c.parseMode.String(), ThreadID: c.threadID, DisablePreview: true, } return c.sendMessage(ctx, payload) }