From 2c0ae2dfc3281f2d3c8e082836301a6ab0d09bcb Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Tue, 6 Jan 2026 21:04:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 26 +++- .gitignore | 3 +- docker-compose.yml.example | 18 +++ src/config.py | 5 - src/usecase/purchase/create_purchase.py | 13 +- telegram_bot/main.go | 23 +++- telegram_bot/screens/add_creative.go | 83 +++++++++-- telegram_bot/screens/creative_details.go | 79 +++++++++-- telegram_bot/screens/creative_editor.go | 10 +- .../screens/purchase_optional_details.go | 6 +- tg_parser/Dockerfile | 17 +++ tg_parser/cmd/auth/main.go | 80 +++++++++++ tg_parser/config/config.go | 44 ++++++ tg_parser/go.mod | 51 +++++++ tg_parser/go.sum | 130 ++++++++++++++++++ .../internal/adapter/database/create_post.go | 42 ++++++ .../adapter/database/create_views_snapshot.go | 40 ++++++ .../internal/adapter/database/database.go | 7 + .../internal/adapter/database/delete_post.go | 37 +++++ .../internal/adapter/database/get_channels.go | 79 +++++++++++ .../get_channels_with_tracked_posts.go | 79 +++++++++++ .../adapter/database/get_tracked_posts.go | 90 ++++++++++++ .../adapter/database/update_channel.go | 49 +++++++ .../adapter/telegram/get_active_views.go | 37 +++++ .../adapter/telegram/get_channel_diff.go | 119 ++++++++++++++++ .../internal/adapter/telegram/history.go | 46 +++++++ .../internal/adapter/telegram/resolve.go | 59 ++++++++ .../internal/adapter/telegram/telegram.go | 20 +++ tg_parser/internal/app/app.go | 91 ++++++++++++ .../internal/controller/httpserver/server.go | 90 ++++++++++++ .../controller/worker/channel_worker.go | 85 ++++++++++++ .../controller/worker/views_worker.go | 65 +++++++++ tg_parser/internal/domain/channel.go | 65 +++++++++ tg_parser/internal/domain/post.go | 27 ++++ tg_parser/internal/domain/views_snapshot.go | 14 ++ .../internal/usecase/fetch_channel_meta.go | 11 ++ tg_parser/internal/usecase/fetch_channels.go | 112 +++++++++++++++ tg_parser/internal/usecase/fetch_views.go | 46 +++++++ tg_parser/internal/usecase/usecase.go | 38 +++++ tg_parser/main.go | 29 ++++ tg_parser/pkg/logger/logger.go | 36 +++++ tg_parser/pkg/postgres/postgres.go | 37 +++++ tg_parser/pkg/telegram/telegram.go | 76 ++++++++++ tg_parser/pkg/transaction/transaction.go | 101 ++++++++++++++ tg_parser/pkg/transaction/wrap.go | 38 +++++ 45 files changed, 2202 insertions(+), 51 deletions(-) create mode 100644 tg_parser/Dockerfile create mode 100644 tg_parser/cmd/auth/main.go create mode 100644 tg_parser/config/config.go create mode 100644 tg_parser/go.mod create mode 100644 tg_parser/go.sum create mode 100644 tg_parser/internal/adapter/database/create_post.go create mode 100644 tg_parser/internal/adapter/database/create_views_snapshot.go create mode 100644 tg_parser/internal/adapter/database/database.go create mode 100644 tg_parser/internal/adapter/database/delete_post.go create mode 100644 tg_parser/internal/adapter/database/get_channels.go create mode 100644 tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go create mode 100644 tg_parser/internal/adapter/database/get_tracked_posts.go create mode 100644 tg_parser/internal/adapter/database/update_channel.go create mode 100644 tg_parser/internal/adapter/telegram/get_active_views.go create mode 100644 tg_parser/internal/adapter/telegram/get_channel_diff.go create mode 100644 tg_parser/internal/adapter/telegram/history.go create mode 100644 tg_parser/internal/adapter/telegram/resolve.go create mode 100644 tg_parser/internal/adapter/telegram/telegram.go create mode 100644 tg_parser/internal/app/app.go create mode 100644 tg_parser/internal/controller/httpserver/server.go create mode 100644 tg_parser/internal/controller/worker/channel_worker.go create mode 100644 tg_parser/internal/controller/worker/views_worker.go create mode 100644 tg_parser/internal/domain/channel.go create mode 100644 tg_parser/internal/domain/post.go create mode 100644 tg_parser/internal/domain/views_snapshot.go create mode 100644 tg_parser/internal/usecase/fetch_channel_meta.go create mode 100644 tg_parser/internal/usecase/fetch_channels.go create mode 100644 tg_parser/internal/usecase/fetch_views.go create mode 100644 tg_parser/internal/usecase/usecase.go create mode 100644 tg_parser/main.go create mode 100644 tg_parser/pkg/logger/logger.go create mode 100644 tg_parser/pkg/postgres/postgres.go create mode 100644 tg_parser/pkg/telegram/telegram.go create mode 100644 tg_parser/pkg/transaction/transaction.go create mode 100644 tg_parser/pkg/transaction/wrap.go diff --git a/.env.example b/.env.example index 0135929..cef0154 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,27 @@ -APP__URL=https://app.example.com -DB__URL=postgresql+asyncpg://user:password@localhost:5432/tgex -LOGGER__APP_NAME="tgex-backend" +# backend (src) +DB__URL=postgres://user:password@localhost:5432/tgex + +LOGGER__APP_NAME=tgex-backend LOGGER__PRETTY_CONSOLE=true -TELEGRAM__TOKEN=your_bot_token_here +LOGGER__LEVEL=info + JWT__SECRET_KEY=your_secret_key_here_please_generate_a_strong_random_string + PARSER__URL=http://localhost:8080 + S3__ENDPOINT_URL=http://localhost:9000 S3__ACCESS_KEY_ID=user S3__SECRET_ACCESS_KEY=password -S3__BUCKET_NAME=tgex-files \ No newline at end of file +S3__BUCKET_NAME=tgex-files + +# tg_bot (telegram_bot) +TELEGRAM__TOKEN=your_bot_token_here +BACKEND__BASE_URL=http://localhost:8000 +LOGIN_URL=https://app.example.com/api/v1/auth/complete?token= + +# tg_parser (tg_parser) +TELEGRAM__API_ID=123456 +TELEGRAM__API_HASH=your_api_hash_here +TELEGRAM__SESSION_FILE=/data/tg_parser.json +TELEGRAM__PHONE=+79999999999 +TELEGRAM__PASSWORD= diff --git a/.gitignore b/.gitignore index e645b08..ee448f0 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,5 @@ docker-compose.yml .uv_cache/ -session.json \ No newline at end of file +session.json +tg_parser.json diff --git a/docker-compose.yml.example b/docker-compose.yml.example index f44c220..dcd1dfb 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -31,6 +31,7 @@ services: - minio environment: DB__URL: "postgres://user:password@postgres:5432/tgex" + PARSER__URL: "http://tg_parser:8080" S3__ENDPOINT_URL: "http://minio:9000" env_file: - .env @@ -45,6 +46,23 @@ services: - .env restart: unless-stopped + tg_parser: + build: + context: tg_parser + ports: + - "8080:8080" + depends_on: + - postgres + environment: + DB__URL: "postgres://user:password@postgres:5432/tgex" + LOGGER__LEVEL: "info" + LOGGER__PRETTY_CONSOLE: "true" + env_file: + - .env + volumes: + - ./tg_parser.session:/data/tg_parser.session + restart: unless-stopped + volumes: postgres_data: minio_data: diff --git a/src/config.py b/src/config.py index 48138a3..567f807 100644 --- a/src/config.py +++ b/src/config.py @@ -9,10 +9,6 @@ from shared.telegram_base import TelegramConfig from src.adapter.s3 import S3Config -class AppConfig(BaseModel): - URL: str - - class ParserConfig(BaseModel): URL: str @@ -20,7 +16,6 @@ class ParserConfig(BaseModel): class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', case_sensitive=False, env_nested_delimiter='__') - app: AppConfig db: DatabaseConfig logger: LoggerConfig telegram: TelegramConfig diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py index 45501f7..11b991e 100644 --- a/src/usecase/purchase/create_purchase.py +++ b/src/usecase/purchase/create_purchase.py @@ -1,5 +1,4 @@ import logging -import random import uuid from typing import TYPE_CHECKING @@ -131,13 +130,17 @@ async def create_purchase( channel = await self.database.get_channel(username=channel_input.username) if not channel: + parser_response = await self.parser.fetch_telegram_channel(channel_input.username) + if not parser_response: + raise domain.TelegramChannelNotFound(channel_input.username) + channel = domain.Channel( - username=channel_input.username, - telegram_id=random.randint(1, 10000), - title='parser_response.title', + 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 with telegram_id=%s', channel_input.username, channel.telegram_id) + log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id) invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) diff --git a/telegram_bot/main.go b/telegram_bot/main.go index 5634317..7778957 100644 --- a/telegram_bot/main.go +++ b/telegram_bot/main.go @@ -1,6 +1,7 @@ package main import ( + "os" "time" "example.com/m/adapter/backend" @@ -12,10 +13,6 @@ import ( "github.com/rs/zerolog/log" ) -const ( - botToken = "7718883894:AAHUqtjqHtSs2Rp6Tb4PokqU6syfKmcFvOo" -) - func main() { logger.Init(logger.Config{ AppName: "tgex-bot", @@ -23,10 +20,13 @@ func main() { PrettyConsole: true, }) - // Инициализация backend client + botToken := mustEnv("TELEGRAM__TOKEN") + loginURL := mustEnv("LOGIN_URL") + baseURL := os.Getenv("BACKEND__BASE_URL") + backendClient := backend.New(backend.Config{ - BaseURL: "http://backend:8000", - LoginURL: "https://unstabilising-lora-unframable.ngrok-free.dev/api/v1/auth/complete?token=", + BaseURL: baseURL, + LoginURL: loginURL, }) var mainMenu = func() bot.State { return &screens.MainMenu{} } @@ -47,3 +47,12 @@ func main() { break } } + +func mustEnv(key string) string { + value := os.Getenv(key) + if value == "" { + log.Fatal().Str("env", key).Msg("missing required environment variable") + } + + return value +} diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go index 0a822d4..c21fafb 100644 --- a/telegram_bot/screens/add_creative.go +++ b/telegram_bot/screens/add_creative.go @@ -74,6 +74,7 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ case data == "add_button": // Начинаем процесс добавления кнопки s.WaitingForButtonText = true + s.PendingButtonType = "invite" s.ShowControlPanel(b, `+ Добавить кнопку @@ -81,7 +82,13 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ Например: • Перейти на сайт -• Написать нам`, [][]echotron.InlineKeyboardButton{ +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button("✓ Ссылка на канал", "button_type_invite"), + bot.Button("Своя ссылка", "button_type_custom"), + ), bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) @@ -115,13 +122,14 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ // Если мы уже создали кнопку с placeholder - удаляем её if s.WaitingForButtonURL && len(s.Buttons) > 0 { lastButton := s.Buttons[len(s.Buttons)-1] - if lastButton.URL == "https://example.com" { + if lastButton.URL == "https://example.com" || lastButton.URL == inviteLinkPlaceholder { s.Buttons = s.Buttons[:len(s.Buttons)-1] s.UpdateCreativePreview(b) } } s.WaitingForButtonText = false s.WaitingForButtonURL = false + s.PendingButtonType = "" s.CurrentButtonText = nil if s.CreativeMessageSent { @@ -130,6 +138,42 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } + case data == "button_type_invite": + s.PendingButtonType = "invite" + s.ShowControlPanel(b, `+ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button("✓ Ссылка на канал", "button_type_invite"), + bot.Button("Своя ссылка", "button_type_custom"), + ), + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + + case data == "button_type_custom": + s.PendingButtonType = "custom" + s.ShowControlPanel(b, `+ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button("Ссылка на канал", "button_type_invite"), + bot.Button("✓ Своя ссылка", "button_type_custom"), + ), + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + case data == "cancel_edit": // Отменяем редактирование текста s.WaitingForTextEdit = false @@ -255,7 +299,6 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { buttonText := u.Message.Text s.CurrentButtonText = &buttonText s.WaitingForButtonText = false - s.WaitingForButtonURL = true // Удаляем сообщение пользователя _, err := b.DeleteMessage(b.ChatID, u.Message.ID) @@ -263,22 +306,35 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { log.Error().Err(err).Msg("Failed to delete user message") } - s.Buttons = append(s.Buttons, InlineButton{ - Text: buttonText, - URL: "https://example.com", - }) - - s.UpdateCreativePreview(b) - - s.ShowControlPanel(b, `✧ Введите URL для кнопки: + if s.PendingButtonType == "custom" { + s.WaitingForButtonURL = true + s.Buttons = append(s.Buttons, InlineButton{ + Text: buttonText, + URL: "https://example.com", + }) + s.UpdateCreativePreview(b) + s.ShowControlPanel(b, `✧ Введите URL для кнопки: URL должен начинаться с https:// Например: https://example.com https://t.me/yourchannel`, [][]echotron.InlineKeyboardButton{ - bot.Row(bot.Button("« Отмена", "cancel_add_button")), - }) + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + } else { + s.Buttons = append(s.Buttons, InlineButton{ + Text: buttonText, + URL: inviteLinkPlaceholder, + }) + s.UpdateCreativePreview(b) + s.PendingButtonType = "" + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } + } return } @@ -312,6 +368,7 @@ URL должен начинаться с https:// } s.WaitingForButtonURL = false s.CurrentButtonText = nil + s.PendingButtonType = "" s.UpdateCreativePreview(b) diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go index d536fb4..8682ee5 100644 --- a/telegram_bot/screens/creative_details.go +++ b/telegram_bot/screens/creative_details.go @@ -101,13 +101,20 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ case data == "add_button": s.WaitingForButtonText = true + s.PendingButtonType = "invite" s.ShowControlPanel(b, `+ Добавить кнопку Введите текст кнопки: Например: • Перейти на сайт -• Написать нам`, [][]echotron.InlineKeyboardButton{ +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button("✓ Ссылка на канал", "button_type_invite"), + bot.Button("Своя ссылка", "button_type_custom"), + ), bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) @@ -132,16 +139,53 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ // Отменяем добавление кнопки if s.WaitingForButtonURL && len(s.Buttons) > 0 { lastButton := s.Buttons[len(s.Buttons)-1] - if lastButton.URL == "https://example.com" { + if lastButton.URL == "https://example.com" || lastButton.URL == inviteLinkPlaceholder { s.Buttons = s.Buttons[:len(s.Buttons)-1] s.UpdateCreativePreview(b) } } s.WaitingForButtonText = false s.WaitingForButtonURL = false + s.PendingButtonType = "" s.CurrentButtonText = nil s.showManagementPanelWithDelete(b) + case data == "button_type_invite": + s.PendingButtonType = "invite" + s.ShowControlPanel(b, `+ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button("✓ Ссылка на канал", "button_type_invite"), + bot.Button("Своя ссылка", "button_type_custom"), + ), + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + + case data == "button_type_custom": + s.PendingButtonType = "custom" + s.ShowControlPanel(b, `+ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ + bot.Row( + bot.Button("Ссылка на канал", "button_type_invite"), + bot.Button("✓ Своя ссылка", "button_type_custom"), + ), + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + case data == "cancel_edit", data == "cancel_media": s.WaitingForMedia = false s.showManagementPanelWithDelete(b) @@ -235,7 +279,6 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { buttonText := u.Message.Text s.CurrentButtonText = &buttonText s.WaitingForButtonText = false - s.WaitingForButtonURL = true // Удаляем сообщение пользователя _, err := b.DeleteMessage(b.ChatID, u.Message.ID) @@ -244,19 +287,28 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { } // Создаём кнопку с placeholder URL - s.Buttons = append(s.Buttons, InlineButton{ - Text: buttonText, - URL: "https://example.com", - }) - - s.UpdateCreativePreview(b) - - s.ShowControlPanel(b, `✧ Введите URL для кнопки: + if s.PendingButtonType == "custom" { + s.WaitingForButtonURL = true + s.Buttons = append(s.Buttons, InlineButton{ + Text: buttonText, + URL: "https://example.com", + }) + s.UpdateCreativePreview(b) + s.ShowControlPanel(b, `✧ Введите URL для кнопки: Например: https://example.com`, [][]echotron.InlineKeyboardButton{ - bot.Row(bot.Button("« Отмена", "cancel_add_button")), - }) + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + } else { + s.Buttons = append(s.Buttons, InlineButton{ + Text: buttonText, + URL: inviteLinkPlaceholder, + }) + s.UpdateCreativePreview(b) + s.PendingButtonType = "" + s.showManagementPanelWithDelete(b) + } return } @@ -289,6 +341,7 @@ URL должен начинаться с http:// или https s.WaitingForButtonURL = false s.CurrentButtonText = nil + s.PendingButtonType = "" s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go index da3a0e9..a5bcd73 100644 --- a/telegram_bot/screens/creative_editor.go +++ b/telegram_bot/screens/creative_editor.go @@ -33,6 +33,7 @@ type CreativeEditorFields struct { WaitingForButtonURL bool CurrentButtonText *string WaitingForMedia bool + PendingButtonType string // ID сообщения с креативом (которое постоянно обновляется) CreativeMessageID *int @@ -43,6 +44,9 @@ type CreativeEditorFields struct { previousHasMedia bool } +const inviteLinkPlaceholder = "{{invite_link}}" +const inviteLinkPreviewURL = "https://t.me/placeholder" + // GetCreativeText возвращает текст для превью креатива func (e *CreativeEditorFields) GetCreativeText() string { var result string @@ -78,10 +82,14 @@ func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardB var buttons [][]echotron.InlineKeyboardButton for _, btn := range e.Buttons { + url := btn.URL + if url == inviteLinkPlaceholder { + url = inviteLinkPreviewURL + } buttons = append(buttons, []echotron.InlineKeyboardButton{ { Text: btn.Text, - URL: btn.URL, + URL: url, }, }) } diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go index 3d06f97..c67ab4c 100644 --- a/telegram_bot/screens/purchase_optional_details.go +++ b/telegram_bot/screens/purchase_optional_details.go @@ -1443,9 +1443,13 @@ func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink( if len(creative.Buttons) > 0 { editor.Buttons = make([]InlineButton, 0, len(creative.Buttons)) for _, btn := range creative.Buttons { + url := btn.URL + if url == inviteLinkPlaceholder { + url = inviteLink + } editor.Buttons = append(editor.Buttons, InlineButton{ Text: btn.Text, - URL: btn.URL, + URL: url, }) } } diff --git a/tg_parser/Dockerfile b/tg_parser/Dockerfile new file mode 100644 index 0000000..8964ce1 --- /dev/null +++ b/tg_parser/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS build + +WORKDIR /app + +# Modules layer +COPY go.mod go.sum ./ +RUN go mod download + +# Build layer +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /parser . + +FROM alpine:latest AS run + +COPY --from=build /parser /parser + +CMD ["/parser"] diff --git a/tg_parser/cmd/auth/main.go b/tg_parser/cmd/auth/main.go new file mode 100644 index 0000000..755b287 --- /dev/null +++ b/tg_parser/cmd/auth/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/auth" + "github.com/gotd/td/tg" +) + +func main() { + apiID := mustEnvInt("TELEGRAM__API_ID") + apiHash := mustEnv("TELEGRAM__API_HASH") + sessionFile := mustEnv("TELEGRAM__SESSION_FILE") + + phone := strings.TrimSpace(os.Getenv("TELEGRAM__PHONE")) + if phone == "" { + phone = prompt("Phone (e.g. +79991234567): ") + } + + password := strings.TrimSpace(os.Getenv("TELEGRAM__PASSWORD")) + if password == "" { + password = prompt("2FA password (empty if not set): ") + } + + ctx := context.Background() + + client := telegram.NewClient(apiID, apiHash, telegram.Options{ + SessionStorage: &session.FileStorage{Path: sessionFile}, + }) + + err := client.Run(ctx, func(ctx context.Context) error { + return client.Auth().IfNecessary(ctx, auth.NewFlow( + auth.Constant(phone, password, auth.CodeAuthenticatorFunc( + func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) { + return prompt("Enter code: "), nil + }, + )), + auth.SendCodeOptions{}, + )) + }) + if err != nil { + fmt.Fprintf(os.Stderr, "auth failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("✓ Authorized") +} + +func mustEnv(key string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + fmt.Fprintf(os.Stderr, "missing required env: %s\n", key) + os.Exit(1) + } + return value +} + +func mustEnvInt(key string) int { + raw := mustEnv(key) + v, err := strconv.Atoi(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid int in %s: %v\n", key, err) + os.Exit(1) + } + return v +} + +func prompt(label string) string { + fmt.Print(label) + reader := bufio.NewReader(os.Stdin) + value, _ := reader.ReadString('\n') + return strings.TrimSpace(value) +} diff --git a/tg_parser/config/config.go b/tg_parser/config/config.go new file mode 100644 index 0000000..90bdcd8 --- /dev/null +++ b/tg_parser/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "errors" + "fmt" + "os" + + "github.com/TelegramExchange/tgex-parser/pkg/logger" + "github.com/TelegramExchange/tgex-parser/pkg/postgres" + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" + + "github.com/TelegramExchange/tgex-parser/internal/controller/worker" + "github.com/TelegramExchange/tgex-parser/pkg/telegram" +) + +type HTTP struct { + Addr string `envconfig:"HTTP__ADDR" default:":8080"` +} + +type Config struct { + Logger logger.Config + Postgres postgres.Config + Telegram telegram.Config + ChannelWorker worker.ChannelConfig + ViewsWorker worker.ViewsConfig + HTTP HTTP +} + +func New() (Config, error) { + var config Config + + err := godotenv.Load(".env") + if err != nil && !errors.Is(err, os.ErrNotExist) { + return config, fmt.Errorf("godotenv.Load: %w", err) + } + + err = envconfig.Process("", &config) + if err != nil { + return config, fmt.Errorf("envconfig.Process: %w", err) + } + + return config, nil +} diff --git a/tg_parser/go.mod b/tg_parser/go.mod new file mode 100644 index 0000000..784b40c --- /dev/null +++ b/tg_parser/go.mod @@ -0,0 +1,51 @@ +module github.com/TelegramExchange/tgex-parser + +go 1.25.0 + +require ( + github.com/gotd/td v0.136.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/rs/zerolog v1.34.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gotd/ige v0.2.2 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ogen-go/ogen v1.16.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.39.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/tg_parser/go.sum b/tg_parser/go.sum new file mode 100644 index 0000000..e2a6a74 --- /dev/null +++ b/tg_parser/go.sum @@ -0,0 +1,130 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= +github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.136.0 h1:f7vx/1rlvP59L5EKR820XpMRO2k267wW8/F0rAWbepc= +github.com/gotd/td v0.136.0/go.mod h1:mStcqs/9FXhNhWnPTguptSwqkQbRIwXLw3SCSpzPJxM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw= +github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/tg_parser/internal/adapter/database/create_post.go b/tg_parser/internal/adapter/database/create_post.go new file mode 100644 index 0000000..a5ac93b --- /dev/null +++ b/tg_parser/internal/adapter/database/create_post.go @@ -0,0 +1,42 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) CreatePost(ctx context.Context, post domain.Post) error { + query := `INSERT INTO post (id, channel_id, message_id, text) +VALUES ($1, $2, $3, $4) +ON CONFLICT (channel_id, message_id) DO UPDATE +SET text = EXCLUDED.text, + updated_at = CURRENT_TIMESTAMP;` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := createPostDTO{ + ID: pgtype.UUID{Bytes: post.ID, Valid: true}, + ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true}, + MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true}, + Text: pgtype.Text{String: post.Text, Valid: true}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type createPostDTO struct { + ID pgtype.UUID + ChannelID pgtype.UUID + MessageID pgtype.Int4 + Text pgtype.Text +} diff --git a/tg_parser/internal/adapter/database/create_views_snapshot.go b/tg_parser/internal/adapter/database/create_views_snapshot.go new file mode 100644 index 0000000..da7d357 --- /dev/null +++ b/tg_parser/internal/adapter/database/create_views_snapshot.go @@ -0,0 +1,40 @@ +package database + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error { + query := `INSERT INTO post_views_history (id, views_count, fetched_at, post_id) +VALUES ($1, $2, $3, $4);` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := createViewsSnapshotDTO{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + ViewsCount: pgtype.Int4{Int32: int32(snapshot.ViewsCount), Valid: true}, + FetchedAt: pgtype.Timestamptz{Time: snapshot.FetchedAt, Valid: true}, + PostID: pgtype.UUID{Bytes: snapshot.PostID, Valid: true}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ViewsCount, dto.FetchedAt, dto.PostID) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type createViewsSnapshotDTO struct { + ID pgtype.UUID + ViewsCount pgtype.Int4 + FetchedAt pgtype.Timestamptz + PostID pgtype.UUID +} diff --git a/tg_parser/internal/adapter/database/database.go b/tg_parser/internal/adapter/database/database.go new file mode 100644 index 0000000..65305af --- /dev/null +++ b/tg_parser/internal/adapter/database/database.go @@ -0,0 +1,7 @@ +package database + +type Database struct{} + +func New() *Database { + return &Database{} +} diff --git a/tg_parser/internal/adapter/database/delete_post.go b/tg_parser/internal/adapter/database/delete_post.go new file mode 100644 index 0000000..4450e04 --- /dev/null +++ b/tg_parser/internal/adapter/database/delete_post.go @@ -0,0 +1,37 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) DeletePost(ctx context.Context, p domain.Post) error { + query := `UPDATE post +SET deleted_from_channel_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE channel_id = $1 AND message_id = $2;` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := deletePostDTO{ + ChannelID: pgtype.UUID{Bytes: p.ChannelID, Valid: true}, + MessageID: pgtype.Int4{Int32: int32(p.MessageID), Valid: true}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ChannelID, dto.MessageID) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type deletePostDTO struct { + ChannelID pgtype.UUID + MessageID pgtype.Int4 +} diff --git a/tg_parser/internal/adapter/database/get_channels.go b/tg_parser/internal/adapter/database/get_channels.go new file mode 100644 index 0000000..17fc0d4 --- /dev/null +++ b/tg_parser/internal/adapter/database/get_channels.go @@ -0,0 +1,79 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) GetChannels(ctx context.Context) []domain.Channel { + query := `SELECT + id, + telegram_id, + username, + title, + access_hash, + pts +FROM channel +WHERE deleted_at IS NULL;` + + txOrPool := transaction.TryExtractTX(ctx) + + rows, err := txOrPool.Query(ctx, query) + if err != nil { + log.Error().Err(err).Msg("txOrPool.Query failed") + return []domain.Channel{} + } + defer rows.Close() + + channels := make([]domain.Channel, 0) + + for rows.Next() { + var dto getChannelsDTO + + err = rows.Scan(dto.destination()...) + if err != nil { + log.Error().Err(err).Msg("rows.Scan failed") + return []domain.Channel{} + } + + channels = append(channels, dto.toDomain()) + } + + return channels +} + +type getChannelsDTO struct { + ID pgtype.UUID + TelegramID pgtype.Int8 + Username pgtype.Text + Title pgtype.Text + AccessHash pgtype.Int8 + Pts pgtype.Int4 +} + +func (dto *getChannelsDTO) destination() []any { + return []any{ + &dto.ID, + &dto.TelegramID, + &dto.Username, + &dto.Title, + &dto.AccessHash, + &dto.Pts, + } +} + +func (dto *getChannelsDTO) toDomain() domain.Channel { + return domain.Channel{ + ID: dto.ID.Bytes, + TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64), + Username: dto.Username.String, + Title: dto.Title.String, + AccessHash: dto.AccessHash.Int64, + Pts: int(dto.Pts.Int32), + } +} diff --git a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go new file mode 100644 index 0000000..fcbb055 --- /dev/null +++ b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go @@ -0,0 +1,79 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error) { + query := `SELECT DISTINCT + c.id, + c.telegram_id, + c.username, + c.title, + c.access_hash, + c.pts +FROM channel c +INNER JOIN placement p ON p.placement_channel_id = c.id +INNER JOIN post po ON po.id = p.post_id +WHERE po.deleted_from_channel_at IS NULL;` + + txOrPool := transaction.TryExtractTX(ctx) + + rows, err := txOrPool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("txOrPool.Query: %w", err) + } + defer rows.Close() + + channels := make([]domain.Channel, 0) + + for rows.Next() { + var dto getChannelsWithTrackedPostsDTO + + err = rows.Scan(dto.destination()...) + if err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + + channels = append(channels, dto.toDomain()) + } + + return channels, nil +} + +type getChannelsWithTrackedPostsDTO struct { + ID pgtype.UUID + TelegramID pgtype.Int8 + Username pgtype.Text + Title pgtype.Text + AccessHash pgtype.Int8 + Pts pgtype.Int4 +} + +func (dto *getChannelsWithTrackedPostsDTO) destination() []any { + return []any{ + &dto.ID, + &dto.TelegramID, + &dto.Username, + &dto.Title, + &dto.AccessHash, + &dto.Pts, + } +} + +func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel { + return domain.Channel{ + ID: dto.ID.Bytes, + TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64), + Username: dto.Username.String, + Title: dto.Title.String, + AccessHash: dto.AccessHash.Int64, + Pts: int(dto.Pts.Int32), + } +} diff --git a/tg_parser/internal/adapter/database/get_tracked_posts.go b/tg_parser/internal/adapter/database/get_tracked_posts.go new file mode 100644 index 0000000..815f528 --- /dev/null +++ b/tg_parser/internal/adapter/database/get_tracked_posts.go @@ -0,0 +1,90 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error) { + query := `SELECT DISTINCT + p.id, + p.channel_id, + p.message_id, + p.text, + CASE + WHEN c.username IS NOT NULL THEN CONCAT('https://t.me/', c.username, '/', p.message_id) + ELSE '' + END as link, + COALESCE( + (SELECT pvh.views_count + FROM post_views_history pvh + WHERE pvh.post_id = p.id + ORDER BY pvh.fetched_at DESC + LIMIT 1), + 0 + ) as views +FROM post p +INNER JOIN channel c ON c.id = p.channel_id +INNER JOIN placement pl ON pl.post_id = p.id +WHERE p.channel_id = $1 + AND p.deleted_from_channel_at IS NULL;` + + txOrPool := transaction.TryExtractTX(ctx) + + rows, err := txOrPool.Query(ctx, query, pgtype.UUID{Bytes: channel.ID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("txOrPool.Query: %w", err) + } + defer rows.Close() + + posts := make([]domain.Post, 0) + + for rows.Next() { + var dto getTrackedPostsDTO + + err = rows.Scan(dto.destination()...) + if err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + + posts = append(posts, dto.toDomain()) + } + + return posts, nil +} + +type getTrackedPostsDTO struct { + ID pgtype.UUID + ChannelID pgtype.UUID + MessageID pgtype.Int4 + Text pgtype.Text + Link pgtype.Text + Views pgtype.Int4 +} + +func (dto *getTrackedPostsDTO) destination() []any { + return []any{ + &dto.ID, + &dto.ChannelID, + &dto.MessageID, + &dto.Text, + &dto.Link, + &dto.Views, + } +} + +func (dto *getTrackedPostsDTO) toDomain() domain.Post { + return domain.Post{ + ID: dto.ID.Bytes, + ChannelID: dto.ChannelID.Bytes, + MessageID: int(dto.MessageID.Int32), + Text: dto.Text.String, + Link: dto.Link.String, + Views: int(dto.Views.Int32), + } +} diff --git a/tg_parser/internal/adapter/database/update_channel.go b/tg_parser/internal/adapter/database/update_channel.go new file mode 100644 index 0000000..1cdb700 --- /dev/null +++ b/tg_parser/internal/adapter/database/update_channel.go @@ -0,0 +1,49 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func (d *Database) UpdateChannel(ctx context.Context, channel domain.Channel) error { + query := `UPDATE channel +SET telegram_id = $2, + username = $3, + title = $4, + access_hash = $5, + pts = $6, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1;` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := updateChannelDTO{ + ID: pgtype.UUID{Bytes: channel.ID, Valid: true}, + TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true}, + Username: pgtype.Text{String: channel.Username, Valid: true}, + Title: pgtype.Text{String: channel.Title, Valid: true}, + AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true}, + Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type updateChannelDTO struct { + ID pgtype.UUID + TelegramID pgtype.Int8 + Username pgtype.Text + Title pgtype.Text + AccessHash pgtype.Int8 + Pts pgtype.Int4 +} diff --git a/tg_parser/internal/adapter/telegram/get_active_views.go b/tg_parser/internal/adapter/telegram/get_active_views.go new file mode 100644 index 0000000..278b048 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/get_active_views.go @@ -0,0 +1,37 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/gotd/td/tg" +) + +func (t *Telegram) UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error { + ids := make([]int, len(posts)) + for i, p := range posts { + ids[i] = p.MessageID + } + + req := &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + ID: ids, + Increment: false, + } + + resp, err := t.API().MessagesGetMessagesViews(ctx, req) + if err != nil { + return fmt.Errorf("get messages views: %w", err) + } + + // Telegram гарантирует, что resp.Views соответствует порядку ids + for i, v := range resp.Views { + posts[i].Views = v.Views + } + + return nil +} diff --git a/tg_parser/internal/adapter/telegram/get_channel_diff.go b/tg_parser/internal/adapter/telegram/get_channel_diff.go new file mode 100644 index 0000000..394e74f --- /dev/null +++ b/tg_parser/internal/adapter/telegram/get_channel_diff.go @@ -0,0 +1,119 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/gotd/td/tg" +) + +func (t *Telegram) GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error) { + req := &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: channel.Pts, + Limit: limit, + } + + rawDiff, err := t.API().UpdatesGetChannelDifference(ctx, req) + if err != nil { + return domain.ChannelDiff{}, fmt.Errorf("get difference: %w", err) + } + + result := domain.ChannelDiff{} + + switch d := rawDiff.(type) { + case *tg.UpdatesChannelDifferenceEmpty: + result.NewPts = d.Pts + + case *tg.UpdatesChannelDifferenceTooLong: + if dialog, ok := d.Dialog.(*tg.Dialog); ok { + result.NewPts = dialog.Pts + } + result.NewPosts = extractPosts(channel, d.Messages) + result.UpdatedChannel = extractChannelMeta(channel, d.Chats) + + case *tg.UpdatesChannelDifference: + result.NewPts = d.Pts + result.NewPosts = extractPosts(channel, d.NewMessages) + result.DeletedPosts = extractDeletedPosts(channel, d.OtherUpdates) + result.UpdatedChannel = extractChannelMeta(channel, d.Chats) + + default: + return domain.ChannelDiff{}, fmt.Errorf("unexpected rawDiff type: %T", rawDiff) + } + + return result, nil +} + +func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post { + posts := make([]domain.Post, 0, len(msgs)) + + for _, raw := range msgs { + m, ok := raw.(*tg.Message) + if !ok { + continue + } + + p := domain.NewPost(channel, m.ID, m.Message, m.Views) + posts = append(posts, p) + } + + return posts +} + +func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []domain.Post { + var deleted []domain.Post + + for _, upd := range updates { + if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok { + for _, msgID := range u.Messages { + p := domain.NewPost(channel, msgID, "", 0) + deleted = append(deleted, p) + } + } + } + + return deleted +} + +func extractChannelMeta(currentChannel domain.Channel, chats []tg.ChatClass) *domain.Channel { + for _, chat := range chats { + ch, ok := chat.(*tg.Channel) + if !ok { + continue + } + + if ch.ID != currentChannel.ChannelID() { + continue + } + + username := "" + if usernameVal, ok := ch.GetUsername(); ok { + username = usernameVal + } + + // Keep current access hash if new one is not provided or is zero + accessHash := currentChannel.AccessHash + if accessHashVal, ok := ch.GetAccessHash(); ok && accessHashVal != 0 { + accessHash = accessHashVal + } + + updated := domain.Channel{ + ID: currentChannel.ID, + TelegramID: domain.ChatIDFromChannelID(ch.ID), + Username: username, + Title: ch.Title, + AccessHash: accessHash, + Pts: currentChannel.Pts, + } + + return &updated + } + + return nil +} diff --git a/tg_parser/internal/adapter/telegram/history.go b/tg_parser/internal/adapter/telegram/history.go new file mode 100644 index 0000000..7ec1e62 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/history.go @@ -0,0 +1,46 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/gotd/td/tg" + "github.com/rs/zerolog/log" +) + +func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error) { + log.Info().Msg(channel.String()) + + req := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + Limit: limit, + } + + resp, err := t.API().MessagesGetHistory(ctx, req) + if err != nil { + return nil, fmt.Errorf("resp: %w", err) + } + + messages, ok := resp.(*tg.MessagesChannelMessages) + if !ok { + return nil, nil + } + + placements := make([]domain.Post, 0, len(messages.Messages)) + + for _, raw := range messages.Messages { + m, ok := raw.(*tg.Message) + if !ok { + continue + } + + p := domain.NewPost(channel, m.ID, m.Message, m.Views) + placements = append(placements, p) + } + + return placements, nil +} diff --git a/tg_parser/internal/adapter/telegram/resolve.go b/tg_parser/internal/adapter/telegram/resolve.go new file mode 100644 index 0000000..ae3dbee --- /dev/null +++ b/tg_parser/internal/adapter/telegram/resolve.go @@ -0,0 +1,59 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-parser/internal/domain" + "github.com/google/uuid" + "github.com/gotd/td/tg" +) + +func (t *Telegram) ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error) { + resolved, err := t.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ + Username: username, + }) + if err != nil { + return domain.Channel{}, fmt.Errorf("resolve username: %w", err) + } + + if len(resolved.Chats) == 0 { + return domain.Channel{}, fmt.Errorf("no chats in resolve result") + } + + ch, ok := resolved.Chats[0].(*tg.Channel) + if !ok { + return domain.Channel{}, fmt.Errorf("not a channel") + } + + req := []tg.InputDialogPeerClass{ + &tg.InputDialogPeer{ + Peer: &tg.InputPeerChannel{ + ChannelID: ch.ID, + AccessHash: ch.AccessHash, + }, + }, + } + + dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req) + if err != nil { + return domain.Channel{}, fmt.Errorf("peer dialogs: %w", err) + } + + pts := 0 + if len(dialogs.Dialogs) > 0 { + d, ok := dialogs.Dialogs[0].(*tg.Dialog) + if ok { + pts = d.Pts + } + } + + return domain.Channel{ + ID: uuid.New(), + TelegramID: domain.ChatIDFromChannelID(ch.ID), + Username: username, + Title: ch.Title, + AccessHash: ch.AccessHash, + Pts: pts, + }, nil +} diff --git a/tg_parser/internal/adapter/telegram/telegram.go b/tg_parser/internal/adapter/telegram/telegram.go new file mode 100644 index 0000000..13c5540 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/telegram.go @@ -0,0 +1,20 @@ +package telegram + +import ( + "github.com/TelegramExchange/tgex-parser/pkg/telegram" + "github.com/gotd/td/tg" +) + +type Telegram struct { + client *telegram.Client +} + +func New(client *telegram.Client) *Telegram { + return &Telegram{ + client: client, + } +} + +func (t *Telegram) API() *tg.Client { + return t.client.API() +} diff --git a/tg_parser/internal/app/app.go b/tg_parser/internal/app/app.go new file mode 100644 index 0000000..60a1a01 --- /dev/null +++ b/tg_parser/internal/app/app.go @@ -0,0 +1,91 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/config" + "github.com/TelegramExchange/tgex-parser/internal/adapter/database" + tgadapter "github.com/TelegramExchange/tgex-parser/internal/adapter/telegram" + "github.com/TelegramExchange/tgex-parser/internal/controller/httpserver" + "github.com/TelegramExchange/tgex-parser/internal/controller/worker" + "github.com/TelegramExchange/tgex-parser/internal/usecase" + "github.com/TelegramExchange/tgex-parser/pkg/postgres" + "github.com/TelegramExchange/tgex-parser/pkg/telegram" + "github.com/TelegramExchange/tgex-parser/pkg/transaction" +) + +func Run(ctx context.Context, c config.Config) error { + // Telegram client + tgClient, err := telegram.New(c.Telegram) + if err != nil { + return fmt.Errorf("telegram.New: %w", err) + } + + // PostgreSQL + pgPool, err := postgres.New(ctx, c.Postgres) + if err != nil { + return fmt.Errorf("broker.New: %w", err) + } + defer pgPool.Close() + + transaction.Init(pgPool) + + // Adapters + tg := tgadapter.New(tgClient) + db := database.New() + + // UseCase + uc := usecase.New(tg, db) + + // Controllers + channelWorker := worker.NewChannelWorker(uc, c.ChannelWorker) + viewsWorker := worker.NewViewsWorker(uc, c.ViewsWorker) + + httpServer := httpserver.New(uc, c.HTTP.Addr) + serverErr := make(chan error, 1) + go func() { + err := httpServer.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + + log.Info().Str("addr", c.HTTP.Addr).Msg("HTTP server started") + log.Info().Msg("App started") + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + select { + case <-sig: + case err := <-serverErr: + return fmt.Errorf("http server: %w", err) + } + + log.Info().Msg("App got signal to stop") + + // Controllers + viewsWorker.Stop() + channelWorker.Stop() + + ctxShutdown, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctxShutdown); err != nil { + log.Error().Err(err).Msg("HTTP server shutdown failed") + } + + // Adapters + tgClient.Close() + + log.Info().Msg("App stopped") + + return nil +} diff --git a/tg_parser/internal/controller/httpserver/server.go b/tg_parser/internal/controller/httpserver/server.go new file mode 100644 index 0000000..62f9c0b --- /dev/null +++ b/tg_parser/internal/controller/httpserver/server.go @@ -0,0 +1,90 @@ +package httpserver + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/TelegramExchange/tgex-parser/internal/usecase" + "github.com/gotd/td/tgerr" + "github.com/rs/zerolog/log" +) + +type Server struct { + httpServer *http.Server +} + +func New(uc *usecase.UseCase, addr string) *Server { + mux := http.NewServeMux() + mux.HandleFunc("/fetch-telegram-channel", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + username := strings.TrimSpace(r.URL.Query().Get("username")) + username = strings.TrimPrefix(username, "@") + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } + + channel, err := uc.FetchChannelMeta(r.Context(), username) + if err != nil { + if isNotFoundError(err) { + http.Error(w, "channel not found", http.StatusNotFound) + return + } + log.Error().Err(err).Str("username", username).Msg("fetch channel meta failed") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + resp := struct { + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Username string `json:"username"` + Title string `json:"title"` + AccessHash int64 `json:"access_hash"` + Pts int `json:"pts"` + }{ + ID: channel.ID.String(), + TelegramID: channel.TelegramID, + Username: channel.Username, + Title: channel.Title, + AccessHash: channel.AccessHash, + Pts: channel.Pts, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error().Err(err).Msg("encode channel response") + } + }) + + return &Server{ + httpServer: &http.Server{ + Addr: addr, + Handler: mux, + }, + } +} + +func (s *Server) ListenAndServe() error { + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +func isNotFoundError(err error) bool { + if tgerr.Is(err, "USERNAME_NOT_OCCUPIED", "USERNAME_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE") { + return true + } + + msg := err.Error() + return strings.Contains(msg, "no chats in resolve result") || + strings.Contains(msg, "not a channel") +} diff --git a/tg_parser/internal/controller/worker/channel_worker.go b/tg_parser/internal/controller/worker/channel_worker.go new file mode 100644 index 0000000..0103566 --- /dev/null +++ b/tg_parser/internal/controller/worker/channel_worker.go @@ -0,0 +1,85 @@ +package worker + +import ( + "context" + "sync" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/internal/usecase" +) + +type ChannelConfig struct { + MaxWorkers int `envconfig:"WORKER__MAX_WORKERS" default:"1"` + ChannelDelay time.Duration `envconfig:"WORKER__CHANNEL_DELAY" default:"500ms"` + RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"` + MessagesLimit int `envconfig:"WORKER__MESSAGES_LIMIT" default:"20"` + PollInterval time.Duration `envconfig:"WORKER__POLL_INTERVAL" default:"20s"` +} + +type ChannelWorker struct { + usecase *usecase.UseCase + config ChannelConfig + stop chan struct{} + done chan struct{} +} + +func NewChannelWorker(uc *usecase.UseCase, cfg ChannelConfig) *ChannelWorker { + w := &ChannelWorker{ + usecase: uc, + config: cfg, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + go w.run() + + return w +} + +func (w *ChannelWorker) run() { + log.Info().Msg("channel worker: started") + + var wg sync.WaitGroup + + wg.Add(w.config.MaxWorkers) + + for range w.config.MaxWorkers { + go w.spawnWorker(&wg) + } + + wg.Wait() + + log.Info().Msg("channel worker: stopped") + + close(w.done) +} + +func (w *ChannelWorker) spawnWorker(wg *sync.WaitGroup) { + defer wg.Done() + + limiter := time.NewTicker(w.config.ChannelDelay) + defer limiter.Stop() + + poll := time.NewTicker(w.config.PollInterval) + defer poll.Stop() + + for { + select { + case <-w.stop: + return + + case <-poll.C: + err := w.usecase.FetchChannels(context.Background()) + if err != nil { + log.Error().Err(err).Msg("usecase.FetchChannels error") + } + } + } +} + +func (w *ChannelWorker) Stop() { + close(w.stop) + <-w.done +} diff --git a/tg_parser/internal/controller/worker/views_worker.go b/tg_parser/internal/controller/worker/views_worker.go new file mode 100644 index 0000000..ae76224 --- /dev/null +++ b/tg_parser/internal/controller/worker/views_worker.go @@ -0,0 +1,65 @@ +package worker + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/internal/usecase" +) + +type ViewsConfig struct { + Interval time.Duration `envconfig:"WORKER__VIEWS_INTERVAL" default:"30s"` + RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"` +} + +type ViewsWorker struct { + usecase *usecase.UseCase + config ViewsConfig + stop chan struct{} + done chan struct{} +} + +func NewViewsWorker(uc *usecase.UseCase, cfg ViewsConfig) *ViewsWorker { + w := &ViewsWorker{ + usecase: uc, + config: cfg, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + go w.run() + + return w +} + +func (w *ViewsWorker) run() { + log.Info().Msg("views worker: started") + + for { + select { + case <-w.stop: + log.Info().Msg("views worker: stopped") + close(w.done) + return + case <-time.After(w.config.Interval): + log.Debug().Dur("interval", w.config.Interval).Msg("views worker: refresh tick") + ctx, cancel := context.WithTimeout(context.Background(), w.config.RequestTimeout) + + err := w.usecase.FetchViews(ctx) + if err != nil { + log.Error().Err(err).Msg("views worker: FetchViews error") + } else { + log.Debug().Msg("views worker: refresh finished") + } + + cancel() + } + } +} + +func (w *ViewsWorker) Stop() { + close(w.stop) + <-w.done +} diff --git a/tg_parser/internal/domain/channel.go b/tg_parser/internal/domain/channel.go new file mode 100644 index 0000000..3d4abcb --- /dev/null +++ b/tg_parser/internal/domain/channel.go @@ -0,0 +1,65 @@ +package domain + +import ( + "fmt" + + "github.com/google/uuid" +) + +type Channel struct { + ID uuid.UUID + TelegramID int64 + Username string + Title string + AccessHash int64 + Pts int +} + +const channelIDOffset int64 = 1000000000000 + +// ChannelID returns the positive channel identifier expected by Telegram API. +func (c Channel) ChannelID() int64 { + return ChannelIDFromChatID(c.TelegramID) +} + +// ChannelIDFromChatID converts stored chat IDs (Bot API style) to channel IDs used by TDLib. +func ChannelIDFromChatID(chatID int64) int64 { + if chatID >= 0 { + return chatID + } + + return -chatID - channelIDOffset +} + +// ChatIDFromChannelID converts Telegram channel IDs to Bot API style chat IDs (-100...). +func ChatIDFromChannelID(channelID int64) int64 { + return -channelIDOffset - channelID +} + +// NormalizeChatID ensures that TelegramID is stored in chat-id form (negative). +func NormalizeChatID(id int64) int64 { + if id < 0 { + return id + } + + return ChatIDFromChannelID(id) +} + +func (c Channel) String() string { + return fmt.Sprintf( + "Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d}", + c.ID, + c.TelegramID, + c.Username, + c.Title, + c.AccessHash, + c.Pts, + ) +} + +type ChannelDiff struct { + NewPts int + NewPosts []Post + DeletedPosts []Post + UpdatedChannel *Channel +} diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go new file mode 100644 index 0000000..64b8632 --- /dev/null +++ b/tg_parser/internal/domain/post.go @@ -0,0 +1,27 @@ +package domain + +import ( + "fmt" + + "github.com/google/uuid" +) + +type Post struct { + ID uuid.UUID + ChannelID uuid.UUID + MessageID int + Text string + Link string + Views int +} + +func NewPost(channel Channel, messageID int, text string, views int) Post { + return Post{ + ID: uuid.New(), + ChannelID: channel.ID, + MessageID: messageID, + Text: text, + Link: fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID), + Views: views, + } +} diff --git a/tg_parser/internal/domain/views_snapshot.go b/tg_parser/internal/domain/views_snapshot.go new file mode 100644 index 0000000..a265e9a --- /dev/null +++ b/tg_parser/internal/domain/views_snapshot.go @@ -0,0 +1,14 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type ViewsSnapshot struct { + ViewsCount int + FetchedAt time.Time + + PostID uuid.UUID +} diff --git a/tg_parser/internal/usecase/fetch_channel_meta.go b/tg_parser/internal/usecase/fetch_channel_meta.go new file mode 100644 index 0000000..3dde6fd --- /dev/null +++ b/tg_parser/internal/usecase/fetch_channel_meta.go @@ -0,0 +1,11 @@ +package usecase + +import ( + "context" + + "github.com/TelegramExchange/tgex-parser/internal/domain" +) + +func (uc *UseCase) FetchChannelMeta(ctx context.Context, username string) (domain.Channel, error) { + return uc.telegram.ParseChannelMeta(ctx, username) +} diff --git a/tg_parser/internal/usecase/fetch_channels.go b/tg_parser/internal/usecase/fetch_channels.go new file mode 100644 index 0000000..9d9c267 --- /dev/null +++ b/tg_parser/internal/usecase/fetch_channels.go @@ -0,0 +1,112 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-parser/pkg/transaction" + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/internal/domain" +) + +func (uc *UseCase) FetchChannels(ctx context.Context) error { + channels := uc.database.GetChannels(ctx) + + for _, c := range channels { + if c.Pts == 0 { + updatedChannel, err := uc.initializeChannel(ctx, c) + if err != nil { + return fmt.Errorf("uc.initializeChannel: %w", err) + } + + c = updatedChannel + } + + err := uc.processChannel(ctx, c) + if err != nil { + return fmt.Errorf("uc.processChannel: %w", err) + } + } + + return nil +} + +func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error { + diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20) + if err != nil { + return fmt.Errorf("telegram.GetChannelDiff: %w", err) + } + + for _, p := range diff.NewPosts { + log.Info().Msgf("New post: %s - Views: %d, Text: %.10s...", p.Link, p.Views, p.Text) + + err = uc.database.CreatePost(ctx, p) + if err != nil { + return fmt.Errorf("database.CreatePost: %w", err) + } + } + + for _, p := range diff.DeletedPosts { + log.Info().Msgf("Deleted post: %s - Views: %d", p.Link, p.Views) + + err = uc.database.DeletePost(ctx, p) + if err != nil { + return fmt.Errorf("database.DeletePost: %w", err) + } + } + + // Update channel metadata if changed + if diff.UpdatedChannel != nil { + diff.UpdatedChannel.Pts = diff.NewPts + err = uc.database.UpdateChannel(ctx, *diff.UpdatedChannel) + if err != nil { + return fmt.Errorf("database.UpdateChannel: %w", err) + } + } else { + channel.Pts = diff.NewPts + err = uc.database.UpdateChannel(ctx, channel) + if err != nil { + return fmt.Errorf("database.UpdateChannel: %w", err) + } + } + + return nil +} + +func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) (domain.Channel, error) { + c, err := uc.telegram.ParseChannelMeta(ctx, channel.Username) + if err != nil { + return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err) + } + + // Preserve persistent channel ID from the database so foreign keys remain valid. + c.ID = channel.ID + + posts, err := uc.telegram.GetChannelHistory(ctx, c, 20) + if err != nil { + return domain.Channel{}, fmt.Errorf("telegram.GetChannelHistory: %w", err) + } + + err = transaction.Wrap(ctx, func(ctx context.Context) error { + err = uc.database.UpdateChannel(ctx, c) + if err != nil { + return fmt.Errorf("database.UpdateChannel: %w", err) + } + + for _, p := range posts { + log.Info().Msgf("New post (init): %s - Views: %d", p.Link, p.Views) + + err = uc.database.CreatePost(ctx, p) + if err != nil { + return fmt.Errorf("database.CreatePost: %w", err) + } + } + return nil + }) + if err != nil { + return domain.Channel{}, fmt.Errorf("transaction.Wrap: %w", err) + } + + return c, nil +} diff --git a/tg_parser/internal/usecase/fetch_views.go b/tg_parser/internal/usecase/fetch_views.go new file mode 100644 index 0000000..3015613 --- /dev/null +++ b/tg_parser/internal/usecase/fetch_views.go @@ -0,0 +1,46 @@ +package usecase + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/internal/domain" +) + +func (uc *UseCase) FetchViews(ctx context.Context) error { + channels, err := uc.database.GetChannelsWithTrackedPosts(ctx) + if err != nil { + return err + } + + for _, channel := range channels { + posts, err := uc.database.GetTrackedPosts(ctx, channel) + if err != nil { + return err + } + + err = uc.telegram.UpdatePostsViews(ctx, channel, posts) + if err != nil { + return err + } + + for _, p := range posts { + v := domain.ViewsSnapshot{ + ViewsCount: p.Views, + FetchedAt: time.Now().UTC(), + PostID: p.ID, + } + + err = uc.database.CreateViewsSnapshot(ctx, v) + if err != nil { + return err + } + + log.Info().Msgf("New ViewsSnapshot: %s - Post ID: %d, Views: %d", p.Link, p.MessageID, p.Views) + } + } + + return nil +} diff --git a/tg_parser/internal/usecase/usecase.go b/tg_parser/internal/usecase/usecase.go new file mode 100644 index 0000000..2acaee1 --- /dev/null +++ b/tg_parser/internal/usecase/usecase.go @@ -0,0 +1,38 @@ +package usecase + +import ( + "context" + + "github.com/TelegramExchange/tgex-parser/internal/domain" +) + +type Telegram interface { + ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error) + GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error) + GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error) + UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error +} + +type Database interface { + CreatePost(ctx context.Context, post domain.Post) error + + CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error + + GetChannels(ctx context.Context) []domain.Channel + UpdateChannel(ctx context.Context, channel domain.Channel) error + GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error) + GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error) + DeletePost(ctx context.Context, p domain.Post) error +} + +type UseCase struct { + telegram Telegram + database Database +} + +func New(telegram Telegram, database Database) *UseCase { + return &UseCase{ + telegram: telegram, + database: database, + } +} diff --git a/tg_parser/main.go b/tg_parser/main.go new file mode 100644 index 0000000..232b1f4 --- /dev/null +++ b/tg_parser/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-parser/config" + "github.com/TelegramExchange/tgex-parser/internal/app" + "github.com/TelegramExchange/tgex-parser/pkg/logger" +) + +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) + log.Info().Msg("parser initialized") + + ctx := context.Background() + + if err := app.Run(ctx, c); err != nil { + log.Error().Err(err).Msg("app.Run") + } +} diff --git a/tg_parser/pkg/logger/logger.go b/tg_parser/pkg/logger/logger.go new file mode 100644 index 0000000..8793b49 --- /dev/null +++ b/tg_parser/pkg/logger/logger.go @@ -0,0 +1,36 @@ +package logger + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Config struct { + Level string `default:"info" envconfig:"LOGGER__LEVEL"` + PrettyConsole bool `default:"false" envconfig:"LOGGER__PRETTY_CONSOLE"` +} + +func Init(c Config) { + zerolog.TimeFieldFormat = time.RFC3339 + + level := zerolog.InfoLevel + if parsedLevel, err := zerolog.ParseLevel(c.Level); err == nil { + level = parsedLevel + } + zerolog.SetGlobalLevel(level) + + log.Logger = log.With(). + // Caller(). + // Str("app_name", c.AppName). + // Str("app_version", c.AppVersion). + Logger() + + if c.PrettyConsole { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}) + } + + log.Info().Msg("Logger initialized") +} diff --git a/tg_parser/pkg/postgres/postgres.go b/tg_parser/pkg/postgres/postgres.go new file mode 100644 index 0000000..848eb9e --- /dev/null +++ b/tg_parser/pkg/postgres/postgres.go @@ -0,0 +1,37 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +type Config struct { + URL string `envconfig:"DB__URL" required:"true"` +} + +type Pool struct { + *pgxpool.Pool +} + +func New(ctx context.Context, c Config) (*Pool, error) { + cfg, err := pgxpool.ParseConfig(c.URL) + if err != nil { + return nil, fmt.Errorf("pgxpool.ParseConfig: %w", err) + } + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("pgxpool.NewWithConfig: %w", err) + } + + return &Pool{Pool: pool}, nil +} + +func (p *Pool) Close() { + p.Pool.Close() + + log.Info().Msg("Postgres closed") +} diff --git a/tg_parser/pkg/telegram/telegram.go b/tg_parser/pkg/telegram/telegram.go new file mode 100644 index 0000000..bd56e42 --- /dev/null +++ b/tg_parser/pkg/telegram/telegram.go @@ -0,0 +1,76 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/rs/zerolog/log" + + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" +) + +type Config struct { + ApiID int `envconfig:"TELEGRAM__API_ID" required:"true"` + ApiHash string `envconfig:"TELEGRAM__API_HASH" required:"true"` + SessionFile string `envconfig:"TELEGRAM__SESSION_FILE" required:"true"` +} + +type Client struct { + *telegram.Client + cancel context.CancelFunc + done chan struct{} +} + +func New(cfg Config) (*Client, error) { + ctx := context.Background() + + tg := telegram.NewClient(cfg.ApiID, cfg.ApiHash, telegram.Options{ + SessionStorage: &session.FileStorage{Path: cfg.SessionFile}, + }) + + runCtx, cancel := context.WithCancel(ctx) + + ready := make(chan struct{}) + done := make(chan struct{}) + + client := &Client{ + Client: tg, + cancel: cancel, + done: done, + } + + go func() { + err := tg.Run(runCtx, func(ctx context.Context) error { + status, err := tg.Auth().Status(ctx) + if err != nil { + close(ready) + return fmt.Errorf("telegram.Auth error: %w", err) + } + if !status.Authorized { + close(ready) + return fmt.Errorf("session not authorized") + } + + close(ready) + + <-ctx.Done() + return ctx.Err() + }) + + if err != nil { + log.Error().Err(err).Msg("telegram client stopped") + } + + close(done) + }() + + <-ready + + return client, nil +} + +func (c *Client) Close() { + c.cancel() + <-c.done +} diff --git a/tg_parser/pkg/transaction/transaction.go b/tg_parser/pkg/transaction/transaction.go new file mode 100644 index 0000000..50b2a4a --- /dev/null +++ b/tg_parser/pkg/transaction/transaction.go @@ -0,0 +1,101 @@ +package transaction + +import ( + "context" + "errors" + "fmt" + + "github.com/TelegramExchange/tgex-parser/pkg/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +var ( + errMissingInit = errors.New("missing `transaction.Init' call before `transaction.Begin'") + errMissingBegin = errors.New("missing `transaction.Begin' call before 'transaction.Get'") +) + +var ( + pool *pgxpool.Pool + IsUnitTest bool +) + +type ctxKey struct{} + +func Init(p *postgres.Pool) { + pool = p.Pool +} + +type Transaction struct { + pgx.Tx +} + +func Begin(ctx context.Context) (context.Context, error) { + if IsUnitTest { + return ctx, nil + } + + if pool == nil { + return nil, errMissingInit + } + + tx, err := pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("pool.Begin: %w", err) + } + + ctx = context.WithValue(ctx, ctxKey{}, &Transaction{tx}) + + return ctx, nil +} + +func Rollback(ctx context.Context) { + if IsUnitTest { + return + } + + tx, ok := ctx.Value(ctxKey{}).(*Transaction) + if !ok { + return + } + + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error().Err(err).Msg("transaction: Rollback") + } +} + +func Commit(ctx context.Context) error { + if IsUnitTest { + return nil + } + + tx, ok := ctx.Value(ctxKey{}).(*Transaction) + if !ok { + return errMissingBegin + } + + err := tx.Commit(ctx) + if err != nil { + return fmt.Errorf("tx.Commit: %w", err) + } + + return nil +} + +type Executor interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +func TryExtractTX(ctx context.Context) Executor { + tx, ok := ctx.Value(ctxKey{}).(*Transaction) + if !ok { + return pool + } + + return tx +} diff --git a/tg_parser/pkg/transaction/wrap.go b/tg_parser/pkg/transaction/wrap.go new file mode 100644 index 0000000..97245e3 --- /dev/null +++ b/tg_parser/pkg/transaction/wrap.go @@ -0,0 +1,38 @@ +package transaction + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +func Wrap(ctx context.Context, fn func(context.Context) error) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("pool.Begin: %w", err) + } + + defer func() { + err = tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error().Err(err).Msg("transaction: Rollback") + } + }() + + ctx = context.WithValue(ctx, ctxKey{}, &Transaction{tx}) + + err = fn(ctx) + if err != nil { + return fmt.Errorf("fn: %w", err) + } + + err = tx.Commit(ctx) + if err != nil { + return fmt.Errorf("tx.Commit: %w", err) + } + + return nil +}