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

300 lines
10 KiB
Go

package telegram
// TelegramRepository provides GORM-based persistence for Telegram channel data.
// Reference: Chatwoot uses ActiveRecord for ChannelTelegram CRUD with callbacks:
// - before_validation :ensure_valid_bot_token
// - before_save :setup_telegram_webhook
// - after_destroy :delete_telegram_webhook
//
// Design: Repository pattern abstracts GORM operations, matching gochat's
// existing repository pattern used by other channel providers.
import (
"context"
"fmt"
"time"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
applogger "github.com/gochat/gochat/pkg/logger"
)
// Repository handles Telegram channel database operations.
type Repository struct {
db *gorm.DB
}
// NewRepository creates a Telegram repository with GORM DB.
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// === CRUD Operations ===
// Create persists a new ChannelTelegram record.
// Reference: Chatwoot ChannelTelegram.create with before_validation + before_save callbacks
//
// The Chatwoot model has these lifecycle hooks:
// before_validation :ensure_valid_bot_token → validates via getMe API
// before_save :setup_telegram_webhook → calls setWebhook API
// after_destroy :delete_telegram_webhook → calls deleteWebhook API
//
// In gochat, we implement these as explicit service-level operations rather than
// model callbacks, since Go doesn't have ActiveRecord-style lifecycle hooks.
func (r *Repository) Create(ctx context.Context, channel *channelmodel.ChannelTelegram) error {
if err := r.db.WithContext(ctx).Create(channel).Error; err != nil {
return fmt.Errorf("failed to create ChannelTelegram: %w", err)
}
applogger.L().Info("ChannelTelegram created",
"id", channel.ID,
"account_id", channel.AccountID,
"bot_name", channel.BotName,
)
return nil
}
// Update modifies a ChannelTelegram record.
// Reference: Chatwoot ChannelTelegram.update — only bot_token is editable
func (r *Repository) Update(ctx context.Context, channel *channelmodel.ChannelTelegram) error {
result := r.db.WithContext(ctx).Save(channel)
if result.Error != nil {
return fmt.Errorf("failed to update ChannelTelegram: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("ChannelTelegram not found (id=%d)", channel.ID)
}
applogger.L().Info("ChannelTelegram updated", "id", channel.ID)
return nil
}
// UpdateBotToken updates only the bot_token field (and re-validates + re-sets webhook).
// Reference: Chatwoot only allows bot_token updates on existing channels
func (r *Repository) UpdateBotToken(ctx context.Context, channelID uint, newBotToken string) error {
result := r.db.WithContext(ctx).
Model(&channelmodel.ChannelTelegram{}).
Where("id = ?", channelID).
Update("bot_token", newBotToken)
if result.Error != nil {
return fmt.Errorf("failed to update bot_token: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("ChannelTelegram not found (id=%d)", channelID)
}
applogger.L().Info("ChannelTelegram bot_token updated", "id", channelID)
return nil
}
// UpdateWelcomeMessage updates only the welcome_message field.
func (r *Repository) UpdateWelcomeMessage(ctx context.Context, channelID uint, welcomeMessage string) error {
result := r.db.WithContext(ctx).
Model(&channelmodel.ChannelTelegram{}).
Where("id = ?", channelID).
Update("welcome_message", welcomeMessage)
if result.Error != nil {
return fmt.Errorf("failed to update welcome_message: %w", result.Error)
}
return nil
}
// Delete removes a ChannelTelegram record (soft delete via GORM).
// Reference: Chatwoot after_destroy :delete_telegram_webhook
// Note: The webhook cleanup should be done BEFORE calling this Delete method
// (in the service layer), since Go doesn't have after_destroy callbacks.
func (r *Repository) Delete(ctx context.Context, channelID uint) error {
result := r.db.WithContext(ctx).
Where("id = ?", channelID).
Delete(&channelmodel.ChannelTelegram{})
if result.Error != nil {
return fmt.Errorf("failed to delete ChannelTelegram: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("ChannelTelegram not found (id=%d)", channelID)
}
applogger.L().Info("ChannelTelegram deleted", "id", channelID)
return nil
}
// === Query Operations ===
// FindByID retrieves a ChannelTelegram by its primary key.
func (r *Repository) FindByID(ctx context.Context, channelID uint) (*channelmodel.ChannelTelegram, error) {
var channel channelmodel.ChannelTelegram
if err := r.db.WithContext(ctx).First(&channel, channelID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("ChannelTelegram not found (id=%d)", channelID)
}
return nil, fmt.Errorf("failed to find ChannelTelegram: %w", err)
}
return &channel, nil
}
// FindByBotToken retrieves a ChannelTelegram by its bot_token.
// Reference: Chatwoot finds inbox by bot_token for webhook routing
func (r *Repository) FindByBotToken(ctx context.Context, botToken string) (*channelmodel.ChannelTelegram, error) {
var channel channelmodel.ChannelTelegram
if err := r.db.WithContext(ctx).Where("bot_token = ?", botToken).First(&channel).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("ChannelTelegram not found for bot_token")
}
return nil, fmt.Errorf("failed to find ChannelTelegram by bot_token: %w", err)
}
return &channel, nil
}
// FindByAccountID retrieves all ChannelTelegram records for an account.
// Reference: Chatwoot scopes channels to account (account_id foreign key)
func (r *Repository) FindByAccountID(ctx context.Context, accountID uint) ([]channelmodel.ChannelTelegram, error) {
var channels []channelmodel.ChannelTelegram
if err := r.db.WithContext(ctx).
Where("account_id = ?", accountID).
Find(&channels).Error; err != nil {
return nil, fmt.Errorf("failed to find ChannelTelegram by account_id: %w", err)
}
return channels, nil
}
// FindByInboxID retrieves the ChannelTelegram associated with an Inbox.
// Reference: Chatwoot: inbox.channel (polymorphic association)
func (r *Repository) FindByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelTelegram, error) {
var channel channelmodel.ChannelTelegram
if err := r.db.WithContext(ctx).Where("inbox_id = ?", inboxID).First(&channel).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("ChannelTelegram not found for inbox_id=%d", inboxID)
}
return nil, fmt.Errorf("failed to find ChannelTelegram by inbox_id: %w", err)
}
return &channel, nil
}
// === Inbox Repository ===
// InboxRepository handles Inbox model operations for Telegram channels.
// Reference: Chatwoot creates Inbox alongside ChannelTelegram
type InboxRepository struct {
db *gorm.DB
}
// NewInboxRepository creates an Inbox repository.
func NewInboxRepository(db *gorm.DB) *InboxRepository {
return &InboxRepository{db: db}
}
// FindByBotToken finds the Inbox associated with a Telegram bot token.
// This is the primary lookup for webhook routing.
// Reference: Chatwoot: Inbox.joins(:channel_telegram).find_by(channel_telegrams: {bot_token: token})
func (r *InboxRepository) FindByBotToken(ctx context.Context, botToken string) (*model.Inbox, error) {
var channel channelmodel.ChannelTelegram
if err := r.db.WithContext(ctx).Where("bot_token = ?", botToken).First(&channel).Error; err != nil {
return nil, fmt.Errorf("no ChannelTelegram found for bot_token: %w", err)
}
var inbox model.Inbox
if err := r.db.WithContext(ctx).
Where("channel_type = ? AND id = ?", "telegram", channel.InboxID).
First(&inbox).Error; err != nil {
return nil, fmt.Errorf("no Inbox found for ChannelTelegram: %w", err)
}
return &inbox, nil
}
// CreateWithChannel creates an Inbox and its associated ChannelTelegram in one transaction.
// Reference: Chatwoot creates both Inbox and ChannelTelegram together
func (r *InboxRepository) CreateWithChannel(ctx context.Context, accountID uint, name string, channelTelegram *channelmodel.ChannelTelegram) (*model.Inbox, error) {
inbox := &model.Inbox{
AccountID: accountID,
Name: name,
ChannelType: "telegram",
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Create Inbox first
if err := tx.Create(inbox).Error; err != nil {
return fmt.Errorf("failed to create Inbox: %w", err)
}
// Set InboxID on ChannelTelegram and create
channelTelegram.InboxID = inbox.ID
if err := tx.Create(channelTelegram).Error; err != nil {
return fmt.Errorf("failed to create ChannelTelegram: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
applogger.L().Info("Telegram Inbox created with Channel",
"inbox_id", inbox.ID,
"account_id", accountID,
)
return inbox, nil
}
// === Auto-Migration ===
// AutoMigrate runs GORM auto-migration for ChannelTelegram model.
func (r *Repository) AutoMigrate(ctx context.Context) error {
if err := r.db.WithContext(ctx).AutoMigrate(&channelmodel.ChannelTelegram{}); err != nil {
return fmt.Errorf("failed to auto-migrate ChannelTelegram: %w", err)
}
applogger.L().Info("ChannelTelegram auto-migration completed")
return nil
}
// === Reauthorization Tracking ===
// MarkReauthorizationRequired flags a channel as needing bot token reauthorization.
// Reference: Chatwoot tracks reauthorization_required on ChannelTelegram
// when webhook delivery fails or bot token becomes invalid.
func (r *Repository) MarkReauthorizationRequired(ctx context.Context, channelID uint, reason string) error {
result := r.db.WithContext(ctx).
Model(&channelmodel.ChannelTelegram{}).
Where("id = ?", channelID).
Updates(map[string]interface{}{
"reauthorization_required": true,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("failed to mark reauthorization_required: %w", result.Error)
}
applogger.L().Warn("ChannelTelegram marked for reauthorization",
"id", channelID,
"reason", reason,
)
return nil
}
// ClearReauthorizationRequired clears the reauthorization flag after successful reauth.
func (r *Repository) ClearReauthorizationRequired(ctx context.Context, channelID uint) error {
result := r.db.WithContext(ctx).
Model(&channelmodel.ChannelTelegram{}).
Where("id = ?", channelID).
Updates(map[string]interface{}{
"reauthorization_required": false,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("failed to clear reauthorization_required: %w", result.Error)
}
return nil
}