From 5473b26787f6f4888403b4dbf1b6932ee2f86e58 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Mon, 19 Jan 2026 12:50:36 +0300 Subject: [PATCH] refactor --- pkg/logger/logger.go | 36 --- src/adapter/postgres.py | 9 +- src/domain/creative.py | 30 +- src/usecase/channel/get_channels.py | 40 ++- .../placement/fetch_placement_post_cycle.py | 5 + tg_bot/backend/backend_client.go | 260 +++++------------ tg_bot/main.go | 32 +- tg_bot/screens/add_creative.go | 2 + tg_bot/screens/my_projects.go | 4 +- tg_bot/screens/placement_details.go | 173 +++++++++++ tg_bot/screens/purchase_details.go | 273 ------------------ tg_bot/screens/purchase_optional_details.go | 66 ++--- tg_bot/screens/purchases.go | 67 ++--- .../screens/select_channels_for_purchase.go | 27 +- tg_bot/screens/ui/message_format.go | 12 +- tg_parser/config/config.go | 8 +- tg_parser/main.go | 33 ++- 17 files changed, 478 insertions(+), 599 deletions(-) delete mode 100644 pkg/logger/logger.go create mode 100644 tg_bot/screens/placement_details.go delete mode 100644 tg_bot/screens/purchase_details.go diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go deleted file mode 100644 index 8793b49..0000000 --- a/pkg/logger/logger.go +++ /dev/null @@ -1,36 +0,0 @@ -package logger - -import ( - "os" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -type Config struct { - Level string `default:"info" envconfig:"LOGGER__LEVEL"` - PrettyConsole bool `default:"false" envconfig:"LOGGER__PRETTY_CONSOLE"` -} - -func Init(c Config) { - zerolog.TimeFieldFormat = time.RFC3339 - - level := zerolog.InfoLevel - if parsedLevel, err := zerolog.ParseLevel(c.Level); err == nil { - level = parsedLevel - } - zerolog.SetGlobalLevel(level) - - log.Logger = log.With(). - // Caller(). - // Str("app_name", c.AppName). - // Str("app_version", c.AppVersion). - Logger() - - if c.PrettyConsole { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}) - } - - log.Info().Msg("Logger initialized") -} diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 2dde1fa..06482d2 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -383,7 +383,14 @@ class Postgres(DatabaseBase): query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id) if not include_archived: - query = query.filter(status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]) + query = query.filter( + status__in=[ + domain.PlacementStatus.PLANNED, + domain.PlacementStatus.APPROVED, + domain.PlacementStatus.IN_PROGRESS, + domain.PlacementStatus.COMPLETED, + ] + ) return ( await query.prefetch_related('project', 'project__channel', 'channel', 'creative') diff --git a/src/domain/creative.py b/src/domain/creative.py index aeea534..277cb85 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -40,22 +40,25 @@ class Creative(TimestampedModel): MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 -_INVITE_LINK_HTML = re.compile(r'([^<]*)', re.IGNORECASE) -_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', re.IGNORECASE) +_INVITE_LINK_HTML = re.compile(r'([^<]*)', re.IGNORECASE) +_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE) _INVITE_LINK_REPLACED = re.compile(r'([^<]*)', re.IGNORECASE) +_INVITE_LINK_REPLACED_URL = re.compile( + r'\s*https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+\s*', + re.IGNORECASE, +) def replace_invite_link_with_tag(text: str) -> str: """Replace Telegram invite link (t.me/+xxx) with tracking tag.""" - # Проверяем, есть ли уже замененная ссылка replaced_count = len(_INVITE_LINK_REPLACED.findall(text)) - html_count = len(_INVITE_LINK_HTML.findall(text)) - # Сначала удаляем HTML-ссылки из текста, чтобы не считать их дважды - text_without_html_links = _INVITE_LINK_HTML.sub('', text) - plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_html_links)) + # Сначала удаляем HTML-ссылки и tg-link теги, чтобы не считать их дважды + text_without_links = _INVITE_LINK_HTML.sub('', text) + text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links) + plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_links)) total = replaced_count + html_count + plain_count @@ -64,12 +67,19 @@ def replace_invite_link_with_tag(text: str) -> str: if total > 1: raise CreativeMultipleInviteLinks() - # Если ссылка уже заменена, возвращаем текст как есть + # Если ссылка уже заменена, нормализуем текст если внутри был URL if replaced_count == 1: - return text + return _INVITE_LINK_REPLACED_URL.sub('', text) if html_count == 1: - return _INVITE_LINK_HTML.sub(r'\1', text) + def _replace_html(match: re.Match) -> str: + inner = match.group(1).strip() + if _INVITE_LINK_PLAIN.fullmatch(inner): + return '' + return f'{inner}' + + return _INVITE_LINK_HTML.sub(_replace_html, text) + return _INVITE_LINK_PLAIN.sub('', text) diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py index ba97f67..485fdc3 100644 --- a/src/usecase/channel/get_channels.py +++ b/src/usecase/channel/get_channels.py @@ -1,7 +1,7 @@ import logging from typing import TYPE_CHECKING -from src import dto +from src import domain, dto if TYPE_CHECKING: from .. import Usecase @@ -12,6 +12,44 @@ log = logging.getLogger(__name__) async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]: channels = await self.database.search_channels(username_query=input.username) + if not channels and input.username: + try: + parser_response = await self.parser.fetch_telegram_channel(input.username) + except Exception as exc: + log.warning('Parser fetch failed for username query %s: %s', input.username, exc) + parser_response = None + + if parser_response: + channel = await self.database.get_channel(telegram_id=parser_response.telegram_id) + if not channel: + channel = await self.database.get_channel(username=parser_response.username) + + if channel: + updated = False + if channel.username != parser_response.username: + channel.username = parser_response.username + updated = True + if channel.title != parser_response.title: + channel.title = parser_response.title + updated = True + if channel.telegram_id != parser_response.telegram_id: + channel.telegram_id = parser_response.telegram_id + updated = True + + if updated: + await self.database.update_channel(channel) + log.info('Updated channel @%s from parser response', channel.username) + else: + channel = domain.Channel( + username=parser_response.username, + telegram_id=parser_response.telegram_id, + title=parser_response.title, + ) + await self.database.create_channel(channel) + log.info('Created channel @%s from parser response', channel.username) + + channels = [channel] + log.debug('Found %s channels for username query: %s', len(channels), input.username) return [ diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py index 6eebdd1..34d9e04 100644 --- a/src/usecase/placement/fetch_placement_post_cycle.py +++ b/src/usecase/placement/fetch_placement_post_cycle.py @@ -46,7 +46,12 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> placement_post = domain.PlacementPost( placement_id=placement.id, + project_id=placement.project_id, + creative_id=placement.creative_id, post_id=post.id, + wanted_placement_date=placement.placement_at or post.created_at, + cost=placement.cost_value, + comment=placement.comment, status=domain.PlacementPostStatus.ACTIVE, ) diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 1062469..89be910 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -532,65 +532,90 @@ func (c *Client) DeleteCreative( } // ============================================================================ -// Placements (Размещения/Закупы) +// Placements (Размещения) // ============================================================================ -type Placement struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - ProjectChannelTitle *string `json:"project_channel_title"` - PlacementChannelID string `json:"placement_channel_id"` - PlacementChannelTitle *string `json:"placement_channel_title"` - CreativeID string `json:"creative_id"` - CreativeName string `json:"creative_name"` - PlacementDate string `json:"placement_date"` - Cost *float64 `json:"cost"` - Comment *string `json:"comment"` - AdPostURL *string `json:"ad_post_url"` - InviteLinkType string `json:"invite_link_type"` - InviteLink string `json:"invite_link"` - Status string `json:"status"` - SubscriptionsCount int `json:"subscriptions_count"` - PurchaseID *string `json:"purchase_id"` - PurchaseChannelID *string `json:"purchase_channel_id"` - CreatedAt string `json:"created_at"` +type Channel struct { + ID string `json:"id"` + TelegramID *int64 `json:"telegram_id"` + Title *string `json:"title"` + Username *string `json:"username"` } -type PlacementsPage struct { - Items []Placement `json:"items"` - Total int `json:"total"` - Page int `json:"page"` - Size int `json:"size"` - Pages int `json:"pages"` +type CostInfo struct { + Type string `json:"type"` + Value float64 `json:"value"` +} + +type PlacementDetails struct { + PlacementAt *string `json:"placement_at,omitempty"` + PaymentAt *string `json:"payment_at,omitempty"` + Cost *CostInfo `json:"cost,omitempty"` + CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` + PlacementType *string `json:"placement_type,omitempty"` + Format *string `json:"format,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +type PlacementOutput struct { + ID string `json:"id"` + Status string `json:"status"` + Comment *string `json:"comment,omitempty"` + InviteLink string `json:"invite_link"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` + Details *PlacementDetails `json:"details,omitempty"` +} + +type CreatePlacementChannelInput struct { + Username string `json:"username"` + Status *string `json:"status,omitempty"` + Comment *string `json:"comment,omitempty"` + Details *PlacementDetails `json:"details,omitempty"` +} + +type CreatePlacementsInput struct { + CreativeID string `json:"creative_id"` + Channels []CreatePlacementChannelInput `json:"channels"` + Details *PlacementDetails `json:"details,omitempty"` +} + +type GetPlacementsOutput struct { + Placements []PlacementOutput `json:"placements"` +} + +func (c *Client) CreatePlacements( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + input CreatePlacementsInput, +) (*GetPlacementsOutput, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID) + + var placements GetPlacementsOutput + err := c.do( + ctx, + http.MethodPost, + path, + input, + &placements, + withBearer(jwt), + ) + + return &placements, err } func (c *Client) GetPlacements( ctx context.Context, jwt string, workspaceID string, - projectID *string, - placementChannelID *string, - creativeID *string, - includeArchived bool, - page, size int, -) (*PlacementsPage, error) { - q := url.Values{} - if projectID != nil { - q.Set("project_id", *projectID) - } - if placementChannelID != nil { - q.Set("placement_channel_id", *placementChannelID) - } - if creativeID != nil { - q.Set("creative_id", *creativeID) - } - q.Set("include_archived", fmt.Sprint(includeArchived)) - q.Set("page", fmt.Sprint(page)) - q.Set("size", fmt.Sprint(size)) + projectID string, +) (*GetPlacementsOutput, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID) - path := fmt.Sprintf("api/v1/workspaces/%s/placements?%s", workspaceID, q.Encode()) + var resp GetPlacementsOutput - var resp PlacementsPage err := c.do( ctx, http.MethodGet, @@ -607,11 +632,12 @@ func (c *Client) GetPlacement( ctx context.Context, jwt string, workspaceID string, + projectID string, placementID string, -) (*Placement, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) +) (*PlacementOutput, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements/%s", workspaceID, projectID, placementID) - var placement Placement + var placement PlacementOutput err := c.do( ctx, http.MethodGet, @@ -624,140 +650,6 @@ func (c *Client) GetPlacement( return &placement, err } -// Note: Placement creation/updates are handled by worker; API exposes only read endpoints. - -// ============================================================================ -// Purchases (Закупы) -// ============================================================================ - -type Channel struct { - ID string `json:"id"` - TelegramID *int64 `json:"telegram_id"` - Title *string `json:"title"` - Username *string `json:"username"` -} - -type Purchase struct { - ID string `json:"id"` - Status string `json:"status"` - CreativeID string `json:"creative_id"` - Channels []PurchaseChannelOut `json:"channels"` - Details *PurchaseDetails `json:"details,omitempty"` -} - -type PurchaseChannelOut struct { - ID string `json:"id"` - Status string `json:"status"` - Comment *string `json:"comment"` - InviteLink string `json:"invite_link"` - InviteLinkType string `json:"invite_link_type"` - Channel Channel `json:"channel"` - Details *PurchaseChannelDetails `json:"details,omitempty"` -} - -type CostInfo struct { - Type string `json:"type"` - Value float64 `json:"value"` -} - -type PurchaseDetails struct { - PlacementAt *string `json:"placement_at,omitempty"` - PaymentAt *string `json:"payment_at,omitempty"` - Cost *CostInfo `json:"cost,omitempty"` - CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` - PurchaseType *string `json:"purchase_type,omitempty"` - Format *string `json:"format,omitempty"` - Comment *string `json:"comment,omitempty"` -} - -type PurchaseChannelDetails struct { - PlacementAt *string `json:"placement_at,omitempty"` - Cost *CostInfo `json:"cost,omitempty"` - CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` - Format *string `json:"format,omitempty"` -} - -type CreatePurchaseChannelInput struct { - Username string `json:"username"` - Status *string `json:"status,omitempty"` - Comment *string `json:"comment,omitempty"` - Details *PurchaseChannelDetails `json:"details,omitempty"` -} - -type CreatePurchaseInput struct { - CreativeID string `json:"creative_id"` - Channels []CreatePurchaseChannelInput `json:"channels"` - Details *PurchaseDetails `json:"details,omitempty"` -} - -func (c *Client) CreatePurchase( - ctx context.Context, - jwt string, - workspaceID string, - projectID string, - input CreatePurchaseInput, -) (*Purchase, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID) - - var purchase Purchase - err := c.do( - ctx, - http.MethodPost, - path, - input, - &purchase, - withBearer(jwt), - ) - - return &purchase, err -} - -func (c *Client) GetPurchases( - ctx context.Context, - jwt string, - workspaceID string, - projectID string, -) ([]Purchase, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID) - - var resp struct { - Items []Purchase `json:"items"` - } - - err := c.do( - ctx, - http.MethodGet, - path, - nil, - &resp, - withBearer(jwt), - ) - - return resp.Items, err -} - -func (c *Client) GetPurchase( - ctx context.Context, - jwt string, - workspaceID string, - projectID string, - purchaseID string, -) (*Purchase, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID) - - var purchase Purchase - err := c.do( - ctx, - http.MethodGet, - path, - nil, - &purchase, - withBearer(jwt), - ) - - return &purchase, err -} - // ============================================================================ // Workspace Members // ============================================================================ diff --git a/tg_bot/main.go b/tg_bot/main.go index e13e46f..e5427d0 100644 --- a/tg_bot/main.go +++ b/tg_bot/main.go @@ -6,17 +6,15 @@ import ( "time" "github.com/NicoNex/echotron/v3" - "github.com/TelegramExchange/pkg/logger" "github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/TelegramExchange/tgex-backend/tg_bot/screens" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func main() { - logger.Init(logger.Config{ - PrettyConsole: true, - }) + initLogger() botToken := mustEnv("TELEGRAM__TOKEN") loginURL := mustEnv("LOGIN_URL") @@ -79,6 +77,32 @@ func main() { } } +func initLogger() { + zerolog.TimeFieldFormat = time.RFC3339 + + level := zerolog.InfoLevel + if parsedLevel, err := zerolog.ParseLevel(os.Getenv("LOGGER__LEVEL")); err == nil { + level = parsedLevel + } + zerolog.SetGlobalLevel(level) + + log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger().Level(level) + + prettyConsole := os.Getenv("LOGGER__PRETTY_CONSOLE") + if prettyConsole == "" { + prettyConsole = "true" + } + if prettyConsole == "true" { + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}). + With(). + Timestamp(). + Logger(). + Level(level) + } + + log.Info().Msg("Logger initialized") +} + func mustEnv(key string) string { v := os.Getenv(key) if v == "" { diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go index 60c4173..ad71780 100644 --- a/tg_bot/screens/add_creative.go +++ b/tg_bot/screens/add_creative.go @@ -371,6 +371,8 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) { }) } + log.Info().Str("text", *s.Text).Msg("Creative text") + creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{ Name: *s.Name, Text: *s.Text, diff --git a/tg_bot/screens/my_projects.go b/tg_bot/screens/my_projects.go index 21668a7..558f521 100644 --- a/tg_bot/screens/my_projects.go +++ b/tg_bot/screens/my_projects.go @@ -304,8 +304,8 @@ func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) { duration time.Duration count int }{ - {1 * time.Second, 30}, // Первые 30 секунд - каждую секунду - {3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно) + {3 * time.Second, 30}, // Первые 30 секунд - каждую секунду + {10 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно) } currentIntervalIdx := 0 diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go new file mode 100644 index 0000000..c936517 --- /dev/null +++ b/tg_bot/screens/placement_details.go @@ -0,0 +1,173 @@ +package screens + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" +) + +type PlacementDetails struct { + WorkspaceID string + ProjectID string + PlacementID string + BackState bot.State +} + +func (s *PlacementDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt := b.Session.JWT + if jwt == "" { + log.Error().Msg("JWT is empty in session") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) + return + } + + s.renderDetails(b, mode, jwt) +} + +func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) { + placement, err := b.Backend.GetPlacement( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + s.PlacementID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get placement") + b.SendNew("❌ Не удалось загрузить размещение", Keyboard()) + return + } + + text := "Детали размещения\n\n" + + channelName := formatChannelName(placement.Channel) + text += fmt.Sprintf("• Канал: %s\n", channelName) + text += fmt.Sprintf("• Статус: %s\n", formatPlacementStatus(placement.Status)) + text += fmt.Sprintf("• Ссылка: %s\n", formatInviteLinkType(placement.InviteLinkType)) + + if placement.Details != nil { + if placement.Details.PlacementType != nil { + text += fmt.Sprintf("• Тип: %s\n", formatPlacementType(*placement.Details.PlacementType)) + } + if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" { + text += fmt.Sprintf("• Дата размещения: %s\n", formatDateTime(*placement.Details.PlacementAt)) + } + if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" { + text += fmt.Sprintf("• Дата оплаты: %s\n", formatDateTime(*placement.Details.PaymentAt)) + } + if placement.Details.Cost != nil { + text += fmt.Sprintf("• Стоимость: %s %.0f₽\n", + formatCostType(placement.Details.Cost.Type), + placement.Details.Cost.Value, + ) + } + if placement.Details.CostBeforeBargain != nil { + text += fmt.Sprintf("• До торга: %.0f₽\n", *placement.Details.CostBeforeBargain) + } + if placement.Details.Format != nil && *placement.Details.Format != "" { + text += fmt.Sprintf("• Формат: %s\n", *placement.Details.Format) + } + if placement.Details.Comment != nil && *placement.Details.Comment != "" { + text += fmt.Sprintf("• Комментарий: %s\n", *placement.Details.Comment) + } + } + + keyboard := Keyboard(Row(Button("← Назад", "back"))) + b.Render(text, keyboard, mode) +} + +func formatChannelName(channel backend.Channel) string { + if channel.Title != nil && *channel.Title != "" { + return *channel.Title + } + if channel.Username != nil && *channel.Username != "" { + return "@" + *channel.Username + } + return "Без названия" +} + +func formatInviteLinkType(value string) string { + if value == "approval" { + return "с одобрением" + } + return "публичная" +} + +func formatPlacementStatus(status string) string { + normalized := strings.TrimSpace(strings.ToLower(status)) + switch normalized { + case "planned": + return "Планируется" + case "approved": + return "Согласовано" + case "rejected": + return "Отклонено" + case "in_progress": + return "В работе" + case "completed": + return "Размещено" + default: + return status + } +} + +func formatPlacementType(value string) string { + normalized := strings.TrimSpace(strings.ToLower(value)) + switch normalized { + case "self_promo": + return "Самопиар" + case "standard": + return "Стандарт" + default: + return value + } +} + +func formatCostType(value string) string { + switch strings.TrimSpace(strings.ToLower(value)) { + case "cpm": + return "СРМ" + default: + return "Фикс" + } +} + +func formatDateTime(value string) string { + if value == "" { + return value + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed.Format("02.01.2006 15:04") + } + if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { + return parsed.Format("02.01.2006 15:04") + } + return value +} + +func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + switch u.CallbackQuery.Data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *PlacementDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PlacementDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PlacementDetails) Exit() {} diff --git a/tg_bot/screens/purchase_details.go b/tg_bot/screens/purchase_details.go deleted file mode 100644 index db26cbd..0000000 --- a/tg_bot/screens/purchase_details.go +++ /dev/null @@ -1,273 +0,0 @@ -package screens - -import ( - "context" - "fmt" - "strings" - - "github.com/NicoNex/echotron/v3" - "github.com/TelegramExchange/tgex-backend/tg_bot/backend" - "github.com/TelegramExchange/tgex-backend/tg_bot/bot" - "github.com/rs/zerolog/log" -) - -type PurchaseDetails struct { - WorkspaceID string - ProjectID string - PurchaseID string - BackState bot.State -} - -func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) { - jwt := b.Session.JWT - if jwt == "" { - log.Error().Msg("JWT is empty in session") - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) - return - } - - s.renderDetails(b, mode, jwt) -} - -func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) { - purchase, err := b.Backend.GetPurchase( - context.Background(), - jwt, - s.WorkspaceID, - s.ProjectID, - s.PurchaseID, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to get purchase") - b.SendNew("❌ Не удалось загрузить закуп", Keyboard()) - return - } - - placementsByChannel := s.loadPlacementsByChannel(b, jwt, purchase) - - // Получаем информацию о креативе - creative, err := b.Backend.GetCreative( - context.Background(), - jwt, - s.WorkspaceID, - purchase.CreativeID, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to get creative") - creative = nil - } - - totalPlacements := 0 - for _, items := range placementsByChannel { - totalPlacements += len(items) - } - - text := "Детали закупа\n\n" - - // Информация о креативе - if creative != nil { - text += fmt.Sprintf("• Креатив: %s\n", creative.Name) - } else { - text += fmt.Sprintf("• Креатив ID: %s\n", purchase.CreativeID) - } - - // Статус закупа - statusSymbol := "●" - statusText := "Активный" - if purchase.Status == "archived" { - statusSymbol = "■" - statusText = "Архивирован" - } - text += fmt.Sprintf("• Статус: %s %s\n", statusSymbol, statusText) - text += fmt.Sprintf("• Каналов: %d\n", len(purchase.Channels)) - text += fmt.Sprintf("• Размещений: %d\n\n", totalPlacements) - - // Информация о каналах - if len(purchase.Channels) == 0 { - text += "Каналы не добавлены\n" - } else { - text += fmt.Sprintf("Каналы (%d):\n\n", len(purchase.Channels)) - - for i, ch := range purchase.Channels { - chStatusText := formatPurchaseChannelStatus(ch.Status) - - // Название канала - channelName := "Без названия" - if ch.Channel.Title != nil { - channelName = *ch.Channel.Title - } else if ch.Channel.Username != nil { - channelName = "@" + *ch.Channel.Username - } - - text += fmt.Sprintf("%d. %s\n", i+1, channelName) - text += fmt.Sprintf(" ◦ %s\n", chStatusText) - - // Стоимость - if ch.Details != nil && ch.Details.Cost != nil { - text += fmt.Sprintf(" ◦ Стоимость: %.0f₽\n", ch.Details.Cost.Value) - } - - // Дата размещения (план) - if ch.Details != nil && ch.Details.PlacementAt != nil && *ch.Details.PlacementAt != "" { - text += fmt.Sprintf(" ◦ План: %s\n", *ch.Details.PlacementAt) - } - - // Комментарий - if ch.Comment != nil && *ch.Comment != "" { - text += fmt.Sprintf(" ◦ %s\n", *ch.Comment) - } - - // Информация о размещении - placements := placementsByChannel[ch.ID] - if len(placements) == 0 { - text += " ◦ Размещение: нет\n" - } else { - placement := placements[0] - text += fmt.Sprintf(" ◦ Размещение: есть (%d)\n", len(placements)) - if placement.Status != "" { - text += fmt.Sprintf(" ◦ Статус размещения: %s\n", formatPlacementStatus(placement.Status)) - } - if placement.PlacementDate != "" { - text += fmt.Sprintf(" ◦ Дата: %s\n", placement.PlacementDate) - } - if placement.AdPostURL != nil && *placement.AdPostURL != "" { - text += fmt.Sprintf(" ◦ Пост: %s\n", *placement.AdPostURL) - } - if placement.SubscriptionsCount > 0 { - text += fmt.Sprintf(" ◦ Подписки: %d\n", placement.SubscriptionsCount) - } - } - - // Тип invite link - inviteLinkTypeText := "публичная" - if ch.InviteLinkType == "approval" { - inviteLinkTypeText = "с одобрением" - } - text += fmt.Sprintf(" ◦ Ссылка: %s\n", inviteLinkTypeText) - - text += "\n" - } - } - - var buttons [][]echotron.InlineKeyboardButton - - // Кнопка назад - buttons = append(buttons, Row( - Button("← Назад", "back"), - )) - - keyboard := Keyboard(buttons...) - b.Render(text, keyboard, mode) -} - -func (s *PurchaseDetails) loadPlacementsByChannel( - b *bot.Bot, - jwt string, - purchase *backend.Purchase, -) map[string][]backend.Placement { - placementsByChannel := make(map[string][]backend.Placement) - if purchase == nil || purchase.CreativeID == "" || len(purchase.Channels) == 0 { - return placementsByChannel - } - - projectID := s.ProjectID - creativeID := purchase.CreativeID - pageSize := 50 - maxPages := 5 - foundChannels := make(map[string]bool) - - for page := 1; page <= maxPages; page++ { - resp, err := b.Backend.GetPlacements( - context.Background(), - jwt, - s.WorkspaceID, - &projectID, - nil, - &creativeID, - false, - page, - pageSize, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to get placements for purchase details") - break - } - - for _, placement := range resp.Items { - if placement.PurchaseID == nil || *placement.PurchaseID != purchase.ID { - continue - } - if placement.PurchaseChannelID == nil || *placement.PurchaseChannelID == "" { - continue - } - chID := *placement.PurchaseChannelID - placementsByChannel[chID] = append(placementsByChannel[chID], placement) - foundChannels[chID] = true - } - - if resp.Pages <= page || len(foundChannels) == len(purchase.Channels) { - break - } - } - - return placementsByChannel -} - -func formatPurchaseChannelStatus(status string) string { - switch status { - case "planned": - return "Планируется" - case "approved": - return "Согласовано" - case "rejected": - return "Отклонено" - case "in_progress": - return "В работе" - case "completed": - return "Завершено" - default: - return "Неизвестно" - } -} - -func formatPlacementStatus(status string) string { - normalized := strings.TrimSpace(strings.ToLower(status)) - switch normalized { - case "planned": - return "Планируется" - case "approved": - return "Согласовано" - case "rejected": - return "Отклонено" - case "in_progress": - return "В работе" - case "completed": - return "Размещено" - default: - return status - } -} - -func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { - if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { - return - } - - data := u.CallbackQuery.Data - - switch data { - case "back": - if s.BackState != nil { - b.SetState(s.BackState, bot.EditMessage) - } - - default: - s.Enter(b, bot.NewMessage) - } -} - -func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } - -func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } - -func (s *PurchaseDetails) Exit() {} diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index 2ec3d72..cc98bdd 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -1400,7 +1400,7 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { return } - purchaseType, ok := s.normalizePurchaseType() + placementType, ok := s.normalizePurchaseType() if !ok { b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard( Row(Button("← Назад", "back")), @@ -1408,31 +1408,31 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { return } - purchaseDetails := s.buildPurchaseDetails(purchaseType) - if purchaseDetails != nil && s.isPurchaseDetailsEmpty(purchaseDetails) { - purchaseDetails = nil + placementDetails := s.buildPlacementDetails(placementType) + if placementDetails != nil && s.isPlacementDetailsEmpty(placementDetails) { + placementDetails = nil } - apiChannels := make([]backend.CreatePurchaseChannelInput, 0, len(s.Channels)) + apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels)) for _, ch := range s.Channels { channelDetails := s.buildChannelDetails(ch.Username) if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) { channelDetails = nil } - apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{ + apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{ Username: ch.Username, Comment: ch.Comment, Details: channelDetails, }) } - input := backend.CreatePurchaseInput{ + input := backend.CreatePlacementsInput{ CreativeID: s.CreativeID, Channels: apiChannels, - Details: purchaseDetails, + Details: placementDetails, } - purchase, err := b.Backend.CreatePurchase( + placements, err := b.Backend.CreatePlacements( context.Background(), jwt, s.WorkspaceID, @@ -1440,8 +1440,8 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { input, ) if err != nil { - log.Error().Err(err).Msg("Failed to create purchase") - b.SendNew("❌ Не удалось создать закуп", Keyboard( + log.Error().Err(err).Msg("Failed to create placements") + b.SendNew("❌ Не удалось создать размещения", Keyboard( Row(Button("← Назад", "back")), )) return @@ -1459,25 +1459,25 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { } if creative != nil { - for _, ch := range purchase.Channels { - channelName := s.formatChannelName(ch) + for _, placement := range placements.Placements { + channelName := s.formatChannelName(placement) header := fmt.Sprintf("Канал: %s", channelName) b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML}) - s.sendCreativeWithInviteLink(b, creative, ch.InviteLink) + s.sendCreativeWithInviteLink(b, creative, placement.InviteLink) } } - b.SetState(&PurchaseDetails{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.ProjectID, - PurchaseID: purchase.ID, - BackState: s.BackState, + b.SetState(&Purchases{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + BackState: s.BackState, }, bot.NewMessage) } -func (s *PurchaseOptionalDetails) buildPurchaseDetails(purchaseType *string) *backend.PurchaseDetails { - details := &backend.PurchaseDetails{} +func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *backend.PlacementDetails { + details := &backend.PlacementDetails{} if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 { if s.PlacementDateTime != nil { @@ -1505,19 +1505,19 @@ func (s *PurchaseOptionalDetails) buildPurchaseDetails(purchaseType *string) *ba if s.Comment != "" { details.Comment = &s.Comment } - if purchaseType != nil { - details.PurchaseType = purchaseType + if placementType != nil { + details.PlacementType = placementType } return details } -func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PurchaseChannelDetails { +func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PlacementDetails { if len(s.Channels) <= 1 { return nil } - details := &backend.PurchaseChannelDetails{} + details := &backend.PlacementDetails{} if s.PlacementMode == "per_channel" { if value, ok := s.PlacementByChannel[username]; ok && value != nil { formatted := value.Format(time.RFC3339) @@ -1556,17 +1556,17 @@ func (s *PurchaseOptionalDetails) buildCostInfo(costType string, value *float64) } } -func (s *PurchaseOptionalDetails) isPurchaseDetailsEmpty(details *backend.PurchaseDetails) bool { +func (s *PurchaseOptionalDetails) isPlacementDetailsEmpty(details *backend.PlacementDetails) bool { return details.PlacementAt == nil && details.PaymentAt == nil && details.Cost == nil && details.CostBeforeBargain == nil && - details.PurchaseType == nil && + details.PlacementType == nil && details.Format == nil && details.Comment == nil } -func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PurchaseChannelDetails) bool { +func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PlacementDetails) bool { return details.PlacementAt == nil && details.Cost == nil && details.CostBeforeBargain == nil && @@ -1592,12 +1592,12 @@ func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) { } } -func (s *PurchaseOptionalDetails) formatChannelName(ch backend.PurchaseChannelOut) string { - if ch.Channel.Title != nil && *ch.Channel.Title != "" { - return *ch.Channel.Title +func (s *PurchaseOptionalDetails) formatChannelName(placement backend.PlacementOutput) string { + if placement.Channel.Title != nil && *placement.Channel.Title != "" { + return *placement.Channel.Title } - if ch.Channel.Username != nil && *ch.Channel.Username != "" { - return "@" + *ch.Channel.Username + if placement.Channel.Username != nil && *placement.Channel.Username != "" { + return "@" + *placement.Channel.Username } return "Без названия" } diff --git a/tg_bot/screens/purchases.go b/tg_bot/screens/purchases.go index 3a079e8..7893b9d 100644 --- a/tg_bot/screens/purchases.go +++ b/tg_bot/screens/purchases.go @@ -28,71 +28,60 @@ func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) { } func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) { - purchases, err := b.Backend.GetPurchases( + resp, err := b.Backend.GetPlacements( context.Background(), jwt, s.WorkspaceID, s.ProjectID, ) if err != nil { - log.Error().Err(err).Msg("Failed to get purchases") - b.SendNew("❌ Не удалось загрузить закупы", Keyboard()) + log.Error().Err(err).Msg("Failed to get placements") + b.SendNew("❌ Не удалось загрузить размещения", Keyboard()) return } - text := "Закупы\n\n" + text := "Размещения\n\n" var buttons [][]echotron.InlineKeyboardButton - if len(purchases) == 0 { - text += `Список закупов пуст. + if len(resp.Placements) == 0 { + text += `Список размещений пуст. -Закуп — это план размещения рекламы в каналах: +Размещение — это план публикации рекламы в канале: ‣ Выбор креатива - ‣ Список каналов для размещения - ‣ Планируемая стоимость + ‣ Канал размещения + ‣ Стоимость и формат ‣ Статус выполнения -Создайте первый закуп` +Создайте первое размещение` } else { - text += "Управление закупами для проекта.\n\n" - text += "Выберите закуп для деталей:\n\n" + text += "Управление размещениями для проекта.\n\n" + text += "Выберите размещение для деталей:\n\n" - for i, purchase := range purchases { - // Определяем статус + for i, placement := range resp.Placements { statusSymbol := "●" - switch purchase.Status { - case "active": - statusSymbol = "●" - case "archived": + if placement.Status == "archived" { statusSymbol = "■" } - // Считаем каналы по статусам - totalChannels := len(purchase.Channels) - completedChannels := 0 - for _, ch := range purchase.Channels { - if ch.Status == "completed" { - completedChannels++ - } + channelName := "Без названия" + if placement.Channel.Title != nil && *placement.Channel.Title != "" { + channelName = *placement.Channel.Title + } else if placement.Channel.Username != nil && *placement.Channel.Username != "" { + channelName = "@" + *placement.Channel.Username } - buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)", - statusSymbol, - i+1, - completedChannels, - totalChannels, - ) + buttonText := fmt.Sprintf("%s Размещение #%d — %s", statusSymbol, i+1, channelName) buttons = append(buttons, Row( - Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)), + Button(buttonText, fmt.Sprintf("placement:%s", placement.ID)), )) } } - // Кнопка создания нового закупа + // Кнопка создания нового размещения buttons = append(buttons, Row( - Button("+ Создать закуп", "add_purchase"), + Button("+ Создать размещение", "add_purchase"), )) // Кнопка назад @@ -127,13 +116,13 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) { }, bot.EditMessage) default: - // Проверяем, не нажали ли на конкретный закуп - if len(data) > 9 && data[:9] == "purchase:" { - purchaseID := data[9:] - b.SetState(&PurchaseDetails{ + // Проверяем, не нажали ли на конкретное размещение + if len(data) > 10 && data[:10] == "placement:" { + placementID := data[10:] + b.SetState(&PlacementDetails{ WorkspaceID: s.WorkspaceID, ProjectID: s.ProjectID, - PurchaseID: purchaseID, + PlacementID: placementID, BackState: s, }, bot.EditMessage) } else { diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go index 15d0f71..78161bc 100644 --- a/tg_bot/screens/select_channels_for_purchase.go +++ b/tg_bot/screens/select_channels_for_purchase.go @@ -226,31 +226,31 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) { // Преобразуем каналы в формат API - var apiChannels []backend.CreatePurchaseChannelInput + var apiChannels []backend.CreatePlacementChannelInput for _, ch := range s.Channels { - var details *backend.PurchaseChannelDetails + var details *backend.PlacementDetails if ch.PlannedCost != nil { cost := backend.CostInfo{ Type: "fixed", Value: *ch.PlannedCost, } - details = &backend.PurchaseChannelDetails{ + details = &backend.PlacementDetails{ Cost: &cost, } } - apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{ + apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{ Username: ch.Username, Comment: ch.Comment, Details: details, }) } - input := backend.CreatePurchaseInput{ + input := backend.CreatePlacementsInput{ CreativeID: s.CreativeID, Channels: apiChannels, } - purchase, err := b.Backend.CreatePurchase( + placements, err := b.Backend.CreatePlacements( context.Background(), jwt, s.WorkspaceID, @@ -259,17 +259,24 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) { ) if err != nil { - log.Error().Err(err).Msg("Failed to create purchase") - b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard( + log.Error().Err(err).Msg("Failed to create placements") + b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard( Row(Button("← Назад", "back_to_select")), )) return } - b.SetState(&PurchaseDetails{ + if len(placements.Placements) == 0 { + b.Edit("❌ Размещения не созданы", Keyboard( + Row(Button("← Назад", "back_to_select")), + )) + return + } + + b.SetState(&PlacementDetails{ WorkspaceID: s.WorkspaceID, ProjectID: s.ProjectID, - PurchaseID: purchase.ID, + PlacementID: placements.Placements[0].ID, BackState: s.BackState, }, bot.EditMessage) } diff --git a/tg_bot/screens/ui/message_format.go b/tg_bot/screens/ui/message_format.go index eedcdab..123847f 100644 --- a/tg_bot/screens/ui/message_format.go +++ b/tg_bot/screens/ui/message_format.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "regexp" "sort" "strings" @@ -203,7 +204,7 @@ func FormatMessageHTML(message *echotron.Message) string { } } - return normalizeHTMLTags(result.String()) + return normalizeInviteLinkTags(normalizeHTMLTags(result.String())) } // isInviteLink detects Telegram invite links used in creatives. @@ -290,6 +291,15 @@ func normalizeHTMLTags(input string) string { return out.String() } +func normalizeInviteLinkTags(input string) string { + if input == "" { + return input + } + + re := regexp.MustCompile(`\s*https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+\s*`) + return re.ReplaceAllString(input, ``) +} + // EscapeHTML escapes HTML special characters for safe output. func EscapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") diff --git a/tg_parser/config/config.go b/tg_parser/config/config.go index 00bd273..bc9c0f0 100644 --- a/tg_parser/config/config.go +++ b/tg_parser/config/config.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - "github.com/TelegramExchange/pkg/logger" "github.com/TelegramExchange/pkg/postgres" "github.com/TelegramExchange/pkg/telegram" "github.com/joho/godotenv" @@ -18,8 +17,13 @@ type HTTP struct { Addr string `envconfig:"HTTP__ADDR" default:":8080"` } +type LoggerConfig struct { + Level string `default:"info" envconfig:"PARSER__LOGGER__LEVEL"` + PrettyConsole bool `default:"true" envconfig:"PARSER__LOGGER__PRETTY_CONSOLE"` +} + type Config struct { - Logger logger.Config + Logger LoggerConfig Postgres postgres.Config Telegram telegram.Config ChannelWorker worker.ChannelConfig diff --git a/tg_parser/main.go b/tg_parser/main.go index a9e2aaf..316714b 100644 --- a/tg_parser/main.go +++ b/tg_parser/main.go @@ -2,23 +2,25 @@ package main import ( "context" + "os" + "time" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/TelegramExchange/pkg/logger" "github.com/TelegramExchange/tgex-backend/tg_parser/config" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/app" ) func main() { - log.Info().Msg("parser starting") c, err := config.New() if err != nil { log.Fatal().Err(err).Msg("config.New") } - logger.Init(c.Logger) + initLogger(c.Logger) + log.Info().Msg("parser starting") log.Info().Msg("parser initialized") ctx := context.Background() @@ -27,3 +29,28 @@ func main() { log.Error().Err(err).Msg("app.Run") } } + +func initLogger(c config.LoggerConfig) { + zerolog.TimeFieldFormat = time.RFC3339 + + level := zerolog.InfoLevel + if parsedLevel, err := zerolog.ParseLevel(c.Level); err == nil { + level = parsedLevel + } + zerolog.SetGlobalLevel(level) + + log.Logger = zerolog.New(os.Stdout).With(). + Timestamp(). + Logger(). + Level(level) + + if c.PrettyConsole { + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}). + With(). + Timestamp(). + Logger(). + Level(level) + } + + log.Info().Msg("Logger initialized") +}