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")
+}