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 SendCallRequest(ctx context.Context, request *model.CallRequest) error SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, 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"` ReplyToMessageID *int64 `json:"reply_to_message_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)) } type sendMessageResponse struct { OK bool `json:"ok"` Result *messageResponse `json:"result,omitempty"` Description string `json:"description,omitempty"` } type messageResponse struct { MessageID int64 `json:"message_id"` Date int64 `json:"date"` Chat messageChat `json:"chat"` From *messageUser `json:"from,omitempty"` Text string `json:"text"` ReplyToMessage *messageResponse `json:"reply_to_message,omitempty"` } type messageChat struct { ID int64 `json:"id"` } type messageUser struct { ID int64 `json:"id"` Username string `json:"username,omitempty"` } func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) (*model.TelegramMessage, error) { body, err := json.Marshal(&payload) if err != nil { c.logger.Warn("Failed to marshal telegram payload", zap.Error(err)) return nil, 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 nil, 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)) } if payload.ReplyToMessageID != nil { fields = append(fields, zap.Int64("reply_to_message_id", *payload.ReplyToMessageID)) } 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 nil, err } defer resp.Body.Close() respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10)) if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { var parsed sendMessageResponse if err := json.Unmarshal(respBody, &parsed); err != nil { c.logger.Warn("Failed to decode telegram response", zap.Error(err)) return nil, err } if !parsed.OK || parsed.Result == nil { msg := "telegram sendMessage response missing result" if parsed.Description != "" { msg = parsed.Description } return nil, merrors.Internal(msg) } c.logger.Debug("Telegram message sent", zap.Int("status_code", resp.StatusCode), zap.Duration("latency", time.Since(start))) return toTelegramMessage(parsed.Result), nil } 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 nil, 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) SendCallRequest(ctx context.Context, request *model.CallRequest) error { if request == nil { return merrors.InvalidArgument("call request payload is nil", "request") } return c.sendForm(ctx, newCallRequestTemplate(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, } _, err := c.sendMessage(ctx, payload) return err } func (c *client) SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) { chatID = strings.TrimSpace(chatID) if chatID == "" { chatID = c.chatID } if chatID == "" { return nil, merrors.InvalidArgument("telegram chat id is empty", "chat_id") } payload := sendMessagePayload{ ChatID: chatID, Text: text, ParseMode: c.parseMode.String(), ThreadID: c.threadID, DisablePreview: true, } if replyToMessageID != "" { val, err := strconv.ParseInt(replyToMessageID, 10, 64) if err != nil { return nil, merrors.InvalidArgumentWrap(err, "invalid reply_to_message_id", "reply_to_message_id") } payload.ReplyToMessageID = &val } return c.sendMessage(ctx, payload) } func toTelegramMessage(msg *messageResponse) *model.TelegramMessage { if msg == nil { return nil } result := &model.TelegramMessage{ ChatID: strconv.FormatInt(msg.Chat.ID, 10), MessageID: strconv.FormatInt(msg.MessageID, 10), Text: msg.Text, SentAt: msg.Date, } if msg.From != nil { result.FromUserID = strconv.FormatInt(msg.From.ID, 10) result.FromUsername = msg.From.Username } if msg.ReplyToMessage != nil { result.ReplyToMessageID = strconv.FormatInt(msg.ReplyToMessage.MessageID, 10) } return result }