Files
gochat/internal/channel/telegram/service.go
T
2026-06-04 15:44:48 +08:00

541 lines
16 KiB
Go

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"`
}