refactor
This commit is contained in:
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -383,7 +383,14 @@ class Postgres(DatabaseBase):
|
|||||||
query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id)
|
query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id)
|
||||||
|
|
||||||
if not include_archived:
|
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 (
|
return (
|
||||||
await query.prefetch_related('project', 'project__channel', 'channel', 'creative')
|
await query.prefetch_related('project', 'project__channel', 'channel', 'creative')
|
||||||
|
|||||||
@@ -40,22 +40,25 @@ class Creative(TimestampedModel):
|
|||||||
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
|
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
_INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/\+[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE)
|
_INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE)
|
||||||
_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', re.IGNORECASE)
|
_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE)
|
||||||
_INVITE_LINK_REPLACED = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', re.IGNORECASE)
|
_INVITE_LINK_REPLACED = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', re.IGNORECASE)
|
||||||
|
_INVITE_LINK_REPLACED_URL = re.compile(
|
||||||
|
r'<a\s+class=["\']tg-link["\']>\s*https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+\s*</a>',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def replace_invite_link_with_tag(text: str) -> str:
|
def replace_invite_link_with_tag(text: str) -> str:
|
||||||
"""Replace Telegram invite link (t.me/+xxx) with tracking tag."""
|
"""Replace Telegram invite link (t.me/+xxx) with tracking tag."""
|
||||||
|
|
||||||
# Проверяем, есть ли уже замененная ссылка
|
# Проверяем, есть ли уже замененная ссылка
|
||||||
replaced_count = len(_INVITE_LINK_REPLACED.findall(text))
|
replaced_count = len(_INVITE_LINK_REPLACED.findall(text))
|
||||||
|
|
||||||
html_count = len(_INVITE_LINK_HTML.findall(text))
|
html_count = len(_INVITE_LINK_HTML.findall(text))
|
||||||
|
|
||||||
# Сначала удаляем HTML-ссылки из текста, чтобы не считать их дважды
|
# Сначала удаляем HTML-ссылки и tg-link теги, чтобы не считать их дважды
|
||||||
text_without_html_links = _INVITE_LINK_HTML.sub('', text)
|
text_without_links = _INVITE_LINK_HTML.sub('', text)
|
||||||
plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_html_links))
|
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
|
total = replaced_count + html_count + plain_count
|
||||||
|
|
||||||
@@ -64,12 +67,19 @@ def replace_invite_link_with_tag(text: str) -> str:
|
|||||||
if total > 1:
|
if total > 1:
|
||||||
raise CreativeMultipleInviteLinks()
|
raise CreativeMultipleInviteLinks()
|
||||||
|
|
||||||
# Если ссылка уже заменена, возвращаем текст как есть
|
# Если ссылка уже заменена, нормализуем текст если внутри был URL
|
||||||
if replaced_count == 1:
|
if replaced_count == 1:
|
||||||
return text
|
return _INVITE_LINK_REPLACED_URL.sub('<a class="tg-link"></a>', text)
|
||||||
|
|
||||||
if html_count == 1:
|
if html_count == 1:
|
||||||
return _INVITE_LINK_HTML.sub(r'<a class="tg-link">\1</a>', text)
|
def _replace_html(match: re.Match) -> str:
|
||||||
|
inner = match.group(1).strip()
|
||||||
|
if _INVITE_LINK_PLAIN.fullmatch(inner):
|
||||||
|
return '<a class="tg-link"></a>'
|
||||||
|
return f'<a class="tg-link">{inner}</a>'
|
||||||
|
|
||||||
|
return _INVITE_LINK_HTML.sub(_replace_html, text)
|
||||||
|
|
||||||
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
|
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import dto
|
from src import domain, dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
@@ -12,6 +12,44 @@ log = logging.getLogger(__name__)
|
|||||||
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
|
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
|
||||||
channels = await self.database.search_channels(username_query=input.username)
|
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)
|
log.debug('Found %s channels for username query: %s', len(channels), input.username)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
|
|||||||
|
|
||||||
placement_post = domain.PlacementPost(
|
placement_post = domain.PlacementPost(
|
||||||
placement_id=placement.id,
|
placement_id=placement.id,
|
||||||
|
project_id=placement.project_id,
|
||||||
|
creative_id=placement.creative_id,
|
||||||
post_id=post.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,
|
status=domain.PlacementPostStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -532,65 +532,90 @@ func (c *Client) DeleteCreative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Placements (Размещения/Закупы)
|
// Placements (Размещения)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type Placement struct {
|
type Channel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id"`
|
TelegramID *int64 `json:"telegram_id"`
|
||||||
ProjectChannelTitle *string `json:"project_channel_title"`
|
Title *string `json:"title"`
|
||||||
PlacementChannelID string `json:"placement_channel_id"`
|
Username *string `json:"username"`
|
||||||
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 PlacementsPage struct {
|
type CostInfo struct {
|
||||||
Items []Placement `json:"items"`
|
Type string `json:"type"`
|
||||||
Total int `json:"total"`
|
Value float64 `json:"value"`
|
||||||
Page int `json:"page"`
|
}
|
||||||
Size int `json:"size"`
|
|
||||||
Pages int `json:"pages"`
|
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(
|
func (c *Client) GetPlacements(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
workspaceID string,
|
workspaceID string,
|
||||||
projectID *string,
|
projectID string,
|
||||||
placementChannelID *string,
|
) (*GetPlacementsOutput, error) {
|
||||||
creativeID *string,
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID)
|
||||||
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))
|
|
||||||
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements?%s", workspaceID, q.Encode())
|
var resp GetPlacementsOutput
|
||||||
|
|
||||||
var resp PlacementsPage
|
|
||||||
err := c.do(
|
err := c.do(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
@@ -607,11 +632,12 @@ func (c *Client) GetPlacement(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
workspaceID string,
|
workspaceID string,
|
||||||
|
projectID string,
|
||||||
placementID string,
|
placementID string,
|
||||||
) (*Placement, error) {
|
) (*PlacementOutput, error) {
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements/%s", workspaceID, projectID, placementID)
|
||||||
|
|
||||||
var placement Placement
|
var placement PlacementOutput
|
||||||
err := c.do(
|
err := c.do(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
@@ -624,140 +650,6 @@ func (c *Client) GetPlacement(
|
|||||||
return &placement, err
|
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
|
// Workspace Members
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -6,17 +6,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
"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/backend"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens"
|
"github.com/TelegramExchange/tgex-backend/tg_bot/screens"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger.Init(logger.Config{
|
initLogger()
|
||||||
PrettyConsole: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
botToken := mustEnv("TELEGRAM__TOKEN")
|
botToken := mustEnv("TELEGRAM__TOKEN")
|
||||||
loginURL := mustEnv("LOGIN_URL")
|
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 {
|
func mustEnv(key string) string {
|
||||||
v := os.Getenv(key)
|
v := os.Getenv(key)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
|
|||||||
@@ -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{
|
creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{
|
||||||
Name: *s.Name,
|
Name: *s.Name,
|
||||||
Text: *s.Text,
|
Text: *s.Text,
|
||||||
|
|||||||
@@ -304,8 +304,8 @@ func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
count int
|
count int
|
||||||
}{
|
}{
|
||||||
{1 * time.Second, 30}, // Первые 30 секунд - каждую секунду
|
{3 * time.Second, 30}, // Первые 30 секунд - каждую секунду
|
||||||
{3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно)
|
{10 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentIntervalIdx := 0
|
currentIntervalIdx := 0
|
||||||
|
|||||||
173
tg_bot/screens/placement_details.go
Normal file
173
tg_bot/screens/placement_details.go
Normal file
@@ -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 := "<b>Детали размещения</b>\n\n"
|
||||||
|
|
||||||
|
channelName := formatChannelName(placement.Channel)
|
||||||
|
text += fmt.Sprintf("• <b>Канал:</b> %s\n", channelName)
|
||||||
|
text += fmt.Sprintf("• <b>Статус:</b> %s\n", formatPlacementStatus(placement.Status))
|
||||||
|
text += fmt.Sprintf("• <b>Ссылка:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
|
||||||
|
|
||||||
|
if placement.Details != nil {
|
||||||
|
if placement.Details.PlacementType != nil {
|
||||||
|
text += fmt.Sprintf("• <b>Тип:</b> %s\n", formatPlacementType(*placement.Details.PlacementType))
|
||||||
|
}
|
||||||
|
if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
|
||||||
|
text += fmt.Sprintf("• <b>Дата размещения:</b> %s\n", formatDateTime(*placement.Details.PlacementAt))
|
||||||
|
}
|
||||||
|
if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" {
|
||||||
|
text += fmt.Sprintf("• <b>Дата оплаты:</b> %s\n", formatDateTime(*placement.Details.PaymentAt))
|
||||||
|
}
|
||||||
|
if placement.Details.Cost != nil {
|
||||||
|
text += fmt.Sprintf("• <b>Стоимость:</b> %s %.0f₽\n",
|
||||||
|
formatCostType(placement.Details.Cost.Type),
|
||||||
|
placement.Details.Cost.Value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if placement.Details.CostBeforeBargain != nil {
|
||||||
|
text += fmt.Sprintf("• <b>До торга:</b> %.0f₽\n", *placement.Details.CostBeforeBargain)
|
||||||
|
}
|
||||||
|
if placement.Details.Format != nil && *placement.Details.Format != "" {
|
||||||
|
text += fmt.Sprintf("• <b>Формат:</b> %s\n", *placement.Details.Format)
|
||||||
|
}
|
||||||
|
if placement.Details.Comment != nil && *placement.Details.Comment != "" {
|
||||||
|
text += fmt.Sprintf("• <b>Комментарий:</b> %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() {}
|
||||||
@@ -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 := "<b>Детали закупа</b>\n\n"
|
|
||||||
|
|
||||||
// Информация о креативе
|
|
||||||
if creative != nil {
|
|
||||||
text += fmt.Sprintf("• <b>Креатив:</b> %s\n", creative.Name)
|
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("• <b>Креатив ID:</b> %s\n", purchase.CreativeID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Статус закупа
|
|
||||||
statusSymbol := "●"
|
|
||||||
statusText := "Активный"
|
|
||||||
if purchase.Status == "archived" {
|
|
||||||
statusSymbol = "■"
|
|
||||||
statusText = "Архивирован"
|
|
||||||
}
|
|
||||||
text += fmt.Sprintf("• <b>Статус:</b> %s %s\n", statusSymbol, statusText)
|
|
||||||
text += fmt.Sprintf("• <b>Каналов:</b> %d\n", len(purchase.Channels))
|
|
||||||
text += fmt.Sprintf("• <b>Размещений:</b> %d\n\n", totalPlacements)
|
|
||||||
|
|
||||||
// Информация о каналах
|
|
||||||
if len(purchase.Channels) == 0 {
|
|
||||||
text += "<i>Каналы не добавлены</i>\n"
|
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("<b>Каналы (%d):</b>\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. <b>%s</b>\n", i+1, channelName)
|
|
||||||
text += fmt.Sprintf(" ◦ <i>%s</i>\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 += " ◦ Размещение: <b>нет</b>\n"
|
|
||||||
} else {
|
|
||||||
placement := placements[0]
|
|
||||||
text += fmt.Sprintf(" ◦ Размещение: <b>есть</b> (%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() {}
|
|
||||||
@@ -1400,7 +1400,7 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
purchaseType, ok := s.normalizePurchaseType()
|
placementType, ok := s.normalizePurchaseType()
|
||||||
if !ok {
|
if !ok {
|
||||||
b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard(
|
b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard(
|
||||||
Row(Button("← Назад", "back")),
|
Row(Button("← Назад", "back")),
|
||||||
@@ -1408,31 +1408,31 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
purchaseDetails := s.buildPurchaseDetails(purchaseType)
|
placementDetails := s.buildPlacementDetails(placementType)
|
||||||
if purchaseDetails != nil && s.isPurchaseDetailsEmpty(purchaseDetails) {
|
if placementDetails != nil && s.isPlacementDetailsEmpty(placementDetails) {
|
||||||
purchaseDetails = nil
|
placementDetails = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
apiChannels := make([]backend.CreatePurchaseChannelInput, 0, len(s.Channels))
|
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
channelDetails := s.buildChannelDetails(ch.Username)
|
channelDetails := s.buildChannelDetails(ch.Username)
|
||||||
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
|
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
|
||||||
channelDetails = nil
|
channelDetails = nil
|
||||||
}
|
}
|
||||||
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
|
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||||
Username: ch.Username,
|
Username: ch.Username,
|
||||||
Comment: ch.Comment,
|
Comment: ch.Comment,
|
||||||
Details: channelDetails,
|
Details: channelDetails,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
input := backend.CreatePurchaseInput{
|
input := backend.CreatePlacementsInput{
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: s.CreativeID,
|
||||||
Channels: apiChannels,
|
Channels: apiChannels,
|
||||||
Details: purchaseDetails,
|
Details: placementDetails,
|
||||||
}
|
}
|
||||||
|
|
||||||
purchase, err := b.Backend.CreatePurchase(
|
placements, err := b.Backend.CreatePlacements(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
jwt,
|
jwt,
|
||||||
s.WorkspaceID,
|
s.WorkspaceID,
|
||||||
@@ -1440,8 +1440,8 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
input,
|
input,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to create purchase")
|
log.Error().Err(err).Msg("Failed to create placements")
|
||||||
b.SendNew("❌ Не удалось создать закуп", Keyboard(
|
b.SendNew("❌ Не удалось создать размещения", Keyboard(
|
||||||
Row(Button("← Назад", "back")),
|
Row(Button("← Назад", "back")),
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -1459,25 +1459,25 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if creative != nil {
|
if creative != nil {
|
||||||
for _, ch := range purchase.Channels {
|
for _, placement := range placements.Placements {
|
||||||
channelName := s.formatChannelName(ch)
|
channelName := s.formatChannelName(placement)
|
||||||
header := fmt.Sprintf("<b>Канал:</b> %s", channelName)
|
header := fmt.Sprintf("<b>Канал:</b> %s", channelName)
|
||||||
b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML})
|
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{
|
b.SetState(&Purchases{
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
PurchaseID: purchase.ID,
|
ProjectTitle: s.ProjectTitle,
|
||||||
BackState: s.BackState,
|
BackState: s.BackState,
|
||||||
}, bot.NewMessage)
|
}, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) buildPurchaseDetails(purchaseType *string) *backend.PurchaseDetails {
|
func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *backend.PlacementDetails {
|
||||||
details := &backend.PurchaseDetails{}
|
details := &backend.PlacementDetails{}
|
||||||
|
|
||||||
if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
|
if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
|
||||||
if s.PlacementDateTime != nil {
|
if s.PlacementDateTime != nil {
|
||||||
@@ -1505,19 +1505,19 @@ func (s *PurchaseOptionalDetails) buildPurchaseDetails(purchaseType *string) *ba
|
|||||||
if s.Comment != "" {
|
if s.Comment != "" {
|
||||||
details.Comment = &s.Comment
|
details.Comment = &s.Comment
|
||||||
}
|
}
|
||||||
if purchaseType != nil {
|
if placementType != nil {
|
||||||
details.PurchaseType = purchaseType
|
details.PlacementType = placementType
|
||||||
}
|
}
|
||||||
|
|
||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PurchaseChannelDetails {
|
func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PlacementDetails {
|
||||||
if len(s.Channels) <= 1 {
|
if len(s.Channels) <= 1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
details := &backend.PurchaseChannelDetails{}
|
details := &backend.PlacementDetails{}
|
||||||
if s.PlacementMode == "per_channel" {
|
if s.PlacementMode == "per_channel" {
|
||||||
if value, ok := s.PlacementByChannel[username]; ok && value != nil {
|
if value, ok := s.PlacementByChannel[username]; ok && value != nil {
|
||||||
formatted := value.Format(time.RFC3339)
|
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 &&
|
return details.PlacementAt == nil &&
|
||||||
details.PaymentAt == nil &&
|
details.PaymentAt == nil &&
|
||||||
details.Cost == nil &&
|
details.Cost == nil &&
|
||||||
details.CostBeforeBargain == nil &&
|
details.CostBeforeBargain == nil &&
|
||||||
details.PurchaseType == nil &&
|
details.PlacementType == nil &&
|
||||||
details.Format == nil &&
|
details.Format == nil &&
|
||||||
details.Comment == nil
|
details.Comment == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PurchaseChannelDetails) bool {
|
func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PlacementDetails) bool {
|
||||||
return details.PlacementAt == nil &&
|
return details.PlacementAt == nil &&
|
||||||
details.Cost == nil &&
|
details.Cost == nil &&
|
||||||
details.CostBeforeBargain == nil &&
|
details.CostBeforeBargain == nil &&
|
||||||
@@ -1592,12 +1592,12 @@ func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) formatChannelName(ch backend.PurchaseChannelOut) string {
|
func (s *PurchaseOptionalDetails) formatChannelName(placement backend.PlacementOutput) string {
|
||||||
if ch.Channel.Title != nil && *ch.Channel.Title != "" {
|
if placement.Channel.Title != nil && *placement.Channel.Title != "" {
|
||||||
return *ch.Channel.Title
|
return *placement.Channel.Title
|
||||||
}
|
}
|
||||||
if ch.Channel.Username != nil && *ch.Channel.Username != "" {
|
if placement.Channel.Username != nil && *placement.Channel.Username != "" {
|
||||||
return "@" + *ch.Channel.Username
|
return "@" + *placement.Channel.Username
|
||||||
}
|
}
|
||||||
return "Без названия"
|
return "Без названия"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||||
purchases, err := b.Backend.GetPurchases(
|
resp, err := b.Backend.GetPlacements(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
jwt,
|
jwt,
|
||||||
s.WorkspaceID,
|
s.WorkspaceID,
|
||||||
s.ProjectID,
|
s.ProjectID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to get purchases")
|
log.Error().Err(err).Msg("Failed to get placements")
|
||||||
b.SendNew("❌ Не удалось загрузить закупы", Keyboard())
|
b.SendNew("❌ Не удалось загрузить размещения", Keyboard())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text := "<b>Закупы</b>\n\n"
|
text := "<b>Размещения</b>\n\n"
|
||||||
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
if len(purchases) == 0 {
|
if len(resp.Placements) == 0 {
|
||||||
text += `Список закупов пуст.
|
text += `Список размещений пуст.
|
||||||
|
|
||||||
<i>Закуп — это план размещения рекламы в каналах:</i>
|
<i>Размещение — это план публикации рекламы в канале:</i>
|
||||||
‣ Выбор креатива
|
‣ Выбор креатива
|
||||||
‣ Список каналов для размещения
|
‣ Канал размещения
|
||||||
‣ Планируемая стоимость
|
‣ Стоимость и формат
|
||||||
‣ Статус выполнения
|
‣ Статус выполнения
|
||||||
|
|
||||||
Создайте первый закуп`
|
Создайте первое размещение`
|
||||||
} else {
|
} 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 := "●"
|
statusSymbol := "●"
|
||||||
switch purchase.Status {
|
if placement.Status == "archived" {
|
||||||
case "active":
|
|
||||||
statusSymbol = "●"
|
|
||||||
case "archived":
|
|
||||||
statusSymbol = "■"
|
statusSymbol = "■"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Считаем каналы по статусам
|
channelName := "Без названия"
|
||||||
totalChannels := len(purchase.Channels)
|
if placement.Channel.Title != nil && *placement.Channel.Title != "" {
|
||||||
completedChannels := 0
|
channelName = *placement.Channel.Title
|
||||||
for _, ch := range purchase.Channels {
|
} else if placement.Channel.Username != nil && *placement.Channel.Username != "" {
|
||||||
if ch.Status == "completed" {
|
channelName = "@" + *placement.Channel.Username
|
||||||
completedChannels++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
|
buttonText := fmt.Sprintf("%s Размещение #%d — %s", statusSymbol, i+1, channelName)
|
||||||
statusSymbol,
|
|
||||||
i+1,
|
|
||||||
completedChannels,
|
|
||||||
totalChannels,
|
|
||||||
)
|
|
||||||
|
|
||||||
buttons = append(buttons, Row(
|
buttons = append(buttons, Row(
|
||||||
Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)),
|
Button(buttonText, fmt.Sprintf("placement:%s", placement.ID)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка создания нового закупа
|
// Кнопка создания нового размещения
|
||||||
buttons = append(buttons, Row(
|
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)
|
}, bot.EditMessage)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Проверяем, не нажали ли на конкретный закуп
|
// Проверяем, не нажали ли на конкретное размещение
|
||||||
if len(data) > 9 && data[:9] == "purchase:" {
|
if len(data) > 10 && data[:10] == "placement:" {
|
||||||
purchaseID := data[9:]
|
placementID := data[10:]
|
||||||
b.SetState(&PurchaseDetails{
|
b.SetState(&PlacementDetails{
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
PurchaseID: purchaseID,
|
PlacementID: placementID,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.EditMessage)
|
}, bot.EditMessage)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -226,31 +226,31 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
|||||||
|
|
||||||
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
||||||
// Преобразуем каналы в формат API
|
// Преобразуем каналы в формат API
|
||||||
var apiChannels []backend.CreatePurchaseChannelInput
|
var apiChannels []backend.CreatePlacementChannelInput
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
var details *backend.PurchaseChannelDetails
|
var details *backend.PlacementDetails
|
||||||
if ch.PlannedCost != nil {
|
if ch.PlannedCost != nil {
|
||||||
cost := backend.CostInfo{
|
cost := backend.CostInfo{
|
||||||
Type: "fixed",
|
Type: "fixed",
|
||||||
Value: *ch.PlannedCost,
|
Value: *ch.PlannedCost,
|
||||||
}
|
}
|
||||||
details = &backend.PurchaseChannelDetails{
|
details = &backend.PlacementDetails{
|
||||||
Cost: &cost,
|
Cost: &cost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
|
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||||
Username: ch.Username,
|
Username: ch.Username,
|
||||||
Comment: ch.Comment,
|
Comment: ch.Comment,
|
||||||
Details: details,
|
Details: details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
input := backend.CreatePurchaseInput{
|
input := backend.CreatePlacementsInput{
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: s.CreativeID,
|
||||||
Channels: apiChannels,
|
Channels: apiChannels,
|
||||||
}
|
}
|
||||||
|
|
||||||
purchase, err := b.Backend.CreatePurchase(
|
placements, err := b.Backend.CreatePlacements(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
jwt,
|
jwt,
|
||||||
s.WorkspaceID,
|
s.WorkspaceID,
|
||||||
@@ -259,17 +259,24 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to create purchase")
|
log.Error().Err(err).Msg("Failed to create placements")
|
||||||
b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
|
b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
|
||||||
Row(Button("← Назад", "back_to_select")),
|
Row(Button("← Назад", "back_to_select")),
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b.SetState(&PurchaseDetails{
|
if len(placements.Placements) == 0 {
|
||||||
|
b.Edit("❌ Размещения не созданы", Keyboard(
|
||||||
|
Row(Button("← Назад", "back_to_select")),
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SetState(&PlacementDetails{
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
PurchaseID: purchase.ID,
|
PlacementID: placements.Placements[0].ID,
|
||||||
BackState: s.BackState,
|
BackState: s.BackState,
|
||||||
}, bot.EditMessage)
|
}, bot.EditMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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.
|
// isInviteLink detects Telegram invite links used in creatives.
|
||||||
@@ -290,6 +291,15 @@ func normalizeHTMLTags(input string) string {
|
|||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeInviteLinkTags(input string) string {
|
||||||
|
if input == "" {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`<a\s+class="tg-link">\s*https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+\s*</a>`)
|
||||||
|
return re.ReplaceAllString(input, `<a class="tg-link"></a>`)
|
||||||
|
}
|
||||||
|
|
||||||
// EscapeHTML escapes HTML special characters for safe output.
|
// EscapeHTML escapes HTML special characters for safe output.
|
||||||
func EscapeHTML(s string) string {
|
func EscapeHTML(s string) string {
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/TelegramExchange/pkg/logger"
|
|
||||||
"github.com/TelegramExchange/pkg/postgres"
|
"github.com/TelegramExchange/pkg/postgres"
|
||||||
"github.com/TelegramExchange/pkg/telegram"
|
"github.com/TelegramExchange/pkg/telegram"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -18,8 +17,13 @@ type HTTP struct {
|
|||||||
Addr string `envconfig:"HTTP__ADDR" default:":8080"`
|
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 {
|
type Config struct {
|
||||||
Logger logger.Config
|
Logger LoggerConfig
|
||||||
Postgres postgres.Config
|
Postgres postgres.Config
|
||||||
Telegram telegram.Config
|
Telegram telegram.Config
|
||||||
ChannelWorker worker.ChannelConfig
|
ChannelWorker worker.ChannelConfig
|
||||||
|
|||||||
@@ -2,23 +2,25 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"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/config"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/app"
|
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Info().Msg("parser starting")
|
|
||||||
|
|
||||||
c, err := config.New()
|
c, err := config.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("config.New")
|
log.Fatal().Err(err).Msg("config.New")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Init(c.Logger)
|
initLogger(c.Logger)
|
||||||
|
log.Info().Msg("parser starting")
|
||||||
log.Info().Msg("parser initialized")
|
log.Info().Msg("parser initialized")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -27,3 +29,28 @@ func main() {
|
|||||||
log.Error().Err(err).Msg("app.Run")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user