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)
|
||||
|
||||
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')
|
||||
|
||||
@@ -40,22 +40,25 @@ class Creative(TimestampedModel):
|
||||
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_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', 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/(?:\+|joinchat/)[a-zA-Z0-9_-]+', 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:
|
||||
"""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('<a class="tg-link"></a>', text)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -532,65 +532,90 @@ func (c *Client) DeleteCreative(
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Placements (Размещения/Закупы)
|
||||
// Placements (Размещения)
|
||||
// ============================================================================
|
||||
|
||||
type Placement struct {
|
||||
type Channel 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"`
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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("<b>Канал:</b> %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{
|
||||
b.SetState(&Purchases{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
PurchaseID: purchase.ID,
|
||||
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 "Без названия"
|
||||
}
|
||||
|
||||
@@ -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 := "<b>Закупы</b>\n\n"
|
||||
text := "<b>Размещения</b>\n\n"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(purchases) == 0 {
|
||||
text += `Список закупов пуст.
|
||||
if len(resp.Placements) == 0 {
|
||||
text += `Список размещений пуст.
|
||||
|
||||
<i>Закуп — это план размещения рекламы в каналах:</i>
|
||||
<i>Размещение — это план публикации рекламы в канале:</i>
|
||||
‣ Выбор креатива
|
||||
‣ Список каналов для размещения
|
||||
‣ Планируемая стоимость
|
||||
‣ Канал размещения
|
||||
‣ Стоимость и формат
|
||||
‣ Статус выполнения
|
||||
|
||||
Создайте первый закуп`
|
||||
Создайте первое размещение`
|
||||
} 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(`<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.
|
||||
func EscapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user