package telegram // TelegramService provides high-level operations for the Telegram channel. // Reference: Chatwoot app/services/telegram_service.rb — wraps channel model methods // // This service coordinates between: // - ChannelTelegram model (CRUD) // - TelegramProvider (Bot API calls) // - IncomingMessageProcessor (pipeline integration) // - OutgoingMessageProcessor (pipeline integration) // // Design: Service pattern follows Chatwoot's service layer where each channel // has a service module that wraps channel-specific business logic. import ( "context" "fmt" "time" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/go-resty/resty/v2" "gorm.io/gorm" applogger "github.com/gochat/gochat/pkg/logger" ) // TelegramService handles Telegram channel business logic. type TelegramService struct { client *resty.Client db *gorm.DB repo *Repository } // NewTelegramService creates a new Telegram service with GORM DB and repository. func NewTelegramService(repo *Repository) *TelegramService { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) return &TelegramService{ client: client, db: repo.db, repo: repo, } } // === CRUD Operations === // CreateChannel creates a new Telegram channel. // Reference: Chatwoot TelegramBotsController#create // Flow: validate bot_token → create ChannelTelegram → setup webhook → create Inbox func (s *TelegramService) CreateChannel(ctx context.Context, accountID uint, botToken string, welcomeMessage string) (*channelmodel.ChannelTelegram, error) { // Step 1: Validate bot token via getMe (Chatwoot: ensure_valid_bot_token) botName, err := s.validateBotToken(ctx, botToken) if err != nil { return nil, fmt.Errorf("bot token validation failed: %w", err) } ch := &channelmodel.ChannelTelegram{ AccountID: accountID, BotToken: botToken, BotName: botName, WelcomeMessage: welcomeMessage, } // Step 2: Setup webhook (Chatwoot: setup_telegram_webhook) webhookURL, err := s.setupWebhook(ctx, botToken) if err != nil { return nil, fmt.Errorf("webhook setup failed: %w", err) } ch.WebhookURL = webhookURL applogger.L().Info("Telegram channel created", "account_id", accountID, "bot_name", botName) return ch, nil } // UpdateChannel updates an existing Telegram channel. // Reference: Chatwoot TelegramBotsController#update // Only bot_token and welcome_message are editable (Chatwoot: EDITABLE_ATTRS = [:bot_token]) func (s *TelegramService) UpdateChannel(ctx context.Context, channelID uint, params map[string]interface{}) (*channelmodel.ChannelTelegram, error) { // Fetch existing ChannelTelegram from DB ch, err := s.repo.FindByID(ctx, channelID) if err != nil { return nil, fmt.Errorf("channel not found: %w", err) } newBotToken, _ := params["bot_token"].(string) if newBotToken != "" && newBotToken != ch.BotToken { // Re-validate and re-setup webhook if bot_token changed botName, err := s.validateBotToken(ctx, newBotToken) if err != nil { return nil, fmt.Errorf("new bot token validation failed: %w", err) } webhookURL, err := s.setupWebhook(ctx, newBotToken) if err != nil { return nil, fmt.Errorf("webhook setup for new token failed: %w", err) } ch.BotName = botName ch.BotToken = newBotToken ch.WebhookURL = webhookURL } if wm, ok := params["welcome_message"].(string); ok { ch.WelcomeMessage = wm } // Persist to DB via repository if err := s.repo.Update(ctx, ch); err != nil { return nil, fmt.Errorf("failed to update channel: %w", err) } return ch, nil } // DeleteChannel removes a Telegram channel and cleans up the webhook. // Reference: Chatwoot TelegramBotsController#destroy func (s *TelegramService) DeleteChannel(ctx context.Context, channelID uint, botToken string) error { // Delete webhook from Telegram _, err := s.client.R(). SetContext(ctx). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook?drop_pending_updates=true", botToken)) if err != nil { applogger.L().Warn("Failed to delete Telegram webhook on destroy", "error", err) } // Delete channel record from DB if err := s.repo.Delete(ctx, channelID); err != nil { applogger.L().Warn("Failed to delete ChannelTelegram from DB", "error", err) return fmt.Errorf("failed to delete channel: %w", err) } applogger.L().Info("Telegram channel destroyed", "channel_id", channelID) return nil } // === Webhook Setup === // setupWebhook configures the Telegram webhook for the bot. // Reference: Chatwoot before_save :setup_telegram_webhook func (s *TelegramService) setupWebhook(ctx context.Context, botToken string) (string, error) { frontendURL := "https://localhost:3000" // TODO: from config webhookURL := fmt.Sprintf("%s/webhooks/telegram/%s", frontendURL, botToken) // Delete existing webhook first (Chatwoot: deleteWebhook before setWebhook) _, _ = s.client.R(). SetContext(ctx). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook", botToken)) // Set new webhook resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "url": webhookURL, "allowed_updates": []string{"message", "edited_message", "callback_query"}, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken)) if err != nil { return "", fmt.Errorf("setWebhook API call failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return "", fmt.Errorf("setWebhook API error: %s", result.Description) } return webhookURL, nil } // === Bot Token Validation === // validateBotToken validates a Telegram bot token via the getMe API. // Reference: Chatwoot ensure_valid_bot_token callback func (s *TelegramService) validateBotToken(ctx context.Context, botToken string) (string, error) { resp, err := s.client.R(). SetContext(ctx). SetResult(&TelegramGetMeResponse{}). Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken)) if err != nil { return "", fmt.Errorf("getMe API call failed: %w", err) } result := resp.Result().(*TelegramGetMeResponse) if !result.Ok { return "", fmt.Errorf("bot token invalid: %s", result.Description) } return result.Result.FirstName, nil } // === Bot Commands === // SetBotCommands sets the bot's command list visible in Telegram UI. // Uses setMyCommands API — not present in Chatwoot, GoChat extension. func (s *TelegramService) SetBotCommands(ctx context.Context, botToken string, commands []BotCommand) error { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "commands": commands, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", botToken)) if err != nil { return fmt.Errorf("setMyCommands failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return fmt.Errorf("setMyCommands API error: %s", result.Description) } return nil } // DefaultBotCommands returns a sensible default command list for customer support bots. func DefaultBotCommands() []BotCommand { return []BotCommand{ {Command: "help", Description: "Get help and support"}, {Command: "start", Description: "Start a conversation"}, {Command: "status", Description: "Check conversation status"}, } } // === Contact Profile === // GetProfileImage downloads a Telegram user's profile image. // Reference: Chatwoot get_telegram_profile_image func (s *TelegramService) GetProfileImage(ctx context.Context, botToken string, userID string) (string, error) { // getUserProfilePhotos → getFile → construct URL photosResp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{"user_id": userID}). SetResult(&TelegramUserProfilePhotos{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/getUserProfilePhotos", botToken)) if err != nil { return "", err } photos := photosResp.Result().(*TelegramUserProfilePhotos) if !photos.Ok || photos.Result.TotalCount == 0 { return "", nil // no profile photo } // Get largest photo size lastPhoto := photos.Result.Photos[0] photo := lastPhoto[len(lastPhoto)-1] // Get file path via getFile fileResp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{"file_id": photo.FileID}). SetResult(&TelegramFileResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/getFile", botToken)) if err != nil { return "", err } fileResult := fileResp.Result().(*TelegramFileResponse) if !fileResult.Ok || fileResult.Result.FilePath == "" { return "", nil } return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, fileResult.Result.FilePath), nil } // === Message Operations === // SendMessage sends a text message to a Telegram chat. // Reference: Chatwoot SendOnTelegramService func (s *TelegramService) SendMessage(ctx context.Context, botToken string, chatID string, text string, parseMode string) (int64, error) { if parseMode == "" { parseMode = "HTML" } resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "text": text, "parse_mode": parseMode, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)) if err != nil { return 0, fmt.Errorf("sendMessage failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return 0, fmt.Errorf("sendMessage API error: %s", result.Description) } // Extract message_id from result if msgData, ok := result.Result.(map[string]interface{}); ok { if msgID, ok2 := msgData["message_id"]; ok2 { switch v := msgID.(type) { case int64: return v, nil case float64: return int64(v), nil } } } return 0, nil } // EditMessage edits a previously sent message. // Reference: Chatwoot handles message.updated → editMessageText func (s *TelegramService) EditMessage(ctx context.Context, botToken string, chatID string, messageID int64, newText string) error { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "message_id": messageID, "text": newText, "parse_mode": "HTML", }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/editMessageText", botToken)) if err != nil { return fmt.Errorf("editMessageText failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok && result.Description != "message is not modified" { return fmt.Errorf("editMessageText API error: %s", result.Description) } return nil } // DeleteMessage deletes a previously sent message. // Reference: Chatwoot handles message.deleted → deleteMessage func (s *TelegramService) DeleteMessage(ctx context.Context, botToken string, chatID string, messageID int64) error { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "message_id": messageID, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteMessage", botToken)) if err != nil { return fmt.Errorf("deleteMessage failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return fmt.Errorf("deleteMessage API error: %s", result.Description) } return nil } // === Attachment Handling === // SendPhoto sends a photo to a Telegram chat. // Reference: Chatwoot SendAttachmentsService.sendPhoto func (s *TelegramService) SendPhoto(ctx context.Context, botToken string, chatID string, photoURL string, caption string) (int64, error) { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "photo": photoURL, "caption": caption, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", botToken)) if err != nil { return 0, fmt.Errorf("sendPhoto failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return 0, fmt.Errorf("sendPhoto API error: %s", result.Description) } return extractMessageID(result), nil } // SendDocument sends a document/file to a Telegram chat. // Reference: Chatwoot SendAttachmentsService.sendDocument func (s *TelegramService) SendDocument(ctx context.Context, botToken string, chatID string, documentURL string, caption string) (int64, error) { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "document": documentURL, "caption": caption, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", botToken)) if err != nil { return 0, fmt.Errorf("sendDocument failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return 0, fmt.Errorf("sendDocument API error: %s", result.Description) } return extractMessageID(result), nil } // SendAudio sends an audio file to a Telegram chat. // Reference: Chatwoot SendAttachmentsService.sendAudio func (s *TelegramService) SendAudio(ctx context.Context, botToken string, chatID string, audioURL string, caption string) (int64, error) { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "audio": audioURL, "caption": caption, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendAudio", botToken)) if err != nil { return 0, fmt.Errorf("sendAudio failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return 0, fmt.Errorf("sendAudio API error: %s", result.Description) } return extractMessageID(result), nil } // SendVideo sends a video to a Telegram chat. // Reference: Chatwoot SendAttachmentsService.sendVideo func (s *TelegramService) SendVideo(ctx context.Context, botToken string, chatID string, videoURL string, caption string) (int64, error) { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "video": videoURL, "caption": caption, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendVideo", botToken)) if err != nil { return 0, fmt.Errorf("sendVideo failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return 0, fmt.Errorf("sendVideo API error: %s", result.Description) } return extractMessageID(result), nil } // SendSticker sends a sticker to a Telegram chat. // Reference: Chatwoot doesn't have sendSticker but GoChat extends this func (s *TelegramService) SendSticker(ctx context.Context, botToken string, chatID string, stickerID string) (int64, error) { resp, err := s.client.R(). SetContext(ctx). SetBody(map[string]interface{}{ "chat_id": chatID, "sticker": stickerID, }). SetResult(&TelegramAPIResponse{}). Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendSticker", botToken)) if err != nil { return 0, fmt.Errorf("sendSticker failed: %w", err) } result := resp.Result().(*TelegramAPIResponse) if !result.Ok { return 0, fmt.Errorf("sendSticker API error: %s", result.Description) } return extractMessageID(result), nil } // === Helper Functions === func extractMessageID(result *TelegramAPIResponse) int64 { if msgData, ok := result.Result.(map[string]interface{}); ok { if msgID, ok2 := msgData["message_id"]; ok2 { switch v := msgID.(type) { case int64: return v case float64: return int64(v) } } } return 0 } // === Type Definitions === type BotCommand struct { Command string `json:"command"` Description string `json:"description"` } type TelegramAPIResponse struct { Ok bool `json:"ok"` Description string `json:"description,omitempty"` Result interface{} `json:"result,omitempty"` } type TelegramGetMeResponse struct { Ok bool `json:"ok"` Description string `json:"description,omitempty"` Result struct { ID int64 `json:"id"` FirstName string `json:"first_name"` Username string `json:"username,omitempty"` } `json:"result,omitempty"` } type TelegramUserProfilePhotos struct { Ok bool `json:"ok"` Result struct { TotalCount int `json:"total_count"` Photos [][]PhotoSize `json:"photos"` } `json:"result,omitempty"` } type PhotoSize struct { FileID string `json:"file_id"` FileSize int `json:"file_size,omitempty"` Width int `json:"width"` Height int `json:"height"` } type TelegramFileResponse struct { Ok bool `json:"ok"` Result struct { FilePath string `json:"file_path,omitempty"` } `json:"result,omitempty"` }