From 284dae9e13b95d210bb182717e8c5245a6748cba Mon Sep 17 00:00:00 2001
From: DKharchenko <DKharchenko@mtt.ru>
Date: Mon, 3 Mar 2025 01:38:25 +0300
Subject: [PATCH] Add tg and notion adapters

---
 go.mod                                        |   2 +
 go.sum                                        |   4 +
 .../repository/adapter/api/cs_market.api.go   |   1 -
 .../adapter/api/impl/cs_market.api.go         |   1 -
 .../adapter/api/impl/lis_skins.api.go         |   1 -
 .../repository/adapter/api/impl/nition.api.go |   1 -
 .../repository/adapter/api/lis_skins.api.go   |   1 -
 internal/repository/adapter/api/nition.api.go |   1 -
 internal/repository/adapter/notion/notion.go  | 173 ++++++++++++++++++
 .../repository/adapter/notion/skin_profit.go  | 147 +++++++++++++++
 .../repository/adapter/notion/total_profit.go |  91 +++++++++
 .../repository/adapter/telegram/parser_bot.go |  37 ++++
 12 files changed, 454 insertions(+), 6 deletions(-)
 delete mode 100644 internal/repository/adapter/api/cs_market.api.go
 delete mode 100644 internal/repository/adapter/api/impl/cs_market.api.go
 delete mode 100644 internal/repository/adapter/api/impl/lis_skins.api.go
 delete mode 100644 internal/repository/adapter/api/impl/nition.api.go
 delete mode 100644 internal/repository/adapter/api/lis_skins.api.go
 delete mode 100644 internal/repository/adapter/api/nition.api.go
 create mode 100644 internal/repository/adapter/notion/notion.go
 create mode 100644 internal/repository/adapter/notion/skin_profit.go
 create mode 100644 internal/repository/adapter/notion/total_profit.go
 create mode 100644 internal/repository/adapter/telegram/parser_bot.go

diff --git a/go.mod b/go.mod
index 0ae00d0..1bd56a5 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.22
 
 require (
 	github.com/go-playground/validator/v10 v10.20.0
+	github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
 	gopkg.in/yaml.v3 v3.0.1
 	gorm.io/gorm v1.25.11
 )
@@ -30,6 +31,7 @@ require (
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+	github.com/technoweenie/multipartstreamer v1.0.1 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/ugorji/go/codec v1.2.12 // indirect
 	go.uber.org/atomic v1.7.0 // indirect
diff --git a/go.sum b/go.sum
index 07adc2f..40e88fd 100644
--- a/go.sum
+++ b/go.sum
@@ -55,6 +55,8 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
 github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
+github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -257,6 +259,8 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
+github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
 github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
diff --git a/internal/repository/adapter/api/cs_market.api.go b/internal/repository/adapter/api/cs_market.api.go
deleted file mode 100644
index 778f64e..0000000
--- a/internal/repository/adapter/api/cs_market.api.go
+++ /dev/null
@@ -1 +0,0 @@
-package api
diff --git a/internal/repository/adapter/api/impl/cs_market.api.go b/internal/repository/adapter/api/impl/cs_market.api.go
deleted file mode 100644
index 4f9d22e..0000000
--- a/internal/repository/adapter/api/impl/cs_market.api.go
+++ /dev/null
@@ -1 +0,0 @@
-package impl
diff --git a/internal/repository/adapter/api/impl/lis_skins.api.go b/internal/repository/adapter/api/impl/lis_skins.api.go
deleted file mode 100644
index 4f9d22e..0000000
--- a/internal/repository/adapter/api/impl/lis_skins.api.go
+++ /dev/null
@@ -1 +0,0 @@
-package impl
diff --git a/internal/repository/adapter/api/impl/nition.api.go b/internal/repository/adapter/api/impl/nition.api.go
deleted file mode 100644
index 4f9d22e..0000000
--- a/internal/repository/adapter/api/impl/nition.api.go
+++ /dev/null
@@ -1 +0,0 @@
-package impl
diff --git a/internal/repository/adapter/api/lis_skins.api.go b/internal/repository/adapter/api/lis_skins.api.go
deleted file mode 100644
index 778f64e..0000000
--- a/internal/repository/adapter/api/lis_skins.api.go
+++ /dev/null
@@ -1 +0,0 @@
-package api
diff --git a/internal/repository/adapter/api/nition.api.go b/internal/repository/adapter/api/nition.api.go
deleted file mode 100644
index 778f64e..0000000
--- a/internal/repository/adapter/api/nition.api.go
+++ /dev/null
@@ -1 +0,0 @@
-package api
diff --git a/internal/repository/adapter/notion/notion.go b/internal/repository/adapter/notion/notion.go
new file mode 100644
index 0000000..0a433f5
--- /dev/null
+++ b/internal/repository/adapter/notion/notion.go
@@ -0,0 +1,173 @@
+package notion
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"sync"
+)
+
+const (
+	BaseURL       = "https://api.notion.com/v1"
+	NotionVersion = "2022-06-28"
+)
+
+// Client инкапсулирует HTTP-клиент и общие параметры для работы с Notion API.
+type Client struct {
+	HTTPClient *http.Client
+	APIKey     string
+}
+
+// NewClient создаёт нового клиента для Notion.
+// Если proxyURL не пустой, то он используется для проксирования запросов.
+func NewClient(apiKey, proxyURL string) (*Client, error) {
+	transport := &http.Transport{}
+	if proxyURL != "" {
+		proxy, err := url.Parse(proxyURL)
+		if err != nil {
+			return nil, err
+		}
+		transport.Proxy = http.ProxyURL(proxy)
+	}
+	client := &http.Client{
+		Transport: transport,
+	}
+	return &Client{
+		HTTPClient: client,
+		APIKey:     apiKey,
+	}, nil
+}
+
+// QueryDatabase выполняет запрос к базе данных Notion.
+// queryBody может быть nil – в этом случае отправляется пустой JSON-объект.
+func (c *Client) QueryDatabase(databaseID string, queryBody interface{}) ([]byte, error) {
+	queryURL := fmt.Sprintf("%s/databases/%s/query", BaseURL, databaseID)
+	var bodyBytes []byte
+	if queryBody == nil {
+		bodyBytes = []byte("{}")
+	} else {
+		b, err := json.Marshal(queryBody)
+		if err != nil {
+			return nil, err
+		}
+		bodyBytes = b
+	}
+	req, err := http.NewRequest("POST", queryURL, bytes.NewBuffer(bodyBytes))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+c.APIKey)
+	req.Header.Set("Notion-Version", NotionVersion)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		b, _ := ioutil.ReadAll(resp.Body)
+		return nil, fmt.Errorf("failed to query database, status: %d, body: %s", resp.StatusCode, string(b))
+	}
+
+	return ioutil.ReadAll(resp.Body)
+}
+
+// PatchPage выполняет PATCH-запрос для обновления страницы с указанными свойствами.
+func (c *Client) PatchPage(pageID string, properties map[string]interface{}) error {
+	updateURL := fmt.Sprintf("%s/pages/%s", BaseURL, pageID)
+	updateBody := map[string]interface{}{
+		"properties": properties,
+	}
+	bodyBytes, err := json.Marshal(updateBody)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequest("PATCH", updateURL, bytes.NewBuffer(bodyBytes))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", "Bearer "+c.APIKey)
+	req.Header.Set("Notion-Version", NotionVersion)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		b, _ := ioutil.ReadAll(resp.Body)
+		return fmt.Errorf("failed to patch page, status: %d, body: %s", resp.StatusCode, string(b))
+	}
+	return nil
+}
+
+// LimitedBatchUpdate выполняет пакетное обновление с ограничением количества одновременных запросов.
+func (c *Client) LimitedBatchUpdate(tasks []func() error, concurrencyLimit int) error {
+	semaphore := make(chan struct{}, concurrencyLimit)
+	var wg sync.WaitGroup
+	errChan := make(chan error, len(tasks))
+
+	for _, task := range tasks {
+		wg.Add(1)
+		go func(t func() error) {
+			defer wg.Done()
+			semaphore <- struct{}{}
+			if err := t(); err != nil {
+				errChan <- err
+			}
+			<-semaphore
+		}(task)
+	}
+	wg.Wait()
+	close(errChan)
+	var err error
+	for e := range errChan {
+		if err == nil {
+			err = e
+		} else {
+			err = fmt.Errorf("%v; %w", err, e)
+		}
+	}
+	return err
+}
+
+// Определения для разбора ответа Notion.
+
+type RichText struct {
+	Text struct {
+		Content string `json:"content"`
+	} `json:"text"`
+}
+
+type StatusProp struct {
+	Name string `json:"name"`
+}
+
+type DateProp struct {
+	Start string `json:"start"`
+}
+
+type Property struct {
+	Type   string      `json:"type"`
+	Number *float64    `json:"number,omitempty"`
+	Title  []RichText  `json:"title,omitempty"`
+	Status *StatusProp `json:"status,omitempty"`
+	Date   *DateProp   `json:"date,omitempty"`
+	URL    *string     `json:"url,omitempty"`
+}
+
+type Page struct {
+	ID         string              `json:"id"`
+	Properties map[string]Property `json:"properties"`
+}
+
+type QueryResponse struct {
+	Results []Page `json:"results"`
+}
diff --git a/internal/repository/adapter/notion/skin_profit.go b/internal/repository/adapter/notion/skin_profit.go
new file mode 100644
index 0000000..c2c9331
--- /dev/null
+++ b/internal/repository/adapter/notion/skin_profit.go
@@ -0,0 +1,147 @@
+package notion
+
+import (
+	"encoding/json"
+	"log"
+)
+
+// SkinDocument представляет данные записи из таблицы SkinProfit.
+type SkinDocument struct {
+	ID                       string
+	Name                     string
+	BuyPrice                 float64
+	CurrentPrice             float64
+	Status                   string
+	CurrentAutobuyPrice      float64
+	SellPrice                float64
+	SellDate                 string
+	CsmUrl                   string
+	PreliminaryProfit        float64
+	PreliminaryAutobuyProfit float64
+	Profit                   float64
+}
+
+// SkinProfitRepository реализует работу с таблицей SkinProfit.
+type SkinProfitRepository struct {
+	notionClient *Client
+	DatabaseID   string
+}
+
+// NewSkinProfitRepository создаёт новый репозиторий для работы с таблицей SkinProfit.
+func NewSkinProfitRepository(apiKey, skinsDatabaseID, proxyURL string) (*SkinProfitRepository, error) {
+	client, err := NewClient(apiKey, proxyURL)
+	if err != nil {
+		return nil, err
+	}
+	return &SkinProfitRepository{
+		notionClient: client,
+		DatabaseID:   skinsDatabaseID,
+	}, nil
+}
+
+// Find выполняет запрос к базе данных скинов и маппит полученные данные в срез структур SkinDocument.
+func (r *SkinProfitRepository) Find() ([]SkinDocument, error) {
+	respBytes, err := r.notionClient.QueryDatabase(r.DatabaseID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var queryResp QueryResponse
+	if err := json.Unmarshal(respBytes, &queryResp); err != nil {
+		return nil, err
+	}
+
+	var skins []SkinDocument
+	for _, page := range queryResp.Results {
+		var skin SkinDocument
+		skin.ID = page.ID
+
+		// Извлекаем название из свойства "Название"
+		if prop, ok := page.Properties["Название"]; ok && len(prop.Title) > 0 {
+			skin.Name = prop.Title[0].Text.Content
+		}
+		// Извлекаем числовые значения
+		if prop, ok := page.Properties["РљСѓРїРёР»"]; ok && prop.Number != nil {
+			skin.BuyPrice = *prop.Number
+		}
+		if prop, ok := page.Properties["Текущая стоимость"]; ok && prop.Number != nil {
+			skin.CurrentPrice = *prop.Number
+		}
+		if prop, ok := page.Properties["Текущий автобай"]; ok && prop.Number != nil {
+			skin.CurrentAutobuyPrice = *prop.Number
+		}
+		if prop, ok := page.Properties["Продажа"]; ok && prop.Number != nil {
+			skin.SellPrice = *prop.Number
+		}
+		// Дата продажи
+		if prop, ok := page.Properties["Дата продажи"]; ok && prop.Date != nil {
+			skin.SellDate = prop.Date.Start
+		}
+		// URL для CSGOMARKET
+		if prop, ok := page.Properties["CSGOMARKET URL"]; ok && prop.URL != nil {
+			skin.CsmUrl = *prop.URL
+		}
+		// Предварительный профит
+		if prop, ok := page.Properties["Предварительный профит"]; ok && prop.Number != nil {
+			skin.PreliminaryProfit = *prop.Number
+		}
+		// Предварительный профит по автобаю
+		if prop, ok := page.Properties["Предварительный профит по автобаю"]; ok && prop.Number != nil {
+			skin.PreliminaryAutobuyProfit = *prop.Number
+		}
+		// Фактический профит
+		if prop, ok := page.Properties["Фактический профит"]; ok && prop.Number != nil {
+			skin.Profit = *prop.Number
+		}
+
+		skins = append(skins, skin)
+	}
+
+	return skins, nil
+}
+
+// SkinUpdate содержит данные для обновления страницы скина.
+type SkinUpdate struct {
+	ID                       string
+	BuyPrice                 float64 // Для логирования; не обновляется
+	Price                    float64 // Обновление "Текущая стоимость"
+	AutobuyPrice             float64 // Обновление "Текущий автобай"
+	PreliminaryProfit        float64 // Обновление "Предварительный профит"
+	PreliminaryAutobuyProfit float64 // Обновление "Предварительный профит по автобаю"
+}
+
+// mapPropertiesToNotionSkin формирует JSON-представление обновляемых свойств для скина.
+func mapPropertiesToNotionSkin(s SkinUpdate) map[string]interface{} {
+	properties := make(map[string]interface{})
+	properties["Текущая стоимость"] = map[string]interface{}{
+		"number": s.Price,
+	}
+	properties["Текущий автобай"] = map[string]interface{}{
+		"number": s.AutobuyPrice,
+	}
+	properties["Предварительный профит"] = map[string]interface{}{
+		"number": s.PreliminaryProfit,
+	}
+	properties["Предварительный профит по автобаю"] = map[string]interface{}{
+		"number": s.PreliminaryAutobuyProfit,
+	}
+	return properties
+}
+
+// BatchUpdatePages выполняет пакетное обновление страниц скинов с ограничением одновременных запросов (до 3-х).
+func (r *SkinProfitRepository) BatchUpdatePages(updatedSkins []SkinUpdate) error {
+	var tasks []func() error
+	for _, skin := range updatedSkins {
+		skinCopy := skin // избегаем проблем с замыканиями
+		tasks = append(tasks, func() error {
+			props := mapPropertiesToNotionSkin(skinCopy)
+			err := r.notionClient.PatchPage(skinCopy.ID, props)
+			if err != nil {
+				log.Printf("Error updating page %s: %v", skinCopy.ID, err)
+			}
+			return err
+		})
+	}
+	// Ограничиваем выполнение до 3 одновременных запросов.
+	return r.notionClient.LimitedBatchUpdate(tasks, 3)
+}
diff --git a/internal/repository/adapter/notion/total_profit.go b/internal/repository/adapter/notion/total_profit.go
new file mode 100644
index 0000000..b4819ab
--- /dev/null
+++ b/internal/repository/adapter/notion/total_profit.go
@@ -0,0 +1,91 @@
+package notion
+
+import (
+	"log"
+	"time"
+)
+
+// TotalProfit представляет данные итогового профита.
+type TotalProfit struct {
+	ID                       string    `json:"id"`
+	PreliminaryProfit        float64   `json:"preliminaryProfit"`
+	PreliminaryAutobuyProfit float64   `json:"preliminaryAutobuyProfit"`
+	Profit                   float64   `json:"profit"`
+	Invested                 float64   `json:"invested"`
+	SyncAt                   time.Time `json:"syncAt"`
+}
+
+// TotalProfitRepository реализует операции для таблицы TotalProfit.
+type TotalProfitRepository struct {
+	notionClient *Client
+	databaseID   string
+}
+
+// NewTotalProfitRepository создаёт репозиторий для работы с таблицей TotalProfit.
+// proxyURL используется для настройки прокси, если он задан.
+func NewTotalProfitRepository(apiKey, databaseID, proxyURL string) (*TotalProfitRepository, error) {
+	client, err := NewClient(apiKey, proxyURL)
+	if err != nil {
+		return nil, err
+	}
+	return &TotalProfitRepository{
+		notionClient: client,
+		databaseID:   databaseID,
+	}, nil
+}
+
+// TotalProfitUpdate содержит данные для обновления страницы итогового профита.
+type TotalProfitUpdate struct {
+	ID                       string
+	PreliminaryProfit        float64
+	PreliminaryAutobuyProfit float64
+	Profit                   float64
+	Invested                 float64
+	SyncAt                   time.Time
+}
+
+// mapPropertiesToNotionTotalProfit формирует свойства для обновления страницы в Notion.
+func mapPropertiesToNotionTotalProfit(data TotalProfitUpdate) map[string]interface{} {
+	properties := make(map[string]interface{})
+	properties["Предварительный итог"] = map[string]interface{}{
+		"number": data.PreliminaryProfit,
+	}
+	properties["Предварительный итог по автобаю"] = map[string]interface{}{
+		"number": data.PreliminaryAutobuyProfit,
+	}
+	properties["Фактический итог"] = map[string]interface{}{
+		"number": data.Profit,
+	}
+	properties["Вложил"] = map[string]interface{}{
+		"number": data.Invested,
+	}
+	properties["Дата последней синхронизации"] = map[string]interface{}{
+		"date": map[string]interface{}{
+			"start": data.SyncAt.Format(time.RFC3339),
+		},
+	}
+	return properties
+}
+
+// UpdateFullProfit обновляет страницу итогового профита в Notion.
+func (r *TotalProfitRepository) UpdateFullProfit(data TotalProfitUpdate) error {
+	props := mapPropertiesToNotionTotalProfit(data)
+	return r.notionClient.PatchPage(data.ID, props)
+}
+
+// BatchUpdatePages выполняет пакетное обновление страниц с ограничением количества одновременных запросов.
+func (r *TotalProfitRepository) BatchUpdatePages(updated []TotalProfitUpdate, concurrencyLimit int) error {
+	var tasks []func() error
+	for _, upd := range updated {
+		updCopy := upd
+		tasks = append(tasks, func() error {
+			props := mapPropertiesToNotionTotalProfit(updCopy)
+			err := r.notionClient.PatchPage(updCopy.ID, props)
+			if err != nil {
+				log.Printf("Error updating page %s: %v", updCopy.ID, err)
+			}
+			return err
+		})
+	}
+	return r.notionClient.LimitedBatchUpdate(tasks, concurrencyLimit)
+}
diff --git a/internal/repository/adapter/telegram/parser_bot.go b/internal/repository/adapter/telegram/parser_bot.go
new file mode 100644
index 0000000..93d5e8e
--- /dev/null
+++ b/internal/repository/adapter/telegram/parser_bot.go
@@ -0,0 +1,37 @@
+package telegram
+
+import (
+	"log"
+
+	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
+)
+
+// Adapter инкапсулирует работу с Adapter Bot API
+type Adapter struct {
+	Bot    *tgbotapi.BotAPI
+	ChatID int64
+}
+
+// NewTelegram создаёт экземпляр Adapter
+func NewAdapter(botToken string, chatID int64) (*Adapter, error) {
+	bot, err := tgbotapi.NewBotAPI(botToken)
+	if err != nil {
+		return nil, err
+	}
+	return &Adapter{
+		Bot:    bot,
+		ChatID: chatID,
+	}, nil
+}
+
+// CreatePost отправляет сообщение в указанный Adapter чат
+func (r *Adapter) CreatePost(text string) error {
+	msg := tgbotapi.NewMessage(r.ChatID, text)
+
+	_, err := r.Bot.Send(msg)
+	if err != nil {
+		log.Printf("Ошибка отправки сообщения: %v", err)
+		return err
+	}
+	return nil
+}
-- 
GitLab