This commit is contained in:
Artem Tsyrulnikov
2026-01-19 12:50:36 +03:00
parent 62e8f98429
commit 5473b26787
17 changed files with 478 additions and 599 deletions

View File

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

View File

@@ -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')

View File

@@ -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)

View File

@@ -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 [

View File

@@ -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,
)

View File

@@ -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
// ============================================================================

View File

@@ -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 == "" {

View File

@@ -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,

View File

@@ -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

View 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() {}

View File

@@ -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() {}

View File

@@ -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 "Без названия"
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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, "&", "&amp;")

View File

@@ -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

View File

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