изображения projects!
This commit is contained in:
@@ -2,6 +2,9 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -28,6 +31,9 @@ type Bot struct {
|
|||||||
CurrentState State
|
CurrentState State
|
||||||
Session *Session
|
Session *Session
|
||||||
LastMessageID int // ID последнего сообщения с inline кнопками
|
LastMessageID int // ID последнего сообщения с inline кнопками
|
||||||
|
LastMessageIsMedia bool // True if LastMessageID points to media message
|
||||||
|
LastMessageTextHash string // Hash for last text/caption
|
||||||
|
LastMessageKBHash string // Hash for last inline keyboard
|
||||||
commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
|
commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
|
||||||
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
|
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
|
||||||
Backend *backend.Client
|
Backend *backend.Client
|
||||||
@@ -323,6 +329,10 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) SetLastMessageIsMedia(isMedia bool) {
|
||||||
|
b.LastMessageIsMedia = isMedia
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
|
func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
|
||||||
res, err := b.GetFile(fileID)
|
res, err := b.GetFile(fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -335,7 +345,6 @@ func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
||||||
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
|
|
||||||
if b.LastMessageID != 0 {
|
if b.LastMessageID != 0 {
|
||||||
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
|
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
|
||||||
b.cleanupKeyboard(b.LastMessageID)
|
b.cleanupKeyboard(b.LastMessageID)
|
||||||
@@ -357,6 +366,9 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
|||||||
|
|
||||||
if res.Result != nil {
|
if res.Result != nil {
|
||||||
b.LastMessageID = res.Result.ID
|
b.LastMessageID = res.Result.ID
|
||||||
|
b.LastMessageIsMedia = false
|
||||||
|
b.LastMessageTextHash = hashString(text)
|
||||||
|
b.LastMessageKBHash = keyboardHash(keyboard)
|
||||||
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
|
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +380,31 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
|
|||||||
Int("keyboard_rows", len(keyboard.InlineKeyboard)).
|
Int("keyboard_rows", len(keyboard.InlineKeyboard)).
|
||||||
Int("text_len", len(text)).
|
Int("text_len", len(text)).
|
||||||
Msg("EditMessageText request")
|
Msg("EditMessageText request")
|
||||||
_, err := b.EditMessageText(
|
textHash := hashString(text)
|
||||||
|
kbHash := keyboardHash(keyboard)
|
||||||
|
if textHash == b.LastMessageTextHash && kbHash == b.LastMessageKBHash {
|
||||||
|
log.Info().Msg("EditMessageText skipped: message is not modified")
|
||||||
|
if b.exec != nil {
|
||||||
|
b.exec.handled = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if b.LastMessageIsMedia {
|
||||||
|
_, err = b.EditMessageCaption(
|
||||||
|
echotron.NewMessageID(b.ChatID, b.LastMessageID),
|
||||||
|
&echotron.MessageCaptionOptions{
|
||||||
|
Caption: text,
|
||||||
|
ParseMode: echotron.HTML,
|
||||||
|
ReplyMarkup: keyboard,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
b.LastMessageTextHash = textHash
|
||||||
|
b.LastMessageKBHash = kbHash
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = b.EditMessageText(
|
||||||
text,
|
text,
|
||||||
echotron.NewMessageID(b.ChatID, b.LastMessageID),
|
echotron.NewMessageID(b.ChatID, b.LastMessageID),
|
||||||
&echotron.MessageTextOptions{
|
&echotron.MessageTextOptions{
|
||||||
@@ -376,14 +412,24 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
|
|||||||
ParseMode: echotron.HTML,
|
ParseMode: echotron.HTML,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if err == nil {
|
||||||
|
b.LastMessageTextHash = textHash
|
||||||
|
b.LastMessageKBHash = kbHash
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "message is not modified") {
|
if strings.Contains(err.Error(), "message is not modified") {
|
||||||
log.Info().Msg("EditMessageText ignored: message is not modified")
|
log.Err(err).Msg("EditMessageText ignored: message is not modified")
|
||||||
if b.exec != nil {
|
if b.exec != nil {
|
||||||
b.exec.handled = true
|
b.exec.handled = true
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), "no text in the message to edit") {
|
||||||
|
b.LastMessageIsMedia = true
|
||||||
|
log.Err(err).Msg("EditMessageText ignored: message has no text")
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Error().Err(err).Msg("EditMessageText")
|
log.Error().Err(err).Msg("EditMessageText")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -420,9 +466,26 @@ func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
|
|||||||
|
|
||||||
if err == nil && res.Result != nil {
|
if err == nil && res.Result != nil {
|
||||||
b.LastMessageID = res.Result.ID
|
b.LastMessageID = res.Result.ID
|
||||||
|
b.LastMessageIsMedia = false
|
||||||
|
b.LastMessageTextHash = hashString(text)
|
||||||
|
b.LastMessageKBHash = keyboardHash(keyboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyboardHash(kb echotron.InlineKeyboardMarkup) string {
|
||||||
|
b, err := json.Marshal(kb)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashString(v string) string {
|
||||||
|
sum := sha256.Sum256([]byte(v))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) Update(u *echotron.Update) {
|
func (b *Bot) Update(u *echotron.Update) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ module github.com/TelegramExchange/tgex-backend/tg_bot
|
|||||||
go 1.24.4
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/NicoNex/echotron/v3 v3.43.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/TelegramExchange/pkg v0.0.0
|
golang.org/x/image v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/NicoNex/echotron/v3 v3.43.0 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||||
|
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
|||||||
@@ -17,14 +17,28 @@ type AddPurchase struct {
|
|||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
ProjectTitle string
|
ProjectTitle string
|
||||||
|
ProjectTelegramID int64
|
||||||
|
ProjectUsername string
|
||||||
|
ProjectStatus string
|
||||||
CreativeID string
|
CreativeID string
|
||||||
CreativeTitle string
|
CreativeTitle string
|
||||||
ProjectPage int
|
ProjectPage int
|
||||||
CreativePage int
|
CreativePage int
|
||||||
ActivePicker string // "" | "project_list" | "creative_list"
|
ActivePicker string // "" | "project_list" | "creative_list"
|
||||||
BackState bot.State
|
BackState bot.State
|
||||||
lastProjects map[string]string
|
lastProjects map[string]projectInfo
|
||||||
lastCreatives map[string]string
|
lastCreatives map[string]creativeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectInfo struct {
|
||||||
|
Title string
|
||||||
|
TelegramID int64
|
||||||
|
Username string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type creativeInfo struct {
|
||||||
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
@@ -78,9 +92,18 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.lastProjects = make(map[string]string)
|
s.lastProjects = make(map[string]projectInfo)
|
||||||
for _, project := range page.Items {
|
for _, project := range page.Items {
|
||||||
s.lastProjects[project.ID] = project.Title
|
username := ""
|
||||||
|
if project.Username != nil {
|
||||||
|
username = *project.Username
|
||||||
|
}
|
||||||
|
s.lastProjects[project.ID] = projectInfo{
|
||||||
|
Title: project.Title,
|
||||||
|
TelegramID: project.TelegramID,
|
||||||
|
Username: username,
|
||||||
|
Status: project.Status,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(page.Items) == 0 {
|
if len(page.Items) == 0 {
|
||||||
@@ -143,9 +166,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.lastCreatives = make(map[string]string)
|
s.lastCreatives = make(map[string]creativeInfo)
|
||||||
for _, creative := range page.Items {
|
for _, creative := range page.Items {
|
||||||
s.lastCreatives[creative.ID] = creative.Name
|
s.lastCreatives[creative.ID] = creativeInfo{
|
||||||
|
Title: creative.Name,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(page.Items) == 0 {
|
if len(page.Items) == 0 {
|
||||||
@@ -202,6 +227,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
|
|||||||
|
|
||||||
keyboard := Keyboard(buttons...)
|
keyboard := Keyboard(buttons...)
|
||||||
b.Render(text, keyboard, mode)
|
b.Render(text, keyboard, mode)
|
||||||
|
|
||||||
|
if s.ActivePicker == "" && s.ProjectTelegramID != 0 {
|
||||||
|
messageID := b.LastMessageID
|
||||||
|
updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
@@ -261,6 +291,9 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
ProjectTitle: s.ProjectTitle,
|
ProjectTitle: s.ProjectTitle,
|
||||||
|
ProjectTelegramID: s.ProjectTelegramID,
|
||||||
|
ProjectUsername: s.ProjectUsername,
|
||||||
|
ProjectStatus: s.ProjectStatus,
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: s.CreativeID,
|
||||||
CreativeTitle: s.CreativeTitle,
|
CreativeTitle: s.CreativeTitle,
|
||||||
Channels: []PurchaseChannelInput{},
|
Channels: []PurchaseChannelInput{},
|
||||||
@@ -272,7 +305,12 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
if len(data) > 8 && data[:8] == "project:" {
|
if len(data) > 8 && data[:8] == "project:" {
|
||||||
projectID := data[8:]
|
projectID := data[8:]
|
||||||
if s.lastProjects != nil {
|
if s.lastProjects != nil {
|
||||||
s.ProjectTitle = s.lastProjects[projectID]
|
if info, ok := s.lastProjects[projectID]; ok {
|
||||||
|
s.ProjectTitle = info.Title
|
||||||
|
s.ProjectTelegramID = info.TelegramID
|
||||||
|
s.ProjectUsername = info.Username
|
||||||
|
s.ProjectStatus = info.Status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.ProjectID = projectID
|
s.ProjectID = projectID
|
||||||
s.CreativeID = ""
|
s.CreativeID = ""
|
||||||
@@ -285,7 +323,9 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
if len(data) > 9 && data[:9] == "creative:" {
|
if len(data) > 9 && data[:9] == "creative:" {
|
||||||
creativeID := data[9:]
|
creativeID := data[9:]
|
||||||
if s.lastCreatives != nil {
|
if s.lastCreatives != nil {
|
||||||
s.CreativeTitle = s.lastCreatives[creativeID]
|
if info, ok := s.lastCreatives[creativeID]; ok {
|
||||||
|
s.CreativeTitle = info.Title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.CreativeID = creativeID
|
s.CreativeID = creativeID
|
||||||
s.ActivePicker = ""
|
s.ActivePicker = ""
|
||||||
|
|||||||
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf
Normal file
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf
Normal file
Binary file not shown.
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
@@ -16,6 +16,10 @@ const creativesPerPage = 6
|
|||||||
type Creatives struct {
|
type Creatives struct {
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
ProjectID string
|
ProjectID string
|
||||||
|
ProjectTitle string
|
||||||
|
ProjectTelegramID int64
|
||||||
|
ProjectUsername string
|
||||||
|
ProjectStatus string
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
BackState bot.State
|
BackState bot.State
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,9 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
|
|||||||
|
|
||||||
keyboard := Keyboard(buttons...)
|
keyboard := Keyboard(buttons...)
|
||||||
b.Render(text, keyboard, mode)
|
b.Render(text, keyboard, mode)
|
||||||
|
|
||||||
|
messageID := b.LastMessageID
|
||||||
|
updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
text := s.formatProjectDetails()
|
text := s.formatProjectDetails()
|
||||||
keyboard := s.buildKeyboard()
|
keyboard := s.buildKeyboard()
|
||||||
b.Render(text, keyboard, mode)
|
b.Render(text, keyboard, mode)
|
||||||
|
|
||||||
|
messageID := b.LastMessageID
|
||||||
|
username := usernameFromProject(s.Project)
|
||||||
|
status := statusFromProject(s.Project)
|
||||||
|
updateProjectHeaderMedia(b, messageID, text, keyboard, s.Project.TelegramID, s.Project.Title, username, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectDetails) formatProjectDetails() string {
|
func (s *ProjectDetails) formatProjectDetails() string {
|
||||||
@@ -123,13 +128,17 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
case data == "back_to_projects", data == "back":
|
case data == "back_to_projects", data == "back":
|
||||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||||
if s.BackState != nil {
|
if s.BackState != nil {
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
s.transitionWithNewMessage(b, s.BackState)
|
||||||
}
|
}
|
||||||
|
|
||||||
case strings.HasPrefix(data, "creatives:"):
|
case strings.HasPrefix(data, "creatives:"):
|
||||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||||
b.SetState(&Creatives{
|
b.SetState(&Creatives{
|
||||||
ProjectID: s.Project.ID,
|
ProjectID: s.Project.ID,
|
||||||
|
ProjectTitle: s.Project.Title,
|
||||||
|
ProjectTelegramID: s.Project.TelegramID,
|
||||||
|
ProjectUsername: usernameFromProject(s.Project),
|
||||||
|
ProjectStatus: statusFromProject(s.Project),
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.EditMessage)
|
}, bot.EditMessage)
|
||||||
@@ -140,6 +149,9 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.Project.ID,
|
ProjectID: s.Project.ID,
|
||||||
ProjectTitle: s.Project.Title,
|
ProjectTitle: s.Project.Title,
|
||||||
|
ProjectTelegramID: s.Project.TelegramID,
|
||||||
|
ProjectUsername: usernameFromProject(s.Project),
|
||||||
|
ProjectStatus: statusFromProject(s.Project),
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.EditMessage)
|
}, bot.EditMessage)
|
||||||
|
|
||||||
@@ -203,3 +215,28 @@ func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return
|
|||||||
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
|
|
||||||
func (s *ProjectDetails) Exit() {}
|
func (s *ProjectDetails) Exit() {}
|
||||||
|
|
||||||
|
func (s *ProjectDetails) transitionWithNewMessage(b *bot.Bot, next bot.State) {
|
||||||
|
if b.LastMessageID != 0 {
|
||||||
|
if _, err := b.DeleteMessage(b.ChatID, b.LastMessageID); err != nil {
|
||||||
|
log.Error().Err(err).Msg("DeleteMessage failed")
|
||||||
|
} else {
|
||||||
|
b.LastMessageID = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.SetState(next, bot.NewMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usernameFromProject(p *backend.Project) string {
|
||||||
|
if p == nil || p.Username == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFromProject(p *backend.Project) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.Status
|
||||||
|
}
|
||||||
|
|||||||
442
tg_bot/screens/project_header.go
Normal file
442
tg_bot/screens/project_header.go
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/NicoNex/echotron/v3"
|
||||||
|
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/fonts/JetBrainsMono-Bold.ttf
|
||||||
|
var jetBrainsMonoBold []byte
|
||||||
|
|
||||||
|
//go:embed assets/fonts/JetBrainsMono-Regular.ttf
|
||||||
|
var jetBrainsMonoRegular []byte
|
||||||
|
|
||||||
|
const projectHeaderCacheTTL = 30 * time.Minute
|
||||||
|
const projectHeaderCacheVersion = "img-v1"
|
||||||
|
const projectHeaderCacheMaxBytes = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
type projectHeaderCacheEntry struct {
|
||||||
|
bytes []byte
|
||||||
|
sizeBytes int
|
||||||
|
uniqueID string
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectHeaderCache = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]projectHeaderCacheEntry
|
||||||
|
total int
|
||||||
|
}{
|
||||||
|
items: make(map[string]projectHeaderCacheEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectHeaderLastMediaKey = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]string
|
||||||
|
}{
|
||||||
|
items: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, chatID int64, title string, username string, status string) {
|
||||||
|
if messageID == 0 || chatID == 0 || title == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := projectHeaderCacheKey(chatID, username, status)
|
||||||
|
if cached, ok := getProjectHeaderFromCache(cacheKey); ok && time.Since(cached.fetchedAt) < projectHeaderCacheTTL {
|
||||||
|
mediaKey := cacheKey + ":" + cached.uniqueID
|
||||||
|
if shouldSkipMediaEdit(chatID, messageID, mediaKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil {
|
||||||
|
setLastMediaKey(chatID, messageID, mediaKey)
|
||||||
|
b.SetLastMessageIsMedia(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chatInfo, err := b.GetChat(chatID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Int64("chat_id", chatID).Msg("GetChat failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if chatInfo.Result == nil || chatInfo.Result.Photo == nil || chatInfo.Result.Photo.SmallFileID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueID := chatInfo.Result.Photo.SmallFileUniqueID
|
||||||
|
if cached, ok := getProjectHeaderFromCache(cacheKey); ok && cached.uniqueID == uniqueID && len(cached.bytes) > 0 {
|
||||||
|
setProjectHeaderCache(cacheKey, cached.bytes, uniqueID)
|
||||||
|
mediaKey := cacheKey + ":" + uniqueID
|
||||||
|
if shouldSkipMediaEdit(chatID, messageID, mediaKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil {
|
||||||
|
setLastMediaKey(chatID, messageID, mediaKey)
|
||||||
|
b.SetLastMessageIsMedia(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photoBytes, err := b.DownloadFileBytes(chatInfo.Result.Photo.SmallFileID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Int64("chat_id", chatID).Msg("Download chat photo failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
compositeBytes, err := buildProjectHeaderImage(photoBytes, title, username, status)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Int64("chat_id", chatID).Msg("Build project header image failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProjectHeaderCache(cacheKey, compositeBytes, uniqueID)
|
||||||
|
|
||||||
|
mediaKey := cacheKey + ":" + uniqueID
|
||||||
|
if !shouldSkipMediaEdit(chatID, messageID, mediaKey) {
|
||||||
|
if err := editProjectHeaderMedia(b, messageID, caption, keyboard, compositeBytes, chatID); err == nil {
|
||||||
|
setLastMediaKey(chatID, messageID, mediaKey)
|
||||||
|
b.SetLastMessageIsMedia(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, compositeBytes []byte, chatID int64) error {
|
||||||
|
if len(compositeBytes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
media := echotron.InputMediaPhoto{
|
||||||
|
Type: echotron.MediaTypePhoto,
|
||||||
|
Media: echotron.NewInputFileBytes("project_header.jpg", compositeBytes),
|
||||||
|
Caption: caption,
|
||||||
|
ParseMode: echotron.HTML,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := b.EditMessageMedia(
|
||||||
|
echotron.NewMessageID(b.ChatID, messageID),
|
||||||
|
media,
|
||||||
|
&echotron.MessageMediaOptions{
|
||||||
|
ReplyMarkup: keyboard,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "message is not modified") {
|
||||||
|
log.Info().Int64("chat_id", chatID).Msg("EditMessageMedia not modified")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Error().Err(err).Int64("chat_id", chatID).Msg("EditMessageMedia failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipMediaEdit(chatID int64, messageID int, mediaKey string) bool {
|
||||||
|
key := fmt.Sprintf("%d:%d", chatID, messageID)
|
||||||
|
projectHeaderLastMediaKey.mu.Lock()
|
||||||
|
defer projectHeaderLastMediaKey.mu.Unlock()
|
||||||
|
lastKey, ok := projectHeaderLastMediaKey.items[key]
|
||||||
|
return ok && lastKey == mediaKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLastMediaKey(chatID int64, messageID int, mediaKey string) {
|
||||||
|
key := fmt.Sprintf("%d:%d", chatID, messageID)
|
||||||
|
projectHeaderLastMediaKey.mu.Lock()
|
||||||
|
defer projectHeaderLastMediaKey.mu.Unlock()
|
||||||
|
projectHeaderLastMediaKey.items[key] = mediaKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectHeaderCacheKey(chatID int64, username string, status string) string {
|
||||||
|
return fmt.Sprintf("%s:%d:%s:%s", projectHeaderCacheVersion, chatID, strings.ToUpper(username), strings.ToUpper(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProjectHeaderFromCache(key string) (projectHeaderCacheEntry, bool) {
|
||||||
|
projectHeaderCache.mu.Lock()
|
||||||
|
defer projectHeaderCache.mu.Unlock()
|
||||||
|
entry, ok := projectHeaderCache.items[key]
|
||||||
|
return entry, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProjectHeaderCache(key string, value []byte, uniqueID string) {
|
||||||
|
projectHeaderCache.mu.Lock()
|
||||||
|
defer projectHeaderCache.mu.Unlock()
|
||||||
|
if existing, ok := projectHeaderCache.items[key]; ok {
|
||||||
|
projectHeaderCache.total -= existing.sizeBytes
|
||||||
|
}
|
||||||
|
projectHeaderCache.items[key] = projectHeaderCacheEntry{
|
||||||
|
bytes: value,
|
||||||
|
sizeBytes: len(value),
|
||||||
|
uniqueID: uniqueID,
|
||||||
|
fetchedAt: time.Now(),
|
||||||
|
}
|
||||||
|
projectHeaderCache.total += len(value)
|
||||||
|
projectHeaderCacheEvictIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectHeaderCacheEvictIfNeeded() {
|
||||||
|
for projectHeaderCache.total > projectHeaderCacheMaxBytes && len(projectHeaderCache.items) > 0 {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestTime time.Time
|
||||||
|
first := true
|
||||||
|
for key, entry := range projectHeaderCache.items {
|
||||||
|
if first || entry.fetchedAt.Before(oldestTime) {
|
||||||
|
oldestKey = key
|
||||||
|
oldestTime = entry.fetchedAt
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectHeaderCache.total -= projectHeaderCache.items[oldestKey].sizeBytes
|
||||||
|
delete(projectHeaderCache.items, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProjectHeaderImage(photoBytes []byte, title string, username string, status string) ([]byte, error) {
|
||||||
|
const (
|
||||||
|
canvasW = 720
|
||||||
|
canvasH = 260
|
||||||
|
)
|
||||||
|
|
||||||
|
srcImg, _, err := image.Decode(bytes.NewReader(photoBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
|
||||||
|
drawGradient(canvas, color.RGBA{R: 6, G: 8, B: 16, A: 255}, color.RGBA{R: 18, G: 10, B: 28, A: 255})
|
||||||
|
|
||||||
|
avatarSize := 168
|
||||||
|
avatarX := 24
|
||||||
|
avatarY := (canvasH - avatarSize) / 2
|
||||||
|
|
||||||
|
cropped := cropCenterSquare(srcImg)
|
||||||
|
scaled := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize))
|
||||||
|
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil)
|
||||||
|
|
||||||
|
mask := circleMask(avatarSize)
|
||||||
|
draw.DrawMask(
|
||||||
|
canvas,
|
||||||
|
image.Rect(avatarX, avatarY, avatarX+avatarSize, avatarY+avatarSize),
|
||||||
|
scaled,
|
||||||
|
image.Point{},
|
||||||
|
mask,
|
||||||
|
image.Point{},
|
||||||
|
draw.Over,
|
||||||
|
)
|
||||||
|
|
||||||
|
textColor := image.NewUniform(color.RGBA{R: 245, G: 247, B: 250, A: 255})
|
||||||
|
usernameColor := image.NewUniform(color.RGBA{R: 84, G: 156, B: 255, A: 255})
|
||||||
|
textX := avatarX + avatarSize + 24
|
||||||
|
textMaxWidth := canvasW - textX - 24
|
||||||
|
|
||||||
|
titleSize := fitFontSize(jetBrainsMonoBold, title, textMaxWidth, float64(canvasH)*0.22, 20)
|
||||||
|
titleFace, err := loadFontFace(jetBrainsMonoBold, titleSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer titleFace.Close()
|
||||||
|
|
||||||
|
usernameText := formatUsername(username)
|
||||||
|
statusText, statusColor := statusInfo(status)
|
||||||
|
statusLine := usernameText
|
||||||
|
if statusText != "" {
|
||||||
|
if statusLine != "" {
|
||||||
|
statusLine += " | "
|
||||||
|
}
|
||||||
|
statusLine += statusText
|
||||||
|
}
|
||||||
|
|
||||||
|
statusSize := titleSize * 0.6
|
||||||
|
if statusSize < 14 {
|
||||||
|
statusSize = 14
|
||||||
|
}
|
||||||
|
if statusLine != "" {
|
||||||
|
statusSize = fitFontSize(jetBrainsMonoRegular, statusLine, textMaxWidth, statusSize, 12)
|
||||||
|
}
|
||||||
|
statusFace, err := loadFontFace(jetBrainsMonoRegular, statusSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer statusFace.Close()
|
||||||
|
|
||||||
|
titleMetrics := titleFace.Metrics()
|
||||||
|
statusMetrics := statusFace.Metrics()
|
||||||
|
lineGapRatio := 0.03
|
||||||
|
lineGap := int(float64(canvasH) * lineGapRatio)
|
||||||
|
totalHeight := titleMetrics.Height.Ceil()
|
||||||
|
if statusLine != "" {
|
||||||
|
totalHeight += lineGap + statusMetrics.Height.Ceil()
|
||||||
|
}
|
||||||
|
verticalOffsetRatio := 0.02
|
||||||
|
startY := (canvasH-totalHeight)/2 + titleMetrics.Ascent.Ceil() + int(float64(canvasH)*verticalOffsetRatio)
|
||||||
|
|
||||||
|
drawText(canvas, titleFace, textColor, textX, startY, title)
|
||||||
|
if statusLine != "" {
|
||||||
|
statusY := startY + titleMetrics.Descent.Ceil() + lineGap + statusMetrics.Ascent.Ceil()
|
||||||
|
drawStatusLine(canvas, statusFace, textX, statusY, usernameText, statusText, usernameColor, textColor, statusColor)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&out, canvas, &jpeg.Options{Quality: 85}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cropCenterSquare(img image.Image) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
size := w
|
||||||
|
if h < w {
|
||||||
|
size = h
|
||||||
|
}
|
||||||
|
x0 := b.Min.X + (w-size)/2
|
||||||
|
y0 := b.Min.Y + (h-size)/2
|
||||||
|
cropRect := image.Rect(x0, y0, x0+size, y0+size)
|
||||||
|
|
||||||
|
if sub, ok := img.(interface {
|
||||||
|
SubImage(r image.Rectangle) image.Image
|
||||||
|
}); ok {
|
||||||
|
return sub.SubImage(cropRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
draw.Draw(dst, dst.Bounds(), img, cropRect.Min, draw.Src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func circleMask(diameter int) *image.Alpha {
|
||||||
|
mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
|
||||||
|
r := float64(diameter) / 2
|
||||||
|
cx := r
|
||||||
|
cy := r
|
||||||
|
for y := 0; y < diameter; y++ {
|
||||||
|
for x := 0; x < diameter; x++ {
|
||||||
|
dx := float64(x) + 0.5 - cx
|
||||||
|
dy := float64(y) + 0.5 - cy
|
||||||
|
if dx*dx+dy*dy <= r*r {
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
func drawGradient(img *image.RGBA, top, bottom color.RGBA) {
|
||||||
|
b := img.Bounds()
|
||||||
|
h := b.Dy()
|
||||||
|
w := b.Dx()
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
t := float64(y) / float64(h-1)
|
||||||
|
r := uint8(float64(top.R)*(1-t) + float64(bottom.R)*t)
|
||||||
|
g := uint8(float64(top.G)*(1-t) + float64(bottom.G)*t)
|
||||||
|
bb := uint8(float64(top.B)*(1-t) + float64(bottom.B)*t)
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{R: r, G: g, B: bb, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFontFace(fontData []byte, size float64) (font.Face, error) {
|
||||||
|
ft, err := opentype.Parse(fontData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return opentype.NewFace(ft, &opentype.FaceOptions{
|
||||||
|
Size: size,
|
||||||
|
DPI: 72,
|
||||||
|
Hinting: font.HintingFull,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitFontSize(fontData []byte, text string, maxWidth int, startSize float64, minSize float64) float64 {
|
||||||
|
size := startSize
|
||||||
|
for size >= minSize {
|
||||||
|
face, err := loadFontFace(fontData, size)
|
||||||
|
if err != nil {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
width := font.MeasureString(face, text).Ceil()
|
||||||
|
face.Close()
|
||||||
|
if width <= maxWidth {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
size -= 2
|
||||||
|
}
|
||||||
|
return minSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawText(dst *image.RGBA, face font.Face, src image.Image, x int, y int, text string) {
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: src,
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUsername(username string) string {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if username == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(username, "@") {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return "@" + username
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusInfo(status string) (string, color.RGBA) {
|
||||||
|
switch status {
|
||||||
|
case "active":
|
||||||
|
return "Активный", color.RGBA{R: 66, G: 211, B: 114, A: 255}
|
||||||
|
case "inactive":
|
||||||
|
return "Неактивен", color.RGBA{R: 160, G: 170, B: 180, A: 255}
|
||||||
|
case "archived":
|
||||||
|
return "Архивный", color.RGBA{R: 180, G: 180, B: 180, A: 255}
|
||||||
|
case "paused":
|
||||||
|
return "Приостановлен", color.RGBA{R: 245, G: 179, B: 66, A: 255}
|
||||||
|
default:
|
||||||
|
if strings.TrimSpace(status) == "" {
|
||||||
|
return "", color.RGBA{}
|
||||||
|
}
|
||||||
|
return status, color.RGBA{R: 160, G: 170, B: 180, A: 255}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawStatusLine(dst *image.RGBA, face font.Face, x int, y int, usernameText string, statusText string, usernameColor image.Image, textColor image.Image, statusColor color.RGBA) {
|
||||||
|
drawX := x
|
||||||
|
if usernameText != "" {
|
||||||
|
drawText(dst, face, usernameColor, drawX, y, usernameText)
|
||||||
|
drawX += font.MeasureString(face, usernameText).Ceil()
|
||||||
|
}
|
||||||
|
if statusText != "" {
|
||||||
|
separator := " | "
|
||||||
|
if usernameText != "" {
|
||||||
|
drawText(dst, face, textColor, drawX, y, separator)
|
||||||
|
drawX += font.MeasureString(face, separator).Ceil()
|
||||||
|
}
|
||||||
|
statusSymbol := "●"
|
||||||
|
statusColorImg := image.NewUniform(statusColor)
|
||||||
|
drawText(dst, face, statusColorImg, drawX, y, statusSymbol)
|
||||||
|
drawX += font.MeasureString(face, statusSymbol).Ceil() + 6
|
||||||
|
drawText(dst, face, textColor, drawX, y, statusText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ type Placements struct {
|
|||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
ProjectTitle string
|
ProjectTitle string
|
||||||
|
ProjectTelegramID int64
|
||||||
|
ProjectUsername string
|
||||||
|
ProjectStatus string
|
||||||
BackState bot.State
|
BackState bot.State
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +94,9 @@ func (s *Placements) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string
|
|||||||
|
|
||||||
keyboard := Keyboard(buttons...)
|
keyboard := Keyboard(buttons...)
|
||||||
b.Render(text, keyboard, mode)
|
b.Render(text, keyboard, mode)
|
||||||
|
|
||||||
|
messageID := b.LastMessageID
|
||||||
|
updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
@@ -111,6 +117,9 @@ func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
ProjectTitle: s.ProjectTitle,
|
ProjectTitle: s.ProjectTitle,
|
||||||
|
ProjectTelegramID: s.ProjectTelegramID,
|
||||||
|
ProjectUsername: s.ProjectUsername,
|
||||||
|
ProjectStatus: s.ProjectStatus,
|
||||||
ActivePicker: "",
|
ActivePicker: "",
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.EditMessage)
|
}, bot.EditMessage)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type SelectChannelsForPurchase struct {
|
|||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
ProjectTitle string
|
ProjectTitle string
|
||||||
|
ProjectTelegramID int64
|
||||||
|
ProjectUsername string
|
||||||
|
ProjectStatus string
|
||||||
CreativeID string
|
CreativeID string
|
||||||
CreativeTitle string
|
CreativeTitle string
|
||||||
Channels []PurchaseChannelInput
|
Channels []PurchaseChannelInput
|
||||||
@@ -143,6 +146,9 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
|||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
ProjectTitle: s.ProjectTitle,
|
ProjectTitle: s.ProjectTitle,
|
||||||
|
ProjectTelegramID: s.ProjectTelegramID,
|
||||||
|
ProjectUsername: s.ProjectUsername,
|
||||||
|
ProjectStatus: s.ProjectStatus,
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: s.CreativeID,
|
||||||
CreativeTitle: s.CreativeTitle,
|
CreativeTitle: s.CreativeTitle,
|
||||||
ActivePicker: "",
|
ActivePicker: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user