Initial commit
Build & Push / build (push) Canceled after 0s

This commit is contained in:
2026-08-05 19:31:35 +03:00
commit a33fa24e99
272 changed files with 40566 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM golang:1.25-alpine AS build
WORKDIR /app/tg_bot
# Modules layer
COPY tg_bot/go.mod tg_bot/go.sum ./
COPY pkg /app/pkg
COPY shared/echotron /app/shared/echotron
RUN go mod download
# Build layer
COPY tg_bot /app/tg_bot
RUN CGO_ENABLED=0 GOOS=linux go build -o /tg_bot .
FROM alpine:latest AS run
COPY --from=build /tg_bot /tg_bot
CMD ["/tg_bot"]
+821
View File
@@ -0,0 +1,821 @@
package backend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
var ErrNotFound = errors.New("not found")
type Config struct {
BaseURL string `envconfig:"BACKEND__BASE_URL" default:"http_v1://localhost:8000"`
LoginURL string `envconfig:"LOGIN_URL" required:"true"`
}
type Client struct {
http *http.Client
baseURL string
loginURL string
}
func New(cfg Config) *Client {
return &Client{
http: &http.Client{
Timeout: 60 * time.Second,
},
baseURL: cfg.BaseURL,
loginURL: cfg.LoginURL,
}
}
func withBearer(token string) func(*http.Request) {
return func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+token)
}
}
func (c *Client) do(ctx context.Context, method string, path string, in any, out any, opts ...func(*http.Request)) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/"+path, body)
if err != nil {
return fmt.Errorf("http_v1.NewRequest: %w", err)
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
for _, opt := range opts {
opt(req)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("client.Do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
return fmt.Errorf("request failed: %s: %s", resp.Status, b)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("json.Decode: %w", err)
}
}
return nil
}
func (c *Client) LoginURL(token string) string {
return c.loginURL + token
}
func (c *Client) CreateLoginToken(ctx context.Context, telegramID int64) (string, error) {
req := struct {
TelegramID int64 `json:"telegram_id"`
}{
TelegramID: telegramID,
}
var token string
err := c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token)
return token, err
}
func (c *Client) AttachLoginTokenMessage(ctx context.Context, token string, messageID int) error {
req := struct {
Token string `json:"token"`
MessageID int `json:"message_id"`
}{
Token: token,
MessageID: messageID,
}
return c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token/message", req, nil)
}
func (c *Client) GetJWTByTelegramID(
ctx context.Context,
telegramID int64,
) (string, error) {
q := url.Values{}
q.Set("telegram_id", fmt.Sprint(telegramID))
path := "api/v1/internal/auth/jwt?" + q.Encode()
var resp struct {
AccessToken string `json:"access_token"`
}
err := c.do(ctx, http.MethodGet, path, nil, &resp)
return resp.AccessToken, err
}
func (c *Client) GetJWTByTelegramUser(
ctx context.Context,
telegramID int64,
username *string,
firstName *string,
lastName *string,
) (string, error) {
q := url.Values{}
q.Set("telegram_id", fmt.Sprint(telegramID))
if username != nil && *username != "" {
q.Set("username", *username)
}
if firstName != nil && *firstName != "" {
q.Set("first_name", *firstName)
}
if lastName != nil && *lastName != "" {
q.Set("last_name", *lastName)
}
path := "api/v1/internal/auth/jwt?" + q.Encode()
var resp struct {
AccessToken string `json:"access_token"`
}
err := c.do(ctx, http.MethodGet, path, nil, &resp)
return resp.AccessToken, err
}
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Project struct {
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Title string `json:"title"`
Username *string `json:"username"`
Status string `json:"status"`
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
}
type Page struct {
Items []Project `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Pages int `json:"pages"`
}
func (c *Client) GetWorkspaces(
ctx context.Context,
jwt string,
) ([]Workspace, error) {
var resp struct {
Items []Workspace `json:"items"`
}
err := c.do(
ctx,
http.MethodGet,
"api/v1/workspaces",
nil,
&resp,
withBearer(jwt),
)
return resp.Items, err
}
func (c *Client) CreateWorkspace(ctx context.Context, jwt string, name string) (Workspace, error) {
req := struct {
Name string `json:"name"`
}{
Name: name,
}
var workspace Workspace
err := c.do(ctx, http.MethodPost, "api/v1/workspaces", req, &workspace, withBearer(jwt))
return workspace, err
}
func (c *Client) GetProjects(
ctx context.Context,
jwt string,
workspaceID string,
page, size int,
) (*Page, error) {
q := url.Values{}
q.Set("page", fmt.Sprint(page))
q.Set("size", fmt.Sprint(size))
path := fmt.Sprintf("api/v1/workspaces/%s/projects?%s", workspaceID, q.Encode())
var resp Page
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&resp,
withBearer(jwt),
)
return &resp, err
}
func (c *Client) GetProject(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
) (*Project, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s", workspaceID, projectID)
var project Project
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&project,
withBearer(jwt),
)
return &project, err
}
func (c *Client) UpdateProjectInviteLinkType(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
inviteLinkType string, // "public" или "approval"
) (*Project, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID)
payload := map[string]string{
"purchase_invite_type_default": inviteLinkType,
}
var project Project
err := c.do(
ctx,
http.MethodPatch,
path,
payload,
&project,
withBearer(jwt),
)
return &project, err
}
func (c *Client) SendEvent(ctx context.Context, payload any) error {
return c.do(
ctx,
http.MethodPost,
"api/v1/internal/events",
payload,
nil,
)
}
func (c *Client) SearchChannels(
ctx context.Context,
jwt string,
username string,
) ([]Channel, error) {
path := fmt.Sprintf("api/v1/channels?username=%s", username)
var response struct {
Items []Channel `json:"items"`
}
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&response,
withBearer(jwt),
)
return response.Items, err
}
func (c *Client) AttachChannelToWorkspace(
ctx context.Context,
channelID string,
workspaceID string,
userTelegramID int64,
) (*Project, error) {
input := map[string]any{
"channel_id": channelID,
"workspace_id": workspaceID,
"user_telegram_id": userTelegramID,
}
var project Project
err := c.do(
ctx,
http.MethodPost,
"api/v1/internal/projects",
input,
&project,
)
return &project, err
}
func (c *Client) AcceptWorkspaceInvite(
ctx context.Context,
jwt string,
inviteID string,
) error {
path := fmt.Sprintf("api/v1/invites/%s/accept", inviteID)
return c.do(
ctx,
http.MethodPost,
path,
nil,
nil,
withBearer(jwt),
)
}
// ============================================================================
// Creatives
// ============================================================================
type Creative struct {
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
MediaItems []CreativeMediaItem `json:"media_items"`
Buttons []CreativeButton `json:"buttons"`
ProjectID string `json:"project_id"`
ProjectChannelTitle string `json:"project_channel_title"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
Tag string `json:"tag"`
PlacementsCount int `json:"placements_count"`
}
type CreativeButton struct {
Text string `json:"text"`
URL string `json:"url"`
}
type CreativeMediaItem struct {
MediaType string `json:"media_type"`
MediaFileID string `json:"media_file_id"`
Position int `json:"position"`
S3URL *string `json:"s3_url,omitempty"`
}
type CreativeMediaInput struct {
MediaType string `json:"media_type"`
MediaFileID string `json:"media_file_id"`
MediaData []byte `json:"media_data,omitempty"`
}
type CreativesPage struct {
Items []Creative `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Pages int `json:"pages"`
}
func (c *Client) GetCreatives(
ctx context.Context,
jwt string,
workspaceID string,
projectID *string,
includeArchived bool,
page, size int,
) (*CreativesPage, error) {
q := url.Values{}
if projectID != nil {
q.Set("project_id", *projectID)
}
q.Set("include_archived", fmt.Sprint(includeArchived))
q.Set("page", fmt.Sprint(page))
q.Set("size", fmt.Sprint(size))
path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode())
var resp CreativesPage
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&resp,
withBearer(jwt),
)
return &resp, err
}
func (c *Client) GetCreative(
ctx context.Context,
jwt string,
workspaceID string,
creativeID string,
) (*Creative, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID)
var creative Creative
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&creative,
withBearer(jwt),
)
return &creative, err
}
type CreateCreativeInput struct {
Name string `json:"name"`
Text string `json:"text"`
MediaItems []CreativeMediaInput `json:"media_items,omitempty"`
Buttons []CreativeButton `json:"buttons,omitempty"`
Tag *string `json:"tag,omitempty"`
}
func (c *Client) CreateCreative(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
input CreateCreativeInput,
) (*Creative, error) {
q := url.Values{}
q.Set("project_id", projectID)
path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode())
var creative Creative
err := c.do(
ctx,
http.MethodPost,
path,
input,
&creative,
withBearer(jwt),
)
return &creative, err
}
type UpdateCreativeInput struct {
Name *string `json:"name,omitempty"`
Text *string `json:"text,omitempty"`
MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"`
Buttons *[]CreativeButton `json:"buttons,omitempty"`
Status *string `json:"status,omitempty"`
Tag *string `json:"tag,omitempty"`
}
func (c *Client) UpdateCreative(
ctx context.Context,
jwt string,
workspaceID string,
creativeID string,
input UpdateCreativeInput,
) (*Creative, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID)
var creative Creative
err := c.do(
ctx,
http.MethodPatch,
path,
input,
&creative,
withBearer(jwt),
)
return &creative, err
}
func (c *Client) DeleteCreative(
ctx context.Context,
jwt string,
workspaceID string,
creativeID string,
) error {
path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID)
return c.do(
ctx,
http.MethodDelete,
path,
nil,
nil,
withBearer(jwt),
)
}
// ============================================================================
// Placements (Размещения)
// ============================================================================
type Channel struct {
ID string `json:"id"`
TelegramID *int64 `json:"telegram_id"`
Title *string `json:"title"`
Username *string `json:"username"`
InviteLink *string `json:"invite_link"`
}
type CreateChannelInput struct {
Username *string `json:"username,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
}
type CreateChannelsInput struct {
Channels []CreateChannelInput `json:"channels"`
}
type CreateChannelResult struct {
Index int `json:"index"`
Status string `json:"status"`
Channel *Channel `json:"channel,omitempty"`
Error *string `json:"error,omitempty"`
}
type ProjectOutput struct {
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Title string `json:"title"`
Username *string `json:"username"`
Status string `json:"status"`
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
Channel Channel `json:"channel"`
}
type CreateChannelsOutput struct {
Results []CreateChannelResult `json:"results"`
}
type CostInfo struct {
Type string `json:"type"`
Value float64 `json:"value"`
}
type PlacementDetails struct {
PlacementAt *string `json:"placement_at,omitempty"`
PaymentAt *string `json:"payment_at,omitempty"`
Cost *CostInfo `json:"cost,omitempty"`
CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
PlacementType *string `json:"placement_type,omitempty"`
Format *string `json:"format,omitempty"`
TopTimeMinutes *int `json:"top_time_minutes,omitempty"`
FeedTimeMinutes *int `json:"feed_time_minutes,omitempty"`
Comment *string `json:"comment,omitempty"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
InviteLinkType *string `json:"invite_link_type,omitempty"`
}
type PlacementOutput struct {
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
ShortID string `json:"short_id"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
CreatedAt string `json:"created_at"`
}
type PlacementPostOutput struct {
SubscriptionsCount int `json:"subscriptions_count"`
ViewsCount *int `json:"views_count,omitempty"`
CreatedAt string `json:"created_at"`
TimeOnTop *int `json:"time_on_top,omitempty"`
Post PostOutput `json:"post"`
}
type PostOutput struct {
ID string `json:"id"`
MessageID int `json:"message_id"`
Text string `json:"text"`
URL *string `json:"url,omitempty"`
DeletedFromChannelAt *string `json:"deleted_from_channel_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CreatePlacementChannelInput struct {
ChannelID string `json:"channel_id"`
Status *string `json:"status,omitempty"`
Comment *string `json:"comment,omitempty"`
Details *PlacementDetails `json:"details,omitempty"`
}
type CreatePlacementsInput struct {
CreativeID *string `json:"creative_id,omitempty"`
Channels []CreatePlacementChannelInput `json:"channels"`
}
type GetPlacementsOutput struct {
Placements []PlacementOutput `json:"placements"`
}
type PlacementsPage struct {
Items []PlacementOutput `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Pages int `json:"pages"`
}
type CreativePreviewOutput struct {
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
MediaItems []CreativeMediaItem `json:"media_items"`
Buttons []CreativeButton `json:"buttons"`
}
func (c *Client) CreatePlacements(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
input CreatePlacementsInput,
) (*GetPlacementsOutput, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID)
var placements GetPlacementsOutput
err := c.do(
ctx,
http.MethodPost,
path,
input,
&placements,
withBearer(jwt),
)
return &placements, err
}
func (c *Client) CreateChannels(
ctx context.Context,
jwt string,
input CreateChannelsInput,
) (*CreateChannelsOutput, error) {
var response CreateChannelsOutput
err := c.do(
ctx,
http.MethodPost,
"api/v1/channels",
input,
&response,
withBearer(jwt),
)
return &response, err
}
func (c *Client) GetPlacements(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
page, size int,
) (*PlacementsPage, error) {
q := url.Values{}
q.Set("page", fmt.Sprint(page))
q.Set("size", fmt.Sprint(size))
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements?%s", workspaceID, projectID, q.Encode())
var resp PlacementsPage
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&resp,
withBearer(jwt),
)
return &resp, err
}
func (c *Client) GetPlacement(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
placementID string,
) (*PlacementOutput, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements/%s", workspaceID, projectID, placementID)
var placement PlacementOutput
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&placement,
withBearer(jwt),
)
return &placement, err
}
func (c *Client) BuildPlacementCreative(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
placementID string,
) (*CreativePreviewOutput, error) {
path := fmt.Sprintf(
"api/v1/workspaces/%s/projects/%s/placements/%s/creative",
workspaceID,
projectID,
placementID,
)
var resp CreativePreviewOutput
err := c.do(
ctx,
http.MethodPost,
path,
nil,
&resp,
withBearer(jwt),
)
return &resp, err
}
// ============================================================================
// Workspace Members
// ============================================================================
type WorkspaceMember struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
}
func (c *Client) GetWorkspaceMembers(
ctx context.Context,
jwt string,
workspaceID string,
) ([]WorkspaceMember, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/members", workspaceID)
var resp struct {
Items []WorkspaceMember `json:"items"`
}
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&resp,
withBearer(jwt),
)
return resp.Items, err
}
+427
View File
@@ -0,0 +1,427 @@
package bot
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/rs/zerolog/log"
)
type exec struct {
handled bool // Событие обработано, дальше не идём
transitioned bool // Был SetState
}
var botUsername string
func SetUsername(username string) { botUsername = username }
func Username() string { return botUsername }
type lastRender struct {
textHash string
kbHash string
isMedia bool
}
type Bot struct {
echotron.API
mu sync.Mutex
ChatID int64
exec *exec
CurrentState State
Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками
lastRender lastRender
commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
Backend *backend.Client
}
func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot {
if commandRouter == nil {
panic("bot: commandRouter cannot be nil")
}
if globalCallbackRouter == nil {
panic("bot: globalCallbackRouter cannot be nil")
}
if commandRouter("/start") == nil {
panic("bot: commandRouter requires /start state answer")
}
if backendClient == nil {
panic("bot: backendClient cannot be nil")
}
api := echotron.NewAPI(token)
return &Bot{
ChatID: chatID,
API: api,
Session: &Session{},
commandRouter: commandRouter,
Backend: backendClient,
globalCallbackRouter: globalCallbackRouter,
}
}
// GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update
func (b *Bot) GetOrCreateJWT(u *echotron.Update) error {
// Извлекаем данные пользователя из update
var user *echotron.User
if u.Message != nil && u.Message.From != nil {
user = u.Message.From
} else if u.CallbackQuery != nil && u.CallbackQuery.From != nil {
user = u.CallbackQuery.From
}
var username, firstName, lastName *string
if user != nil {
if user.Username != "" {
username = &user.Username
}
if user.FirstName != "" {
firstName = &user.FirstName
}
if user.LastName != "" {
lastName = &user.LastName
}
}
jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName)
if err != nil {
return err
}
b.Session.JWT = jwt
if firstName != nil {
b.Session.FirstName = *firstName
}
return nil
}
func (b *Bot) SetState(s State, mode RenderMode) {
log.Info().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s))
if b.CurrentState != nil {
b.CurrentState.Exit()
}
b.CurrentState = s
if b.exec != nil {
b.exec.transitioned = true
b.exec.handled = true
}
s.Enter(b, mode)
}
func (b *Bot) MarkHandled() {
if b.exec != nil {
b.exec.handled = true
}
}
func isChannelChat(chatType string) bool {
return chatType == "channel" || chatType == "supergroup"
}
func stringPtr(value string) *string {
if value == "" {
return nil
}
return &value
}
func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
switch mode {
case EditMessage:
b.Edit(text, keyboard)
case NewMessage:
b.SendNew(text, keyboard)
default:
panic("unknown RenderMode")
}
}
func (b *Bot) SetLastMessageIsMedia(isMedia bool) {
b.lastRender.isMedia = isMedia
}
func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
res, err := b.GetFile(fileID)
if err != nil {
return nil, err
}
if res.Result == nil || res.Result.FilePath == "" {
return nil, fmt.Errorf("telegram file not available")
}
return b.DownloadFile(res.Result.FilePath)
}
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
if b.exec != nil {
defer func() { b.exec.handled = true }()
}
if b.LastMessageID != 0 {
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
b.cleanupKeyboard(b.LastMessageID)
}
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
if err != nil {
log.Error().Err(err).Msg("SendMessage failed")
return
}
if res.Result == nil {
return
}
b.LastMessageID = res.Result.ID
b.lastRender.isMedia = false
b.lastRender.textHash = hashString(text)
b.lastRender.kbHash = keyboardHash(keyboard)
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
}
func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
if b.exec != nil {
defer func() { b.exec.handled = true }()
}
if b.LastMessageID == 0 {
b.SendNew(text, keyboard)
return
}
newTextHash := hashString(text)
newKBHash := keyboardHash(keyboard)
textChanged := newTextHash != b.lastRender.textHash
kbChanged := newKBHash != b.lastRender.kbHash
if !textChanged && !kbChanged {
log.Info().Msg("Edit skipped: message not modified")
return
}
var err error
msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID)
if b.lastRender.isMedia {
_, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{
Caption: text,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
})
} else {
_, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
}
if err == nil {
if textChanged {
b.lastRender.textHash = newTextHash
}
if kbChanged {
b.lastRender.kbHash = newKBHash
}
return
}
switch {
case strings.Contains(err.Error(), "message is not modified"):
log.Err(err).Msg("EditMessageText ignored: message is not modified")
case strings.Contains(err.Error(), "no text in the message to edit"):
b.lastRender.isMedia = true
log.Err(err).Msg("EditMessageText ignored: message has no text")
default:
log.Error().Err(err).Msg("EditMessageText")
}
}
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) {
b.mu.Lock()
defer b.mu.Unlock()
log.Info().Int64("chat_id", b.ChatID).Msg("Update received")
// 1. Системные события -> отправляем доменные события в backend
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
go b.forwardSystemEvent(u)
return
}
// 2. Игнорируем события не из private чатов
chatType := ""
switch {
case u.Message != nil:
chatType = u.Message.Chat.Type
case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
chatType = u.CallbackQuery.Message.Chat.Type
case u.EditedMessage != nil:
chatType = u.EditedMessage.Chat.Type
case u.ChannelPost != nil || u.EditedChannelPost != nil:
return // Каналы игнорируем сразу
}
if chatType != "" && chatType != "private" {
log.Debug().Str("chat_type", chatType).Msg("Ignoring non-private chat")
return
}
// 3. Игнорируем edited messages
if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil {
log.Debug().Msg("Ignoring edited message")
return
}
err := b.GetOrCreateJWT(u)
if err != nil || b.Session.JWT == "" {
b.Edit(
"❌ Ошибка авторизации",
echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}},
)
b.cleanupCallbackUI(u)
return
}
e := &exec{}
b.exec = e
defer func() { b.exec = nil }()
isStartCommand := u.Message != nil && (u.Message.Text == "/start" ||
u.Message.Text == "/start login" ||
strings.HasPrefix(u.Message.Text, "/start project_"))
if b.CurrentState == nil && !isStartCommand {
b.Session.WasRestarted = true
b.SetState(b.commandRouter("/start"), EditMessage)
return
}
// === Обработка входящего события ===
// 1. Команды (приоритетный маршрут)
if u.Message != nil {
if strings.HasPrefix(u.Message.Text, "/") {
b.SetState(b.commandRouter(u.Message.Text), NewMessage)
return
}
b.CurrentState.HandleMessage(b, u)
if e.handled || e.transitioned {
return
}
}
// 2. Callback запросы
if u.CallbackQuery != nil {
b.cleanupCallbackUI(u)
switch u.CallbackQuery.Data {
case "empty":
return
case "refresh":
if b.CurrentState != nil {
b.CurrentState.Enter(b, EditMessage)
e.handled = true
}
return
}
if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil {
b.SetState(newState, NewMessage)
return
}
b.CurrentState.HandleCallback(b, u)
if e.handled || e.transitioned {
return
}
}
// 3. Универсальный обработчик состояния (если ничего не подошло)
b.CurrentState.Handle(b, u)
if e.handled || e.transitioned {
return
}
// 4. Fallback - ререндер текущего экрана, чтобы не терять прогресс
if b.CurrentState != nil {
mode := NewMessage
if u.CallbackQuery != nil {
mode = EditMessage
}
b.CurrentState.Enter(b, mode)
if e.handled || e.transitioned {
return
}
}
// 5. Крайний fallback - сброс в начальное состояние
b.SetState(b.commandRouter("/start"), NewMessage)
}
func (b *Bot) cleanupKeyboard(messageID int) {
var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
_, err := b.EditMessageReplyMarkup(
echotron.NewMessageID(b.ChatID, messageID),
&echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard},
)
if err != nil {
log.Error().Err(err).Int("message_id", messageID).Msg("Failed to remove keyboard")
}
}
func (b *Bot) cleanupCallbackUI(u *echotron.Update) {
_, err := b.AnswerCallbackQuery(u.CallbackQuery.ID, nil)
if err != nil {
log.Error().Err(err).Msg("b.AnswerCallbackQuery error")
}
if u.CallbackQuery.Message != nil {
callbackMessageID := u.CallbackQuery.Message.ID
// Если это сообщение последнее, не удаляем клавиатуру
if b.LastMessageID == callbackMessageID {
return
}
b.cleanupKeyboard(callbackMessageID)
}
}
+196
View File
@@ -0,0 +1,196 @@
package bot
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
func (b *Bot) forwardSystemEvent(u *echotron.Update) {
ctx := context.Background()
switch {
case u.ChatJoinRequest != nil:
req := u.ChatJoinRequest
if !isChannelChat(req.Chat.Type) {
return
}
inviteLink := ""
if req.InviteLink != nil {
inviteLink = req.InviteLink.InviteLink
}
if inviteLink == "" {
log.Debug().Msg("No invite link in chat join request")
return
}
userID := req.From.ID
username := stringPtr(req.From.Username)
firstName := stringPtr(req.From.FirstName)
lastName := stringPtr(req.From.LastName)
if userID == 0 && req.UserChatID != 0 {
userID = req.UserChatID
}
if userID == 0 {
log.Error().Msg("No user id in chat join request")
return
}
payload := map[string]any{
"type": "subscription",
"user_telegram_id": userID,
"invite_link": inviteLink,
"username": username,
"first_name": firstName,
"last_name": lastName,
}
if err := b.Backend.SendEvent(ctx, payload); err != nil {
log.Error().Err(err).Msg("Failed to send subscription event")
}
case u.ChatMember != nil:
event := u.ChatMember
if !isChannelChat(event.Chat.Type) {
return
}
memberUser := event.NewChatMember.User
if memberUser == nil {
log.Error().Msg("No user in chat member update")
return
}
oldStatus := event.OldChatMember.Status
newStatus := event.NewChatMember.Status
wasNotMember := oldStatus == "left" || oldStatus == "kicked"
isNowMember := newStatus == "member" || newStatus == "administrator" || newStatus == "creator"
userJoined := wasNotMember && isNowMember
wasMember := oldStatus == "member" || oldStatus == "administrator" || oldStatus == "creator" || oldStatus == "restricted"
isNowNotMember := newStatus == "left" || newStatus == "kicked"
userLeft := wasMember && isNowNotMember
if userJoined {
inviteLink := ""
if event.InviteLink != nil {
inviteLink = event.InviteLink.InviteLink
}
if inviteLink == "" {
log.Debug().Msg("No invite link in chat member update")
return
}
username := stringPtr(memberUser.Username)
firstName := stringPtr(memberUser.FirstName)
lastName := stringPtr(memberUser.LastName)
payload := map[string]any{
"type": "subscription",
"user_telegram_id": memberUser.ID,
"invite_link": inviteLink,
"username": username,
"first_name": firstName,
"last_name": lastName,
}
if err := b.Backend.SendEvent(ctx, payload); err != nil {
log.Error().Err(err).Msg("Failed to send subscription event")
}
return
}
if userLeft {
payload := map[string]any{
"type": "unsubscription",
"user_telegram_id": memberUser.ID,
"channel_telegram_id": event.Chat.ID,
}
if err := b.Backend.SendEvent(ctx, payload); err != nil {
log.Error().Err(err).Msg("Failed to send unsubscription event")
}
}
case u.MyChatMember != nil:
event := u.MyChatMember
if !isChannelChat(event.Chat.Type) {
return
}
actorID := event.From.ID
if actorID == 0 {
log.Error().Msg("No user in my chat member update")
return
}
oldStatus := event.OldChatMember.Status
newStatus := event.NewChatMember.Status
wasMember := oldStatus == "administrator" || oldStatus == "member"
isNowNotMember := newStatus == "left" || newStatus == "kicked"
botRemoved := wasMember && isNowNotMember
permissionsChanged := oldStatus == "administrator" && newStatus == "administrator"
if botRemoved {
payload := map[string]any{
"type": "bot_removed",
"telegram_id": event.Chat.ID,
"user_telegram_id": actorID,
}
if err := b.Backend.SendEvent(ctx, payload); err != nil {
log.Error().Err(err).Msg("Failed to send bot removed event")
}
return
}
if permissionsChanged {
title := event.Chat.Title
if title == "" {
title = fmt.Sprintf("Channel %d", event.Chat.ID)
}
payload := map[string]any{
"type": "bot_permissions",
"telegram_id": event.Chat.ID,
"chat_title": title,
"user_telegram_id": actorID,
"permissions": map[string]any{
"is_admin": newStatus == "administrator",
"can_invite_users": event.NewChatMember.CanInviteUsers,
"can_restrict_members": event.NewChatMember.CanRestrictMembers,
},
}
if err := b.Backend.SendEvent(ctx, payload); err != nil {
log.Error().Err(err).Msg("Failed to send bot permissions event")
}
return
}
wasNotMember := oldStatus == "left" || oldStatus == "kicked"
isNowMember := newStatus == "administrator" || newStatus == "member"
botAdded := wasNotMember && isNowMember
if botAdded {
title := event.Chat.Title
if title == "" {
title = fmt.Sprintf("Channel %d", event.Chat.ID)
}
payload := map[string]any{
"type": "bot_added",
"telegram_id": event.Chat.ID,
"title": title,
"username": stringPtr(event.Chat.Username),
"user_telegram_id": actorID,
"bot_permissions": map[string]any{
"is_admin": newStatus == "administrator",
"can_invite_users": event.NewChatMember.CanInviteUsers,
"can_restrict_members": event.NewChatMember.CanRestrictMembers,
},
}
if err := b.Backend.SendEvent(ctx, payload); err != nil {
log.Error().Err(err).Msg("Failed to send bot added event")
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package bot
import "github.com/NicoNex/echotron/v3"
type RenderMode int
const (
NewMessage RenderMode = iota
EditMessage
)
type State interface {
Enter(*Bot, RenderMode)
HandleCallback(*Bot, *echotron.Update)
HandleMessage(*Bot, *echotron.Update)
Handle(*Bot, *echotron.Update)
Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин)
}
type Session struct {
FirstName string
JWT string
WorkspaceID string
WasRestarted bool
}
+24
View File
@@ -0,0 +1,24 @@
module github.com/TelegramExchange/tgex-backend/tg_bot
go 1.24.4
require (
github.com/NicoNex/echotron/v3 v3.43.0
github.com/rs/zerolog v1.34.0
golang.org/x/image v0.31.0
)
require (
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/olebedev/when v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // 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
)
replace github.com/TelegramExchange/pkg => ../pkg
replace github.com/NicoNex/echotron/v3 => ../shared/echotron
+31
View File
@@ -0,0 +1,31 @@
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/NicoNex/echotron/v3 v3.43.0 h1:efE2spw3mfU0Ev20m0PqqvgMSm0xHzgSxlWAEmC9RC4=
github.com/NicoNex/echotron/v3 v3.43.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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=
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.6.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/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/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"os"
"regexp"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var botCommands = []echotron.BotCommand{
{Command: "start", Description: "Главное меню"},
{Command: "projects", Description: "Мои проекты"},
{Command: "placements", Description: "Размещения"},
{Command: "platform", Description: "Веб-платформа"},
{Command: "help", Description: "Помощь"},
}
func main() {
initLogger()
botToken := mustEnv("TELEGRAM__TOKEN")
backendClient := backend.New(backend.Config{
BaseURL: mustEnv("BACKEND__BASE_URL"),
LoginURL: mustEnv("LOGIN_URL"),
})
// Регистрируем команды бота в Telegram
api := echotron.NewAPI(botToken)
if me, err := api.GetMe(); err != nil {
log.Error().Err(err).Msg("Failed to get bot profile")
} else if me.Result != nil {
bot.SetUsername(me.Result.Username)
log.Info().Str("username", me.Result.Username).Msg("Telegram bot authorized")
}
if _, err := api.SetMyCommands(nil, botCommands...); err != nil {
log.Error().Err(err).Msg("Failed to set bot commands")
} else {
log.Info().Msg("Bot commands registered successfully")
}
var commandRouter = func(command string) bot.State {
createCreativeRe := regexp.MustCompile(`^/start project_(.+)_createcreative$`)
switch {
case command == "/start":
return &screens.MainMenu{}
case command == "/start login":
return &screens.Login{}
case command == "/projects":
return &screens.MyProjects{BackState: &screens.MainMenu{}}
case command == "/placements":
return &screens.MyProjects{BackState: &screens.MainMenu{}, OpenPlacements: true}
case command == "/platform":
return &screens.PlatformLink{}
case command == "/help":
return &screens.Help{}
case createCreativeRe.MatchString(command):
projectID := createCreativeRe.FindStringSubmatch(command)[1]
return &screens.AddCreativeStart{Ctx: &screens.AddCreativeCtx{
ProjectID: projectID,
BackState: &screens.MainMenu{},
}}
}
panic("Unknown command: " + command)
}
var handleGlobalCallback = func(callbackData string) bot.State {
id, ok := strings.CutPrefix(callbackData, "pending_channel:")
if ok {
return &screens.SelectWorkspace{ChannelID: id, BackState: &screens.MainMenu{}}
}
id, ok = strings.CutPrefix(callbackData, "workspace_invite_accept:")
if ok {
return &screens.AcceptWorkspaceInvite{InviteID: id}
}
return nil
}
newBot := func(chatID int64) echotron.Bot {
return bot.NewBot(chatID, botToken, commandRouter, backendClient, handleGlobalCallback)
}
dsp := echotron.NewDispatcher(botToken, newBot)
updateOpts := echotron.UpdateOptions{
AllowedUpdates: []echotron.UpdateType{
echotron.MessageUpdate,
echotron.CallbackQueryUpdate,
echotron.MyChatMemberUpdate,
echotron.ChatMemberUpdate,
echotron.UpdateType("chat_join_request"),
},
}
echotron.SetChatRequestLimit(0, 0)
for {
err := dsp.PollOptions(false, updateOpts)
if err != nil {
log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...")
time.Sleep(5 * time.Second)
continue
}
break
}
}
func initLogger() {
zerolog.TimeFieldFormat = time.RFC3339
level := zerolog.InfoLevel
if parsedLevel, err := zerolog.ParseLevel(os.Getenv("LOGGER__LEVEL")); err == nil {
level = parsedLevel
}
zerolog.SetGlobalLevel(level)
log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger().Level(level)
prettyConsole := os.Getenv("LOGGER__PRETTY_CONSOLE")
if prettyConsole == "" {
prettyConsole = "true"
}
if prettyConsole == "true" {
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}).
With().
Timestamp().
Logger().
Level(level)
}
log.Info().Msg("Logger initialized")
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatal().Str("env", key).Msg("missing required environment variable")
}
return v
}
+45
View File
@@ -0,0 +1,45 @@
package screens
import (
"context"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
type AcceptWorkspaceInvite struct {
InviteID string
}
const msgAcceptWorkspaceInviteSuccessfully = `
✅ Вы присоединились к рабочему пространству!
Администратор сможет выдать вам необходимые права в веб-интерфейсе.
`
func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt := b.Session.JWT
if jwt == "" {
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", emptyKeyboard)
return
}
err := b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID)
if err != nil {
b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", emptyKeyboard)
return
}
kb := Keyboard(Row(Button("Главное меню", "main_menu")))
b.SendNew(msgAcceptWorkspaceInviteSuccessfully, kb)
}
func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) {}
func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) {
b.SetState(&MainMenu{}, bot.EditMessage)
}
func (s *AcceptWorkspaceInvite) Exit() {}
+489
View File
@@ -0,0 +1,489 @@
package screens
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type AddCreativeCtx struct {
CreativeEditorFields
ProjectID string
BackState bot.State
WaitMessageSent bool
}
type AddCreativeStart struct{ Ctx *AddCreativeCtx }
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
type AddCreativeInput struct{ ctx *AddCreativeCtx }
const msgWaitCreative = `
<b>+ Добавить креатив</b>
📄 <b>Пришлите креатив который хотите добавить</b>
• Обрабатываем любое сообщение Telegram
• Отправляйте сразу оформленный пост
• На следующем шаге можно добавить кнопки или изменить контент
`
const msgConfirmCreativeFormat = `
<b>➕ Добавляем этот креатив?</b>
<b>Название:</b> %s
<i>Чтобы изменить название, отправь мне сообщение 👇:</i>
`
const msgDownloadMediaError = `
<b>❌ Не удалось загрузить медиа</b>
Попробуйте удалить медиа и добавить заново.
`
const msgInviteLinkRequired = `
<b>❌ Ошибка валидации</b>
Текст должен содержать <b>хотя бы одну</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример правильного текста:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
`
const msgInviteLinkTooMany = `
<b>❌ Слишком много ссылок</b>
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
У вас их несколько. Удалите лишние.
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
`
const msgCreateError = `
<b>❌ Ошибка создания</b>
Не удалось создать креатив.
Попробуйте ещё раз или вернитесь назад.
`
const msgCreativeCreated = `
<b>✅ Креатив успешно создан!</b>
📄 <b>Название:</b> %s
`
const msgCreativeCreatedButtons = `
🔘 <b>Кнопок добавлено:</b> %d
`
func filterCreateButtons(rows [][]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
filtered := make([][]echotron.InlineKeyboardButton, 0, len(rows))
for _, row := range rows {
skip := false
for _, btn := range row {
switch btn.CallbackData {
case "edit_text", "add_media", "delete_media":
skip = true
}
}
if !skip {
filtered = append(filtered, row)
}
}
return filtered
}
func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
if s.Ctx.WaitMessageSent {
return
}
kb := Keyboard(Row(Button("« Отмена", "cancel")))
b.Render(msgWaitCreative, kb, mode)
s.Ctx.WaitMessageSent = true
}
func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
if u.CallbackQuery.Data == "cancel" && s.Ctx.BackState != nil {
b.SetState(s.Ctx.BackState, bot.EditMessage)
}
}
func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
s.Ctx.extractCreativeFromMessage(u.Message)
if s.Ctx.Name == nil {
name := s.Ctx.generateCreativeName()
s.Ctx.Name = &name
}
if u.Message.MediaGroupID != "" {
groupID := u.Message.MediaGroupID
s.Ctx.scheduleMediaGroupAction(groupID, func() {
b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
})
b.MarkHandled()
return
}
b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
}
func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *AddCreativeStart) Exit() {}
func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) {
s.ctx.showCreativeConfirmation(b)
}
func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "cancel":
if s.ctx.BackState != nil {
b.SetState(s.ctx.BackState, bot.EditMessage)
}
case data == "edit_text":
s.ctx.InputMode = inputModeText
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "add_button":
s.ctx.InputMode = inputModeButtonText
s.ctx.PendingButtonType = "invite"
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "add_media":
s.ctx.InputMode = inputModeMedia
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "delete_media":
s.ctx.MediaItems = nil
s.ctx.MediaChanged = true
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
case data == "edit_tag":
s.ctx.ShowTagPanel(b)
case data == "tag_testing":
tag := "testing"
s.ctx.Tag = &tag
s.Enter(b, bot.EditMessage)
case data == "tag_production":
tag := "production"
s.ctx.Tag = &tag
s.Enter(b, bot.EditMessage)
case data == "cancel_edit":
s.Enter(b, bot.EditMessage)
case data == "confirm_create":
s.ctx.createCreative(b)
case strings.HasPrefix(data, "delete_button:"):
indexStr := strings.TrimPrefix(data, "delete_button:")
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 || index >= len(s.ctx.Buttons) {
return
}
s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...)
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
if u.Message.Text == "" {
if s.ctx.SetMediaFromMessage(u.Message) {
if u.Message.MediaGroupID != "" {
groupID := u.Message.MediaGroupID
s.ctx.scheduleMediaGroupAction(groupID, func() {
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
})
b.MarkHandled()
return
}
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
}
return
}
newName := u.Message.Text
runes := []rune(newName)
if len(runes) > 200 {
newName = string(runes[:200])
}
s.ctx.Name = &newName
s.ctx.DeleteUserMessage(b, u.Message.ID)
s.Enter(b, bot.EditMessage)
}
func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddCreativeEdit) Exit() {}
func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) {
switch s.ctx.InputMode {
case inputModeText:
s.ctx.ShowTextEditPanel(b)
case inputModeButtonText:
s.ctx.ShowAddButtonPanel(b)
case inputModeButtonURL:
s.ctx.ShowButtonURLPanel(b)
case inputModeMedia:
s.ctx.ShowMediaPanel(b)
}
}
func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
switch u.CallbackQuery.Data {
case "button_type_invite":
s.ctx.PendingButtonType = "invite"
s.ctx.ShowAddButtonPanel(b)
case "button_type_custom":
s.ctx.PendingButtonType = "custom"
s.ctx.ShowAddButtonPanel(b)
case "cancel_add_button":
s.ctx.CancelPendingButton(b)
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case "cancel_edit", "cancel_media":
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
}
}
func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
switch s.ctx.InputMode {
case inputModeText:
if u.Message.Text == "" {
return
}
text := ui.FormatMessageHTML(u.Message)
s.ctx.Text = &text
s.ctx.DeleteUserMessage(b, u.Message.ID)
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeButtonText:
if u.Message.Text == "" {
return
}
buttonText := u.Message.Text
s.ctx.DeleteUserMessage(b, u.Message.ID)
if s.ctx.PendingButtonType == "custom" {
s.ctx.AddCustomButtonPlaceholder(buttonText)
s.ctx.UpdateCreativePreview(b)
s.ctx.InputMode = inputModeButtonURL
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
return
}
s.ctx.AddInviteButton(buttonText)
s.ctx.UpdateCreativePreview(b)
s.ctx.PendingButtonType = ""
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeButtonURL:
if u.Message.Text == "" {
return
}
url := strings.TrimSpace(u.Message.Text)
s.ctx.DeleteUserMessage(b, u.Message.ID)
if !s.ctx.IsValidButtonURL(url, true) {
s.ctx.ShowInvalidButtonURLPanel(b)
return
}
s.ctx.UpdateLastButtonURL(url)
s.ctx.UpdateCreativePreview(b)
s.ctx.PendingButtonType = ""
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeMedia:
if !s.ctx.SetMediaFromMessage(u.Message) {
return
}
if u.Message.MediaGroupID != "" {
groupID := u.Message.MediaGroupID
s.ctx.scheduleMediaGroupAction(groupID, func() {
s.ctx.UpdateCreativePreview(b)
s.ctx.ShowMediaPanel(b)
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
})
b.MarkHandled()
return
}
s.ctx.UpdateCreativePreview(b)
s.ctx.ShowMediaPanel(b)
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
}
}
func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *AddCreativeInput) Exit() {}
func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) {
if message.Text != "" || message.Caption != "" {
text := ui.FormatMessageHTML(message)
if text != "" {
s.Text = &text
}
}
s.SetMediaFromMessage(message)
if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 {
for _, row := range message.ReplyMarkup.InlineKeyboard {
for _, button := range row {
if button.URL != "" {
s.Buttons = append(s.Buttons, InlineButton{
Text: button.Text,
URL: button.URL,
})
}
}
}
}
}
func (s *AddCreativeCtx) generateCreativeName() string {
if s.Text != nil && *s.Text != "" {
re := regexp.MustCompile(`<[^>]*>`)
cleanText := re.ReplaceAllString(*s.Text, "")
runes := []rune(strings.TrimSpace(cleanText))
if len(runes) > 10 {
return string(runes[:10]) + "..."
}
if len(runes) > 0 {
return string(runes)
}
}
return "Новый креатив"
}
func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) {
if s.CreativeMessageID == nil {
s.SendCreativePreview(b)
} else {
s.UpdateCreativePreview(b)
}
escapedName := ui.EscapeHTML(*s.Name)
confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
buttons := filterCreateButtons(s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel"))
s.ShowControlPanel(b, confirmText, buttons)
}
func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
var err error
var mediaItems []backend.CreativeMediaInput
if len(s.MediaItems) > 0 {
mediaItems, err = s.BuildMediaInputs(b)
if err != nil {
log.Error().Err(err).Msg("Failed to download creative media")
buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
s.ShowControlPanel(b, msgDownloadMediaError, buttons)
return
}
}
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
for _, button := range s.Buttons {
buttons = append(buttons, backend.CreativeButton{
Text: button.Text,
URL: button.URL,
})
}
log.Info().Str("text", *s.Text).Msg("Creative text")
tag := "testing"
if s.Tag != nil {
tag = *s.Tag
}
input := backend.CreateCreativeInput{
Name: *s.Name,
Text: *s.Text,
Buttons: buttons,
Tag: &tag,
}
if len(mediaItems) > 0 {
input.MediaItems = mediaItems
}
creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, input)
if err != nil {
log.Error().Err(err).Msg("Failed to create creative")
errMsg := err.Error()
var userMsg string
if strings.Contains(errMsg, "Creative text must contain one invite link") {
userMsg = msgInviteLinkRequired
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
userMsg = msgInviteLinkTooMany
} else {
userMsg = msgCreateError
}
buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
s.ShowControlPanel(b, userMsg, buttons)
return
}
successText := fmt.Sprintf(msgCreativeCreated, creative.Name)
if len(s.Buttons) > 0 {
successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons))
}
b.SendNew(successText, Keyboard())
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
}
+59
View File
@@ -0,0 +1,59 @@
package screens
import (
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
type AddProject struct {
BackState bot.State
}
const addProjectInstructionText = `<b>Как добавить канал в проект</b>
Назначьте <b>@%s</b> администратором канала.
<blockquote expandable><b>Необходимые права:</b>
➔ Управление сообщениями
➔ Добавление участников
<b>После добавления бота:</b>
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора</blockquote>`
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
botUsername := strings.TrimPrefix(bot.Username(), "@")
addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername)
buttons := [][]echotron.InlineKeyboardButton{
Row(Stylish(URLButton("Назначить администратором", addBotURL), echotron.DangerButtonStyle)),
Row(Button("← Назад", "back")),
}
keyboard := Keyboard(buttons...)
b.Render(fmt.Sprintf(addProjectInstructionText, botUsername), keyboard, mode)
}
func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
switch u.CallbackQuery.Data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
}
func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *AddProject) Exit() {}
+325
View File
@@ -0,0 +1,325 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
const addPurchaseCreativesPerPage = 5
const addPurchaseProjectsPerPage = 6
type AddPurchase struct {
ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
ProjectDefaultLinkType string
CreativeID string
CreativeTitle string
ProjectPage int
CreativePage int
ActivePicker string // "" | "project_list" | "creative_list"
BackState bot.State
lastProjects map[string]projectInfo
lastCreatives map[string]creativeInfo
}
type projectInfo struct {
Title string
TelegramID int64
Username string
Status string
DefaultInviteType string
}
type creativeInfo struct {
Title string
}
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderSelection(b, mode)
}
func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Создание закупа</b>\n\n"
text += "Выберите проект и креатив.\n\n"
var buttons [][]echotron.InlineKeyboardButton
projectLabel := "Проект: не выбрано"
if s.ProjectTitle != "" {
projectLabel = fmt.Sprintf("Проект: %s", s.ProjectTitle)
}
creativeLabel := "Креатив: не выбрано"
if s.CreativeTitle != "" {
creativeLabel = fmt.Sprintf("Креатив: %s", s.CreativeTitle)
}
if s.ActivePicker == "" {
buttons = append(buttons, Row(
Button(projectLabel, "pick_project"),
))
buttons = append(buttons, Row(
Button(creativeLabel, "pick_creative"),
))
}
switch s.ActivePicker {
case "project_list":
page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectPage+1, addPurchaseProjectsPerPage)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
return
}
s.lastProjects = make(map[string]projectInfo)
for _, project := range page.Items {
username := ""
if project.Username != nil {
username = *project.Username
}
s.lastProjects[project.ID] = projectInfo{
Title: project.Title,
TelegramID: project.TelegramID,
Username: username,
Status: project.Status,
DefaultInviteType: project.PurchaseInviteTypeDefault,
}
}
if len(page.Items) == 0 {
text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
} else {
text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages))
buttons = append(buttons, ui.BuildGrid(
page.Items,
2,
addPurchaseProjectsPerPage,
page.Pages,
func(project backend.Project) (string, string) {
return project.Title, "project:" + project.ID
},
)...)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.ProjectPage,
TotalPages: page.Pages,
}); navRow != nil {
buttons = append(buttons, navRow)
}
}
buttons = append(buttons, Row(
Button("← Назад", "back_to_selection"),
))
case "creative_list":
if s.ProjectID == "" {
text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n"
buttons = append(buttons, Row(
Button("← Назад", "back_to_selection"),
))
break
}
page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CreativePage+1, addPurchaseCreativesPerPage)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", Keyboard())
return
}
s.lastCreatives = make(map[string]creativeInfo)
for _, creative := range page.Items {
s.lastCreatives[creative.ID] = creativeInfo{
Title: creative.Name,
}
}
if len(page.Items) == 0 {
text += `Для создания закупа сначала нужно создать креатив.
Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
buttons = append(buttons, Row(
Button("🎨 Перейти к креативам", "go_to_creatives"),
))
} else {
text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages))
buttons = append(buttons, ui.BuildGrid(
page.Items,
2,
addPurchaseCreativesPerPage,
page.Pages,
func(creative backend.Creative) (string, string) {
return creative.Name, "creative:" + creative.ID
},
)...)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CreativePage,
TotalPages: page.Pages,
}); navRow != nil {
buttons = append(buttons, navRow)
}
}
buttons = append(buttons, Row(
Button("← Назад", "back_to_selection"),
))
}
if s.ActivePicker == "" {
if s.ProjectID != "" && s.CreativeID != "" {
buttons = append(buttons, Row(
Button("← Отмена", "back"),
Button("→ Далее", "next"),
))
} else {
buttons = append(buttons, Row(
Button("← Отмена", "back"),
))
}
}
keyboard := Keyboard(buttons...)
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) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "pick_project":
s.ActivePicker = "project_list"
s.Enter(b, bot.EditMessage)
case "pick_creative":
s.ActivePicker = "creative_list"
s.Enter(b, bot.EditMessage)
case "back_to_selection":
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
case "go_to_creatives":
b.SetState(&Creatives{
CurrentPage: 0,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
BackState: s,
}, bot.EditMessage)
case "prev":
if s.ActivePicker == "project_list" {
if s.ProjectPage > 0 {
s.ProjectPage--
}
s.Enter(b, bot.EditMessage)
return
}
if s.ActivePicker == "creative_list" {
if s.CreativePage > 0 {
s.CreativePage--
}
s.Enter(b, bot.EditMessage)
return
}
case "next":
if s.ActivePicker == "project_list" {
s.ProjectPage++
s.Enter(b, bot.EditMessage)
return
}
if s.ActivePicker == "creative_list" {
s.CreativePage++
s.Enter(b, bot.EditMessage)
return
}
if s.ProjectID != "" && s.CreativeID != "" {
b.SetState(&SelectChannelsForPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: []PurchaseChannelInput{},
Duplicates: []string{},
ParsingErrors: []ParseError{},
BackState: s.BackState,
}, bot.EditMessage)
}
default:
if len(data) > 8 && data[:8] == "project:" {
projectID := data[8:]
if s.lastProjects != nil {
if info, ok := s.lastProjects[projectID]; ok {
s.ProjectTitle = info.Title
s.ProjectTelegramID = info.TelegramID
s.ProjectUsername = info.Username
s.ProjectStatus = info.Status
s.ProjectDefaultLinkType = info.DefaultInviteType
}
}
s.ProjectID = projectID
s.CreativeID = ""
s.CreativeTitle = ""
s.CreativePage = 0
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
return
}
if len(data) > 9 && data[:9] == "creative:" {
creativeID := data[9:]
if s.lastCreatives != nil {
if info, ok := s.lastCreatives[creativeID]; ok {
s.CreativeTitle = info.Title
}
}
s.CreativeID = creativeID
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
return
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Exit() {}
Binary file not shown.
+380
View File
@@ -0,0 +1,380 @@
package screens
import (
"net/url"
"regexp"
"strings"
)
// ChannelInput представляет результат парсинга ввода пользователя
type ChannelInput struct {
Input string // оригинальный ввод пользователя
Username string // извлеченный username (если применимо)
InviteLink string // извлеченный invite link (если применимо)
Source string // источник: telegram, tgstat, telemetr
Type string // тип: username, invite, deeplink, stat
Valid bool // валидный ли формат
}
// Парсеры для разных форматов
var (
// Username без @: 4-32 символа, начинается с буквы
channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,31}$`)
// Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx
inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`)
inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`)
// TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username
tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{3,31})`)
// Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username
telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{3,31})`)
)
// ParseChannelInput парсит ввод пользователя и определяет тип канала
func ParseChannelInput(input string) ChannelInput {
input = strings.TrimSpace(input)
if input == "" {
return ChannelInput{Valid: false}
}
result := ChannelInput{Input: input}
// 1. Проверяем Telegram deeplink (tg://...)
if strings.HasPrefix(input, "tg://") {
return parseTelegramDeeplink(input)
}
// 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx)
if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) {
result.Type = "invite"
result.Source = "telegram"
result.InviteLink = normalizeInviteLink(input)
result.Valid = true
return result
}
// 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/)
if isURLFormat(input) {
return parseURLFormat(input)
}
// 4. Проверяем @username
if strings.HasPrefix(input, "@") {
username := strings.TrimPrefix(input, "@")
username = strings.TrimSpace(username)
if channelUsernameRe.MatchString(username) {
result.Username = username
result.Type = "username"
result.Source = "telegram"
result.Valid = true
return result
}
result.Valid = false
return result
}
// 5. Проверяем username (без @)
if channelUsernameRe.MatchString(input) {
result.Username = input
result.Type = "username"
result.Source = "telegram"
result.Valid = true
return result
}
// 6. Ничего не подошло
result.Valid = false
return result
}
// parseTelegramDeeplink парсит tg:// ссылки
func parseTelegramDeeplink(input string) ChannelInput {
result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"}
// tg://resolve?domain=username&post=123
if strings.HasPrefix(input, "tg://resolve?domain=") {
parsed, err := url.Parse(input)
if err != nil {
result.Valid = false
return result
}
query := parsed.Query()
domain := query.Get("domain")
if domain != "" && channelUsernameRe.MatchString(domain) {
result.Username = domain
result.Type = "username"
result.Valid = true
return result
}
}
// tg://join?invite=abcdef
if strings.HasPrefix(input, "tg://join?invite=") {
parsed, err := url.Parse(input)
if err != nil {
result.Valid = false
return result
}
query := parsed.Query()
invite := query.Get("invite")
if invite != "" {
result.InviteLink = "t.me/+" + invite
result.Type = "invite"
result.Valid = true
return result
}
}
result.Valid = false
return result
}
// isURLFormat проверяет, является ли ввод URL или ссылкой
func isURLFormat(input string) bool {
return strings.HasPrefix(input, "http://") ||
strings.HasPrefix(input, "https://") ||
strings.HasPrefix(input, "t.me/") ||
strings.HasPrefix(input, "telegram.me/") ||
strings.Contains(input, "t.me/") ||
strings.Contains(input, "telegram.me/")
}
// parseURLFormat парсит URL-форматы ссылок
func parseURLFormat(input string) ChannelInput {
result := ChannelInput{Input: input}
// Нормализуем: убираем протокол
normalized := input
normalized = strings.TrimPrefix(normalized, "https://")
normalized = strings.TrimPrefix(normalized, "http://")
normalized = strings.TrimPrefix(normalized, "www.")
// Проверяем TGStat
if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 {
username := strings.TrimSuffix(matches[1], "_")
result.Username = username
result.Type = "stat"
result.Source = "tgstat"
result.Valid = true
return result
}
// Проверяем Telemetr
if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 {
username := strings.TrimSuffix(matches[1], "_")
result.Username = username
result.Type = "stat"
result.Source = "telemetr"
result.Valid = true
return result
}
// Проверяем telegram.me или t.me
if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") {
return parseTelegramLink(normalized)
}
result.Valid = false
return result
}
// parseTelegramLink парсит ссылки t.me/... и telegram.me/...
func parseTelegramLink(link string) ChannelInput {
result := ChannelInput{Source: "telegram"}
// Убираем t.me/ или telegram.me/
path := link
if strings.HasPrefix(path, "telegram.me/") {
path = strings.TrimPrefix(path, "telegram.me/")
} else {
path = strings.TrimPrefix(path, "t.me/")
}
// Проверяем invite link: +xxxxx или joinchat/xxxxx
if strings.HasPrefix(path, "+") {
inviteHash := strings.TrimPrefix(path, "+")
inviteHash = extractBeforeChar(inviteHash, '?', '/')
if inviteHash != "" {
result.InviteLink = "t.me/+" + inviteHash
result.Type = "invite"
result.Valid = true
return result
}
}
if strings.HasPrefix(path, "joinchat/") {
inviteHash := strings.TrimPrefix(path, "joinchat/")
inviteHash = extractBeforeChar(inviteHash, '?', '/')
if inviteHash != "" {
result.InviteLink = "t.me/joinchat/" + inviteHash
result.Type = "invite"
result.Valid = true
return result
}
}
// Извлекаем username из t.me/username или t.me/username/12345
parts := strings.SplitN(path, "/", 2)
if len(parts) > 0 {
username := parts[0]
// Убираем @ если есть
username = strings.TrimPrefix(username, "@")
// Валидируем username
if channelUsernameRe.MatchString(username) {
result.Username = username
result.Type = "username"
result.Valid = true
return result
}
}
result.Valid = false
return result
}
// normalizeInviteLink нормализует invite ссылку
func normalizeInviteLink(link string) string {
link = strings.TrimSpace(link)
// Добавляем протокол если нужно
if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") {
if strings.HasPrefix(link, "t.me/") {
// Уже в нужном формате
return link
}
}
// Нормализуем t.me/+xxxxx
if strings.HasPrefix(link, "t.me/+") {
return link
}
// Нормализуем t.me/joinchat/xxxxx
if strings.HasPrefix(link, "t.me/joinchat/") {
return link
}
return link
}
// extractBeforeChar извлекает часть строки до первого встреченного символа
func extractBeforeChar(s string, chars ...rune) string {
for _, c := range chars {
if idx := strings.IndexRune(s, c); idx != -1 {
return s[:idx]
}
}
return s
}
// SuggestFix предлагает исправление для некорректного ввода
func SuggestFix(input string) (string, bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", false
}
// 1. Исправляем опечатки в протоколе
if strings.HasPrefix(input, "htp://") {
fixed := strings.Replace(input, "htp://", "https://", 1)
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
if strings.HasPrefix(input, "ttps://") {
fixed := strings.Replace(input, "ttps://", "https://", 1)
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
if strings.HasPrefix(input, "ttp://") {
fixed := strings.Replace(input, "ttp://", "http://", 1)
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
// 2. Исправляем tme/ → t.me/
if strings.HasPrefix(input, "tme/") {
fixed := strings.Replace(input, "tme/", "t.me/", 1)
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
if strings.Contains(input, "tme/") {
fixed := strings.Replace(input, "tme/", "t.me/", 1)
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
// 3. Исправляем @@ или @@
if strings.HasPrefix(input, "@@") {
fixed := strings.TrimPrefix(input, "@")
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
// 4. Пробуем добавить @ в начало username
if !strings.HasPrefix(input, "@") && !isURLFormat(input) {
fixed := "@" + input
if parsed := ParseChannelInput(fixed); parsed.Valid {
return fixed, true
}
}
// 5. Пробуем убрать лишние символы в конце
cleaned := input
cleaned = strings.TrimSuffix(cleaned, ".")
cleaned = strings.TrimSuffix(cleaned, ",")
cleaned = strings.TrimSuffix(cleaned, ";")
cleaned = strings.TrimSpace(cleaned)
if cleaned != input {
if parsed := ParseChannelInput(cleaned); parsed.Valid {
return cleaned, true
}
}
// 6. Пробуем извлечь username из сложной ссылки
if strings.Contains(input, "/") {
parts := strings.Split(input, "/")
for i := len(parts) - 1; i >= 0; i-- {
part := strings.TrimSpace(parts[i])
part = strings.TrimPrefix(part, "@")
if channelUsernameRe.MatchString(part) {
return "@" + part, true
}
}
}
return "", false
}
// FormatChannelLabel форматирует метку канала для отображения
func FormatChannelLabel(input ChannelInput) string {
if input.Username != "" {
return "@" + input.Username
}
if input.InviteLink != "" {
return formatInviteLabel(input.InviteLink)
}
return input.Input
}
// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username)
func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool {
for _, ch := range channels {
// Case-insensitive сравнение username (@MyChannel == @mychannel)
if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) {
return true
}
if input.InviteLink != "" && ch.InviteLink == input.InviteLink {
return true
}
}
return false
}
+384
View File
@@ -0,0 +1,384 @@
package screens
import (
"context"
"fmt"
"sort"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type CreativeDetails struct {
CreativeEditorFields // Встраивание общих полей
CreativeID string
ProjectID string
ProjectTitle string // Название проекта для сообщений
BackState bot.State
// View mode: открыты из режима просмотра
ViewMode bool // Открыты из CreativeView
ViewBackTo bot.State // Состояние для возврата (CreativeView)
// Флаг подтверждения удаления
confirmingDelete bool
}
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
// Удаляем сообщение предыдущего экрана
if b.LastMessageID != 0 {
b.DeleteMessage(b.ChatID, b.LastMessageID)
b.LastMessageID = 0
}
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
// Заполняем поля из загруженного креатива
s.Name = &creative.Name
if creative.Text != "" {
sanitizedText := ui.SanitizeHTML(creative.Text)
s.Text = &sanitizedText
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
} else {
log.Info().Msg("Creative text is empty from backend")
}
s.MediaItems = nil
if len(creative.MediaItems) > 0 {
mediaItems := append([]backend.CreativeMediaItem(nil), creative.MediaItems...)
sort.Slice(mediaItems, func(i, j int) bool {
return mediaItems[i].Position < mediaItems[j].Position
})
for _, item := range mediaItems {
s.MediaItems = append(s.MediaItems, MediaItem{
MediaType: item.MediaType,
MediaFileID: item.MediaFileID,
})
}
}
s.Buttons = nil
for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
}
if creative.Tag != "" {
s.Tag = &creative.Tag
}
s.MediaChanged = false
s.ClearInputMode()
log.Info().
Str("creative_id", s.CreativeID).
Str("name", creative.Name).
Bool("has_text", creative.Text != "").
Bool("s_text_is_nil", s.Text == nil).
Msg("Sending creative preview and panel")
// Отправляем превью и панель управления
s.SendCreativePreview(b)
s.showManagementPanelWithDelete(b)
}
func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
// Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media)
switch {
case data == "cancel", data == "back":
// Удаляем сообщения текущего экрана перед переходом
s.CleanupMessages(b)
if s.ViewMode && s.ViewBackTo != nil {
b.SetState(s.ViewBackTo, bot.NewMessage)
} else if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
case data == "edit_text":
s.InputMode = inputModeText
s.ShowTextEditPanel(b)
case data == "add_button":
s.InputMode = inputModeButtonText
s.PendingButtonType = "invite"
s.ShowAddButtonPanel(b)
case data == "add_media":
s.InputMode = inputModeMedia
s.ShowMediaPanel(b)
case data == "delete_media":
s.MediaItems = nil
s.MediaChanged = true
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
case data == "cancel_add_button":
// Отменяем добавление кнопки
s.CancelPendingButton(b)
s.showManagementPanelWithDelete(b)
case data == "button_type_invite":
s.PendingButtonType = "invite"
s.InputMode = inputModeButtonText
s.ShowAddButtonPanel(b)
case data == "button_type_custom":
s.PendingButtonType = "custom"
s.InputMode = inputModeButtonText
s.ShowAddButtonPanel(b)
case data == "cancel_edit", data == "cancel_media":
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
case data == "confirm_save":
s.updateCreative(b)
case data == "delete_creative":
if !s.confirmingDelete {
// Первое нажатие - просим подтверждение
s.confirmingDelete = true
s.showManagementPanelWithDelete(b)
} else {
// Второе нажатие - удаляем
s.deleteCreative(b)
}
case data == "confirm_delete":
// Это только для безопасности, главная логика в delete_creative
s.deleteCreative(b)
case strings.HasPrefix(data, "delete_button:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
var index int
if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil {
if index >= 0 && index < len(s.Buttons) {
s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
}
}
}
default:
// Сбрасываем флаг подтверждения удаления при любом другом действии
if s.confirmingDelete {
s.confirmingDelete = false
s.showManagementPanelWithDelete(b)
}
}
}
func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
switch s.InputMode {
case inputModeMedia:
if !s.SetMediaFromMessage(u.Message) {
return
}
if u.Message.MediaGroupID != "" {
groupID := u.Message.MediaGroupID
s.scheduleMediaGroupAction(groupID, func() {
s.UpdateCreativePreview(b)
s.ShowMediaPanel(b)
})
b.MarkHandled()
return
}
s.UpdateCreativePreview(b)
s.ShowMediaPanel(b)
return
case inputModeButtonText:
if u.Message.Text == "" {
return
}
buttonText := u.Message.Text
s.DeleteUserMessage(b, u.Message.ID)
// Создаём кнопку с placeholder URL
if s.PendingButtonType == "custom" {
s.AddCustomButtonPlaceholder(buttonText)
s.UpdateCreativePreview(b)
s.InputMode = inputModeButtonURL
s.ShowButtonURLPanel(b)
} else {
s.AddInviteButton(buttonText)
s.UpdateCreativePreview(b)
s.PendingButtonType = ""
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
}
return
case inputModeButtonURL:
if u.Message.Text == "" {
return
}
url := strings.TrimSpace(u.Message.Text)
s.DeleteUserMessage(b, u.Message.ID)
// Простая валидация URL
if !s.IsValidButtonURL(url, true) {
s.ShowInvalidButtonURLPanel(b)
return
}
// Обновляем URL последней кнопки
s.UpdateLastButtonURL(url)
s.ClearInputMode()
s.PendingButtonType = ""
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
return
case inputModeText:
if u.Message.Text == "" {
return
}
text := ui.FormatMessageHTML(u.Message)
s.Text = &text
s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b)
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
return
}
// Обрабатываем имя креатива (если нет других состояний)
if u.Message.Text != "" {
text := ui.FormatMessageHTML(u.Message)
s.Text = &text
s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
}
}
func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *CreativeDetails) Exit() {}
func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) {
var confirmText, confirmCallback string
if s.confirmingDelete {
confirmText = "✖ Подтвердить удаление"
confirmCallback = "confirm_delete"
} else {
confirmText = "✓ Сохранить"
confirmCallback = "confirm_save"
}
// Добавляем кнопку удаления креатива
deleteButton := Row(Button("⌦ Удалить креатив", "delete_creative"))
s.ShowManagementPanel(b, confirmText, confirmCallback, "cancel", deleteButton)
}
func (s *CreativeDetails) updateCreative(b *bot.Bot) {
// JWT уже создан в Bot.Update(), просто берем из сессии
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", Keyboard())
return
}
var err error
// Подготавливаем данные для обновления
input := backend.UpdateCreativeInput{
Name: s.Name,
Text: s.Text,
Tag: s.Tag,
}
if s.MediaChanged {
if len(s.MediaItems) > 0 {
mediaItems, err := s.BuildMediaInputs(b)
if err != nil {
log.Error().Err(err).Msg("Failed to download creative media")
// Показываем ошибку через панель управления
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("✓ Сохранить без медиа", "confirm_save")),
})
return
}
input.MediaItems = &mediaItems
} else {
emptyItems := []backend.CreativeMediaInput{}
input.MediaItems = &emptyItems
}
}
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
for _, button := range s.Buttons {
buttons = append(buttons, backend.CreativeButton{
Text: button.Text,
URL: button.URL,
})
}
input.Buttons = &buttons
// Отправляем запрос на обновление
_, err = b.Backend.UpdateCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID, input)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative")
// Показываем ошибку через панель управления
s.ShowControlPanel(b, "<b>❌ Не удалось сохранить изменения</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("✓ Попробовать снова", "confirm_save")),
})
return
}
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
// Возвращаемся назад
s.CleanupMessages(b)
if s.ViewMode && s.ViewBackTo != nil {
b.SetState(s.ViewBackTo, bot.NewMessage)
} else if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
}
func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
if err != nil {
s.confirmingDelete = false // Сбрасываем флаг подтверждения
s.ShowControlPanel(b, "<b>❌ Не удалось удалить креатив</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("⌦ Попробовать снова", "delete_creative")),
})
return
}
s.CleanupMessages(b)
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
}
+777
View File
@@ -0,0 +1,777 @@
package screens
import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
// InlineButton представляет кнопку с URL
type InlineButton struct {
Text string
URL string
}
type MediaItem struct {
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
}
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
type CreativeEditorFields struct {
// Данные креатива
Name *string
Text *string
Buttons []InlineButton
Tag *string // "testing" or "production"
// Медиа
MediaItems []MediaItem
MediaChanged bool
// Режим ввода
InputMode string
// Состояние для добавления кнопки
PendingButtonType string
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// ID сообщений дополнительного медиа (после первого)
ExtraMediaMessageIDs []int
pendingMediaGroupMu sync.Mutex
pendingMediaGroupID string
pendingMediaGroupTimer *time.Timer
pendingMediaGroupGen int64
// Предыдущее состояние для определения изменения типа сообщения
previousMediaItems []MediaItem
}
const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const buttonURLPlaceholder = "https://example.com"
var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`)
var inviteLinkTagPattern = regexp.MustCompile(`<a\s+class="tg-link">\s*(.*?)\s*</a>`)
const (
inputModeText = "text"
inputModeButtonText = "button_text"
inputModeButtonURL = "button_url"
inputModeMedia = "media"
)
const msgCreativeDefault = `
<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>
`
const msgCreativeManagement = `
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:
`
const msgCreativePreviewBroken = "⚠️ Не удалось показать превью: креатив содержит некорректную разметку. Создайте его заново или отредактируйте текст."
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: buttons,
}
if len(buttons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
return keyboard
}
func copyMediaItems(items []MediaItem) []MediaItem {
if len(items) == 0 {
return nil
}
clone := make([]MediaItem, len(items))
copy(clone, items)
return clone
}
func mediaItemsEqual(a, b []MediaItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID {
return false
}
}
return true
}
func (e *CreativeEditorFields) primaryMedia() *MediaItem {
if len(e.MediaItems) == 0 {
return nil
}
return &e.MediaItems[0]
}
func (e *CreativeEditorFields) hasMultipleMedia() bool {
return len(e.MediaItems) > 1
}
func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) {
for _, msgID := range e.ExtraMediaMessageIDs {
b.DeleteMessage(b.ChatID, msgID)
}
e.ExtraMediaMessageIDs = nil
}
func (e *CreativeEditorFields) CleanupMessages(b *bot.Bot) {
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil
}
b.LastMessageID = 0
}
const mediaGroupDebounce = 2 * time.Second
func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
e.pendingMediaGroupMu.Lock()
defer e.pendingMediaGroupMu.Unlock()
if groupID == "" {
action()
return
}
if e.pendingMediaGroupTimer != nil {
e.pendingMediaGroupTimer.Stop()
}
e.pendingMediaGroupID = groupID
e.pendingMediaGroupGen++
gen := e.pendingMediaGroupGen
e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() {
e.pendingMediaGroupMu.Lock()
currentGroup := e.pendingMediaGroupID
currentGen := e.pendingMediaGroupGen
if currentGen == gen && currentGroup == groupID {
e.pendingMediaGroupID = ""
e.pendingMediaGroupTimer = nil
}
e.pendingMediaGroupMu.Unlock()
if currentGen == gen && currentGroup == groupID {
action()
}
})
}
func isTelegramParseError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "can't parse entities") || strings.Contains(msg, "Unexpected end tag")
}
// GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string {
if e.Text == nil {
return msgCreativeDefault
}
text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
if text == "" {
return msgCreativeDefault
}
// Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку.
text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string {
sub := inviteLinkTagPattern.FindStringSubmatch(match)
inner := ""
if len(sub) > 1 {
inner = strings.TrimSpace(sub[1])
}
if inner == "" {
inner = inviteLinkPreviewURL
}
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLinkPreviewURL, inner)
})
// Заменяем пригласительные ссылки на preview URL только для отображения.
text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL)
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках).
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
return text
}
// GetCreativeButtons возвращает кнопки для превью креатива
func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton {
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: url,
},
})
}
return buttons
}
// SendCreativePreview отправляет превью креатива
func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons()
// Создаем клавиатуру с пустым массивом (не nil!)
keyboard := buildCreativeKeyboard(creativeButtons)
var msgID int
sendText := func() (int, error) {
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err != nil {
return 0, err
}
if res.Result == nil {
return 0, fmt.Errorf("send message: empty result")
}
return res.Result.ID, nil
}
sendFallback := func() (int, error) {
res, err := b.SendMessage(msgCreativePreviewBroken, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err != nil {
return 0, err
}
if res.Result == nil {
return 0, fmt.Errorf("send message: empty result")
}
return res.Result.ID, nil
}
// Если есть медиа, отправляем с медиа
if len(e.MediaItems) > 1 {
e.clearExtraMediaMessages(b)
group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems))
for i, item := range e.MediaItems {
caption := ""
parseMode := echotron.ParseMode("")
if i == 0 {
caption = creativeText
parseMode = echotron.HTML
}
switch item.MediaType {
case "photo":
group = append(group, echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
case "video":
group = append(group, echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
case "animation":
group = append(group, echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
default:
log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group")
}
}
if len(group) > 0 {
res, err := b.SendMediaGroup(b.ChatID, group, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to send media group")
if id, err := sendText(); err == nil {
msgID = id
} else if isTelegramParseError(err) {
if id, err := sendFallback(); err == nil {
msgID = id
}
}
} else if len(res.Result) > 0 {
msgID = res.Result[0].ID
for i := 1; i < len(res.Result); i++ {
e.ExtraMediaMessageIDs = append(e.ExtraMediaMessageIDs, res.Result[i].ID)
}
}
}
} else if media := e.primaryMedia(); media != nil {
var res echotron.APIResponseMessage
var err error
switch media.MediaType {
case "photo":
res, err = b.SendPhoto(
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.PhotoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "video":
res, err = b.SendVideo(
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.VideoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "animation":
res, err = b.SendAnimation(
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.AnimationOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
}
if err != nil {
log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media")
// Fallback to text message - используем прямой SendMessage
if id, err := sendText(); err == nil {
msgID = id
} else if isTelegramParseError(err) {
if id, err := sendFallback(); err == nil {
msgID = id
}
}
} else if res.Result != nil {
msgID = res.Result.ID
}
} else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не затронуть LastMessageID и не затронуть cleanup
id, err := sendText()
if err != nil {
log.Error().Err(err).Msg("Failed to send creative preview")
if isTelegramParseError(err) {
id, err = sendFallback()
if err == nil {
msgID = id
} else {
log.Error().Err(err).Msg("Failed to send fallback creative preview")
return
}
} else {
return
}
} else {
msgID = id
}
}
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID
e.previousMediaItems = copyMediaItems(e.MediaItems)
// Устанавливаем LastMessageID чтобы ShowControlPanel мог корректно работать
// ВАЖНО: Это позволяет избежать конфликтов при навигации между экранами
b.LastMessageID = msgID
}
// UpdateCreativePreview обновляет превью креатива
func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
if e.CreativeMessageID == nil {
log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
// Если превью еще не создано, создаем его
e.SendCreativePreview(b)
return
}
currentHasMedia := len(e.MediaItems) > 0
previousHasMedia := len(e.previousMediaItems) > 0
mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems)
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
if currentHasMedia != previousHasMedia {
// Удаляем оба сообщения
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil
}
// Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
e.SendCreativePreview(b)
return
}
if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) {
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
e.SendCreativePreview(b)
return
}
// Тип не изменился - просто редактируем на месте
creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons()
keyboard := buildCreativeKeyboard(creativeButtons)
msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID)
logEditError := func(msg string, err error) {
textPreview := creativeText
runes := []rune(textPreview)
if len(runes) > 200 {
textPreview = string(runes[:200]) + "..."
}
mediaType := ""
if media := e.primaryMedia(); media != nil {
mediaType = media.MediaType
}
log.Error().
Err(err).
Int("creative_message_id", *e.CreativeMessageID).
Str("media_type", mediaType).
Int("text_len", len([]rune(creativeText))).
Str("text_preview", textPreview).
Msg(msg)
}
// Если есть медиа - редактируем caption
updated := false
if media := e.primaryMedia(); media != nil {
var prevMedia MediaItem
if len(e.previousMediaItems) > 0 {
prevMedia = e.previousMediaItems[0]
}
mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType
if mediaChanged {
var inputMedia echotron.InputMedia
switch media.MediaType {
case "photo":
inputMedia = echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "video":
inputMedia = echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "animation":
inputMedia = echotron.InputMediaAnimation{
Type: echotron.MediaTypeAnimation,
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
}
if inputMedia != nil {
_, err := b.EditMessageMedia(
msgID,
inputMedia,
&echotron.MessageMediaOptions{
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit message media", err)
} else {
updated = true
}
} else {
log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit")
}
} else {
_, err := b.EditMessageCaption(
msgID,
&echotron.MessageCaptionOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit media caption", err)
} else {
updated = true
}
}
} else {
// Текстовое сообщение - редактируем текст
_, err := b.EditMessageText(
creativeText,
msgID,
&echotron.MessageTextOptions{
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
},
)
if err != nil {
logEditError("Failed to edit message text", err)
} else {
updated = true
}
}
if updated {
if currentHasMedia {
e.previousMediaItems = copyMediaItems(e.MediaItems)
} else {
e.previousMediaItems = nil
}
}
}
func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) {
_, err := b.DeleteMessage(b.ChatID, messageID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
}
func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
var item MediaItem
switch {
case message.Photo != nil && len(message.Photo) > 0:
photo := message.Photo[len(message.Photo)-1]
item.MediaType = "photo"
item.MediaFileID = photo.FileID
case message.Video != nil:
item.MediaType = "video"
item.MediaFileID = message.Video.FileID
case message.Animation != nil:
item.MediaType = "animation"
item.MediaFileID = message.Animation.FileID
default:
return false
}
e.MediaItems = append(e.MediaItems, item)
e.MediaChanged = true
return true
}
func (e *CreativeEditorFields) AddInviteButton(text string) {
e.Buttons = append(e.Buttons, InlineButton{
Text: text,
URL: inviteLinkPlaceholder,
})
}
func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) {
e.Buttons = append(e.Buttons, InlineButton{
Text: text,
URL: buttonURLPlaceholder,
})
}
func (e *CreativeEditorFields) UpdateLastButtonURL(url string) {
if len(e.Buttons) > 0 {
e.Buttons[len(e.Buttons)-1].URL = url
}
}
func (e *CreativeEditorFields) ClearInputMode() {
e.InputMode = ""
e.PendingButtonType = ""
}
func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) {
if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 {
lastButton := e.Buttons[len(e.Buttons)-1]
if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder {
e.Buttons = e.Buttons[:len(e.Buttons)-1]
e.UpdateCreativePreview(b)
}
}
e.ClearInputMode()
}
func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool {
if allowHTTP {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}
return strings.HasPrefix(url, "https://")
}
// ShowManagementPanel показывает панель управления креативом
// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить")
// confirmCallback - callback для кнопки подтверждения
// cancelCallback - callback для кнопки отмены
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
text := msgCreativeManagement
buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...)
e.ShowControlPanel(b, text, buttons)
}
func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
var buttons [][]echotron.InlineKeyboardButton
// Блок редактирования контента
buttons = append(buttons, Row(
Button("✎ Текст", "edit_text"),
Button(" Медиа", "add_media"),
))
addButtonLabel := "+ Добавить кнопку"
addButtonCallback := "add_button"
if e.hasMultipleMedia() {
addButtonLabel = "Кнопки недоступны для >1 медиа"
addButtonCallback = "empty"
}
buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
// Кнопка удаления медиа (если медиа добавлено)
if len(e.MediaItems) > 0 {
label := "⌫ Убрать медиа"
if len(e.MediaItems) > 1 {
label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems))
}
buttons = append(buttons, Row(Button(label, "delete_media")))
}
// Кнопки удаления (если есть кнопки)
if len(e.Buttons) > 0 {
var row []echotron.InlineKeyboardButton
for i, btn := range e.Buttons {
row = append(row, Button(
fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text),
fmt.Sprintf("delete_button:%d", i),
))
if len(row) == 2 {
buttons = append(buttons, row)
row = nil
}
}
if len(row) > 0 {
buttons = append(buttons, row)
}
}
// Дополнительные кнопки (например "Удалить креатив")
for _, extraRow := range extraButtons {
buttons = append(buttons, extraRow)
}
// Финальные кнопки
cancelLabel := "← " + cancelText
if e.Text != nil {
buttons = append(buttons, Row(
Button(cancelLabel, cancelCallback),
Button(confirmText, confirmCallback),
))
} else {
buttons = append(buttons, Row(
Button(cancelLabel, cancelCallback),
))
}
return buttons
}
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
if e.ControlPanelMessageID == nil {
// Отправляем новую панель
keyboard := Keyboard(buttons...)
b.SendNew(text, keyboard)
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
controlPanelMsgID := b.LastMessageID
e.ControlPanelMessageID = &controlPanelMsgID
} else {
// Редактируем существующую панель
// Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID
defer func() { b.LastMessageID = oldLastMessageID }()
keyboard := Keyboard(buttons...)
b.Edit(text, keyboard)
}
}
func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) {
if len(e.MediaItems) == 0 {
return nil, nil
}
mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems))
for _, item := range e.MediaItems {
if item.MediaFileID == "" {
continue
}
mediaData, err := b.DownloadFileBytes(item.MediaFileID)
if err != nil {
return nil, err
}
mediaItems = append(mediaItems, backend.CreativeMediaInput{
MediaType: item.MediaType,
MediaFileID: item.MediaFileID,
MediaData: mediaData,
})
}
return mediaItems, nil
}
+138
View File
@@ -0,0 +1,138 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const msgEditCreativeText = `
<b>✎ Введите текст креатива:</b>
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
`
const msgAddButtonPrompt = `
<b>+ Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>
`
const msgAddMediaPrompt = `
<b>+ Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b>
<i>Можно добавить несколько медиа — отправляйте по одному.</i>
<i>Нажмите «Отмена», когда закончите.</i>
`
const msgButtonURLPrompt = `
<b>✧ Введите URL для кнопки:</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
<i>Например:</i>
<code>https://example.com</code>
<code>https://t.me/yourchannel</code>
`
const msgInvalidURL = `
<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
Попробуйте ещё раз.
`
const msgEditTag = `
<b>🏷 Выберите тег креатива:</b>
🧪 <b>Тестовый</b> — для A/B тестов и экспериментов
🚀 <b>Рабочий</b> — проверенный эффективный креатив
`
func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) {
testingLabel := "🧪 Тестовый"
productionLabel := "🚀 Рабочий"
if e.Tag != nil {
if *e.Tag == "testing" {
testingLabel = "✓ 🧪 Тестовый"
} else if *e.Tag == "production" {
productionLabel = "✓ 🚀 Рабочий"
}
}
e.ShowControlPanel(b, msgEditTag, [][]echotron.InlineKeyboardButton{
Row(
Button(testingLabel, "tag_testing"),
Button(productionLabel, "tag_production"),
),
Row(Button("« Назад", "cancel_edit")),
})
}
func (e *CreativeEditorFields) GetTagLabel() string {
if e.Tag != nil && *e.Tag == "production" {
return "◉ Рабочий"
}
return "◉ Тестовый"
}
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
inviteLabel := "Ссылка на канал"
customLabel := "Своя ссылка"
if e.PendingButtonType == "invite" {
inviteLabel = "✓ Ссылка на канал"
}
if e.PendingButtonType == "custom" {
customLabel = "✓ Своя ссылка"
}
e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{
Row(
Button(inviteLabel, "button_type_invite"),
Button(customLabel, "button_type_custom"),
),
Row(Button("« Отмена", "cancel_add_button")),
})
}
func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_edit")),
})
}
func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_media")),
})
}
func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
}
func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
}
+250
View File
@@ -0,0 +1,250 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
)
// CreativeView - экран просмотра креатива (read-only mode)
// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить
type CreativeView struct {
CreativeEditorFields // Встраивание общих полей для отображения превью
CreativeID string
ProjectID string
// Данные проекта для передачи в AddPurchase
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
BackState bot.State
renaming bool // режим ввода нового названия
}
func msgCreativeView(name string) string {
return fmt.Sprintf("<b>📋 %s</b>\n\nВыберите действие:", name)
}
func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
// Удаляем сообщение предыдущего экрана (его клавиатуру не очищает SendCreativePreview)
if b.LastMessageID != 0 {
b.DeleteMessage(b.ChatID, b.LastMessageID)
b.LastMessageID = 0
}
// Сбрасываем старые ID — при re-enter всегда создаём свежие сообщения
s.CreativeMessageID = nil
s.ControlPanelMessageID = nil
// Загружаем креатив
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
// Загружаем проект для получения данных
project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID)
if err != nil {
log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project")
} else {
s.ProjectTitle = project.Title
s.ProjectTelegramID = project.TelegramID
s.ProjectStatus = project.Status
if project.Username != nil {
s.ProjectUsername = *project.Username
}
}
// Заполняем поля креатива для отображения превью
s.Name = &creative.Name
if creative.Text != "" {
s.Text = &creative.Text
}
s.MediaItems = nil
if len(creative.MediaItems) > 0 {
for _, item := range creative.MediaItems {
s.MediaItems = append(s.MediaItems, MediaItem{
MediaType: item.MediaType,
MediaFileID: item.MediaFileID,
})
}
}
s.Buttons = nil
for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
}
if creative.Tag != "" {
s.Tag = &creative.Tag
}
// Отправляем превью креатива
s.SendCreativePreview(b)
// Отправляем панель управления с кнопками действий
s.showActionPanel(b)
}
func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "back":
s.CleanupMessages(b)
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
case data == "rename":
s.renaming = true
s.ShowControlPanel(b, "<b>✏️ Введите новое название креатива:</b>", [][]echotron.InlineKeyboardButton{
Row(Button("← Отмена", "cancel_rename")),
})
case data == "cancel_rename":
s.renaming = false
s.showActionPanel(b)
case data == "edit":
s.CleanupMessages(b)
b.SetState(&CreativeDetails{
CreativeID: s.CreativeID,
ProjectID: s.ProjectID,
ViewMode: true,
ViewBackTo: s,
ProjectTitle: s.ProjectTitle,
BackState: s.BackState,
}, bot.NewMessage)
case data == "toggle_tag":
// Переключение тега (testing <-> production)
s.toggleTag(b)
case data == "purchase_creative":
s.CleanupMessages(b)
creativeTitle := ""
if s.Name != nil {
creativeTitle = *s.Name
}
b.SetState(&AddPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
CreativeID: s.CreativeID,
CreativeTitle: creativeTitle,
BackState: s,
}, bot.NewMessage)
default:
log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView")
}
}
func (s *CreativeView) HandleMessage(b *bot.Bot, u *echotron.Update) {
if !s.renaming || u.Message == nil || u.Message.Text == "" {
return
}
newName := strings.TrimSpace(u.Message.Text)
s.DeleteUserMessage(b, u.Message.ID)
input := backend.UpdateCreativeInput{
Name: &newName,
}
_, err := b.Backend.UpdateCreative(
context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID, input,
)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to rename creative")
s.ShowControlPanel(b, "<b>❌ Не удалось переименовать</b>", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "cancel_rename")),
})
return
}
s.Name = &newName
s.renaming = false
s.showActionPanel(b)
}
func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *CreativeView) Exit() {}
// showActionPanel показывает панель действий с кнопками
func (s *CreativeView) showActionPanel(b *bot.Bot) {
tagLabel := s.GetTagLabel()
buttons := [][]echotron.InlineKeyboardButton{
Row(
Button("✎ Переименовать", "rename"),
Button("✎ Редактировать", "edit"),
),
Row(Button(" Закупить", "purchase_creative")),
Row(
Button("← Назад", "back"),
Button(tagLabel, "toggle_tag"),
),
}
name := "Креатив"
if s.Name != nil && *s.Name != "" {
name = *s.Name
}
s.ShowControlPanel(b, msgCreativeView(name), buttons)
}
// toggleTag переключает тег креатива (testing <-> production)
func (s *CreativeView) toggleTag(b *bot.Bot) {
newTag := "testing"
if s.Tag != nil && *s.Tag == "testing" {
newTag = "production"
}
// Обновляем тег через backend
input := backend.UpdateCreativeInput{
Tag: &newTag,
}
_, err := b.Backend.UpdateCreative(
context.Background(),
b.Session.JWT,
b.Session.WorkspaceID,
s.CreativeID,
input,
)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag")
s.ShowControlPanel(b, "<b>❌ Не удалось изменить тег</b>\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("↻ Попробовать снова", "toggle_tag")),
})
return
}
// Обновляем локальное значение
s.Tag = &newTag
// Обновляем панель (показывает новый тег)
s.showActionPanel(b)
}
+145
View File
@@ -0,0 +1,145 @@
package screens
import (
"context"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type Creatives struct {
CurrentPage int
ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
BackState bot.State
}
const creativesPerPage = 6
const creativesPerRow = 2
const msgNoCreatives = `
<b>Креативы</b>
У вас пока нет креативов
<i>Креатив — это рекламный материал:</i>
‣ Текст объявления
‣ Медиа контент (фото, видео)
‣ Шаблон для автопостинга
‣ Варианты для A/B тестирования
Начните с добавления первого креатива
`
const msgCreatives = `
<b>Креативы</b>
Управление рекламными материалами
Выберите креатив
`
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", refreshKeyboard)
return
}
text := msgCreatives
if len(page.Items) == 0 {
text = msgNoCreatives
}
var kbRows [][]echotron.InlineKeyboardButton
rowsGrid := ui.BuildGrid(page.Items, creativesPerRow, creativesPerPage, page.Pages,
func(creative backend.Creative) (string, string) { return creative.Name, "creative:" + creative.ID },
)
kbRows = append(kbRows, rowsGrid...)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalPages: page.Pages,
MiddleButtons: Row(Button(" Добавить", "add_creative")),
}); navRow != nil {
kbRows = append(kbRows, navRow)
}
kbRows = append(kbRows, Row(Button("← Назад", "back"), Button("≡ Архив", "archive")))
kb := Keyboard(kbRows...)
b.Render(text, kb, mode)
updateProjectHeaderMedia(b, b.LastMessageID, text, kb, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
}
func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case data == "archive":
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back"))))
case data == "add_creative":
b.SetState(&AddCreativeStart{Ctx: &AddCreativeCtx{
ProjectID: s.ProjectID,
BackState: s,
}}, bot.EditMessage)
case strings.HasPrefix(data, "creative:"):
creativeID, ok := strings.CutPrefix(data, "creative:")
if !ok || creativeID == "" {
return
}
// Открываем CreativeView вместо CreativeDetails
b.SetState(&CreativeView{
CreativeID: creativeID,
ProjectID: s.ProjectID,
// Данные проекта для передачи в AddPurchase
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
BackState: s,
}, bot.NewMessage)
default:
s.Enter(b, bot.NewMessage)
}
return
}
func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *Creatives) Exit() {}
+54
View File
@@ -0,0 +1,54 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const msgHelp = `
<b>❓ Помощь</b>
<b>Команды бота:</b>
/start — Главное меню
/projects — Мои проекты
/placements — Размещения
/platform — Веб-платформа
/help — Эта справка
<b>Как работать с ботом:</b>
1. Создайте проект и добавьте креативы
2. Выберите каналы для размещения
3. Оформите покупку через безопасную сделку
4. Получите автоматический отчёт
<b>Поддержка:</b>
Если возникли вопросы, напишите в поддержку.
`
type Help struct{}
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("↩ В главное меню", "main_menu")),
)
b.Render(msgHelp, keyboard, mode)
}
func (s *Help) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data {
case "main_menu":
b.SetState(&MainMenu{}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *Help) HandleMessage(b *bot.Bot, u *echotron.Update) {}
func (s *Help) Handle(b *bot.Bot, u *echotron.Update) {
b.SetState(&MainMenu{}, bot.NewMessage)
}
func (s *Help) Exit() {}
+41
View File
@@ -0,0 +1,41 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
)
var emptyKeyboard = Keyboard()
var refreshKeyboard = Keyboard(Row(Button("↻ Обновить", "refresh")))
func Button(text, data string) echotron.InlineKeyboardButton {
return echotron.InlineKeyboardButton{
Text: text,
CallbackData: data,
}
}
func Stylish(btn echotron.InlineKeyboardButton, style echotron.ButtonStyle) echotron.InlineKeyboardButton {
btn.Style = style
return btn
}
func URLButton(text, url string) echotron.InlineKeyboardButton {
return echotron.InlineKeyboardButton{
Text: text,
URL: url,
}
}
func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
if len(rows) == 0 {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
}
return echotron.InlineKeyboardMarkup{
InlineKeyboard: rows,
}
}
+59
View File
@@ -0,0 +1,59 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
)
type Login struct{}
const msgHelloLogin = `
Привет, %s!
Нажми кнопку для входа на сайт. 👇
`
func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID)
if err != nil {
b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard)
return
}
loginURL := b.Backend.LoginURL(token)
usernameDisplay := "аноним"
if b.Session.FirstName != "" {
usernameDisplay = b.Session.FirstName
}
kb := Keyboard(Row(URLButton("Войти на сайт", loginURL)))
res, err := b.SendMessage(fmt.Sprintf(msgHelloLogin, usernameDisplay), b.ChatID, &echotron.MessageOptions{
ReplyMarkup: kb,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
if err != nil {
log.Err(err).Msg("SendMessage error")
return
}
if res.Result != nil {
if err := b.Backend.AttachLoginTokenMessage(context.Background(), token, res.Result.ID); err != nil {
log.Err(err).Msg("AttachLoginTokenMessage error")
}
}
b.SetState(&MainMenu{}, bot.NewMessage)
}
func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {}
func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {}
func (s *Login) Handle(b *bot.Bot, u *echotron.Update) {}
func (s *Login) Exit() {}
+100
View File
@@ -0,0 +1,100 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
)
type MainMenu struct{}
const msgAbout = `
<i>Главное меню</i>
<blockquote expandable>
За что отвечает каждая кнопка в этом окне:
<b>• Рабочее пространство</b> — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.
<b>• Мои проекты</b> — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы.
<b>• Размещения</b> — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку).
<b>• Веб-платформа</b> — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов.
<b>• Помощь</b> — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы.
</blockquote>
Чтобы делать закупы, добавьте канал и креатив:
<b>/addchannel</b> — новый канал
`
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
msg := msgAbout
if b.Session.WasRestarted {
msg = "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\n" + msgAbout
b.Session.WasRestarted = false
}
loadWorkspace := func() string {
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
if err != nil {
log.Error().Err(err).Msg("Failed to get workspaces for main menu")
return "ошибка"
}
if len(workspaces) == 0 {
workspace, err := b.Backend.CreateWorkspace(context.Background(), b.Session.JWT, "Личное")
if err != nil {
log.Error().Err(err).Msg("Failed to create workspace")
}
workspaces = append(workspaces, workspace)
}
selected := workspaces[0] // дефолт — первый
if b.Session.WorkspaceID != "" {
for _, ws := range workspaces {
if ws.ID == b.Session.WorkspaceID {
selected = ws
break
}
}
}
b.Session.WorkspaceID = selected.ID
return selected.Name
}
workspaceName := loadWorkspace()
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
)
b.Render(msg, keyboard, mode)
}
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data {
case "my_projects":
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
case "placements":
b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPlacements: true}, bot.EditMessage)
case "workspace_menu":
b.SetState(&WorkspaceMenu{BackState: &MainMenu{}}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {}
func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
func (s *MainMenu) Exit() {}
+197
View File
@@ -0,0 +1,197 @@
package screens
import (
"context"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
const projectsPerPage = 6
const projectsPerRow = 2
type MyProjects struct {
CurrentPage int
BackState bot.State
OpenPlacements bool
cancel context.CancelFunc
lastProjectCount int
}
const msgNoProjects = `
<b>Мои проекты</b>
У вас пока нет подключенных каналов.
<i>Что дает подключение канала:</i>
▸ Управление типом приглашений (публичные/с одобрением)
▸ Автоматическая отправка креативов в канал
▸ Интеграция с планом закупов
▸ Статистика и аналитика подписчиков
▸ Настройка уведомлений
Начните с добавления первого проекта
`
const msgProjects = `
<b>Мои проекты</b>
Управление вашими Telegram-каналами.
Выберите канал
`
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderProjects(b, mode)
s.startPolling(b)
}
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode) {
page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
if err != nil {
b.SendNew("❌ Не удалось загрузить проекты", refreshKeyboard)
return
}
s.lastProjectCount = page.Total
var kbRows [][]echotron.InlineKeyboardButton
grid := ui.BuildGrid(page.Items, projectsPerRow, projectsPerPage, page.Pages,
func(project backend.Project) (string, string) { return project.Title, "project:" + project.ID },
)
kbRows = append(kbRows, grid...)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalPages: page.Pages,
MiddleButtons: Row(Button(" Добавить", "add_project")),
}); navRow != nil {
kbRows = append(kbRows, navRow)
}
kbRows = append(kbRows, Row(Button("← Назад", "main_menu"), Button("≡ Архив", "archive")))
keyboard := Keyboard(kbRows...)
text := msgProjects
if len(page.Items) == 0 {
text = msgNoProjects
}
b.Render(text, keyboard, mode)
}
func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "main_menu":
b.SetState(&MainMenu{}, bot.EditMessage)
case data == "archive":
b.Edit("Эта функция в разработке", Keyboard(Row(Button("← Назад", "in_dev"))))
case data == "add_project":
b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage)
case strings.HasPrefix(data, "project:"):
projectID, ok := strings.CutPrefix(data, "project:")
if !ok || projectID == "" {
return
}
project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, projectID)
if err != nil {
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
return
}
if s.OpenPlacements {
b.SetState(&Placements{
ProjectID: project.ID,
ProjectTitle: project.Title,
BackState: &MyProjects{OpenPlacements: true},
}, bot.EditMessage)
} else {
b.SetState(&ProjectDetails{
Project: project,
BackState: &MyProjects{},
}, bot.EditMessage)
}
default:
s.Enter(b, bot.EditMessage)
}
return
}
func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *MyProjects) Exit() {
if s.cancel != nil {
log.Info().Msg("MyProjects: cancelling polling goroutine")
s.cancel()
}
}
func (s *MyProjects) startPolling(b *bot.Bot) {
if s.cancel != nil {
s.cancel()
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
s.cancel = cancel
go s.pollProjects(ctx, b)
}
func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot) {
interval := 3 * time.Second
timeToSlow := time.Now().Add(30 * time.Second)
timer := time.NewTimer(interval)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
if time.Now().After(timeToSlow) {
interval = 10 * time.Second
}
page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
if err == nil && page.Total != s.lastProjectCount {
s.lastProjectCount = page.Total
s.renderProjects(b, bot.EditMessage)
}
timer.Reset(interval)
}
}
}
+322
View File
@@ -0,0 +1,322 @@
package screens
import (
"context"
"fmt"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
)
type PlacementDetails struct {
ProjectID string
PlacementID string
BackState bot.State
}
func (s *PlacementDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderDetails(b, mode)
}
func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
placement, err := b.Backend.GetPlacement(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, s.PlacementID)
if err != nil {
b.SendNew("❌ Не удалось загрузить размещение", Keyboard())
return
}
placementPost := placement.PlacementPost
text := "<b>Детали размещения</b>\n\n"
// Размещение в канале
text += "📺 <b>Размещение в канале:</b>\n"
text += formatChannelWithName(placement.Channel)
text += "\n"
// Рекламируемый проект
if placement.Project != nil {
text += "🎯 <b>Рекламируемый проект:</b>\n"
text += formatProject(*placement.Project)
text += "\n"
}
// Ссылка на пост
if placementPost != nil && placementPost.Post.URL != nil && *placementPost.Post.URL != "" {
text += fmt.Sprintf("🔗 <b>Ссылка на пост:</b> %s\n", *placementPost.Post.URL)
}
// Пригласительная ссылка
if placement.InviteLink != nil && *placement.InviteLink != "" {
text += fmt.Sprintf("📩 <b>Пригласительная ссылка:</b> %s\n", *placement.InviteLink)
}
// Тип ссылки
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
// Short ID
if placement.ShortID != "" {
text += fmt.Sprintf("🔢 <b>ID:</b> %s\n", placement.ShortID)
}
// Креатив
if placement.Details != nil && placement.Details.CreativeName != nil && *placement.Details.CreativeName != "" {
text += fmt.Sprintf("✨ <b>Креатив:</b> %s\n", *placement.Details.CreativeName)
} else if placement.CreativeName != nil && *placement.CreativeName != "" {
text += fmt.Sprintf("✨ <b>Креатив:</b> %s\n", *placement.CreativeName)
}
text += "\n"
// Финансовая информация
if placement.Details != nil {
// Стоимость
if placement.Details.Cost != nil {
costType := formatCostType(placement.Details.Cost.Type)
text += fmt.Sprintf("💰 <b>Стоимость:</b> %s %.0f₽\n", costType, placement.Details.Cost.Value)
}
// Стоимость до торга
if placement.Details.CostBeforeBargain != nil {
costType := formatCostType(placement.Details.CostBeforeBargain.Type)
text += fmt.Sprintf("💸 <b>До торга:</b> %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value)
}
// Дата размещения
if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
text += fmt.Sprintf("📅 <b>Дата размещения:</b> %s\n", formatDateTime(*placement.Details.PlacementAt))
}
// Дата оплаты
if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" {
text += fmt.Sprintf("💳 <b>Дата оплаты:</b> %s\n", formatDateTime(*placement.Details.PaymentAt))
}
// Формат
if placement.Details.Format != nil && *placement.Details.Format != "" {
text += fmt.Sprintf("📐 <b>Формат:</b> %s\n", *placement.Details.Format)
}
// Тип закупа
if placement.Details.PlacementType != nil {
text += fmt.Sprintf("🔄 <b>Тип закупа:</b> %s\n", formatPlacementType(*placement.Details.PlacementType))
}
}
text += "\n"
// Статистика
if placementPost != nil {
// Кол-во подписчиков
if placementPost.SubscriptionsCount > 0 {
text += fmt.Sprintf("👥 <b>Подписчики:</b> %d\n", placementPost.SubscriptionsCount)
}
// Просмотры поста
if placementPost.ViewsCount != nil {
text += fmt.Sprintf("👁️ <b>Просмотры:</b> %d\n", *placementPost.ViewsCount)
}
// CPF (стоимость подписчика)
if placementPost.SubscriptionsCount > 0 && placement.Details != nil && placement.Details.Cost != nil {
cpf := calculateCPF(placement.Details.Cost.Value, placementPost.SubscriptionsCount)
text += fmt.Sprintf("💵 <b>CPF:</b> %.2f₽\n", cpf)
}
// Конверсия
if placementPost.ViewsCount != nil && *placementPost.ViewsCount > 0 && placementPost.SubscriptionsCount > 0 {
conversion := calculateConversion(placementPost.SubscriptionsCount, *placementPost.ViewsCount)
text += fmt.Sprintf("📊 <b>Конверсия:</b> %.1f%%\n", conversion)
}
}
// Комментарий
if placement.Details != nil && placement.Details.Comment != nil && *placement.Details.Comment != "" {
text += fmt.Sprintf("\n📝 <b>Комментарий:</b> %s\n", *placement.Details.Comment)
}
keyboard := Keyboard(Row(Button("← Назад", "back")))
b.Render(text, keyboard, mode)
}
func formatChannelWithName(channel backend.Channel) string {
var name string
if channel.Title != nil && *channel.Title != "" {
name = *channel.Title
} else if channel.Username != nil && *channel.Username != "" {
name = "@" + *channel.Username
} else {
name = "Без названия"
}
// Добавляем ссылку если есть username или invite_link
if channel.Username != nil && *channel.Username != "" {
return fmt.Sprintf(" %s (@%s)\n", name, *channel.Username)
} else if channel.InviteLink != nil && *channel.InviteLink != "" {
return fmt.Sprintf(" %s\n", name)
}
return fmt.Sprintf(" %s\n", name)
}
func formatProject(project backend.ProjectOutput) string {
var name string
if project.Channel.Title != nil && *project.Channel.Title != "" {
name = *project.Channel.Title
} else if project.Channel.Username != nil && *project.Channel.Username != "" {
name = "@" + *project.Channel.Username
} else {
name = "Без названия"
}
// Добавляем ссылку если есть username
if project.Channel.Username != nil && *project.Channel.Username != "" {
return fmt.Sprintf(" %s (@%s)\n", name, *project.Channel.Username)
}
return fmt.Sprintf(" %s\n", name)
}
func calculateCPF(cost float64, subscriptions int) float64 {
if subscriptions == 0 {
return 0
}
return cost / float64(subscriptions)
}
func calculateConversion(subscriptions int, views int) float64 {
if views == 0 {
return 0
}
return float64(subscriptions) / float64(views) * 100
}
func formatInviteLinkType(value string) string {
if value == "approval" {
return "с одобрением"
}
return "публичная"
}
func formatPlacementStatus(status string) string {
normalized := strings.TrimSpace(strings.ToLower(status))
switch normalized {
case "planned":
return "Планируется"
case "approved":
return "Согласовано"
case "rejected":
return "Отклонено"
case "in_progress":
return "В работе"
case "completed":
return "Размещено"
default:
return status
}
}
func formatPlacementType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
switch normalized {
case "self_promo":
return "Самопиар"
case "standard":
return "Стандарт"
default:
return value
}
}
func formatCostType(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "cpm":
return "СРМ"
default:
return "Фикс"
}
}
func formatDateTime(value string) string {
if value == "" {
return value
}
var parsed time.Time
var err error
// Try parsing with timezone
if parsed, err = time.Parse(time.RFC3339, value); err == nil {
return formatCompactDateTime(parsed.In(ui.MskLocation))
}
if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil {
return formatCompactDateTime(parsed.In(ui.MskLocation))
}
if parsed, err = time.ParseInLocation("2006-01-02T15:04:05", value, ui.MskLocation); err == nil {
return formatCompactDateTime(parsed.In(ui.MskLocation))
}
if parsed, err = time.ParseInLocation("2006-01-02", value, ui.MskLocation); err == nil {
return formatCompactDateTime(parsed.In(ui.MskLocation))
}
return value
}
func WeekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func MonthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}
func formatCompactDateTime(value time.Time) string {
weekday := WeekdayName(value.Weekday())
month := MonthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
switch u.CallbackQuery.Data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PlacementDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PlacementDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PlacementDetails) Exit() {}
+246
View File
@@ -0,0 +1,246 @@
package screens
import (
"context"
"fmt"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
)
type Placements struct {
ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
BackState bot.State
}
const msgPlacements = `
<b>Размещения</b>
Управление размещениями для проекта.
`
func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, Row(Button("+ Создать размещение", "add_purchase")))
rows = append(rows, Row(Button("Список размещений", "placements_list"), URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID))))
rows = append(rows, Row(Button("← Назад", "back")))
keyboard := Keyboard(rows...)
b.Render(msgPlacements, keyboard, mode)
updateProjectHeaderMedia(b, b.LastMessageID, msgPlacements, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
}
func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "add_purchase":
b.SetState(&AddPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
ActivePicker: "",
BackState: s,
}, bot.EditMessage)
case "placements_list":
b.SetState(&PlacementsList{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
BackState: s,
}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *Placements) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Placements) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Placements) Exit() {}
// PlacementsList - экран со списком размещений
const placementsPerPage = 6
const placementsPerRow = 1
type PlacementsList struct {
ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
CurrentPage int
BackState bot.State
}
func (s *PlacementsList) Enter(b *bot.Bot, mode bot.RenderMode) {
s.render(b, mode)
}
func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
page, err := b.Backend.GetPlacements(
context.Background(),
b.Session.JWT,
b.Session.WorkspaceID,
s.ProjectID,
s.CurrentPage+1,
placementsPerPage,
)
if err != nil {
b.SendNew("❌ Не удалось загрузить размещения", Keyboard())
return
}
text := fmt.Sprintf("<b>Список размещений</b>%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages))
var rows [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += `Список размещений пуст.
<i>Размещение — это план публикации рекламы в канале:</i>
‣ Выбор креатива
‣ Канал размещения
‣ Стоимость и формат
‣ Статус выполнения
Создайте первое размещение`
} else {
// Формируем кнопки для каждого размещения
buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items))
for i, placement := range page.Items {
// Нумерация по порядку создания: первое размещение = #1 (на последней странице),
// последнее размещение = #N (вверху).
globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i
// Название канала
channelName := "Без названия"
if placement.Channel.Title != nil && *placement.Channel.Title != "" {
channelName = *placement.Channel.Title
} else if placement.Channel.Username != nil && *placement.Channel.Username != "" {
channelName = "@" + *placement.Channel.Username
}
// Дата размещения
dateStr := ""
if placement.Details != nil && placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
dateStr = formatDate(*placement.Details.PlacementAt)
}
// Текст кнопки: #N • Канал • DD.MM
buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
if dateStr != "" {
buttonText += fmt.Sprintf(" • %s", dateStr)
}
buttons = append(buttons, echotron.InlineKeyboardButton{
Text: buttonText,
CallbackData: fmt.Sprintf("placement:%s", placement.ID),
})
}
// Раскладываем по сетке
rows = ui.BuildPageRows(buttons, placementsPerRow, placementsPerPage, page.Pages)
}
// Навигация (без MiddleButtons - без "Создать размещение")
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalPages: page.Pages,
}); navRow != nil {
rows = append(rows, navRow)
}
// Кнопка назад
rows = append(rows, Row(Button("← Назад", "back")))
keyboard := Keyboard(rows...)
b.Render(text, keyboard, mode)
}
func (s *PlacementsList) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
default:
// Проверяем, не нажали ли на конкретное размещение
if len(data) > 10 && data[:10] == "placement:" {
placementID := data[10:]
b.SetState(&PlacementDetails{
ProjectID: s.ProjectID,
PlacementID: placementID,
BackState: s,
}, bot.EditMessage)
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *PlacementsList) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PlacementsList) Exit() {}
// formatDate парсит ISO дату и форматирует как "27.01"
func formatDate(isoDate string) string {
// Пробуем парсить RFC3339
t, err := time.Parse(time.RFC3339, isoDate)
if err != nil {
// Пробуем другие форматы
t, err = time.ParseInLocation("2006-01-02T15:04:05", isoDate, ui.MskLocation)
if err != nil {
// Иногда бэкенд может вернуть только дату без времени
t, err = time.ParseInLocation("2006-01-02", isoDate, ui.MskLocation)
if err != nil {
return ""
}
}
}
return t.In(ui.MskLocation).Format("02.01")
}
+44
View File
@@ -0,0 +1,44 @@
package screens
import (
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const msgPlatform = `
<b>🌐 Веб-платформа</b>
Управляйте проектами в браузере — удобно для работы с компьютера.
Полный функционал доступен на сайте.
`
type PlatformLink struct{}
func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
Row(Button("↩ В главное меню", "main_menu")),
)
b.Render(msgPlatform, keyboard, mode)
}
func (s *PlatformLink) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data {
case "main_menu":
b.SetState(&MainMenu{}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PlatformLink) HandleMessage(b *bot.Bot, u *echotron.Update) {}
func (s *PlatformLink) Handle(b *bot.Bot, u *echotron.Update) {
b.SetState(&MainMenu{}, bot.NewMessage)
}
func (s *PlatformLink) Exit() {}
+191
View File
@@ -0,0 +1,191 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
)
type ProjectDetails struct {
Project *backend.Project
BackState bot.State
}
func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
if s.Project == nil {
b.SendNew("❌ Проект не найден", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
text := s.formatProjectDetails()
keyboard := s.buildKeyboard()
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 {
p := s.Project
text := fmt.Sprintf("<b>%s</b>\n\n", p.Title)
if p.Username != nil && *p.Username != "" {
text += fmt.Sprintf("▸ Tg username: @%s\n", *p.Username)
} else {
text += "▸ Tg username: <i>не привязан</i>\n"
}
// Статус проекта
var statusSymbol, statusText string
switch p.Status {
case "active":
statusSymbol, statusText = "●", "Активный"
case "inactive":
statusSymbol, statusText = "○", "Неактивен"
case "archived":
statusSymbol, statusText = "■", "Архивный"
case "paused":
statusSymbol, statusText = "◐", "Приостановлен"
default:
statusSymbol, statusText = "○", p.Status
}
text += fmt.Sprintf("▸ Статус: %s %s\n", statusSymbol, statusText)
text += "\nУправление проектом"
return text
}
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
var buttons [][]echotron.InlineKeyboardButton
buttons = append(buttons, Row(
Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
Button("Размещения", fmt.Sprintf("placements:%s", s.Project.ID)),
))
// Второй ряд: Тип вступления по умолчанию для закупов
var currentType string
if s.Project.PurchaseInviteTypeDefault == "public" {
currentType = "открытая"
} else {
currentType = "с заявками"
}
buttons = append(buttons, Row(
Button(fmt.Sprintf("Ссылка: %s", currentType), fmt.Sprintf("link_type:%s", s.Project.ID)),
))
// Нижний ряд: Назад и Архивировать
buttons = append(buttons, Row(
Button("← Назад", "back"),
Button("≡ Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)),
))
return Keyboard(buttons...)
}
func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "back":
if s.BackState != nil {
s.transitionWithNewMessage(b, s.BackState)
}
case strings.HasPrefix(data, "creatives:"):
b.SetState(&Creatives{
ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
ProjectTelegramID: s.Project.TelegramID,
ProjectUsername: usernameFromProject(s.Project),
ProjectStatus: statusFromProject(s.Project),
BackState: s,
}, bot.EditMessage)
case strings.HasPrefix(data, "placements:"):
b.SetState(&Placements{
ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
ProjectTelegramID: s.Project.TelegramID,
ProjectUsername: usernameFromProject(s.Project),
ProjectStatus: statusFromProject(s.Project),
BackState: s,
}, bot.EditMessage)
case data == "back_to_project":
s.Enter(b, bot.EditMessage)
case strings.HasPrefix(data, "link_type:"):
var newType string
if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "approval"
} else {
newType = "public"
}
updatedProject, err := b.Backend.UpdateProjectInviteLinkType(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.Project.ID, newType)
if err != nil {
b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), Keyboard(
Row(Button("← Назад", "back_to_project")),
))
return
}
s.Project = updatedProject
s.Enter(b, bot.EditMessage)
default:
// Для остальных кнопок показываем заглушку
b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), Keyboard(
Row(Button("← Назад", "back_to_project")),
))
}
return
}
func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
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
View 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)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type PurchaseChannelInput struct {
ChannelID string
Username string
Title string
InviteLink string
PlannedCost *float64
Comment *string
}
type SelectChannelsForPurchase struct {
ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
ProjectDefaultLinkType string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
CurrentPage int
InvalidUsernames []string
Duplicates []string // дубликаты каналов
ParsingErrors []ParseError // ошибки парсинга с предложениями
OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей
BackState bot.State
}
// ParseError представляет ошибку парсинга с предложением исправления
type ParseError struct {
Input string
Suggestion string
}
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderChannelSelection(b, mode)
}
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Создание закупа</b>\n\n"
text += "<b>Шаг 2/2:</b> Добавление каналов\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы
Отправьте username (без @) или invite link приватного канала
Например: <code>channel_name</code> или <code>https://t.me/+abcdef</code>
После добавления всех каналов нажмите <b>Далее</b>`
} else {
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
for i, ch := range s.Channels {
channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch))
if ch.PlannedCost != nil {
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
}
text += channelText + "\n"
}
text += "\n"
const channelsPerPage = 6
if s.CurrentPage*channelsPerPage >= len(s.Channels) {
s.CurrentPage = 0
}
start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels))
var slots []echotron.InlineKeyboardButton
for i := start; i < end; i++ {
ch := s.Channels[i]
buttonText := channelLabel(ch)
if ch.PlannedCost != nil {
buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *ch.PlannedCost)
}
slots = append(slots, Button("✖ "+buttonText, fmt.Sprintf("remove_channel:%d", i)))
}
if len(s.Channels) > channelsPerPage {
for len(slots) < channelsPerPage {
slots = append(slots, Button(" ", "empty"))
}
}
for i := 0; i < len(slots); i += 2 {
row := []echotron.InlineKeyboardButton{slots[i]}
if i+1 < len(slots) {
row = append(row, slots[i+1])
} else {
row = append(row, Button(" ", "empty"))
}
buttons = append(buttons, row)
}
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalPages: pages,
})
if navRow != nil {
buttons = append(buttons, navRow)
}
text += "\nДобавьте еще каналы или создайте закуп"
// Кнопка создания закупа (доступна только если есть каналы)
}
if len(s.InvalidUsernames) > 0 {
text += fmt.Sprintf("\n\n⚠️ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
s.InvalidUsernames = nil
}
// Показываем дубликаты
if len(s.Duplicates) > 0 {
text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", "))
s.Duplicates = nil
}
// Показываем ошибки с предложениями
if len(s.ParsingErrors) > 0 {
text += "\n\n❌ Ошибки форматирования:\n"
for i, err := range s.ParsingErrors {
if i < 3 { // Показываем максимум 3 ошибки
if err.Suggestion != "" {
text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion)
} else {
text += fmt.Sprintf("• %s\n", err.Input)
}
}
}
if len(s.ParsingErrors) > 3 {
text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3)
}
s.ParsingErrors = nil
}
// Кнопки навигации
if len(s.Channels) > 0 {
buttons = append(buttons, Row(
Button("← Назад", "back"),
Button("→ Далее", "next_step"),
))
} else {
buttons = append(buttons, Row(
Button("← Назад", "back"),
))
}
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
// Возвращаемся к выбору креатива
b.SetState(&AddPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
ActivePicker: "",
BackState: s.BackState,
}, bot.EditMessage)
case "next_step":
if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
// Проверяем, что все каналы резолвлены (имеют ChannelID)
var unresolved []string
for _, ch := range s.Channels {
if ch.ChannelID == "" {
label := channelLabel(ch)
unresolved = append(unresolved, label)
}
}
if len(unresolved) > 0 {
text := "❌ Некоторые каналы не были найдены:\n\n"
for _, label := range unresolved {
text += fmt.Sprintf("• %s\n", label)
}
text += "\nУдалите их из списка и попробуйте снова"
b.Edit(text, Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
// Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
// Если есть сохранённое состояние - используем его, иначе создаём новое
var optionalDetails *PurchaseOptionalDetails
if s.OptionalDetailsState != nil {
optionalDetails = s.OptionalDetailsState
// Обновляем каналы на случай если они изменились
optionalDetails.Channels = s.Channels
} else {
optionalDetails = &PurchaseOptionalDetails{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
BackState: s,
}
}
b.SetState(optionalDetails, bot.EditMessage)
case "back_to_select":
s.Enter(b, bot.EditMessage)
case "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
default:
if len(data) > 15 && data[:15] == "remove_channel:" {
var index int
if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil {
if index >= 0 && index < len(s.Channels) {
s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
if s.CurrentPage > 0 {
channelsPerPage := 6
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
if s.CurrentPage >= pages {
s.CurrentPage = pages - 1
}
}
s.Enter(b, bot.EditMessage)
return
}
}
}
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
// Преобразуем каналы в формат API
var apiChannels []backend.CreatePlacementChannelInput
for _, ch := range s.Channels {
var details *backend.PlacementDetails
if ch.PlannedCost != nil {
cost := backend.CostInfo{
Type: "fixed",
Value: *ch.PlannedCost,
}
details = &backend.PlacementDetails{
Cost: &cost,
}
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
ChannelID: ch.ChannelID,
Comment: ch.Comment,
Details: details,
})
}
input := backend.CreatePlacementsInput{
CreativeID: &s.CreativeID,
Channels: apiChannels,
}
placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
if err != nil {
log.Error().Err(err).Msg("Failed to create placements")
b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
if len(placements.Placements) == 0 {
b.Edit("❌ Размещения не созданы", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
b.SetState(&PlacementDetails{
ProjectID: s.ProjectID,
PlacementID: placements.Placements[0].ID,
BackState: s.BackState,
}, bot.EditMessage)
}
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
raw := strings.TrimSpace(u.Message.Text)
if raw == "" {
return
}
// Проверяем JWT
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
// Разбиваем ввод на токены
tokens := strings.FieldsFunc(raw, func(r rune) bool {
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
})
added := 0
s.Duplicates = nil
s.InvalidUsernames = nil
s.ParsingErrors = nil
tokensLoop:
for _, token := range tokens {
entry := strings.TrimSpace(token)
entry = strings.Trim(entry, ",;")
if entry == "" {
continue
}
// Используем новый парсер
parsed := ParseChannelInput(entry)
if !parsed.Valid {
// Пробуем предложить исправление
if suggestion, ok := SuggestFix(entry); ok {
s.ParsingErrors = append(s.ParsingErrors, ParseError{
Input: entry,
Suggestion: suggestion,
})
} else {
s.InvalidUsernames = append(s.InvalidUsernames, entry)
}
continue
}
// Проверяем дубликаты по username/invite link
if IsDuplicate(parsed, s.Channels) {
label := FormatChannelLabel(parsed)
s.Duplicates = append(s.Duplicates, label)
continue
}
// Сразу резолвим канал через бэкенд
channel, err := s.resolveSingleChannel(b, jwt, parsed)
if err != nil {
// Канал не найден или ошибка
s.InvalidUsernames = append(s.InvalidUsernames, entry)
log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel")
continue
}
// Проверяем дубликаты по ID канала (после резолва)
for _, ch := range s.Channels {
if ch.ChannelID != "" && ch.ChannelID == channel.ID {
var title, username string
if channel.Title != nil {
title = *channel.Title
}
if channel.Username != nil {
username = *channel.Username
}
label := channelLabelByID(&channel.ID, &title, &username)
s.Duplicates = append(s.Duplicates, label)
continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего!
}
// Дополнительная проверка по username (case-insensitive)
// На случай, если канал еще не резолвлен или ID отличается
if channel.Username != nil && ch.Username != "" {
if strings.EqualFold(ch.Username, *channel.Username) {
var title string
if channel.Title != nil {
title = *channel.Title
}
label := channelLabelByID(&channel.ID, &title, channel.Username)
s.Duplicates = append(s.Duplicates, label)
continue tokensLoop
}
}
}
// Добавляем канал с полными данными
var title string
if channel.Title != nil {
title = *channel.Title
}
s.Channels = append(s.Channels, PurchaseChannelInput{
ChannelID: channel.ID,
Username: parsed.Username,
Title: title,
InviteLink: parsed.InviteLink,
})
added++
}
// Если ничего не добавлено и нет ошибок - не обновляем экран
if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
return
}
// Обновляем экран
s.Enter(b, bot.NewMessage)
}
// resolveSingleChannel резолвит один канал через бэкенд
func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) {
var input backend.CreateChannelInput
if parsed.InviteLink != "" {
link := parsed.InviteLink
input = backend.CreateChannelInput{InviteLink: &link}
} else {
username := parsed.Username
input = backend.CreateChannelInput{Username: &username}
}
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
Channels: []backend.CreateChannelInput{input},
})
if err != nil {
return nil, err
}
if len(resp.Results) == 0 {
return nil, fmt.Errorf("no response from backend")
}
result := resp.Results[0]
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
errMsg := "channel not found"
if result.Error != nil {
errMsg = *result.Error
}
return nil, fmt.Errorf(errMsg)
}
// Если пришел username из ответа, обновляем его
if result.Channel.Username != nil && *result.Channel.Username != "" {
parsed.Username = *result.Channel.Username
}
return result.Channel, nil
}
// channelLabelByID формирует метку канала по ID
func channelLabelByID(id, title, username *string) string {
if title != nil && *title != "" {
return *title
}
if username != nil && *username != "" {
return "@" + *username
}
if id != nil && *id != "" {
return *id
}
return "канал"
}
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {
if ch.InviteLink != "" {
link := ch.InviteLink
inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
} else {
username := ch.Username
inputs = append(inputs, backend.CreateChannelInput{Username: &username})
}
}
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
Channels: inputs,
})
if err != nil {
log.Error().Err(err).Msg("Failed to create channels")
return []string{"ошибка сервера"}
}
var failed []string
for _, result := range resp.Results {
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
if result.Index >= 0 && result.Index < len(s.Channels) {
failed = append(failed, channelLabel(s.Channels[result.Index]))
}
continue
}
if result.Index < 0 || result.Index >= len(s.Channels) {
continue
}
ch := &s.Channels[result.Index]
ch.ChannelID = result.Channel.ID
if result.Channel.Username != nil {
ch.Username = *result.Channel.Username
}
if result.Channel.Title != nil {
ch.Title = *result.Channel.Title
}
}
return failed
}
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
// Обрабатываем callback после успешного создания
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
// Возвращаемся к списку закупов
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
}
func (s *SelectChannelsForPurchase) Exit() {}
func channelLabel(ch PurchaseChannelInput) string {
if ch.Username != "" {
return "@" + ch.Username
}
if ch.Title != "" {
return ch.Title
}
if ch.InviteLink != "" {
return formatInviteLabel(ch.InviteLink)
}
if ch.ChannelID != "" {
return ch.ChannelID
}
return "канал"
}
func channelKey(ch PurchaseChannelInput) string {
if ch.ChannelID != "" {
return ch.ChannelID
}
if ch.Username != "" {
return ch.Username
}
if ch.InviteLink != "" {
return ch.InviteLink
}
return ""
}
func channelLabelByKey(channels []PurchaseChannelInput, key string) string {
for _, ch := range channels {
if channelKey(ch) == key {
return channelLabel(ch)
}
}
return key
}
func isInviteLink(value string) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") {
return true
}
if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") {
return true
}
return false
}
func formatInviteLabel(inviteLink string) string {
link := strings.TrimSpace(inviteLink)
if link == "" {
return "приватный канал"
}
code := link
if strings.HasPrefix(code, "tg://") || strings.HasPrefix(code, "tg:") {
if idx := strings.Index(code, "invite="); idx != -1 {
code = code[idx+len("invite="):]
if end := strings.IndexAny(code, "&?#"); end != -1 {
code = code[:end]
}
}
} else {
code = strings.TrimPrefix(code, "https://")
code = strings.TrimPrefix(code, "http://")
code = strings.TrimPrefix(code, "t.me/")
code = strings.TrimPrefix(code, "telegram.me/")
code = strings.TrimPrefix(code, "joinchat/")
code = strings.TrimPrefix(code, "+")
if idx := strings.LastIndex(code, "/"); idx != -1 {
code = code[idx+1:]
}
if end := strings.IndexAny(code, "?#"); end != -1 {
code = code[:end]
}
}
code = strings.Trim(code, "/+ ")
if code == "" {
return "приватный канал"
}
if len(code) > 10 {
code = code[:6] + "..." + code[len(code)-2:]
}
return "приватный: " + code
}
+151
View File
@@ -0,0 +1,151 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type SelectWorkspace struct {
ChannelID string
BackState bot.State
CurrentPage int
}
const msgChooseWorkspace = `
📁 Выбор workspace для канала
В какой workspace добавить канал?
`
const msgSuccessChooseWorkspace = `
Канал успешно добавлен!
📊 Проект ID: %s
📝 Название: %s
🏷 Статус: %s
`
func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) {
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
if err != nil {
b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard())
return
}
if len(workspaces) == 0 {
kb := Keyboard(Row(Button("Главное меню", "back")))
b.SendNew("У вас пока нет workspace'ов", kb)
return
}
// Создаем кнопки для всех workspace'ов
var allButtons []echotron.InlineKeyboardButton
for _, ws := range workspaces {
allButtons = append(allButtons, Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)))
}
// Используем компонент пагинации для автоматической раскладки
const workspacesPerPage = 6
var buttons [][]echotron.InlineKeyboardButton
// Раскладываем workspace'ы в grid (2 в ряд) с пагинацией
elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
CurrentPage: s.CurrentPage,
ItemsPerPage: workspacesPerPage,
ItemsPerRow: 2,
}, allButtons)
buttons = append(buttons, elementRows...)
// Добавляем навигацию (стрелки появятся только если страниц > 1)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalItems: len(allButtons),
ItemsPerPage: workspacesPerPage,
}); navRow != nil {
buttons = append(buttons, navRow)
}
// Кнопка отмены
buttons = append(buttons, Row(Button("Отмена", "cancel")))
kb := Keyboard(buttons...)
b.Render(msgChooseWorkspace, kb, mode)
}
func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "cancel", data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
case strings.HasPrefix(data, "select_workspace:"):
parts := strings.Split(data, ":")
if len(parts) != 2 {
log.Error().Str("callback_data", data).Msg("Invalid callback data format")
return
}
workspaceID := parts[1]
project, err := b.Backend.AttachChannelToWorkspace(
context.Background(),
s.ChannelID,
workspaceID,
b.ChatID,
)
if err != nil {
b.Edit("❌ Не удалось добавить канал в workspace", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
successText := fmt.Sprintf(msgSuccessChooseWorkspace, project.ID, project.Title, project.Status)
kb := Keyboard(
Row(Button("Мои проекты", "my_projects")),
Row(Button("Главное меню", "main_menu")),
)
b.Edit(successText, kb)
case data == "back_to_select":
s.Enter(b, bot.EditMessage)
case data == "my_projects":
b.SetState(&MyProjects{}, bot.NewMessage)
case data == "main_menu":
b.SetState(&MainMenu{}, bot.NewMessage)
default:
s.Enter(b, bot.NewMessage)
}
return
}
func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectWorkspace) Exit() {}
+21
View File
@@ -0,0 +1,21 @@
package ui
import "github.com/NicoNex/echotron/v3"
// BuildGrid builds button rows from items using a callback builder.
func BuildGrid[T any](items []T, itemsPerRow, itemsPerPage, totalPages int, itemFn func(T) (title string, callback string)) [][]echotron.InlineKeyboardButton {
if len(items) == 0 {
return nil
}
buttons := make([]echotron.InlineKeyboardButton, 0, len(items))
for _, item := range items {
title, callback := itemFn(item)
buttons = append(buttons, echotron.InlineKeyboardButton{
Text: title,
CallbackData: callback,
})
}
return BuildPageRows(buttons, itemsPerRow, itemsPerPage, totalPages)
}
+720
View File
@@ -0,0 +1,720 @@
package ui
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/olebedev/when"
"github.com/olebedev/when/rules/common"
"github.com/olebedev/when/rules/ru"
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeCombined = "time_combined"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
)
var MskLocation = time.FixedZone("MSK", 3*60*60)
type DateTimePickerConfig struct {
Title string
Key string
IncludeTime bool
AllowPast bool
Selected *time.Time
BackState bot.State
}
type DateTimeSelectionTarget interface {
SetDateTimeSelection(key string, value time.Time)
}
type DateTimePicker struct {
Title string
Key string
IncludeTime bool
AllowPast bool
BackState bot.State
view string
year int
month time.Month
selectedDate *time.Time
selectedHour *int
selectedMinute *int
}
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
now := time.Now().In(MskLocation)
year := now.Year()
month := now.Month()
var selected *time.Time
if cfg.Selected != nil {
value := cfg.Selected.In(MskLocation)
selected = &value
year = value.Year()
month = value.Month()
}
return &DateTimePicker{
Title: cfg.Title,
Key: cfg.Key,
IncludeTime: cfg.IncludeTime,
AllowPast: cfg.AllowPast,
BackState: cfg.BackState,
view: pickerViewCalendar,
year: year,
month: month,
selectedDate: selected,
}
}
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
text := fmt.Sprintf("<b>%s</b>\n\n", s.Title)
text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime())
switch s.view {
case pickerViewCalendar:
text += s.renderCalendarTitle()
b.Render(text, s.calendarKeyboard(), mode)
case pickerViewMonths:
text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeCombined:
b.Render(text, s.timeCombinedKeyboard(), mode)
case pickerViewConfirm:
text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode)
}
}
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
if data == "back" {
s.handleBack(b)
return
}
if data == "cancel" {
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
return
}
if !strings.HasPrefix(data, dtpCallbackPrefix) {
s.Enter(b, bot.EditMessage)
return
}
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
switch {
case payload == "month_prev":
s.prevMonth()
case payload == "month_next":
s.nextMonth()
case payload == "open_months":
s.view = pickerViewMonths
case payload == "year_prev":
s.year--
case payload == "year_next":
s.year++
case strings.HasPrefix(payload, "month:"):
month := parseInt(strings.TrimPrefix(payload, "month:"))
if month >= 1 && month <= 12 {
s.month = time.Month(month)
s.view = pickerViewCalendar
}
case strings.HasPrefix(payload, "day:"):
s.handleDaySelect(payload, b)
return // confirmSelection уже делает Enter
case strings.HasPrefix(payload, "hour:"):
s.handleHourSelect(payload)
case strings.HasPrefix(payload, "min:"):
s.handleMinuteSelect(payload)
case payload == "confirm":
s.confirmSelection(b)
return
}
// Если не было подтверждения (для дат без времени), перерисовываем
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view {
case pickerViewMonths:
s.view = pickerViewCalendar
case pickerViewTimeCombined:
s.view = pickerViewCalendar
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewCalendar
}
default:
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) renderCalendarTitle() string {
if s.year == time.Now().In(MskLocation).Year() {
return "\n"
}
return fmt.Sprintf("%d\n\n", s.year)
}
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, []echotron.InlineKeyboardButton{
s.monthPrevButton(),
{Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"},
{Text: "→", CallbackData: dtpCallbackPrefix + "month_next"},
})
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "Пн", CallbackData: "empty"},
{Text: "Вт", CallbackData: "empty"},
{Text: "Ср", CallbackData: "empty"},
{Text: "Чт", CallbackData: "empty"},
{Text: "Пт", CallbackData: "empty"},
{Text: "Сб", CallbackData: "empty"},
{Text: "Вс", CallbackData: "empty"},
})
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation)
weekday := int(firstOfMonth.Weekday())
if weekday == 0 {
weekday = 7
}
daysInMonth := daysInMonth(s.year, s.month)
today := time.Now().In(MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
var row []echotron.InlineKeyboardButton
for i := 1; i < weekday; i++ {
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
}
for day := 1; day <= daysInMonth; day++ {
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation)
if !s.AllowPast && date.Before(todayDate) {
row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
} else {
label := fmt.Sprintf("%d", day)
row = append(row, echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day),
})
}
if len(row) == 7 {
rows = append(rows, row)
row = nil
}
}
if len(row) > 0 {
for len(row) < 7 {
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
}
rows = append(rows, row)
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
{Text: "Отмена", CallbackData: "cancel"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 {
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)},
{Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)},
{Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)},
})
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}})
for i := 0; i < 24; i += 6 {
rows = append(rows, []echotron.InlineKeyboardButton{
s.hourButton(i),
s.hourButton(i + 1),
s.hourButton(i + 2),
s.hourButton(i + 3),
s.hourButton(i + 4),
s.hourButton(i + 5),
})
}
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}})
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 6 {
rows = append(rows, []echotron.InlineKeyboardButton{
s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]),
s.minuteButton(minutes[i+4]),
s.minuteButton(minutes[i+5]),
})
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{
{
{Text: "← Назад", CallbackData: "back"},
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
},
},
}
}
func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil {
return "—"
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
}
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
hour := "__"
minute := "__"
if s.selectedHour != nil {
hour = fmt.Sprintf("%02d", *s.selectedHour)
} else if !showPlaceholders {
hour = "00"
}
if s.selectedMinute != nil {
minute = fmt.Sprintf("%02d", *s.selectedMinute)
} else if !showPlaceholders {
minute = "00"
}
return fmt.Sprintf("%s:%s", hour, minute)
}
func (s *DateTimePicker) formatSelectedDateTime() string {
displayDate, ok := s.displayDate()
if !ok {
return "—"
}
timeLabel := "--:--"
if s.selectedHour != nil && s.selectedMinute != nil {
timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute)
}
weekday := WeekdayName(displayDate.Weekday())
month := MonthShort(displayDate.Month())
return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year())
}
func (s *DateTimePicker) displayDate() (time.Time, bool) {
if s.selectedDate == nil {
if s.view == pickerViewCalendar || s.view == pickerViewMonths {
return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true
}
return time.Time{}, false
}
if (s.view == pickerViewCalendar || s.view == pickerViewMonths) &&
(s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) {
day := s.selectedDate.Day()
maxDay := daysInMonth(s.year, s.month)
if day > maxDay {
day = maxDay
}
return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true
}
return s.selectedDate.In(MskLocation), true
}
func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) {
datePart := strings.TrimPrefix(payload, "day:")
parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation)
if err != nil {
return
}
s.selectedDate = &parsed
if s.IncludeTime {
s.view = pickerViewTimeCombined
s.selectedHour = nil
s.selectedMinute = nil
s.Enter(b, bot.EditMessage)
} else {
// Сразу подтверждаем выбор для даты без времени
s.confirmSelection(b)
}
}
func (s *DateTimePicker) handleHourSelect(payload string) {
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
if hour < 0 || hour > 23 {
return
}
if s.isHourDisabled(hour) {
return
}
s.selectedHour = &hour
}
func (s *DateTimePicker) handleMinuteSelect(payload string) {
minute := parseInt(strings.TrimPrefix(payload, "min:"))
if minute < 0 || minute > 59 {
return
}
if s.isMinuteDisabled(minute) {
return
}
s.selectedMinute = &minute
}
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
if s.selectedDate == nil {
s.Enter(b, bot.EditMessage)
return
}
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
s.view = pickerViewTimeCombined
s.Enter(b, bot.EditMessage)
return
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
target.SetDateTimeSelection(s.Key, value)
}
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", hour)
if s.isHourDisabled(hour) {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
if s.selectedHour != nil && *s.selectedHour == hour {
label = "● " + label
}
return echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour),
}
}
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", minute)
if s.isMinuteDisabled(minute) {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
if s.selectedMinute != nil && *s.selectedMinute == minute {
label = "● " + label
}
return echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute),
}
}
func (s *DateTimePicker) isHourDisabled(hour int) bool {
if s.AllowPast {
return false
}
if s.selectedDate == nil {
return false
}
today := time.Now().In(MskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
if date.After(todayDate) {
return false
}
return hour < today.Hour()
}
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
if s.AllowPast {
return false
}
if s.selectedDate == nil || s.selectedHour == nil {
return false
}
today := time.Now().In(MskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
if date.After(todayDate) {
return false
}
if *s.selectedHour > today.Hour() {
return false
}
return minute < today.Minute()
}
func (s *DateTimePicker) prevMonth() {
if s.AllowPast {
if s.month == time.January {
s.month = time.December
s.year--
} else {
s.month--
}
return
}
now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() {
return
}
if s.month == time.January {
s.month = time.December
s.year--
} else {
s.month--
}
}
func (s *DateTimePicker) nextMonth() {
if s.month == time.December {
s.month = time.January
s.year++
} else {
s.month++
}
}
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
if s.AllowPast {
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
}
now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day()
}
func monthName(month time.Month) string {
names := []string{
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
}
if int(month) < 1 || int(month) > len(names) {
return ""
}
return names[int(month)-1]
}
func parseInt(value string) int {
result := 0
for _, r := range value {
if r < '0' || r > '9' {
return 0
}
result = result*10 + int(r-'0')
}
return result
}
func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
return
}
input := strings.TrimSpace(u.Message.Text)
switch s.view {
case pickerViewCalendar:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
if !hasDate {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
if hasTime {
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
}
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewConfirm
}
s.Enter(b, bot.NewMessage)
case pickerViewTimeCombined:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
if !hasTime {
s.Enter(b, bot.NewMessage)
return
}
if hasDate {
s.selectedDate = &value
}
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.Enter(b, bot.NewMessage)
default:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
if !hasDate && !hasTime {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.view = pickerViewConfirm
s.Enter(b, bot.NewMessage)
}
}
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Exit() {}
var whenParser *when.Parser
func init() {
whenParser = when.New(nil)
whenParser.Add(ru.All...)
whenParser.Add(common.All...)
}
var (
timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`)
dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`)
)
func detectTime(matched string) bool {
return timeIndicator.MatchString(matched)
}
func detectDate(matched string) bool {
return dateIndicator.MatchString(matched)
}
func parseDateTimeInput(
input string,
_ *time.Time,
_ *int,
_ *int,
allowPast bool,
) (time.Time, bool, bool) {
now := time.Now().In(MskLocation)
r, err := whenParser.Parse(input, now)
if err != nil || r == nil {
return time.Time{}, false, false
}
result := r.Time.In(MskLocation)
matched := strings.ToLower(r.Text)
hasDate := detectDate(matched)
hasTime := detectTime(matched)
if !allowPast && result.Before(now) {
return time.Time{}, false, false
}
return result, hasDate, hasTime
}
func WeekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func formatCompactDateTime(value time.Time) string {
weekday := WeekdayName(value.Weekday())
month := MonthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func MonthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}
+305
View File
@@ -0,0 +1,305 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/NicoNex/echotron/v3"
)
type htmlTag struct {
open string
close string
}
var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
echotron.BoldEntity: {open: "<b>", close: "</b>"},
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
echotron.CodeEntity: {open: "<code>", close: "</code>"},
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
}
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
func FormatMessageHTML(message *echotron.Message) string {
if message == nil {
return ""
}
// Telegram sends formatting as entities, so we rebuild the HTML from offsets.
text := message.Text
entities := message.Entities
if text == "" {
text = message.Caption
entities = message.CaptionEntities
}
if text == "" {
return ""
}
if len(entities) == 0 {
return EscapeHTML(text)
}
type tagSpan struct {
open string
close string
start int
end int
len int
}
runes := []rune(text)
positions := make([]int, len(runes)+1)
utf16Count := 0
for i, r := range runes {
positions[i] = utf16Count
if r > 0xFFFF {
utf16Count += 2
} else {
utf16Count++
}
}
positions[len(runes)] = utf16Count
utf16ToRuneIndex := func(utf16Index int) (int, bool) {
i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
if i < len(positions) && positions[i] == utf16Index {
return i, true
}
return 0, false
}
toRuneRange := func(offset, length int) (int, int, bool) {
start, ok := utf16ToRuneIndex(offset)
if !ok {
return 0, 0, false
}
end, ok := utf16ToRuneIndex(offset + length)
if !ok || end > len(runes) || end < start {
return 0, 0, false
}
return start, end, true
}
// URL ranges are used to prevent nested tags inside links.
urlRanges := make([][2]int, 0, len(entities))
for _, entity := range entities {
if entity == nil {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
urlRanges = append(urlRanges, [2]int{start, end})
if entity.Type == echotron.UrlEntity {
_ = string(runes[start:end])
}
}
isInsideURL := func(offset, length int) bool {
for _, urlRange := range urlRanges {
if offset > urlRange[0] && offset+length <= urlRange[1] {
return true
}
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
return true
}
}
return false
}
var spans []tagSpan
for _, entity := range entities {
if entity == nil {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
continue
}
var openTag, closeTag string
if tag, ok := simpleHTMLTags[entity.Type]; ok {
openTag, closeTag = tag.open, tag.close
} else if entity.Type == echotron.UrlEntity {
entityText := string(runes[start:end])
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
} else if entity.Type == echotron.TextLinkEntity {
if entity.URL != "" {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
if openTag != "" && closeTag != "" {
spans = append(spans, tagSpan{
open: openTag,
close: closeTag,
start: start,
end: end,
len: end - start,
})
}
}
opens := make(map[int][]tagSpan)
closes := make(map[int][]tagSpan)
for _, span := range spans {
opens[span.start] = append(opens[span.start], span)
closes[span.end] = append(closes[span.end], span)
}
// Build the final text with tags inserted and HTML escaped.
var result strings.Builder
for i := 0; i <= len(runes); i++ {
if closing, ok := closes[i]; ok {
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
for _, span := range closing {
result.WriteString(span.close)
}
}
if opening, ok := opens[i]; ok {
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
for _, span := range opening {
result.WriteString(span.open)
}
}
if i < len(runes) {
switch runes[i] {
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return normalizeHTMLTags(result.String())
}
func normalizeHTMLTags(input string) string {
if input == "" {
return input
}
allowed := map[string]bool{
"a": true,
"b": true,
"i": true,
"u": true,
"s": true,
"code": true,
"pre": true,
}
var out strings.Builder
out.Grow(len(input))
stack := make([]string, 0, 8)
for i := 0; i < len(input); {
if input[i] != '<' {
out.WriteByte(input[i])
i++
continue
}
end := strings.IndexByte(input[i:], '>')
if end == -1 {
out.WriteByte(input[i])
i++
continue
}
end += i
tag := input[i+1 : end]
if tag == "" {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
isClosing := tag[0] == '/'
tagName := tag
if isClosing {
tagName = tag[1:]
}
if space := strings.IndexByte(tagName, ' '); space != -1 {
tagName = tagName[:space]
}
tagName = strings.TrimSpace(tagName)
if !allowed[tagName] {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
if isClosing {
// Find the tag in stack
foundIndex := -1
for j := len(stack) - 1; j >= 0; j-- {
if stack[j] == tagName {
foundIndex = j
break
}
}
if foundIndex >= 0 {
// Close all tags from top of stack down to foundIndex
for j := len(stack) - 1; j > foundIndex; j-- {
out.WriteString("</")
out.WriteString(stack[j])
out.WriteString(">")
}
// Close the found tag
out.WriteString(input[i : end+1])
// Remove closed tags from stack
stack = stack[:foundIndex]
}
} else {
stack = append(stack, tagName)
out.WriteString(input[i : end+1])
}
i = end + 1
}
for i := len(stack) - 1; i >= 0; i-- {
out.WriteString("</")
out.WriteString(stack[i])
out.WriteString(">")
}
return out.String()
}
// EscapeHTML escapes HTML special characters for safe output.
func EscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
func SanitizeHTML(input string) string {
return normalizeHTMLTags(input)
}
+219
View File
@@ -0,0 +1,219 @@
package ui
import (
"fmt"
"github.com/NicoNex/echotron/v3"
)
// PaginationConfig конфигурация для построения навигационного ряда пагинации
type PaginationConfig struct {
CurrentPage int
TotalPages int // Для API пагинации (из page.Pages)
TotalItems int // Для локальной пагинации (len(array))
ItemsPerPage int // Для расчета TotalPages из TotalItems
PrevCallback string // По умолчанию "prev"
NextCallback string // По умолчанию "next"
MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, "+ Добавить")
}
// BuildNavigationRow создает навигационный ряд с динамической шириной.
// Показывает ряд только если страниц > 1.
// Возвращает nil если пагинация не нужна.
func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
// Вычисляем TotalPages из TotalItems если не задан
totalPages := config.TotalPages
if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
}
// Устанавливаем значения по умолчанию для callbacks
prevCallback := config.PrevCallback
if prevCallback == "" {
prevCallback = "prev"
}
nextCallback := config.NextCallback
if nextCallback == "" {
nextCallback = "next"
}
var row []echotron.InlineKeyboardButton
// Левая стрелка (только если есть предыдущая страница)
if totalPages > 1 && config.CurrentPage > 0 {
row = append(row, echotron.InlineKeyboardButton{
Text: "←",
CallbackData: prevCallback,
})
}
// Средние кнопки (если есть)
if len(config.MiddleButtons) > 0 {
row = append(row, config.MiddleButtons...)
}
// Правая стрелка (только если есть следующая страница)
if totalPages > 1 && config.CurrentPage+1 < totalPages {
row = append(row, echotron.InlineKeyboardButton{
Text: "→",
CallbackData: nextCallback,
})
}
if len(row) == 0 {
return nil
}
return row
}
// CalculatePages вычисляет количество страниц из общего количества элементов
func CalculatePages(totalItems, itemsPerPage int) int {
if itemsPerPage <= 0 {
return 0
}
return (totalItems + itemsPerPage - 1) / itemsPerPage
}
// GetPageBounds возвращает start/end индексы для текущей страницы.
// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
start = currentPage * itemsPerPage
if start >= totalItems {
start = 0
}
end = start + itemsPerPage
if end > totalItems {
end = totalItems
}
return start, end
}
// FormatPageInfo форматирует текст индикатора страницы для заголовка
func FormatPageInfo(currentPage, totalPages int) string {
if totalPages <= 1 {
return ""
}
return fmt.Sprintf(" <i>(стр. %d/%d)</i>", currentPage+1, totalPages)
}
// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией
type ElementLayoutConfig struct {
CurrentPage int // Текущая страница (0-indexed)
ItemsPerPage int // Элементов на странице
ItemsPerRow int // Элементов в одном ряду
EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ")
}
// BuildElementRows автоматически раскладывает элементы с пагинацией.
// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой.
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру.
//
// Пример использования:
//
// config := ui.ElementLayoutConfig{
// CurrentPage: 0,
// ItemsPerPage: 6,
// ItemsPerRow: 2,
// }
// rows := ui.BuildElementRows(config, allButtons)
// keyboard = append(keyboard, rows...)
func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
if len(allItems) == 0 {
return nil
}
// Параметры по умолчанию
if config.ItemsPerPage <= 0 {
config.ItemsPerPage = 6
}
if config.ItemsPerRow <= 0 {
config.ItemsPerRow = 2
}
// Кнопка-заполнитель по умолчанию
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
if config.EmptyButton != nil {
emptyButton = *config.EmptyButton
}
// Вычисляем границы страницы
totalPages := CalculatePages(len(allItems), config.ItemsPerPage)
start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems))
// Получаем элементы текущей страницы
pageItems := allItems[start:end]
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
// Это обеспечивает одинаковую высоту клавиатуры на всех страницах
if totalPages >= 2 {
for len(pageItems) < config.ItemsPerPage {
pageItems = append(pageItems, emptyButton)
}
}
// Раскладываем элементы по рядам
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < len(pageItems); i += config.ItemsPerRow {
var row []echotron.InlineKeyboardButton
// Добавляем элементы в ряд
for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ {
row = append(row, pageItems[i+j])
}
// Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми
for len(row) < config.ItemsPerRow {
row = append(row, emptyButton)
}
rows = append(rows, row)
}
return rows
}
// BuildPageRows раскладывает элементы текущей страницы без собственной пагинации.
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
func BuildPageRows(pageItems []echotron.InlineKeyboardButton, itemsPerRow, itemsPerPage, totalPages int) [][]echotron.InlineKeyboardButton {
if len(pageItems) == 0 {
return nil
}
// Параметры по умолчанию
if itemsPerPage <= 0 {
itemsPerPage = 6
}
if itemsPerRow <= 0 {
itemsPerRow = 2
}
// Кнопка-заполнитель по умолчанию
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
if totalPages >= 2 {
for len(pageItems) < itemsPerPage {
pageItems = append(pageItems, emptyButton)
}
}
// Раскладываем элементы по рядам
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < len(pageItems); i += itemsPerRow {
var row []echotron.InlineKeyboardButton
for j := 0; j < itemsPerRow && i+j < len(pageItems); j++ {
row = append(row, pageItems[i+j])
}
for len(row) < itemsPerRow {
row = append(row, emptyButton)
}
rows = append(rows, row)
}
return rows
}
+134
View File
@@ -0,0 +1,134 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type WorkspaceMenu struct {
CurrentPage int
BackState bot.State
}
const msgWorkspaceEmpty = `
У вас пока нет рабочих пространств.
`
const workspacesPerPage = 6
func (s *WorkspaceMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
if err != nil {
log.Error().Err(err).Msg("Failed to get workspaces for workspace menu")
b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back"))))
return
}
if len(workspaces) == 0 {
kb := Keyboard(Row(Button("← Назад", "back")))
b.Render(msgWorkspaceEmpty, kb, mode)
return
}
var allButtons []echotron.InlineKeyboardButton
for _, ws := range workspaces {
label := ws.Name
if ws.ID == b.Session.WorkspaceID {
label = "● " + label
}
allButtons = append(allButtons, Button(label, fmt.Sprintf("workspace_select:%s", ws.ID)))
}
elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
CurrentPage: s.CurrentPage,
ItemsPerPage: workspacesPerPage,
ItemsPerRow: 2,
}, allButtons)
var buttons [][]echotron.InlineKeyboardButton
buttons = append(buttons, elementRows...)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalItems: len(allButtons),
ItemsPerPage: workspacesPerPage,
}); navRow != nil {
buttons = append(buttons, navRow)
}
buttons = append(buttons, Row(Button("← Назад", "back")))
totalPages := ui.CalculatePages(len(allButtons), workspacesPerPage)
text := fmt.Sprintf(`<b>Рабочие пространства</b>%s
Выберите текущее рабочее пространство.`, ui.FormatPageInfo(s.CurrentPage, totalPages))
b.Render(text, Keyboard(buttons...), mode)
}
func (s *WorkspaceMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
b.SetState(&MainMenu{}, bot.EditMessage)
case strings.HasPrefix(data, "workspace_select:"):
parts := strings.Split(data, ":")
if len(parts) != 2 {
return
}
workspaceID := parts[1]
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
if err != nil {
log.Error().Err(err).Msg("Failed to get workspaces for selection")
b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back"))))
return
}
for _, ws := range workspaces {
if ws.ID == workspaceID {
b.Session.WorkspaceID = ws.ID
break
}
}
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
b.SetState(&MainMenu{}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *WorkspaceMenu) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *WorkspaceMenu) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *WorkspaceMenu) Exit() {}