refactor
This commit is contained in:
18
tg_bot/Dockerfile
Normal file
18
tg_bot/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
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"]
|
||||
793
tg_bot/backend/backend_client.go
Normal file
793
tg_bot/backend/backend_client.go
Normal file
@@ -0,0 +1,793 @@
|
||||
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: 5 * 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) 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,
|
||||
workspaceID string,
|
||||
projectID string,
|
||||
) (*Project, error) {
|
||||
path := fmt.Sprintf("api/v1/internal/projects/%s/%s", workspaceID, projectID)
|
||||
|
||||
var project Project
|
||||
err := c.do(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
path,
|
||||
nil,
|
||||
&project,
|
||||
)
|
||||
|
||||
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) ForwardTelegramUpdate(
|
||||
ctx context.Context,
|
||||
update any,
|
||||
) error {
|
||||
return c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"api/v1/internal/telegram/updates",
|
||||
update,
|
||||
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"`
|
||||
MediaType string `json:"media_type"`
|
||||
MediaFileID string `json:"media_file_id"`
|
||||
Buttons []CreativeButton `json:"buttons"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectChannelTitle string `json:"project_channel_title"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
PlacementsCount int `json:"placements_count"`
|
||||
}
|
||||
|
||||
type CreativeButton struct {
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
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"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
MediaFileID string `json:"media_file_id,omitempty"`
|
||||
MediaData []byte `json:"media_data,omitempty"`
|
||||
Buttons []CreativeButton `json:"buttons,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"`
|
||||
MediaType *string `json:"media_type,omitempty"`
|
||||
MediaFileID *string `json:"media_file_id,omitempty"`
|
||||
MediaData *[]byte `json:"media_data,omitempty"`
|
||||
Buttons *[]CreativeButton `json:"buttons,omitempty"`
|
||||
Status *string `json:"status,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 Placement struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectChannelTitle *string `json:"project_channel_title"`
|
||||
PlacementChannelID string `json:"placement_channel_id"`
|
||||
PlacementChannelTitle *string `json:"placement_channel_title"`
|
||||
CreativeID string `json:"creative_id"`
|
||||
CreativeName string `json:"creative_name"`
|
||||
PlacementDate string `json:"placement_date"`
|
||||
Cost *float64 `json:"cost"`
|
||||
Comment *string `json:"comment"`
|
||||
AdPostURL *string `json:"ad_post_url"`
|
||||
InviteLinkType string `json:"invite_link_type"`
|
||||
InviteLink string `json:"invite_link"`
|
||||
Status string `json:"status"`
|
||||
SubscriptionsCount int `json:"subscriptions_count"`
|
||||
PurchaseID *string `json:"purchase_id"`
|
||||
PurchaseChannelID *string `json:"purchase_channel_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type PlacementsPage struct {
|
||||
Items []Placement `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Pages int `json:"pages"`
|
||||
}
|
||||
|
||||
func (c *Client) GetPlacements(
|
||||
ctx context.Context,
|
||||
jwt string,
|
||||
workspaceID string,
|
||||
projectID *string,
|
||||
placementChannelID *string,
|
||||
creativeID *string,
|
||||
includeArchived bool,
|
||||
page, size int,
|
||||
) (*PlacementsPage, error) {
|
||||
q := url.Values{}
|
||||
if projectID != nil {
|
||||
q.Set("project_id", *projectID)
|
||||
}
|
||||
if placementChannelID != nil {
|
||||
q.Set("placement_channel_id", *placementChannelID)
|
||||
}
|
||||
if creativeID != nil {
|
||||
q.Set("creative_id", *creativeID)
|
||||
}
|
||||
q.Set("include_archived", fmt.Sprint(includeArchived))
|
||||
q.Set("page", fmt.Sprint(page))
|
||||
q.Set("size", fmt.Sprint(size))
|
||||
|
||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements?%s", workspaceID, 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,
|
||||
placementID string,
|
||||
) (*Placement, error) {
|
||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
|
||||
|
||||
var placement Placement
|
||||
err := c.do(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
path,
|
||||
nil,
|
||||
&placement,
|
||||
withBearer(jwt),
|
||||
)
|
||||
|
||||
return &placement, err
|
||||
}
|
||||
|
||||
// Note: Placement creation/updates are handled by worker; API exposes only read endpoints.
|
||||
|
||||
// ============================================================================
|
||||
// Purchases (Закупы)
|
||||
// ============================================================================
|
||||
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
TelegramID *int64 `json:"telegram_id"`
|
||||
Title *string `json:"title"`
|
||||
Username *string `json:"username"`
|
||||
}
|
||||
|
||||
type Purchase struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreativeID string `json:"creative_id"`
|
||||
Channels []PurchaseChannelOut `json:"channels"`
|
||||
Details *PurchaseDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type PurchaseChannelOut struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Comment *string `json:"comment"`
|
||||
InviteLink string `json:"invite_link"`
|
||||
InviteLinkType string `json:"invite_link_type"`
|
||||
Channel Channel `json:"channel"`
|
||||
Details *PurchaseChannelDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type CostInfo struct {
|
||||
Type string `json:"type"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type PurchaseDetails struct {
|
||||
PlacementAt *string `json:"placement_at,omitempty"`
|
||||
PaymentAt *string `json:"payment_at,omitempty"`
|
||||
Cost *CostInfo `json:"cost,omitempty"`
|
||||
CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"`
|
||||
PurchaseType *string `json:"purchase_type,omitempty"`
|
||||
Format *string `json:"format,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type PurchaseChannelDetails struct {
|
||||
PlacementAt *string `json:"placement_at,omitempty"`
|
||||
Cost *CostInfo `json:"cost,omitempty"`
|
||||
CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"`
|
||||
Format *string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePurchaseChannelInput struct {
|
||||
Username string `json:"username"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
Details *PurchaseChannelDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePurchaseInput struct {
|
||||
CreativeID string `json:"creative_id"`
|
||||
Channels []CreatePurchaseChannelInput `json:"channels"`
|
||||
Details *PurchaseDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) CreatePurchase(
|
||||
ctx context.Context,
|
||||
jwt string,
|
||||
workspaceID string,
|
||||
projectID string,
|
||||
input CreatePurchaseInput,
|
||||
) (*Purchase, error) {
|
||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
|
||||
|
||||
var purchase Purchase
|
||||
err := c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
path,
|
||||
input,
|
||||
&purchase,
|
||||
withBearer(jwt),
|
||||
)
|
||||
|
||||
return &purchase, err
|
||||
}
|
||||
|
||||
func (c *Client) GetPurchases(
|
||||
ctx context.Context,
|
||||
jwt string,
|
||||
workspaceID string,
|
||||
projectID string,
|
||||
) ([]Purchase, error) {
|
||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
|
||||
|
||||
var resp struct {
|
||||
Items []Purchase `json:"items"`
|
||||
}
|
||||
|
||||
err := c.do(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
path,
|
||||
nil,
|
||||
&resp,
|
||||
withBearer(jwt),
|
||||
)
|
||||
|
||||
return resp.Items, err
|
||||
}
|
||||
|
||||
func (c *Client) GetPurchase(
|
||||
ctx context.Context,
|
||||
jwt string,
|
||||
workspaceID string,
|
||||
projectID string,
|
||||
purchaseID string,
|
||||
) (*Purchase, error) {
|
||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID)
|
||||
|
||||
var purchase Purchase
|
||||
err := c.do(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
path,
|
||||
nil,
|
||||
&purchase,
|
||||
withBearer(jwt),
|
||||
)
|
||||
|
||||
return &purchase, err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Members
|
||||
// ============================================================================
|
||||
|
||||
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
|
||||
}
|
||||
371
tg_bot/bot/bot.go
Normal file
371
tg_bot/bot/bot.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var emptyKeyboard = echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
|
||||
}
|
||||
|
||||
type Exec struct {
|
||||
handled bool
|
||||
transitioned bool
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
echotron.API
|
||||
mu sync.Mutex
|
||||
ChatID int64
|
||||
exec *Exec
|
||||
CurrentState State
|
||||
Session *Session
|
||||
LastMessageID int // ID последнего сообщения с inline кнопками
|
||||
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")
|
||||
}
|
||||
|
||||
return &Bot{
|
||||
ChatID: chatID,
|
||||
API: echotron.NewAPI(token),
|
||||
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().
|
||||
Int64("chat_id", b.ChatID).
|
||||
Str("from_state", fmt.Sprintf("%T", b.CurrentState)).
|
||||
Str("to_state", fmt.Sprintf("%T", s)).
|
||||
Str("mode", fmt.Sprintf("%v", mode)).
|
||||
Msg("State transition")
|
||||
|
||||
// Вызываем Exit() у старого состояния для cleanup (например, отмены горутин)
|
||||
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) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
|
||||
if mode == EditMessage {
|
||||
b.Edit(text, keyboard)
|
||||
} else {
|
||||
b.SendNew(text, keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
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.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,
|
||||
})
|
||||
|
||||
if b.exec != nil {
|
||||
b.exec.handled = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("SendMessage failed")
|
||||
return
|
||||
}
|
||||
|
||||
if res.Result != nil {
|
||||
b.LastMessageID = res.Result.ID
|
||||
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.LastMessageID != 0 {
|
||||
log.Info().
|
||||
Int("last_msg_id", b.LastMessageID).
|
||||
Int("keyboard_rows", len(keyboard.InlineKeyboard)).
|
||||
Int("text_len", len(text)).
|
||||
Msg("EditMessageText request")
|
||||
_, err := b.EditMessageText(
|
||||
text,
|
||||
echotron.NewMessageID(b.ChatID, b.LastMessageID),
|
||||
&echotron.MessageTextOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
ParseMode: echotron.HTML,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "message is not modified") {
|
||||
log.Info().Msg("EditMessageText ignored: message is not modified")
|
||||
if b.exec != nil {
|
||||
b.exec.handled = true
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Error().Err(err).Msg("EditMessageText")
|
||||
}
|
||||
} else {
|
||||
b.SendNew(text, keyboard)
|
||||
}
|
||||
|
||||
if b.exec != nil {
|
||||
b.exec.handled = true
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
|
||||
keyboard := echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]echotron.InlineKeyboardButton{
|
||||
{
|
||||
{
|
||||
Text: buttonText,
|
||||
URL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("SendMessageWithURLButton")
|
||||
}
|
||||
|
||||
if b.exec != nil {
|
||||
b.exec.handled = true
|
||||
}
|
||||
|
||||
if err == nil && res.Result != nil {
|
||||
b.LastMessageID = res.Result.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Update(u *echotron.Update) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
log.Info().
|
||||
Int64("chat_id", b.ChatID).
|
||||
Str("current_state", fmt.Sprintf("%T", b.CurrentState)).
|
||||
Bool("has_callback", u.CallbackQuery != nil).
|
||||
Bool("has_message", u.Message != nil).
|
||||
Bool("has_edited_message", u.EditedMessage != nil).
|
||||
Msg("Update received")
|
||||
|
||||
// 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно
|
||||
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
raw, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
log.Error().RawJSON("update", raw).Msg("Failed to marshal update")
|
||||
}
|
||||
err = b.Backend.ForwardTelegramUpdate(ctx, u)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to forward system event to backend")
|
||||
return
|
||||
}
|
||||
log.Info().Msg("System event forwarded to backend")
|
||||
}()
|
||||
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.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard)
|
||||
return
|
||||
}
|
||||
|
||||
exec := &Exec{}
|
||||
b.exec = exec
|
||||
defer func() { b.exec = nil }()
|
||||
|
||||
if b.CurrentState == nil {
|
||||
b.CurrentState = b.commandRouter("/start")
|
||||
}
|
||||
|
||||
// === Обработка входящего события ===
|
||||
|
||||
// 1. Команды (приоритетный маршрут)
|
||||
if u.Message != nil {
|
||||
switch u.Message.Text {
|
||||
case "/start":
|
||||
b.SetState(b.commandRouter("/start"), NewMessage)
|
||||
return
|
||||
case "/start login":
|
||||
b.SetState(b.commandRouter("/start login"), NewMessage)
|
||||
return
|
||||
default:
|
||||
b.CurrentState.HandleMessage(b, u)
|
||||
if exec.handled || exec.transitioned {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Callback запросы
|
||||
if u.CallbackQuery != nil {
|
||||
b.cleanupCallbackUI(u)
|
||||
|
||||
if u.CallbackQuery.Data == "empty" {
|
||||
return
|
||||
}
|
||||
|
||||
// Глобальные callbacks (из уведомлений)
|
||||
if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil {
|
||||
b.SetState(newState, NewMessage)
|
||||
return
|
||||
}
|
||||
|
||||
b.CurrentState.HandleCallback(b, u)
|
||||
if exec.handled || exec.transitioned {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Универсальный обработчик состояния (если ничего не подошло)
|
||||
b.CurrentState.Handle(b, u)
|
||||
if exec.handled || exec.transitioned {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Fallback - сброс в начальное состояние
|
||||
b.SetState(b.commandRouter("/start"), NewMessage)
|
||||
}
|
||||
|
||||
func (b *Bot) cleanupKeyboard(messageID int) {
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
23
tg_bot/bot/state.go
Normal file
23
tg_bot/bot/state.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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
|
||||
}
|
||||
18
tg_bot/go.mod
Normal file
18
tg_bot/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module github.com/TelegramExchange/tgex-backend/tg_bot
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/TelegramExchange/pkg v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/NicoNex/echotron/v3 v3.43.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/TelegramExchange/pkg => ../pkg
|
||||
22
tg_bot/go.sum
Normal file
22
tg_bot/go.sum
Normal file
@@ -0,0 +1,22 @@
|
||||
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/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/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/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
89
tg_bot/main.go
Normal file
89
tg_bot/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/pkg/logger"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger.Init(logger.Config{
|
||||
PrettyConsole: true,
|
||||
})
|
||||
|
||||
botToken := mustEnv("TELEGRAM__TOKEN")
|
||||
loginURL := mustEnv("LOGIN_URL")
|
||||
baseURL := os.Getenv("BACKEND__BASE_URL")
|
||||
|
||||
backendClient := backend.New(backend.Config{
|
||||
BaseURL: baseURL,
|
||||
LoginURL: loginURL,
|
||||
})
|
||||
|
||||
var commandRouter = func(command string) bot.State {
|
||||
switch command {
|
||||
case "/start":
|
||||
return &screens.MainMenu{}
|
||||
case "/start login":
|
||||
return &screens.Login{}
|
||||
}
|
||||
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)
|
||||
|
||||
log.Info().Msg("bot starting...")
|
||||
|
||||
updateOpts := echotron.UpdateOptions{
|
||||
AllowedUpdates: []echotron.UpdateType{
|
||||
echotron.MessageUpdate,
|
||||
echotron.CallbackQueryUpdate,
|
||||
echotron.MyChatMemberUpdate,
|
||||
echotron.ChatMemberUpdate,
|
||||
echotron.UpdateType("chat_join_request"),
|
||||
},
|
||||
}
|
||||
|
||||
for {
|
||||
if err := dsp.PollOptions(false, updateOpts); err != nil {
|
||||
log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...")
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func mustEnv(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
log.Fatal().Str("env", key).Msg("missing required environment variable")
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
47
tg_bot/screens/accept_workspace_invite.go
Normal file
47
tg_bot/screens/accept_workspace_invite.go
Normal file
@@ -0,0 +1,47 @@
|
||||
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("Главное меню", "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() {}
|
||||
412
tg_bot/screens/add_creative.go
Normal file
412
tg_bot/screens/add_creative.go
Normal file
@@ -0,0 +1,412 @@
|
||||
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
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
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>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</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 (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
kb := Keyboard(Row(Button("« Отмена", "cancel")))
|
||||
b.Render(msgWaitCreative, kb, mode)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
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.MediaType = ""
|
||||
s.ctx.MediaFileID = ""
|
||||
s.ctx.MediaChanged = true
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
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 || u.Message.Text == "" {
|
||||
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
|
||||
}
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.ctx.ClearInputMode()
|
||||
b.SetState(&AddCreativeEdit{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 := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel")
|
||||
s.ShowControlPanel(b, confirmText, buttons)
|
||||
}
|
||||
|
||||
func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
|
||||
var err error
|
||||
var mediaData []byte
|
||||
if s.MediaFileID != "" {
|
||||
mediaData, err = b.DownloadFileBytes(s.MediaFileID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to download creative media")
|
||||
buttons := 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,
|
||||
})
|
||||
}
|
||||
|
||||
creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{
|
||||
Name: *s.Name,
|
||||
Text: *s.Text,
|
||||
MediaType: s.MediaType,
|
||||
MediaFileID: s.MediaFileID,
|
||||
MediaData: mediaData,
|
||||
Buttons: buttons,
|
||||
})
|
||||
|
||||
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 := 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)
|
||||
}
|
||||
}
|
||||
62
tg_bot/screens/add_project.go
Normal file
62
tg_bot/screens/add_project.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
const addProjectInstructionText = `
|
||||
<b>Как добавить канал в проект</b>
|
||||
|
||||
<b>Инструкция:</b>
|
||||
|
||||
<b>1.</b> Добавьте бота в ваш канал как администратора
|
||||
|
||||
<b>2.</b> Дайте боту следующие права:
|
||||
• Удаление сообщений
|
||||
• Приглашение пользователей
|
||||
• Блокировка пользователей
|
||||
|
||||
<b>3.</b> После добавления бота:
|
||||
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
|
||||
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
|
||||
|
||||
<b>4.</b> Готово! Канал появится в списке проектов
|
||||
`
|
||||
|
||||
type AddProject struct {
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
buttons := [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Назад", "back")),
|
||||
Row(URLButton("Добавить", "tg://resolve?domain=chat_cruiser_bot&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages")),
|
||||
}
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(addProjectInstructionText, 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)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddProject) Exit() {}
|
||||
304
tg_bot/screens/add_purchase.go
Normal file
304
tg_bot/screens/add_purchase.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const addPurchaseCreativesPerPage = 5
|
||||
const addPurchaseProjectsPerPage = 6
|
||||
|
||||
type AddPurchase struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
ProjectPage int
|
||||
CreativePage int
|
||||
ActivePicker string // "" | "project_list" | "creative_list"
|
||||
BackState bot.State
|
||||
lastProjects map[string]string
|
||||
lastCreatives map[string]string
|
||||
}
|
||||
|
||||
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// JWT уже создан в Bot.Update(), просто берем из сессии
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.renderSelection(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
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(),
|
||||
jwt,
|
||||
s.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]string)
|
||||
for _, project := range page.Items {
|
||||
s.lastProjects[project.ID] = project.Title
|
||||
}
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
|
||||
} else {
|
||||
text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages))
|
||||
|
||||
var slots []echotron.InlineKeyboardButton
|
||||
for _, project := range page.Items {
|
||||
slots = append(slots, Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
|
||||
}
|
||||
if page.Pages >= 2 {
|
||||
for len(slots) < addPurchaseProjectsPerPage {
|
||||
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)
|
||||
}
|
||||
|
||||
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(),
|
||||
jwt,
|
||||
s.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]string)
|
||||
for _, creative := range page.Items {
|
||||
s.lastCreatives[creative.ID] = creative.Name
|
||||
}
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += `Для создания закупа сначала нужно создать креатив.
|
||||
|
||||
Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
|
||||
} else {
|
||||
text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages))
|
||||
|
||||
var slots []echotron.InlineKeyboardButton
|
||||
for _, creative := range page.Items {
|
||||
slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
|
||||
}
|
||||
if page.Pages >= 2 {
|
||||
for len(slots) < addPurchaseCreativesPerPage {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 "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{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: []PurchaseChannelInput{},
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
if len(data) > 8 && data[:8] == "project:" {
|
||||
projectID := data[8:]
|
||||
if s.lastProjects != nil {
|
||||
s.ProjectTitle = s.lastProjects[projectID]
|
||||
}
|
||||
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 {
|
||||
s.CreativeTitle = s.lastCreatives[creativeID]
|
||||
}
|
||||
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() {}
|
||||
359
tg_bot/screens/creative_details.go
Normal file
359
tg_bot/screens/creative_details.go
Normal file
@@ -0,0 +1,359 @@
|
||||
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 CreativeDetails struct {
|
||||
CreativeEditorFields // Встраивание общих полей
|
||||
|
||||
CreativeID string
|
||||
ProjectID string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
|
||||
// Флаг подтверждения удаления
|
||||
confirmingDelete bool
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, s.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.MediaType = creative.MediaType
|
||||
s.MediaFileID = creative.MediaFileID
|
||||
s.Buttons = nil
|
||||
for _, btn := range creative.Buttons {
|
||||
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
|
||||
}
|
||||
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":
|
||||
// Отмена - возвращаемся назад
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
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.MediaType = ""
|
||||
s.MediaFileID = ""
|
||||
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
|
||||
}
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(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,
|
||||
}
|
||||
if s.MediaChanged {
|
||||
mediaType := s.MediaType
|
||||
mediaFileID := s.MediaFileID
|
||||
input.MediaType = &mediaType
|
||||
input.MediaFileID = &mediaFileID
|
||||
|
||||
if s.MediaFileID != "" {
|
||||
mediaData, err := b.DownloadFileBytes(s.MediaFileID)
|
||||
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.MediaData = &mediaData
|
||||
} else {
|
||||
emptyData := []byte{}
|
||||
input.MediaData = &emptyData
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
s.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")
|
||||
|
||||
// Возвращаемся назад
|
||||
if s.BackState != nil {
|
||||
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
|
||||
err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, s.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
|
||||
}
|
||||
|
||||
if s.BackState != nil {
|
||||
b.SendNew("✅ Креатив успешно удален!", Keyboard())
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
556
tg_bot/screens/creative_editor.go
Normal file
556
tg_bot/screens/creative_editor.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// InlineButton представляет кнопку с URL
|
||||
type InlineButton struct {
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
|
||||
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
|
||||
type CreativeEditorFields struct {
|
||||
// Данные креатива
|
||||
Name *string
|
||||
Text *string
|
||||
Buttons []InlineButton
|
||||
|
||||
// Медиа
|
||||
MediaType string // "photo", "video", "animation"
|
||||
MediaFileID string // File ID из Telegram
|
||||
MediaChanged bool
|
||||
|
||||
// Режим ввода
|
||||
InputMode string
|
||||
|
||||
// Состояние для добавления кнопки
|
||||
PendingButtonType string
|
||||
|
||||
// ID сообщения с креативом (которое постоянно обновляется)
|
||||
CreativeMessageID *int
|
||||
// ID сообщения с панелью управления
|
||||
ControlPanelMessageID *int
|
||||
|
||||
// Предыдущее состояние для определения изменения типа сообщения
|
||||
previousHasMedia bool
|
||||
previousMediaType string
|
||||
previousMediaFileID string
|
||||
}
|
||||
|
||||
const inviteLinkPlaceholder = "{{invite_link}}"
|
||||
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
|
||||
const buttonURLPlaceholder = "https://example.com"
|
||||
|
||||
const (
|
||||
inputModeText = "text"
|
||||
inputModeButtonText = "button_text"
|
||||
inputModeButtonURL = "button_url"
|
||||
inputModeMedia = "media"
|
||||
)
|
||||
const msgCreativeDefault = `
|
||||
<i>💭 Я твой креатив
|
||||
|
||||
У меня пока нет текста, добавь его ниже.
|
||||
|
||||
</i>
|
||||
`
|
||||
const msgCreativeManagement = `
|
||||
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
|
||||
|
||||
Выберите, что изменить:
|
||||
|
||||
`
|
||||
|
||||
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
|
||||
keyboard := echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: buttons,
|
||||
}
|
||||
if len(buttons) == 0 {
|
||||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||||
}
|
||||
return keyboard
|
||||
}
|
||||
|
||||
// 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-ссылку
|
||||
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
|
||||
for strings.Contains(text, `<a class="tg-link">`) {
|
||||
startIdx := strings.Index(text, `<a class="tg-link">`)
|
||||
if startIdx == -1 {
|
||||
break
|
||||
}
|
||||
endIdx := strings.Index(text[startIdx:], "</a>")
|
||||
if endIdx == -1 {
|
||||
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
|
||||
break
|
||||
}
|
||||
endIdx += startIdx
|
||||
|
||||
// Извлекаем текст между тегами
|
||||
linkText := text[startIdx+len(`<a class="tg-link">`) : endIdx]
|
||||
// ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности
|
||||
// Это нужно для старых креативов с неправильным HTML
|
||||
for {
|
||||
openTag := strings.Index(linkText, "<")
|
||||
if openTag == -1 {
|
||||
break
|
||||
}
|
||||
closeTag := strings.Index(linkText[openTag:], ">")
|
||||
if closeTag == -1 {
|
||||
log.Warn().Str("linkText", linkText).Msg("Found < but no closing >")
|
||||
break
|
||||
}
|
||||
// Удаляем тег
|
||||
linkText = linkText[:openTag] + linkText[openTag+closeTag+1:]
|
||||
}
|
||||
|
||||
linkText = strings.TrimSpace(linkText)
|
||||
|
||||
// Если текст ссылки не пустой, добавляем атрибут href с примером ссылки
|
||||
// Если пустой, показываем просто текст примера ссылки (без тега)
|
||||
var replacement string
|
||||
if linkText != "" {
|
||||
// Есть текст - добавляем href атрибут к тегу
|
||||
replacement = fmt.Sprintf(`<a class="tg-link" href="%s">%s</a>`, inviteLinkPreviewURL, linkText)
|
||||
} else {
|
||||
// Пусто - показываем просто текст примера ссылки
|
||||
replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL)
|
||||
}
|
||||
|
||||
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
|
||||
}
|
||||
|
||||
// Также заменяем плейсхолдер {{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
|
||||
}
|
||||
|
||||
// Если есть медиа, отправляем с медиа
|
||||
if e.MediaFileID != "" {
|
||||
var res echotron.APIResponseMessage
|
||||
var err error
|
||||
|
||||
switch e.MediaType {
|
||||
case "photo":
|
||||
res, err = b.SendPhoto(
|
||||
echotron.NewInputFileID(e.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.PhotoOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
case "video":
|
||||
res, err = b.SendVideo(
|
||||
echotron.NewInputFileID(e.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.VideoOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
case "animation":
|
||||
res, err = b.SendAnimation(
|
||||
echotron.NewInputFileID(e.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.AnimationOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media")
|
||||
// Fallback to text message - используем прямой SendMessage
|
||||
if id, err := sendText(); 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")
|
||||
return
|
||||
}
|
||||
msgID = id
|
||||
}
|
||||
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
e.CreativeMessageID = &msgID
|
||||
e.previousHasMedia = e.MediaFileID != ""
|
||||
e.previousMediaType = e.MediaType
|
||||
e.previousMediaFileID = e.MediaFileID
|
||||
}
|
||||
|
||||
// UpdateCreativePreview обновляет превью креатива
|
||||
func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||||
if e.CreativeMessageID == nil {
|
||||
log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
|
||||
// Если превью еще не создано, создаем его
|
||||
e.SendCreativePreview(b)
|
||||
return
|
||||
}
|
||||
|
||||
currentHasMedia := e.MediaFileID != ""
|
||||
|
||||
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
|
||||
if currentHasMedia != e.previousHasMedia {
|
||||
// Удаляем оба сообщения
|
||||
if e.CreativeMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||||
e.CreativeMessageID = nil
|
||||
}
|
||||
if e.ControlPanelMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
|
||||
e.ControlPanelMessageID = nil
|
||||
}
|
||||
|
||||
// Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
|
||||
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]) + "..."
|
||||
}
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("creative_message_id", *e.CreativeMessageID).
|
||||
Str("media_type", e.MediaType).
|
||||
Int("text_len", len([]rune(creativeText))).
|
||||
Str("text_preview", textPreview).
|
||||
Msg(msg)
|
||||
}
|
||||
|
||||
// Если есть медиа - редактируем caption
|
||||
updated := false
|
||||
if e.MediaFileID != "" {
|
||||
mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType
|
||||
if mediaChanged {
|
||||
var media echotron.InputMedia
|
||||
switch e.MediaType {
|
||||
case "photo":
|
||||
media = echotron.InputMediaPhoto{
|
||||
Type: echotron.MediaTypePhoto,
|
||||
Media: echotron.NewInputFileID(e.MediaFileID),
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
case "video":
|
||||
media = echotron.InputMediaVideo{
|
||||
Type: echotron.MediaTypeVideo,
|
||||
Media: echotron.NewInputFileID(e.MediaFileID),
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
case "animation":
|
||||
media = echotron.InputMediaAnimation{
|
||||
Type: echotron.MediaTypeAnimation,
|
||||
Media: echotron.NewInputFileID(e.MediaFileID),
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
}
|
||||
|
||||
if media != nil {
|
||||
_, err := b.EditMessageMedia(
|
||||
msgID,
|
||||
media,
|
||||
&echotron.MessageMediaOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logEditError("Failed to edit message media", err)
|
||||
} else {
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
log.Warn().Str("media_type", e.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 {
|
||||
e.previousHasMedia = currentHasMedia
|
||||
e.previousMediaType = e.MediaType
|
||||
e.previousMediaFileID = e.MediaFileID
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
switch {
|
||||
case message.Photo != nil && len(message.Photo) > 0:
|
||||
photo := message.Photo[len(message.Photo)-1]
|
||||
e.MediaType = "photo"
|
||||
e.MediaFileID = photo.FileID
|
||||
case message.Video != nil:
|
||||
e.MediaType = "video"
|
||||
e.MediaFileID = message.Video.FileID
|
||||
case message.Animation != nil:
|
||||
e.MediaType = "animation"
|
||||
e.MediaFileID = message.Animation.FileID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
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"),
|
||||
))
|
||||
buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button")))
|
||||
|
||||
// Кнопка удаления медиа (если медиа добавлено)
|
||||
if e.MediaFileID != "" {
|
||||
buttons = append(buttons, Row(
|
||||
Button("⌫ Убрать медиа", "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)
|
||||
}
|
||||
}
|
||||
101
tg_bot/screens/creative_editor_ui.go
Normal file
101
tg_bot/screens/creative_editor_ui.go
Normal file
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
|
||||
`
|
||||
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>
|
||||
|
||||
Попробуйте ещё раз.
|
||||
|
||||
`
|
||||
|
||||
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")),
|
||||
})
|
||||
}
|
||||
183
tg_bot/screens/creatives.go
Normal file
183
tg_bot/screens/creatives.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const creativesPerPage = 6
|
||||
|
||||
type Creatives struct {
|
||||
CurrentPage int
|
||||
ProjectID string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// JWT уже создан в Bot.Update(), просто берем из сессии
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.renderCreatives(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
page, err := b.Backend.GetCreatives(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
&s.ProjectID,
|
||||
false, // includeArchived
|
||||
s.CurrentPage+1,
|
||||
creativesPerPage,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creatives")
|
||||
b.SendNew("❌ Не удалось загрузить креативы", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
var text string
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text = `<b>Креативы</b>
|
||||
|
||||
У вас пока нет креативов
|
||||
|
||||
<i>Креатив — это рекламный материал:</i>
|
||||
‣ Текст объявления
|
||||
‣ Медиа контент (фото, видео)
|
||||
‣ Шаблон для автопостинга
|
||||
‣ Варианты для A/B тестирования
|
||||
|
||||
Начните с добавления первого креатива`
|
||||
} else {
|
||||
text = fmt.Sprintf(`<b>Креативы</b>%s
|
||||
|
||||
Управление рекламными материалами
|
||||
|
||||
Выберите креатив`,
|
||||
ui.FormatPageInfo(s.CurrentPage, page.Pages))
|
||||
}
|
||||
|
||||
// Создаем слоты для креативов
|
||||
var slots []echotron.InlineKeyboardButton
|
||||
for _, creative := range page.Items {
|
||||
slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
|
||||
}
|
||||
|
||||
// Если страниц >= 2, заполняем до 6 слотов пустыми кнопками
|
||||
if page.Pages >= 2 {
|
||||
for len(slots) < creativesPerPage {
|
||||
slots = append(slots, Button(" ", "empty"))
|
||||
}
|
||||
}
|
||||
|
||||
// Разбиваем слоты по 2 в ряду
|
||||
for i := 0; i < len(slots); i += 2 {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
row = append(row, slots[i])
|
||||
|
||||
// Второй слот в ряду (если есть)
|
||||
if i+1 < len(slots) {
|
||||
row = append(row, slots[i+1])
|
||||
} else {
|
||||
// Если нечетное количество, добавляем пустую кнопку
|
||||
row = append(row, Button(" ", "empty"))
|
||||
}
|
||||
|
||||
buttons = append(buttons, row)
|
||||
}
|
||||
|
||||
// Ряд: Пагинация + Добавить (динамическая ширина)
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalPages: page.Pages,
|
||||
MiddleButtons: []echotron.InlineKeyboardButton{Button("+ Добавить", "add_creative")},
|
||||
}); navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
// Нижний ряд: Назад + Архив
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back"),
|
||||
Button("≡ Архив", "archive"),
|
||||
))
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
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":
|
||||
// TODO: реализовать экран архива креативов
|
||||
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(
|
||||
Row(Button("← Назад", "back")),
|
||||
))
|
||||
|
||||
case data == "add_creative":
|
||||
b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{
|
||||
ProjectID: s.ProjectID,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "creative:"):
|
||||
// Открытие деталей креатива
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
creativeID := parts[1]
|
||||
|
||||
b.SetState(&CreativeDetails{
|
||||
CreativeID: creativeID,
|
||||
ProjectID: s.ProjectID,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}, bot.NewMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Creatives) Exit() {}
|
||||
34
tg_bot/screens/helpers.go
Normal file
34
tg_bot/screens/helpers.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
var emptyKeyboard = Keyboard()
|
||||
|
||||
func Button(text, data string) echotron.InlineKeyboardButton {
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: text,
|
||||
CallbackData: data,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
42
tg_bot/screens/login.go
Normal file
42
tg_bot/screens/login.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
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)))
|
||||
|
||||
b.SendNew(fmt.Sprintf(msgHelloLogin, usernameDisplay), kb)
|
||||
}
|
||||
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) { b.SetState(&MainMenu{}, bot.NewMessage) }
|
||||
|
||||
func (s *Login) Exit() {}
|
||||
53
tg_bot/screens/main_menu.go
Normal file
53
tg_bot/screens/main_menu.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
type MainMenu struct{}
|
||||
|
||||
const msgAbout = `
|
||||
<i>Главное меню</i>
|
||||
|
||||
Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
|
||||
|
||||
• Внутренний счёт и безопасные сделки
|
||||
• Фиксация цен, дат и форматов
|
||||
• Автоматические отчёты
|
||||
• Повторные закупки в пару кликов
|
||||
|
||||
Комиссия — 7%. Без скрытых платежей.
|
||||
`
|
||||
|
||||
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
keyboard := Keyboard(
|
||||
Row(
|
||||
Button("Мои проекты", "my_projects"),
|
||||
URLButton("Поддержка", "https://example.com"),
|
||||
),
|
||||
Row(
|
||||
Button("Закупы", "purchases"),
|
||||
URLButton("Дашборд", "https://example.com"),
|
||||
),
|
||||
)
|
||||
|
||||
b.Render(msgAbout, 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 "purchases":
|
||||
b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPurchases: true}, 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() {}
|
||||
356
tg_bot/screens/my_projects.go
Normal file
356
tg_bot/screens/my_projects.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const projectsPerPage = 6
|
||||
|
||||
type MyProjects struct {
|
||||
CurrentPage int
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
OpenPurchases bool
|
||||
|
||||
// Поля для управления фоновой горутиной polling
|
||||
cancel context.CancelFunc
|
||||
pollDone chan struct{}
|
||||
lastProjectCount int
|
||||
}
|
||||
|
||||
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// JWT уже создан в Bot.Update(), просто берем из сессии
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Если workspace еще не выбран, получаем список workspace'ов
|
||||
if s.WorkspaceID == "" {
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get workspaces")
|
||||
b.SendNew("❌ Не удалось загрузить workspace'ы", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
// Автоматически создаем workspace с именем пользователя
|
||||
username := "My Workspace"
|
||||
chatInfo, err := b.GetChat(b.ChatID)
|
||||
if err == nil && chatInfo.Result != nil {
|
||||
if chatInfo.Result.Username != "" {
|
||||
username = chatInfo.Result.Username
|
||||
} else if chatInfo.Result.FirstName != "" {
|
||||
username = chatInfo.Result.FirstName
|
||||
if chatInfo.Result.LastName != "" {
|
||||
username += " " + chatInfo.Result.LastName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("username", username).Msg("Creating default workspace")
|
||||
workspace, err := b.Backend.CreateWorkspace(context.Background(), jwt, username)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create workspace")
|
||||
b.SendNew("❌ Не удалось создать workspace", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.WorkspaceID = workspace.ID
|
||||
log.Info().Str("workspace_id", s.WorkspaceID).Str("name", workspace.Name).Msg("Workspace created successfully")
|
||||
} else {
|
||||
// Используем первый workspace по умолчанию
|
||||
s.WorkspaceID = workspaces[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
s.renderProjects(b, mode, jwt)
|
||||
|
||||
// Запускаем фоновую горутину для автообновления экрана
|
||||
s.startPolling(b, jwt)
|
||||
}
|
||||
|
||||
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get projects")
|
||||
b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем количество проектов для polling
|
||||
s.lastProjectCount = page.Total
|
||||
|
||||
var text string
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
headerTitle := "Мои проекты"
|
||||
emptyStateText := `У вас пока нет подключенных каналов.
|
||||
|
||||
<i>Что дает подключение канала:</i>
|
||||
▸ Управление типом приглашений (публичные/с одобрением)
|
||||
▸ Автоматическая отправка креативов в канал
|
||||
▸ Интеграция с планом закупов
|
||||
▸ Статистика и аналитика подписчиков
|
||||
▸ Настройка уведомлений
|
||||
|
||||
Начните с добавления первого проекта`
|
||||
nonEmptyText := "Управление вашими Telegram-каналами.\n\nВыберите канал"
|
||||
if s.OpenPurchases {
|
||||
headerTitle = "Закупы"
|
||||
nonEmptyText = "Выберите проект для просмотра закупов"
|
||||
}
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text = fmt.Sprintf(`<b>%s</b>
|
||||
|
||||
%s`, headerTitle, emptyStateText)
|
||||
} else {
|
||||
text = fmt.Sprintf(`<b>%s</b>%s
|
||||
|
||||
%s`,
|
||||
headerTitle,
|
||||
ui.FormatPageInfo(s.CurrentPage, page.Pages),
|
||||
nonEmptyText)
|
||||
}
|
||||
|
||||
// Создаем слоты для проектов
|
||||
var slots []echotron.InlineKeyboardButton
|
||||
for _, project := range page.Items {
|
||||
slots = append(slots, Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
|
||||
}
|
||||
|
||||
// Если страниц >= 2, заполняем до 6 слотов пустыми кнопками
|
||||
if page.Pages >= 2 {
|
||||
for len(slots) < projectsPerPage {
|
||||
slots = append(slots, Button(" ", "empty"))
|
||||
}
|
||||
}
|
||||
|
||||
// Разбиваем слоты по 2 в ряду
|
||||
for i := 0; i < len(slots); i += 2 {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
row = append(row, slots[i])
|
||||
|
||||
// Второй слот в ряду (если есть)
|
||||
if i+1 < len(slots) {
|
||||
row = append(row, slots[i+1])
|
||||
} else {
|
||||
// Если нечетное количество, добавляем пустую кнопку
|
||||
row = append(row, Button(" ", "empty"))
|
||||
}
|
||||
|
||||
buttons = append(buttons, row)
|
||||
}
|
||||
|
||||
// Ряд: Пагинация + Добавить (динамическая ширина)
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalPages: page.Pages,
|
||||
MiddleButtons: []echotron.InlineKeyboardButton{Button("+ Добавить", "add_project")},
|
||||
}); navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
// Нижний ряд: Назад + Архив
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "menu"),
|
||||
Button("≡ Архив", "archive"),
|
||||
))
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
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 == "menu", data == "main_menu":
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
|
||||
case data == "archive":
|
||||
b.Edit("📦 Архив проектов\n\nЭта функция в разработке", Keyboard(
|
||||
Row(Button("← Назад", "back_to_projects")),
|
||||
))
|
||||
|
||||
case data == "add_project":
|
||||
b.SetState(&AddProject{
|
||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
||||
}, bot.EditMessage)
|
||||
|
||||
case data == "back_to_projects":
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "project:"):
|
||||
// Открытие деталей проекта
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
projectID := parts[1]
|
||||
|
||||
// Получаем проект напрямую по ID
|
||||
project, err := b.Backend.GetProject(
|
||||
context.Background(),
|
||||
s.WorkspaceID,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
if s.OpenPurchases {
|
||||
b.SetState(&Purchases{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: project.ID,
|
||||
ProjectTitle: project.Title,
|
||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true},
|
||||
}, bot.EditMessage)
|
||||
} else {
|
||||
b.SetState(&ProjectDetails{
|
||||
Project: project,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
case strings.HasPrefix(data, "workspace:"):
|
||||
// Позволяет переключаться между workspace'ами в будущем
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
s.WorkspaceID = parts[1]
|
||||
s.CurrentPage = 0
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Exit() {
|
||||
// Останавливаем polling горутину
|
||||
if s.cancel != nil {
|
||||
log.Info().Msg("MyProjects: cancelling polling goroutine")
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
// Ждем завершения горутины с таймаутом 100ms
|
||||
// Это не критично - горутина сама завершится при следующем tick
|
||||
if s.pollDone != nil {
|
||||
select {
|
||||
case <-s.pollDone:
|
||||
log.Info().Msg("MyProjects: polling goroutine finished")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
log.Info().Msg("MyProjects: polling cleanup timeout, goroutine will finish in background")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MyProjects) startPolling(b *bot.Bot, jwt string) {
|
||||
// Останавливаем предыдущую горутину если она есть
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
if s.pollDone != nil {
|
||||
<-s.pollDone
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем новый контекст для горутины
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.pollDone = make(chan struct{})
|
||||
|
||||
go s.pollProjects(ctx, b, jwt)
|
||||
}
|
||||
|
||||
func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) {
|
||||
defer close(s.pollDone)
|
||||
|
||||
// Адаптивный интервал: первые 30 секунд проверяем часто (1 сек),
|
||||
// потом переходим на более редкий polling (3 сек)
|
||||
// Это улучшает UX когда пользователь только добавил проект
|
||||
intervals := []struct {
|
||||
duration time.Duration
|
||||
count int
|
||||
}{
|
||||
{1 * time.Second, 30}, // Первые 30 секунд - каждую секунду
|
||||
{3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно)
|
||||
}
|
||||
|
||||
currentIntervalIdx := 0
|
||||
tickCount := 0
|
||||
ticker := time.NewTicker(intervals[0].duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("MyProjects polling: context cancelled, stopping")
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
tickCount++
|
||||
|
||||
// Проверяем, нужно ли переключить интервал
|
||||
if currentIntervalIdx < len(intervals)-1 {
|
||||
if intervals[currentIntervalIdx].count > 0 && tickCount >= intervals[currentIntervalIdx].count {
|
||||
currentIntervalIdx++
|
||||
tickCount = 0
|
||||
ticker.Reset(intervals[currentIntervalIdx].duration)
|
||||
log.Info().
|
||||
Dur("new_interval", intervals[currentIntervalIdx].duration).
|
||||
Msg("MyProjects polling: switching to slower interval")
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем количество проектов
|
||||
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("MyProjects polling: failed to get projects")
|
||||
continue
|
||||
}
|
||||
|
||||
// Если количество изменилось - перерендериваем
|
||||
if page.Total != s.lastProjectCount {
|
||||
log.Info().
|
||||
Int("old_count", s.lastProjectCount).
|
||||
Int("new_count", page.Total).
|
||||
Msg("MyProjects polling: project count changed, re-rendering")
|
||||
|
||||
s.lastProjectCount = page.Total
|
||||
s.renderProjects(b, bot.EditMessage, jwt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
206
tg_bot/screens/project_details.go
Normal file
206
tg_bot/screens/project_details.go
Normal file
@@ -0,0 +1,206 @@
|
||||
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
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
confirmingLinkTypeChange bool // Флаг для двухшагового подтверждения смены типа ссылки
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
if s.Project == nil {
|
||||
b.SendNew("❌ Проект не найден", Keyboard(
|
||||
Row(Button("← Назад", "back_to_projects")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
text := s.formatProjectDetails()
|
||||
keyboard := s.buildKeyboard()
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
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("▸ Telegram: @%s\n", *p.Username)
|
||||
} else {
|
||||
text += "▸ Telegram: <i>не привязан</i>\n"
|
||||
}
|
||||
|
||||
// Статус проекта
|
||||
var statusSymbol string
|
||||
var 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("purchases:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
|
||||
var linkTypeButtonText string
|
||||
if s.confirmingLinkTypeChange {
|
||||
// Показываем подтверждение
|
||||
var newType string
|
||||
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||
newType = "По заявке"
|
||||
} else {
|
||||
newType = "Свободное"
|
||||
}
|
||||
linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType)
|
||||
} else {
|
||||
// Показываем текущий тип вступления
|
||||
var currentType string
|
||||
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||
currentType = "Свободное"
|
||||
} else {
|
||||
currentType = "По заявке"
|
||||
}
|
||||
linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType)
|
||||
}
|
||||
buttons = append(buttons, Row(
|
||||
Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Нижний ряд: Назад и Архивировать
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back_to_projects"),
|
||||
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_to_projects", data == "back":
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(data, "creatives:"):
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
b.SetState(&Creatives{
|
||||
ProjectID: s.Project.ID,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "purchases:"):
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
b.SetState(&Purchases{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.Project.ID,
|
||||
ProjectTitle: s.Project.Title,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case data == "back_to_project":
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "link_type:"):
|
||||
if !s.confirmingLinkTypeChange {
|
||||
// Первое нажатие - включаем режим подтверждения
|
||||
s.confirmingLinkTypeChange = true
|
||||
s.Enter(b, bot.EditMessage)
|
||||
} else {
|
||||
// Второе нажатие - меняем тип ссылки
|
||||
var newType string
|
||||
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||
newType = "approval"
|
||||
} else {
|
||||
newType = "public"
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("workspace_id", s.WorkspaceID).
|
||||
Str("project_id", s.Project.ID).
|
||||
Str("old_type", s.Project.PurchaseInviteTypeDefault).
|
||||
Str("new_type", newType).
|
||||
Msg("Updating project invite link type")
|
||||
|
||||
updatedProject, err := b.Backend.UpdateProjectInviteLinkType(
|
||||
context.Background(),
|
||||
b.Session.JWT,
|
||||
s.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.confirmingLinkTypeChange = false
|
||||
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() {}
|
||||
273
tg_bot/screens/purchase_details.go
Normal file
273
tg_bot/screens/purchase_details.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PurchaseDetails struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
PurchaseID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.renderDetails(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
purchase, err := b.Backend.GetPurchase(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
s.ProjectID,
|
||||
s.PurchaseID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get purchase")
|
||||
b.SendNew("❌ Не удалось загрузить закуп", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
placementsByChannel := s.loadPlacementsByChannel(b, jwt, purchase)
|
||||
|
||||
// Получаем информацию о креативе
|
||||
creative, err := b.Backend.GetCreative(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
purchase.CreativeID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creative")
|
||||
creative = nil
|
||||
}
|
||||
|
||||
totalPlacements := 0
|
||||
for _, items := range placementsByChannel {
|
||||
totalPlacements += len(items)
|
||||
}
|
||||
|
||||
text := "<b>Детали закупа</b>\n\n"
|
||||
|
||||
// Информация о креативе
|
||||
if creative != nil {
|
||||
text += fmt.Sprintf("• <b>Креатив:</b> %s\n", creative.Name)
|
||||
} else {
|
||||
text += fmt.Sprintf("• <b>Креатив ID:</b> %s\n", purchase.CreativeID)
|
||||
}
|
||||
|
||||
// Статус закупа
|
||||
statusSymbol := "●"
|
||||
statusText := "Активный"
|
||||
if purchase.Status == "archived" {
|
||||
statusSymbol = "■"
|
||||
statusText = "Архивирован"
|
||||
}
|
||||
text += fmt.Sprintf("• <b>Статус:</b> %s %s\n", statusSymbol, statusText)
|
||||
text += fmt.Sprintf("• <b>Каналов:</b> %d\n", len(purchase.Channels))
|
||||
text += fmt.Sprintf("• <b>Размещений:</b> %d\n\n", totalPlacements)
|
||||
|
||||
// Информация о каналах
|
||||
if len(purchase.Channels) == 0 {
|
||||
text += "<i>Каналы не добавлены</i>\n"
|
||||
} else {
|
||||
text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels))
|
||||
|
||||
for i, ch := range purchase.Channels {
|
||||
chStatusText := formatPurchaseChannelStatus(ch.Status)
|
||||
|
||||
// Название канала
|
||||
channelName := "Без названия"
|
||||
if ch.Channel.Title != nil {
|
||||
channelName = *ch.Channel.Title
|
||||
} else if ch.Channel.Username != nil {
|
||||
channelName = "@" + *ch.Channel.Username
|
||||
}
|
||||
|
||||
text += fmt.Sprintf("%d. <b>%s</b>\n", i+1, channelName)
|
||||
text += fmt.Sprintf(" ◦ <i>%s</i>\n", chStatusText)
|
||||
|
||||
// Стоимость
|
||||
if ch.Details != nil && ch.Details.Cost != nil {
|
||||
text += fmt.Sprintf(" ◦ Стоимость: %.0f₽\n", ch.Details.Cost.Value)
|
||||
}
|
||||
|
||||
// Дата размещения (план)
|
||||
if ch.Details != nil && ch.Details.PlacementAt != nil && *ch.Details.PlacementAt != "" {
|
||||
text += fmt.Sprintf(" ◦ План: %s\n", *ch.Details.PlacementAt)
|
||||
}
|
||||
|
||||
// Комментарий
|
||||
if ch.Comment != nil && *ch.Comment != "" {
|
||||
text += fmt.Sprintf(" ◦ %s\n", *ch.Comment)
|
||||
}
|
||||
|
||||
// Информация о размещении
|
||||
placements := placementsByChannel[ch.ID]
|
||||
if len(placements) == 0 {
|
||||
text += " ◦ Размещение: <b>нет</b>\n"
|
||||
} else {
|
||||
placement := placements[0]
|
||||
text += fmt.Sprintf(" ◦ Размещение: <b>есть</b> (%d)\n", len(placements))
|
||||
if placement.Status != "" {
|
||||
text += fmt.Sprintf(" ◦ Статус размещения: %s\n", formatPlacementStatus(placement.Status))
|
||||
}
|
||||
if placement.PlacementDate != "" {
|
||||
text += fmt.Sprintf(" ◦ Дата: %s\n", placement.PlacementDate)
|
||||
}
|
||||
if placement.AdPostURL != nil && *placement.AdPostURL != "" {
|
||||
text += fmt.Sprintf(" ◦ Пост: %s\n", *placement.AdPostURL)
|
||||
}
|
||||
if placement.SubscriptionsCount > 0 {
|
||||
text += fmt.Sprintf(" ◦ Подписки: %d\n", placement.SubscriptionsCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Тип invite link
|
||||
inviteLinkTypeText := "публичная"
|
||||
if ch.InviteLinkType == "approval" {
|
||||
inviteLinkTypeText = "с одобрением"
|
||||
}
|
||||
text += fmt.Sprintf(" ◦ Ссылка: %s\n", inviteLinkTypeText)
|
||||
|
||||
text += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Кнопка назад
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back"),
|
||||
))
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *PurchaseDetails) loadPlacementsByChannel(
|
||||
b *bot.Bot,
|
||||
jwt string,
|
||||
purchase *backend.Purchase,
|
||||
) map[string][]backend.Placement {
|
||||
placementsByChannel := make(map[string][]backend.Placement)
|
||||
if purchase == nil || purchase.CreativeID == "" || len(purchase.Channels) == 0 {
|
||||
return placementsByChannel
|
||||
}
|
||||
|
||||
projectID := s.ProjectID
|
||||
creativeID := purchase.CreativeID
|
||||
pageSize := 50
|
||||
maxPages := 5
|
||||
foundChannels := make(map[string]bool)
|
||||
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
resp, err := b.Backend.GetPlacements(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
&projectID,
|
||||
nil,
|
||||
&creativeID,
|
||||
false,
|
||||
page,
|
||||
pageSize,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get placements for purchase details")
|
||||
break
|
||||
}
|
||||
|
||||
for _, placement := range resp.Items {
|
||||
if placement.PurchaseID == nil || *placement.PurchaseID != purchase.ID {
|
||||
continue
|
||||
}
|
||||
if placement.PurchaseChannelID == nil || *placement.PurchaseChannelID == "" {
|
||||
continue
|
||||
}
|
||||
chID := *placement.PurchaseChannelID
|
||||
placementsByChannel[chID] = append(placementsByChannel[chID], placement)
|
||||
foundChannels[chID] = true
|
||||
}
|
||||
|
||||
if resp.Pages <= page || len(foundChannels) == len(purchase.Channels) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return placementsByChannel
|
||||
}
|
||||
|
||||
func formatPurchaseChannelStatus(status string) string {
|
||||
switch status {
|
||||
case "planned":
|
||||
return "Планируется"
|
||||
case "approved":
|
||||
return "Согласовано"
|
||||
case "rejected":
|
||||
return "Отклонено"
|
||||
case "in_progress":
|
||||
return "В работе"
|
||||
case "completed":
|
||||
return "Завершено"
|
||||
default:
|
||||
return "Неизвестно"
|
||||
}
|
||||
}
|
||||
|
||||
func formatPlacementStatus(status string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(status))
|
||||
switch normalized {
|
||||
case "planned":
|
||||
return "Планируется"
|
||||
case "approved":
|
||||
return "Согласовано"
|
||||
case "rejected":
|
||||
return "Отклонено"
|
||||
case "in_progress":
|
||||
return "В работе"
|
||||
case "completed":
|
||||
return "Размещено"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch data {
|
||||
case "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PurchaseDetails) Exit() {}
|
||||
1651
tg_bot/screens/purchase_optional_details.go
Normal file
1651
tg_bot/screens/purchase_optional_details.go
Normal file
File diff suppressed because it is too large
Load Diff
149
tg_bot/screens/purchases.go
Normal file
149
tg_bot/screens/purchases.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Purchases struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.renderPurchases(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
purchases, err := b.Backend.GetPurchases(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
s.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get purchases")
|
||||
b.SendNew("❌ Не удалось загрузить закупы", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
text := "<b>Закупы</b>\n\n"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(purchases) == 0 {
|
||||
text += `Список закупов пуст.
|
||||
|
||||
<i>Закуп — это план размещения рекламы в каналах:</i>
|
||||
‣ Выбор креатива
|
||||
‣ Список каналов для размещения
|
||||
‣ Планируемая стоимость
|
||||
‣ Статус выполнения
|
||||
|
||||
Создайте первый закуп`
|
||||
} else {
|
||||
text += "Управление закупами для проекта.\n\n"
|
||||
text += "Выберите закуп для деталей:\n\n"
|
||||
|
||||
for i, purchase := range purchases {
|
||||
// Определяем статус
|
||||
statusSymbol := "●"
|
||||
switch purchase.Status {
|
||||
case "active":
|
||||
statusSymbol = "●"
|
||||
case "archived":
|
||||
statusSymbol = "■"
|
||||
}
|
||||
|
||||
// Считаем каналы по статусам
|
||||
totalChannels := len(purchase.Channels)
|
||||
completedChannels := 0
|
||||
for _, ch := range purchase.Channels {
|
||||
if ch.Status == "completed" {
|
||||
completedChannels++
|
||||
}
|
||||
}
|
||||
|
||||
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
|
||||
statusSymbol,
|
||||
i+1,
|
||||
completedChannels,
|
||||
totalChannels,
|
||||
)
|
||||
|
||||
buttons = append(buttons, Row(
|
||||
Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка создания нового закупа
|
||||
buttons = append(buttons, Row(
|
||||
Button("+ Создать закуп", "add_purchase"),
|
||||
))
|
||||
|
||||
// Кнопка назад
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back"),
|
||||
))
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *Purchases) 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{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ActivePicker: "",
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
default:
|
||||
// Проверяем, не нажали ли на конкретный закуп
|
||||
if len(data) > 9 && data[:9] == "purchase:" {
|
||||
purchaseID := data[9:]
|
||||
b.SetState(&PurchaseDetails{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
PurchaseID: purchaseID,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
} else {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Purchases) Exit() {}
|
||||
379
tg_bot/screens/select_channels_for_purchase.go
Normal file
379
tg_bot/screens/select_channels_for_purchase.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"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 {
|
||||
Username string
|
||||
PlannedCost *float64
|
||||
Comment *string
|
||||
}
|
||||
|
||||
type SelectChannelsForPurchase struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
Channels []PurchaseChannelInput
|
||||
CurrentPage int
|
||||
InvalidUsernames []string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||||
|
||||
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 канала (без @)
|
||||
Например: <code>channel_name</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, ch.Username)
|
||||
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 := fmt.Sprintf("@%s", ch.Username)
|
||||
if ch.PlannedCost != nil {
|
||||
buttonText = fmt.Sprintf("@%s — %.0f₽", ch.Username, *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.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{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
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
|
||||
}
|
||||
|
||||
// Проверяем существование каналов через API
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
notFoundChannels := s.validateChannels(b, jwt)
|
||||
if len(notFoundChannels) > 0 {
|
||||
text := "❌ Следующие каналы не найдены:\n\n"
|
||||
for _, username := range notFoundChannels {
|
||||
text += fmt.Sprintf("• @%s\n", username)
|
||||
}
|
||||
text += "\nУдалите их из списка и попробуйте снова"
|
||||
|
||||
b.Edit(text, Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
b.SetState(&PurchaseOptionalDetails{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: s.Channels,
|
||||
BackState: s.BackState,
|
||||
}, 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.CreatePurchaseChannelInput
|
||||
for _, ch := range s.Channels {
|
||||
var details *backend.PurchaseChannelDetails
|
||||
if ch.PlannedCost != nil {
|
||||
cost := backend.CostInfo{
|
||||
Type: "fixed",
|
||||
Value: *ch.PlannedCost,
|
||||
}
|
||||
details = &backend.PurchaseChannelDetails{
|
||||
Cost: &cost,
|
||||
}
|
||||
}
|
||||
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
|
||||
Username: ch.Username,
|
||||
Comment: ch.Comment,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
input := backend.CreatePurchaseInput{
|
||||
CreativeID: s.CreativeID,
|
||||
Channels: apiChannels,
|
||||
}
|
||||
|
||||
purchase, err := b.Backend.CreatePurchase(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
s.ProjectID,
|
||||
input,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create purchase")
|
||||
b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
b.SetState(&PurchaseDetails{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
PurchaseID: purchase.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
|
||||
}
|
||||
|
||||
tokens := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
|
||||
})
|
||||
|
||||
added := 0
|
||||
var invalid []string
|
||||
for _, token := range tokens {
|
||||
username := strings.TrimSpace(token)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
username = strings.Trim(username, "@")
|
||||
username = strings.Trim(username, ",;")
|
||||
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
if !channelUsernameRe.MatchString(username) {
|
||||
invalid = append(invalid, username)
|
||||
continue
|
||||
}
|
||||
|
||||
exists := false
|
||||
for _, ch := range s.Channels {
|
||||
if ch.Username == username {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||
Username: username,
|
||||
})
|
||||
added++
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
if len(invalid) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.InvalidUsernames = invalid
|
||||
|
||||
// Обновляем экран
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
|
||||
var notFound []string
|
||||
|
||||
for _, ch := range s.Channels {
|
||||
channels, err := b.Backend.SearchChannels(
|
||||
context.Background(),
|
||||
jwt,
|
||||
ch.Username,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
|
||||
notFound = append(notFound, ch.Username)
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем что нашли хотя бы один канал с точным совпадением username
|
||||
found := false
|
||||
for _, channel := range channels {
|
||||
if channel.Username != nil && *channel.Username == ch.Username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
notFound = append(notFound, ch.Username)
|
||||
}
|
||||
}
|
||||
|
||||
return notFound
|
||||
}
|
||||
|
||||
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() {}
|
||||
151
tg_bot/screens/select_workspace.go
Normal file
151
tg_bot/screens/select_workspace.go
Normal 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() {}
|
||||
829
tg_bot/screens/ui/date_time_picker.go
Normal file
829
tg_bot/screens/ui/date_time_picker.go
Normal file
@@ -0,0 +1,829 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
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
|
||||
Selected *time.Time
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
type DateTimeSelectionTarget interface {
|
||||
SetDateTimeSelection(key string, value time.Time)
|
||||
}
|
||||
|
||||
type DateTimePicker struct {
|
||||
Title string
|
||||
Key string
|
||||
IncludeTime 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,
|
||||
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)
|
||||
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 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) {
|
||||
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
|
||||
} else {
|
||||
s.view = pickerViewConfirm
|
||||
}
|
||||
}
|
||||
|
||||
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.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.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() {
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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() {}
|
||||
|
||||
func parseDateTimeInput(
|
||||
input string,
|
||||
currentDate *time.Time,
|
||||
currentHour *int,
|
||||
currentMinute *int,
|
||||
) (time.Time, bool, bool) {
|
||||
text := strings.ToLower(strings.TrimSpace(input))
|
||||
text = strings.ReplaceAll(text, ",", " ")
|
||||
text = strings.ReplaceAll(text, " ", " ")
|
||||
|
||||
now := time.Now().In(MskLocation)
|
||||
baseDate := now
|
||||
if currentDate != nil {
|
||||
baseDate = currentDate.In(MskLocation)
|
||||
}
|
||||
|
||||
date, dateOk := parseDate(text, baseDate)
|
||||
hour, minute, timeOk := parseTime(text, currentHour, currentMinute)
|
||||
|
||||
if !dateOk && !timeOk {
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
if !dateOk {
|
||||
date = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), 0, 0, 0, 0, MskLocation)
|
||||
}
|
||||
if !timeOk {
|
||||
hour = 0
|
||||
minute = 0
|
||||
}
|
||||
|
||||
value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation)
|
||||
if value.Before(now) {
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
return value, dateOk, timeOk
|
||||
}
|
||||
|
||||
func parseDate(text string, baseDate time.Time) (time.Time, bool) {
|
||||
if strings.Contains(text, "сегодня") {
|
||||
return baseDate, true
|
||||
}
|
||||
if strings.Contains(text, "завтра") {
|
||||
return baseDate.AddDate(0, 0, 1), true
|
||||
}
|
||||
if strings.Contains(text, "послезавтра") {
|
||||
return baseDate.AddDate(0, 0, 2), true
|
||||
}
|
||||
|
||||
if date := parseNumericDate(text); !date.IsZero() {
|
||||
return date, true
|
||||
}
|
||||
if date := parseWordDate(text); !date.IsZero() {
|
||||
return date, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseNumericDate(text string) time.Time {
|
||||
reYMD := regexp.MustCompile(`\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b`)
|
||||
if match := reYMD.FindStringSubmatch(text); len(match) == 4 {
|
||||
year := parseInt(match[1])
|
||||
month := parseInt(match[2])
|
||||
day := parseInt(match[3])
|
||||
return safeDate(year, month, day)
|
||||
}
|
||||
|
||||
reDMY := regexp.MustCompile(`\b(\d{1,2})[./-](\d{1,2})(?:[./-](\d{2,4}))?\b`)
|
||||
if match := reDMY.FindStringSubmatch(text); len(match) >= 3 {
|
||||
day := parseInt(match[1])
|
||||
month := parseInt(match[2])
|
||||
year := time.Now().In(MskLocation).Year()
|
||||
if len(match) == 4 && match[3] != "" {
|
||||
year = parseInt(match[3])
|
||||
if year < 100 {
|
||||
year += 2000
|
||||
}
|
||||
}
|
||||
return safeDate(year, month, day)
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseWordDate(text string) time.Time {
|
||||
monthMap := map[string]time.Month{
|
||||
"янв": 1, "январ": 1, "января": 1,
|
||||
"фев": 2, "феврал": 2, "февраля": 2,
|
||||
"мар": 3, "марта": 3,
|
||||
"апр": 4, "апрел": 4, "апреля": 4,
|
||||
"май": 5, "мая": 5,
|
||||
"июн": 6, "июня": 6,
|
||||
"июл": 7, "июля": 7,
|
||||
"авг": 8, "август": 8, "августа": 8,
|
||||
"сен": 9, "сент": 9, "сентябр": 9, "сентября": 9,
|
||||
"окт": 10, "октябр": 10, "октября": 10,
|
||||
"ноя": 11, "ноябр": 11, "ноября": 11,
|
||||
"дек": 12, "декабр": 12, "декабря": 12,
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`\b(\d{1,2})\s+([a-zа-яё]+)(?:\s+(\d{2,4}))?\b`)
|
||||
match := re.FindStringSubmatch(text)
|
||||
if len(match) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
day := parseInt(match[1])
|
||||
monthToken := match[2]
|
||||
var month time.Month
|
||||
for key, value := range monthMap {
|
||||
if strings.HasPrefix(monthToken, key) {
|
||||
month = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if month == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
year := time.Now().In(MskLocation).Year()
|
||||
if len(match) == 4 && match[3] != "" {
|
||||
year = parseInt(match[3])
|
||||
if year < 100 {
|
||||
year += 2000
|
||||
}
|
||||
}
|
||||
|
||||
return safeDate(year, int(month), day)
|
||||
}
|
||||
|
||||
func parseTime(text string, currentHour *int, currentMinute *int) (int, int, bool) {
|
||||
re := regexp.MustCompile(`\b(\d{1,2})[:.](\d{2})\b`)
|
||||
if match := re.FindStringSubmatch(text); len(match) == 3 {
|
||||
hour := parseInt(match[1])
|
||||
minute := parseInt(match[2])
|
||||
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
reCompact := regexp.MustCompile(`\b(\d{1,2})\s+(\d{2})\b`)
|
||||
if match := reCompact.FindStringSubmatch(text); len(match) == 3 {
|
||||
hour := parseInt(match[1])
|
||||
minute := parseInt(match[2])
|
||||
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
reHour := regexp.MustCompile(`\b(\d{1,2})\s*(?:ч|час|часов)\b`)
|
||||
if match := reHour.FindStringSubmatch(text); len(match) == 2 {
|
||||
hour := parseInt(match[1])
|
||||
if hour >= 0 && hour <= 23 {
|
||||
minute := 0
|
||||
if currentMinute != nil {
|
||||
minute = *currentMinute
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
reJustHour := regexp.MustCompile(`\b(\d{1,2})\b`)
|
||||
if match := reJustHour.FindStringSubmatch(text); len(match) == 2 && strings.Contains(text, "в") {
|
||||
hour := parseInt(match[1])
|
||||
if hour >= 0 && hour <= 23 {
|
||||
minute := 0
|
||||
if currentMinute != nil {
|
||||
minute = *currentMinute
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
if currentHour != nil && currentMinute != nil {
|
||||
return *currentHour, *currentMinute, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func safeDate(year, month, day int) time.Time {
|
||||
if month < 1 || month > 12 {
|
||||
return time.Time{}
|
||||
}
|
||||
if day < 1 || day > 31 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, MskLocation)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
304
tg_bot/screens/ui/message_format.go
Normal file
304
tg_bot/screens/ui/message_format.go
Normal file
@@ -0,0 +1,304 @@
|
||||
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.
|
||||
// Invite links are normalized into <a class="tg-link"> placeholders for previews.
|
||||
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])
|
||||
if isInviteLink(entityText) {
|
||||
openTag = `<a class="tg-link">`
|
||||
closeTag = "</a>"
|
||||
} else {
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
|
||||
closeTag = "</a>"
|
||||
}
|
||||
} else if entity.Type == echotron.TextLinkEntity {
|
||||
if entity.URL != "" {
|
||||
if isInviteLink(entity.URL) {
|
||||
openTag = `<a class="tg-link">`
|
||||
closeTag = "</a>"
|
||||
} else {
|
||||
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("<")
|
||||
case '>':
|
||||
result.WriteString(">")
|
||||
case '&':
|
||||
result.WriteString("&")
|
||||
default:
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeHTMLTags(result.String())
|
||||
}
|
||||
|
||||
// isInviteLink detects Telegram invite links used in creatives.
|
||||
func isInviteLink(url string) bool {
|
||||
return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/")
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(stack) > 0 && stack[len(stack)-1] == tagName {
|
||||
stack = stack[:len(stack)-1]
|
||||
out.WriteString(input[i : end+1])
|
||||
}
|
||||
} 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
|
||||
func SanitizeHTML(input string) string {
|
||||
return normalizeHTMLTags(input)
|
||||
}
|
||||
175
tg_bot/screens/ui/pagination.go
Normal file
175
tg_bot/screens/ui/pagination.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user