' and handles the path.
+func WebhookUpdatesOptions(whURL, token string, dropPendingUpdates bool, opts *WebhookOptions) <-chan *Update {
+ u, err := url.Parse(whURL)
+ if err != nil {
+ panic(err)
+ }
+
+ wURL := u.Hostname() + u.EscapedPath()
+ api := NewAPI(token)
+ if _, err := api.SetWebhook(wURL, dropPendingUpdates, opts); err != nil {
+ panic(err)
+ }
+
+ var updates = make(chan *Update)
+ http.HandleFunc(u.EscapedPath(), func(w http.ResponseWriter, r *http.Request) {
+ var update Update
+
+ jsn, err := readRequest(r)
+ if err != nil {
+ log.Println("echotron.WebhookUpdates", err)
+ return
+ }
+
+ if err := json.Unmarshal(jsn, &update); err != nil {
+ log.Println("echotron.WebhookUpdates", err)
+ return
+ }
+
+ updates <- &update
+ })
+
+ go func() {
+ defer close(updates)
+ port := fmt.Sprintf(":%s", u.Port())
+ for {
+ if err := http.ListenAndServe(port, nil); err != nil {
+ log.Println("echotron.WebhookUpdates", err)
+ time.Sleep(5 * time.Second)
+ }
+ }
+ }()
+
+ return updates
+}
diff --git a/shared/echotron/simpledsp_test.go b/shared/echotron/simpledsp_test.go
new file mode 100644
index 0000000..b83ccec
--- /dev/null
+++ b/shared/echotron/simpledsp_test.go
@@ -0,0 +1,7 @@
+package echotron
+
+import "testing"
+
+func TestPollingUpdates(t *testing.T) {
+ PollingUpdates(api.token)
+}
diff --git a/shared/echotron/stickers.go b/shared/echotron/stickers.go
new file mode 100644
index 0000000..241edcc
--- /dev/null
+++ b/shared/echotron/stickers.go
@@ -0,0 +1,271 @@
+/*
+ * Echotron
+ * Copyright (C) 2018 The Echotron Contributors
+ *
+ * Echotron is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Echotron is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package echotron
+
+import (
+ "encoding/json"
+ "net/url"
+)
+
+// Sticker represents a sticker.
+type Sticker struct {
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ MaskPosition *MaskPosition `json:"mask_position,omitempty"`
+ Type StickerSetType `json:"type"`
+ FileUniqueID string `json:"file_unique_id"`
+ SetName string `json:"set_name,omitempty"`
+ FileID string `json:"file_id"`
+ Emoji string `json:"emoji,omitempty"`
+ CustomEmojiID string `json:"custom_emoji_id,omitempty"`
+ PremiumAnimation File `json:"premium_animation,omitempty"`
+ FileSize int `json:"file_size,omitempty"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ IsVideo bool `json:"is_video"`
+ IsAnimated bool `json:"is_animated"`
+ NeedsRepainting bool `json:"needs_repainting,omitempty"`
+}
+
+// StickerSet represents a sticker set.
+type StickerSet struct {
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ Title string `json:"title"`
+ Name string `json:"name"`
+ StickerType StickerSetType `json:"sticker_type"`
+ Stickers []Sticker `json:"stickers"`
+}
+
+// StickerSetType represents the type of a sticker or of the entire set
+type StickerSetType string
+
+const (
+ RegularStickerSet StickerSetType = "regular"
+ MaskStickerSet = "mask"
+ CustomEmojiStickerSet = "custom_emoji"
+)
+
+// StickerFormat is a custom type for the various sticker formats.
+type StickerFormat string
+
+// These are all the possible sticker formats.
+const (
+ StaticFormat StickerFormat = "static"
+ AnimatedFormat = "animated"
+ VideoFormat = "video"
+)
+
+// MaskPosition describes the position on faces where a mask should be placed by default.
+type MaskPosition struct {
+ Point MaskPoint `json:"point"`
+ XShift float32 `json:"x_shift"`
+ YShift float32 `json:"y_shift"`
+ Scale float32 `json:"scale"`
+}
+
+// MaskPoint is a custom type for the various part of face where a mask should be placed.
+type MaskPoint string
+
+// These are all the possible parts of the face for a mask.
+const (
+ ForeheadPoint MaskPoint = "forehead"
+ EyesPoint = "eyes"
+ MouthPoint = "mouth"
+ ChinPoint = "chin"
+)
+
+// NewStickerSetOptions contains the optional parameters used in the CreateNewStickerSet method.
+type NewStickerSetOptions struct {
+ StickerType StickerSetType `query:"sticker_type"`
+ NeedsRepainting bool `query:"needs_repainting"`
+}
+
+// InputSticker is a struct which describes a sticker to be added to a sticker set.
+type InputSticker struct {
+ MaskPosition *MaskPosition `json:"mask_position,omitempty"`
+ Keywords *[]string `json:"keywords,omitempty"`
+ Format StickerFormat `json:"format"`
+ Sticker InputFile `json:"-"`
+ EmojiList []string `json:"emoji_list"`
+}
+
+// stickerEnvelope is a generic struct for all the various structs under the InputSticker interface.
+type stickerEnvelope struct {
+ Sticker string `json:"sticker"`
+ InputSticker
+}
+
+// SendSticker is used to send static .WEBP or animated .TGS stickers.
+func (a API) SendSticker(stickerID string, chatID int64, opts *StickerOptions) (res APIResponseMessage, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("sticker", stickerID)
+ vals.Set("chat_id", itoa(chatID))
+ return res, client.get(a.base, "sendSticker", addValues(vals, opts), &res)
+}
+
+// GetStickerSet is used to get a sticker set.
+func (a API) GetStickerSet(name string) (res APIResponseStickerSet, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("name", name)
+ return res, client.get(a.base, "getStickerSet", vals, &res)
+}
+
+// GetCustomEmojiStickers is used to get information about custom emoji stickers by their identifiers.
+func (a API) GetCustomEmojiStickers(customEmojiIDs ...string) (res APIResponseStickers, err error) {
+ var vals = make(url.Values)
+
+ jsn, _ := json.Marshal(customEmojiIDs)
+ vals.Set("custom_emoji_ids", string(jsn))
+ return res, client.get(a.base, "getCustomEmojiStickers", vals, &res)
+}
+
+// UploadStickerFile is used to upload a .PNG file with a sticker for later use in
+// CreateNewStickerSet and AddStickerToSet methods (can be used multiple times).
+func (a API) UploadStickerFile(userID int64, sticker InputFile, format StickerFormat) (res APIResponseFile, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("user_id", itoa(userID))
+ vals.Set("sticker_format", string(format))
+ return res, client.postFile(a.base, "uploadStickerFile", "sticker", sticker, InputFile{}, vals, &res)
+}
+
+// CreateNewStickerSet is used to create a new sticker set owned by a user.
+func (a API) CreateNewStickerSet(userID int64, name, title string, stickers []InputSticker, opts *NewStickerSetOptions) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("user_id", itoa(userID))
+ vals.Set("name", name)
+ vals.Set("title", title)
+ return res, client.postStickers(a.base, "createNewStickerSet", addValues(vals, opts), &res, stickers...)
+}
+
+// AddStickerToSet is used to add a new sticker to a set created by the bot.
+func (a API) AddStickerToSet(userID int64, name string, sticker InputSticker) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("user_id", itoa(userID))
+ vals.Set("name", name)
+ return res, client.postStickers(a.base, "addStickerToSet", vals, &res, sticker)
+}
+
+// SetStickerPositionInSet is used to move a sticker in a set created by the bot to a specific position.
+func (a API) SetStickerPositionInSet(sticker string, position int) (res APIResponseBase, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("sticker", sticker)
+ vals.Set("position", itoa(int64(position)))
+ return res, client.get(a.base, "setStickerPositionInSet", vals, &res)
+}
+
+// DeleteStickerFromSet is used to delete a sticker from a set created by the bot.
+func (a API) DeleteStickerFromSet(sticker string) (res APIResponseBase, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("sticker", sticker)
+ return res, client.get(a.base, "deleteStickerFromSet", vals, &res)
+}
+
+// ReplaceStickerInSet is used to replace an existing sticker in a sticker set with a new one.
+// The method is equivalent to calling DeleteStickerFromSet, then AddStickerToSet, then SetStickerPositionInSet.
+func (a API) ReplaceStickerInSet(userID int64, name string, old_sticker string, sticker InputSticker) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("user_id", itoa(userID))
+ vals.Set("name", name)
+ vals.Set("old_sticker", old_sticker)
+ return res, client.postStickers(a.base, "replaceStickerInSet", vals, &res, sticker)
+}
+
+// SetStickerEmojiList is used to change the list of emoji assigned to a regular or custom emoji sticker.
+// The sticker must belong to a sticker set created by the bot.
+func (a API) SetStickerEmojiList(sticker string, emojis []string) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ jsn, _ := json.Marshal(emojis)
+
+ vals.Set("sticker", sticker)
+ vals.Set("emoji_list", string(jsn))
+ return res, client.get(a.base, "setStickerEmojiList", vals, &res)
+}
+
+// SetStickerKeywords is used to change search keywords assigned to a regular or custom emoji sticker.
+// The sticker must belong to a sticker set created by the bot.
+func (a API) SetStickerKeywords(sticker string, keywords []string) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ jsn, _ := json.Marshal(keywords)
+
+ vals.Set("sticker", sticker)
+ vals.Set("keywords", string(jsn))
+ return res, client.get(a.base, "setStickerKeywords", vals, &res)
+}
+
+// SetStickerMaskPosition is used to change the mask position of a mask sticker.
+// The sticker must belong to a sticker set that was created by the bot.
+func (a API) SetStickerMaskPosition(sticker string, mask MaskPosition) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ jsn, _ := json.Marshal(mask)
+
+ vals.Set("sticker", sticker)
+ vals.Set("mask_position", string(jsn))
+ return res, client.get(a.base, "setStickerMaskPosition", vals, &res)
+}
+
+// SetStickerSetTitle is used to set the title of a created sticker set.
+func (a API) SetStickerSetTitle(name, title string) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("name", name)
+ vals.Set("title", title)
+ return res, client.get(a.base, "setStickerSetTitle", vals, &res)
+}
+
+// SetStickerSetThumbnail is used to set the thumbnail of a sticker set.
+func (a API) SetStickerSetThumbnail(name string, userID int64, thumbnail InputFile, format StickerFormat) (res APIResponseBase, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("name", name)
+ vals.Set("user_id", itoa(userID))
+ vals.Set("format", string(format))
+ return res, client.postFile(a.base, "setStickerSetThumbnail", "thumbnail", thumbnail, InputFile{}, vals, &res)
+}
+
+// SetCustomEmojiStickerSetThumbnail is used to set the thumbnail of a custom emoji sticker set.
+func (a API) SetCustomEmojiStickerSetThumbnail(name, emojiID string) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("name", name)
+ vals.Set("custom_emoji_id", emojiID)
+ return res, client.get(a.base, "setCustomEmojiStickerSetThumbnail", vals, &res)
+}
+
+// DeleteStickerSet is used to delete a sticker set that was created by the bot.
+func (a API) DeleteStickerSet(name string) (res APIResponseBool, err error) {
+ var vals = make(url.Values)
+
+ vals.Set("name", name)
+ return res, client.get(a.base, "DeleteStickerSet", vals, &res)
+}
+
+// GetForumTopicIconStickers is used to get custom emoji stickers, which can be used as a forum topic icon by any user.
+func (a API) GetForumTopicIconStickers() (res APIResponseStickers, err error) {
+ return res, client.get(a.base, "getForumTopicIconStickers", nil, &res)
+}
diff --git a/shared/echotron/stickers_test.go b/shared/echotron/stickers_test.go
new file mode 100644
index 0000000..84c649c
--- /dev/null
+++ b/shared/echotron/stickers_test.go
@@ -0,0 +1,229 @@
+/*
+ * Echotron
+ * Copyright (C) 2018 The Echotron Contributors
+ *
+ * Echotron is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Echotron is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package echotron
+
+import (
+ "fmt"
+ "testing"
+ "time"
+)
+
+var (
+ stickerFile *File
+ stickerSet *StickerSet
+ stickerSetName = fmt.Sprintf("set%d_by_echotron_coverage_bot", time.Now().Unix())
+)
+
+func TestUploadStickerFile(t *testing.T) {
+ resp, err := api.UploadStickerFile(
+ chatID,
+ NewInputFilePath("assets/tests/echotron_test.png"),
+ StaticFormat,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ stickerFile = resp.Result
+}
+
+func TestCreateNewStickerSet(t *testing.T) {
+ _, err := api.CreateNewStickerSet(
+ chatID,
+ stickerSetName,
+ "Echotron Coverage Pack",
+ []InputSticker{
+ {
+ Sticker: NewInputFileID(stickerFile.FileID),
+ EmojiList: []string{"🤖"},
+ Format: StaticFormat,
+ },
+ {
+ Sticker: NewInputFilePath("assets/tests/echotron_test.png"),
+ EmojiList: []string{"🤖"},
+ Format: StaticFormat,
+ },
+ {
+ Sticker: NewInputFileURL(photoURL),
+ EmojiList: []string{"🤖"},
+ Format: StaticFormat,
+ },
+ },
+ nil,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestAddStickerToSet(t *testing.T) {
+ _, err := api.AddStickerToSet(
+ chatID,
+ stickerSetName,
+ InputSticker{
+ Sticker: NewInputFilePath("assets/tests/echotron_sticker.png"),
+ EmojiList: []string{"🤖"},
+ Format: StaticFormat,
+ },
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetCustomEmojiStickers(t *testing.T) {
+ _, err := api.GetCustomEmojiStickers(
+ "5407041870620531251",
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetStickerSet(t *testing.T) {
+ resp, err := api.GetStickerSet(
+ stickerSetName,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ stickerSet = resp.Result
+}
+
+func TestSetStickerPositionInSet(t *testing.T) {
+ _, err := api.SetStickerPositionInSet(
+ stickerSet.Stickers[1].FileID,
+ 0,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSetStickerEmojiList(t *testing.T) {
+ _, err := api.SetStickerEmojiList(
+ stickerSet.Stickers[0].FileID,
+ []string{"🤖", "👾"},
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSetStickerKeywords(t *testing.T) {
+ _, err := api.SetStickerKeywords(
+ stickerSet.Stickers[0].FileID,
+ []string{"echotron"},
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSetStickerSetTitle(t *testing.T) {
+ _, err := api.SetStickerSetTitle(
+ stickerSetName,
+ fmt.Sprintf("new_%s", stickerSetName),
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestReplaceStickerInSet(t *testing.T) {
+ _, err := api.ReplaceStickerInSet(
+ chatID,
+ stickerSetName,
+ stickerSet.Stickers[0].FileID,
+ InputSticker{
+ Sticker: NewInputFileURL(photoURL),
+ EmojiList: []string{"🤖"},
+ Format: StaticFormat,
+ },
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeleteStickerFromSet(t *testing.T) {
+ _, err := api.DeleteStickerFromSet(
+ stickerSet.Stickers[1].FileID,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSendSticker(t *testing.T) {
+ _, err := api.SendSticker(
+ stickerSet.Stickers[0].FileID,
+ chatID,
+ nil,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSetStickerSetThumbnail(t *testing.T) {
+ _, err := api.SetStickerSetThumbnail(
+ stickerSetName,
+ chatID,
+ NewInputFilePath("assets/tests/echotron_thumb.png"),
+ StaticFormat,
+ )
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeleteStickerSet(t *testing.T) {
+ _, err := api.DeleteStickerSet(stickerSetName)
+
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetForumTopicIconStickers(t *testing.T) {
+ res, err := api.GetForumTopicIconStickers()
+
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(res.Result) == 0 {
+ t.Fatal("error: Telegram returned no forum topic icon stickers")
+ }
+}
diff --git a/shared/echotron/types.go b/shared/echotron/types.go
new file mode 100644
index 0000000..4ff4ced
--- /dev/null
+++ b/shared/echotron/types.go
@@ -0,0 +1,1986 @@
+/*
+ * Echotron
+ * Copyright (C) 2018 The Echotron Contributors
+ *
+ * Echotron is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Echotron is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package echotron
+
+import "encoding/json"
+
+// Update represents an incoming update.
+// At most one of the optional parameters can be present in any given update.
+type Update struct {
+ ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"`
+ ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"`
+ RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
+ Message *Message `json:"message,omitempty"`
+ EditedMessage *Message `json:"edited_message,omitempty"`
+ ChannelPost *Message `json:"channel_post,omitempty"`
+ EditedChannelPost *Message `json:"edited_channel_post,omitempty"`
+ BusinessConnection *BusinessConnection `json:"business_connection,omitempty"`
+ BusinessMessage *Message `json:"business_message,omitempty"`
+ EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
+ DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
+ MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
+ MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
+ InlineQuery *InlineQuery `json:"inline_query,omitempty"`
+ ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"`
+ CallbackQuery *CallbackQuery `json:"callback_query,omitempty"`
+ ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"`
+ PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"`
+ Poll *Poll `json:"poll,omitempty"`
+ PollAnswer *PollAnswer `json:"poll_answer,omitempty"`
+ MyChatMember *ChatMemberUpdated `json:"my_chat_member,omitempty"`
+ ChatMember *ChatMemberUpdated `json:"chat_member,omitempty"`
+ PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"`
+ ID int `json:"update_id"`
+}
+
+// ChatID returns the ID of the chat the update is coming from.
+func (u Update) ChatID() int64 {
+ switch {
+ case u.ChatJoinRequest != nil:
+ return u.ChatJoinRequest.Chat.ID
+ case u.ChatBoost != nil:
+ return u.ChatBoost.Chat.ID
+ case u.RemovedChatBoost != nil:
+ return u.RemovedChatBoost.Chat.ID
+ case u.Message != nil:
+ return u.Message.Chat.ID
+ case u.EditedMessage != nil:
+ return u.EditedMessage.Chat.ID
+ case u.ChannelPost != nil:
+ return u.ChannelPost.Chat.ID
+ case u.EditedChannelPost != nil:
+ return u.EditedChannelPost.Chat.ID
+ case u.BusinessConnection != nil:
+ return u.BusinessConnection.User.ID
+ case u.BusinessMessage != nil:
+ return u.BusinessMessage.Chat.ID
+ case u.EditedBusinessMessage != nil:
+ return u.EditedBusinessMessage.Chat.ID
+ case u.DeletedBusinessMessages != nil:
+ return u.DeletedBusinessMessages.Chat.ID
+ case u.MessageReaction != nil:
+ return u.MessageReaction.Chat.ID
+ case u.MessageReactionCount != nil:
+ return u.MessageReactionCount.Chat.ID
+ case u.InlineQuery != nil:
+ return u.InlineQuery.From.ID
+ case u.ChosenInlineResult != nil:
+ return u.ChosenInlineResult.From.ID
+ case u.CallbackQuery != nil:
+ return u.CallbackQuery.Message.Chat.ID
+ case u.ShippingQuery != nil:
+ return u.ShippingQuery.From.ID
+ case u.PreCheckoutQuery != nil:
+ return u.PreCheckoutQuery.From.ID
+ case u.PollAnswer != nil:
+ return u.PollAnswer.User.ID
+ case u.MyChatMember != nil:
+ return u.MyChatMember.Chat.ID
+ case u.ChatMember != nil:
+ return u.ChatMember.Chat.ID
+ default:
+ return 0
+ }
+}
+
+// WebhookInfo contains information about the current status of a webhook.
+type WebhookInfo struct {
+ URL string `json:"url"`
+ IPAddress string `json:"ip_address,omitempty"`
+ LastErrorMessage string `json:"last_error_message,omitempty"`
+ AllowedUpdates []*UpdateType `json:"allowed_updates,omitempty"`
+ MaxConnections int `json:"max_connections,omitempty"`
+ LastErrorDate int64 `json:"last_error_date,omitempty"`
+ LastSynchronizationErrorDate int64 `json:"last_synchronization_error_date,omitempty"`
+ PendingUpdateCount int `json:"pending_update_count"`
+ HasCustomCertificate bool `json:"has_custom_certificate"`
+}
+
+// APIResponse is implemented by all the APIResponse* types.
+type APIResponse interface {
+ // Base returns the object of type APIResponseBase contained in each implemented type.
+ Base() APIResponseBase
+}
+
+// APIResponseBase is a base type that represents the incoming response from Telegram servers.
+// Used by APIResponse* to slim down the implementation.
+type APIResponseBase struct {
+ Description string `json:"description,omitempty"`
+ ErrorCode int `json:"error_code,omitempty"`
+ Ok bool `json:"ok"`
+}
+
+// Base returns the APIResponseBase itself.
+func (a APIResponseBase) Base() APIResponseBase {
+ return a
+}
+
+// APIResponseUpdate represents the incoming response from Telegram servers.
+// Used by all methods that return an array of Update objects on success.
+type APIResponseUpdate struct {
+ Result []*Update `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseUpdate) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseUser represents the incoming response from Telegram servers.
+// Used by all methods that return a User object on success.
+type APIResponseUser struct {
+ Result *User `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseUser) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseMessage represents the incoming response from Telegram servers.
+// Used by all methods that return a Message object on success.
+type APIResponseMessage struct {
+ Result *Message `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseMessage) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseMessageArray represents the incoming response from Telegram servers.
+// Used by all methods that return an array of Message objects on success.
+type APIResponseMessageArray struct {
+ Result []*Message `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseMessageArray) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseMessageID represents the incoming response from Telegram servers.
+// Used by all methods that return a MessageID object on success.
+type APIResponseMessageID struct {
+ Result *MessageID `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseMessageID) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseMessageIDs represents the incoming response from Telegram servers.
+// Used by all methods that return a MessageID object on success.
+type APIResponseMessageIDs struct {
+ Result []*MessageID `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseMessageIDs) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseCommands represents the incoming response from Telegram servers.
+// Used by all methods that return an array of BotCommand objects on success.
+type APIResponseCommands struct {
+ Result []*BotCommand `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseCommands) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseBool represents the incoming response from Telegram servers.
+// Used by all methods that return True on success.
+type APIResponseBool struct {
+ APIResponseBase
+ Result bool `json:"result,omitempty"`
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseBool) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseString represents the incoming response from Telegram servers.
+// Used by all methods that return a string on success.
+type APIResponseString struct {
+ Result string `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseString) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseChat represents the incoming response from Telegram servers.
+// Used by all methods that return a ChatFullInfo object on success.
+type APIResponseChat struct {
+ Result *ChatFullInfo `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseChat) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseInviteLink represents the incoming response from Telegram servers.
+// Used by all methods that return a ChatInviteLink object on success.
+type APIResponseInviteLink struct {
+ Result *ChatInviteLink `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseInviteLink) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseStickers represents the incoming response from Telegram servers.
+// Used by all methods that return an array of Stickers on success.
+type APIResponseStickers struct {
+ Result []*Sticker `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseStickers) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseStickerSet represents the incoming response from Telegram servers.
+// Used by all methods that return a StickerSet object on success.
+type APIResponseStickerSet struct {
+ Result *StickerSet `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseStickerSet) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseUserProfile represents the incoming response from Telegram servers.
+// Used by all methods that return a UserProfilePhotos object on success.
+type APIResponseUserProfile struct {
+ Result *UserProfilePhotos `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseUserProfile) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseUserProfileAudios represents the incoming response from Telegram servers.
+// Used by all methods that return a UserProfileAudios object on success.
+type APIResponseUserProfileAudios struct {
+ Result *UserProfileAudios `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseUserProfileAudios) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseFile represents the incoming response from Telegram servers.
+// Used by all methods that return a File object on success.
+type APIResponseFile struct {
+ Result *File `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseFile) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseAdministrators represents the incoming response from Telegram servers.
+// Used by all methods that return an array of ChatMember objects on success.
+type APIResponseAdministrators struct {
+ Result []*ChatMember `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseAdministrators) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseChatMember represents the incoming response from Telegram servers.
+// Used by all methods that return a ChatMember object on success.
+type APIResponseChatMember struct {
+ Result *ChatMember `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseChatMember) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseInteger represents the incoming response from Telegram servers.
+// Used by all methods that return an integer on success.
+type APIResponseInteger struct {
+ APIResponseBase
+ Result int `json:"result,omitempty"`
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseInteger) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponsePoll represents the incoming response from Telegram servers.
+// Used by all methods that return a Poll object on success.
+type APIResponsePoll struct {
+ Result *Poll `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponsePoll) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseGameHighScore represents the incoming response from Telegram servers.
+// Used by all methods that return an array of GameHighScore objects on success.
+type APIResponseGameHighScore struct {
+ Result []*GameHighScore `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseGameHighScore) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseWebhook represents the incoming response from Telegram servers.
+// Used by all methods that return a WebhookInfo object on success.
+type APIResponseWebhook struct {
+ Result *WebhookInfo `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseWebhook) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseSentWebAppMessage represents the incoming response from Telegram servers.
+// Used by all methods that return a SentWebAppMessage object on success.
+type APIResponseSentWebAppMessage struct {
+ Result *SentWebAppMessage `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseSentWebAppMessage) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseMenuButton represents the incoming response from Telegram servers.
+// Used by all methods that return a MenuButton object on success.
+type APIResponseMenuButton struct {
+ Result *MenuButton `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseMenuButton) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseChatAdministratorRights represents the incoming response from Telegram servers.
+// Used by all methods that return a ChatAdministratorRights object on success.
+type APIResponseChatAdministratorRights struct {
+ Result *ChatAdministratorRights `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseChatAdministratorRights) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseForumTopic represents the incoming response from Telegram servers.
+// Used by all methods that return a ForumTopic object on success.
+type APIResponseForumTopic struct {
+ Result *ForumTopic `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseForumTopic) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseBotDescription represents the incoming response from Telegram servers.
+// Used by all methods that return a BotDescription object on success.
+type APIResponseBotDescription struct {
+ Result *BotDescription `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseBotDescription) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseBotShortDescription represents the incoming response from Telegram servers.
+// Used by all methods that return a BotShortDescription object on success.
+type APIResponseBotShortDescription struct {
+ Result *BotShortDescription `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseBotShortDescription) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseBotName represents the incoming response from Telegram servers.
+// Used by all methods that return a BotName object on success.
+type APIResponseBotName struct {
+ Result *BotName `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseBotName) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseUserChatBoosts represents the incoming response from Telegram servers.
+// Used by all methods that return a UserChatBoosts object on success.
+type APIResponseUserChatBoosts struct {
+ Result *UserChatBoosts `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseUserChatBoosts) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseBusinessConnection represents the incoming response from Telegram servers.
+// Used by all methods that return a BusinessConnection object on success.
+type APIResponseBusinessConnection struct {
+ Result *BusinessConnection `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseBusinessConnection) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseStarTransactions represents the incoming response from Telegram servers.
+// Used by all methods that return a StarTransactions object on success.
+type APIResponseStarTransactions struct {
+ Result *StarTransactions `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseStarTransactions) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponsePreparedInlineMessage represents the incoming response from Telegram servers.
+// Used by all methods that return a PreparedInlineMessage object on success.
+type APIResponsePreparedInlineMessage struct {
+ Result *PreparedInlineMessage `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponsePreparedInlineMessage) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// APIResponseGifts represents the incoming response from Telegram servers.
+// Used by all methods that return a Gifts object on success.
+type APIResponseGifts struct {
+ Result *Gifts `json:"result,omitempty"`
+ APIResponseBase
+}
+
+// Base returns the contained object of type APIResponseBase.
+func (a APIResponseGifts) Base() APIResponseBase {
+ return a.APIResponseBase
+}
+
+// User represents a Telegram user or bot.
+type User struct {
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name,omitempty"`
+ Username string `json:"username,omitempty"`
+ LanguageCode string `json:"language_code,omitempty"`
+ ID int64 `json:"id"`
+ IsBot bool `json:"is_bot"`
+ IsPremium bool `json:"is_premium,omitempty"`
+ AddedToAttachmentMenu bool `json:"added_to_attachment_menu,omitempty"`
+ CanJoinGroups bool `json:"can_join_groups,omitempty"`
+ CanReadAllGroupMessages bool `json:"can_read_all_group_messages,omitempty"`
+ SupportsInlineQueries bool `json:"supports_inline_queries,omitempty"`
+ CanConnectToBusiness bool `json:"can_connect_to_business,omitempty"`
+ HasMainWebApp bool `json:"has_main_web_app,omitempty"`
+ AllowsUsersToCreateTopics bool `json:"allows_users_to_create_topics,omitempty"`
+}
+
+// Chat represents a chat.
+type Chat struct {
+ Type string `json:"type"`
+ Title string `json:"title,omitempty"`
+ Username string `json:"username,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+ ID int64 `json:"id"`
+ IsForum bool `json:"is_forum,omitempty"`
+}
+
+// ChatFullInfo contains full information about a chat.
+type ChatFullInfo struct {
+ Permissions *ChatPermissions `json:"permissions,omitempty"`
+ Location *ChatLocation `json:"location,omitempty"`
+ PinnedMessage *Message `json:"pinned_message,omitempty"`
+ Photo *ChatPhoto `json:"photo,omitempty"`
+ ActiveUsernames *[]string `json:"active_usernames,omitempty"`
+ AvailableReactions *[]ReactionType `json:"available_reactions,omitempty"`
+ BusinessIntro *BusinessIntro `json:"business_intro,omitempty"`
+ BusinessLocation *BusinessLocation `json:"business_location,omitempty"`
+ BusinessOpeningHours *BusinessOpeningHours `json:"business_opening_hours,omitempty"`
+ PersonalChat *Chat `json:"personal_chat,omitempty"`
+ Birthdate *Birthdate `json:"birthdate,omitempty"`
+ FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"`
+ BackgroundCustomEmojiID string `json:"background_custom_emoji_id,omitempty"`
+ ProfileBackgroundCustomEmojiID string `json:"profile_background_custom_emoji_id,omitempty"`
+ Bio string `json:"bio,omitempty"`
+ Username string `json:"username,omitempty"`
+ Title string `json:"title,omitempty"`
+ StickerSetName string `json:"sticker_set_name,omitempty"`
+ Description string `json:"description,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+ InviteLink string `json:"invite_link,omitempty"`
+ EmojiStatusCustomEmojiID string `json:"emoji_status_custom_emoji_id,omitempty"`
+ Type string `json:"type"`
+ CustomEmojiStickerSetName string `json:"custom_emoji_sticker_set_name,omitempty"`
+ AccentColorID int `json:"accent_color_id,omitempty"`
+ MaxReactionCount int `json:"max_reaction_count,omitempty"`
+ ProfileAccentColorID int `json:"profile_accent_color_id,omitempty"`
+ EmojiStatusExpirationDate int `json:"emoji_status_expiration_date,omitempty"`
+ MessageAutoDeleteTime int `json:"message_auto_delete_time,omitempty"`
+ SlowModeDelay int `json:"slow_mode_delay,omitempty"`
+ UnrestrictBoostCount int `json:"unrestrict_boost_count,omitempty"`
+ LinkedChatID int64 `json:"linked_chat_id,omitempty"`
+ ID int64 `json:"id"`
+ IsForum bool `json:"is_forum,omitempty"`
+ CanSendPaidMedia bool `json:"can_send_paid_media,omitempty"`
+ HasAggressiveAntiSpamEnabled bool `json:"has_aggressive_anti_spam_enabled,omitempty"`
+ HasHiddenMembers bool `json:"has_hidden_members,omitempty"`
+ HasProtectedContent bool `json:"has_protected_content,omitempty"`
+ HasVisibleHistory bool `json:"has_visible_history,omitempty"`
+ HasPrivateForwards bool `json:"has_private_forwards,omitempty"`
+ CanSetStickerSet bool `json:"can_set_sticker_set,omitempty"`
+ JoinToSendMessages bool `json:"join_to_send_messages,omitempty"`
+ JoinByRequest bool `json:"join_by_request,omitempty"`
+ HasRestrictedVoiceAndVideoMessages bool `json:"has_restricted_voice_and_video_messages,omitempty"`
+ AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types,omitempty"`
+}
+
+type AcceptedGiftTypes struct {
+ UnlimitedGifts bool `json:"unlimited_gifs,omitempty"`
+ LimitedGifts bool `json:"limited_gifts,omitempty"`
+ UniqueGifts bool `json:"unique_gifs,omitempty"`
+ PremiumSubscription bool `json:"premium_subscription,omitempty"`
+}
+
+// Message represents a message.
+type Message struct {
+ MessageAutoDeleteTimerChanged *MessageAutoDeleteTimerChanged `json:"message_auto_delete_timer_changed,omitempty"`
+ Contact *Contact `json:"contact,omitempty"`
+ SenderChat *Chat `json:"sender_chat,omitempty"`
+ WebAppData *WebAppData `json:"web_app_data,omitempty"`
+ From *User `json:"from,omitempty"`
+ VideoChatParticipantsInvited *VideoChatParticipantsInvited `json:"video_chat_participants_invited,omitempty"`
+ Invoice *Invoice `json:"invoice,omitempty"`
+ SuccessfulPayment *SuccessfulPayment `json:"successful_payment,omitempty"`
+ RefundedPayment *RefundedPayment `json:"refunded_payment,omitempty"`
+ VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"`
+ VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"`
+ ReplyToMessage *Message `json:"reply_to_message,omitempty"`
+ ViaBot *User `json:"via_bot,omitempty"`
+ Poll *Poll `json:"poll,omitempty"`
+ ProximityAlertTriggered *ProximityAlertTriggered `json:"proximity_alert_triggered,omitempty"`
+ ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
+ Document *Document `json:"document,omitempty"`
+ PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"`
+ PinnedMessage *Message `json:"pinned_message,omitempty"`
+ LeftChatMember *User `json:"left_chat_member,omitempty"`
+ Animation *Animation `json:"animation,omitempty"`
+ Audio *Audio `json:"audio,omitempty"`
+ Voice *Voice `json:"voice,omitempty"`
+ Location *Location `json:"location,omitempty"`
+ Sticker *Sticker `json:"sticker,omitempty"`
+ Video *Video `json:"video,omitempty"`
+ VideoNote *VideoNote `json:"video_note,omitempty"`
+ Venue *Venue `json:"venue,omitempty"`
+ Game *Game `json:"game,omitempty"`
+ Dice *Dice `json:"dice,omitempty"`
+ ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"`
+ ForumTopicEdited *ForumTopicEdited `json:"forum_topic_edited,omitempty"`
+ VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"`
+ ForumTopicClosed *ForumTopicClosed `json:"forum_topic_closed,omitempty"`
+ ForumTopicReopened *ForumTopicReopened `json:"forum_topic_reopened,omitempty"`
+ GeneralForumTopicHidden *GeneralForumTopicHidden `json:"general_forum_topic_hidden,omitempty"`
+ GeneralForumTopicUnhidden *GeneralForumTopicUnhidden `json:"general_forum_topic_unhidden,omitempty"`
+ ChatOwnerLeft *ChatOwnerLeft `json:"chat_owner_left,omitempty"`
+ ChatOwnerChanged *ChatOwnerChanged `json:"chat_owner_changed,omitempty"`
+ GiveawayCreated *GiveawayCreated `json:"giveaway_created,omitempty"`
+ Giveaway *Giveaway `json:"giveaway,omitempty"`
+ GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"`
+ GiveawayCompleted *GiveawayCompleted `json:"giveaway_completed,omitempty"`
+ WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"`
+ UsersShared *UsersShared `json:"users_shared,omitempty"`
+ ChatShared *ChatShared `json:"chat_shared,omitempty"`
+ Story *Story `json:"story,omitempty"`
+ ReplyToStory *Story `json:"reply_to_story,omitempty"`
+ ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"`
+ Quote *TextQuote `json:"quote,omitempty"`
+ LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
+ ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"`
+ BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"`
+ ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"`
+ SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
+ MediaGroupID string `json:"media_group_id,omitempty"`
+ ConnectedWebsite string `json:"connected_website,omitempty"`
+ NewChatTitle string `json:"new_chat_title,omitempty"`
+ AuthorSignature string `json:"author_signature,omitempty"`
+ Caption string `json:"caption,omitempty"`
+ Text string `json:"text,omitempty"`
+ BusinessConnectionID string `json:"business_connection_id,omitempty"`
+ EffectID string `json:"effect_id,omitempty"`
+ CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
+ NewChatPhoto []*PhotoSize `json:"new_chat_photo,omitempty"`
+ NewChatMembers []*User `json:"new_chat_members,omitempty"`
+ Photo []*PhotoSize `json:"photo,omitempty"`
+ Entities []*MessageEntity `json:"entities,omitempty"`
+ Chat Chat `json:"chat"`
+ ID int `json:"message_id"`
+ ThreadID int `json:"message_thread_id,omitempty"`
+ MigrateFromChatID int `json:"migrate_from_chat_id,omitempty"`
+ Date int `json:"date"`
+ MigrateToChatID int `json:"migrate_to_chat_id,omitempty"`
+ EditDate int `json:"edit_date,omitempty"`
+ SenderBoostCount int `json:"sender_boost_count,omitempty"`
+ DeleteChatPhoto bool `json:"delete_chat_photo,omitempty"`
+ IsTopicMessage bool `json:"is_topic_message,omitempty"`
+ IsAutomaticForward bool `json:"is_automatic_forward,omitempty"`
+ GroupChatCreated bool `json:"group_chat_created,omitempty"`
+ SupergroupChatCreated bool `json:"supergroup_chat_created,omitempty"`
+ ChannelChatCreated bool `json:"channel_chat_created,omitempty"`
+ HasProtectedContent bool `json:"has_protected_content,omitempty"`
+ HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"`
+ IsFromOffline bool `json:"is_from_offline,omitempty"`
+ ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
+}
+
+// MessageID represents a unique message identifier.
+type MessageID struct {
+ MessageID int `json:"message_id"`
+}
+
+// MessageEntity represents one special entity in a text message.
+// For example, hashtags, usernames, URLs, etc.
+type MessageEntity struct {
+ User *User `json:"user,omitempty"`
+ Type MessageEntityType `json:"type"`
+ URL string `json:"url,omitempty"`
+ Language string `json:"language,omitempty"`
+ CustomEmojiID string `json:"custom_emoji_id,omitempty"`
+ Offset int `json:"offset"`
+ Length int `json:"length"`
+}
+
+// PhotoSize represents one size of a photo or a file / sticker thumbnail.
+type PhotoSize struct {
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ FileSize int `json:"file_size,omitempty"`
+}
+
+// Animation represents an animation file (GIF or H.264/MPEG-4 AVC video without sound).
+type Animation struct {
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ FileName string `json:"file_name,omitempty"`
+ MimeType string `json:"mime_type,omitempty"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Duration int `json:"duration"`
+ FileSize int64 `json:"file_size,omitempty"`
+}
+
+// Audio represents an audio file to be treated as music by the Telegram clients.
+type Audio struct {
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ Performer string `json:"performer,omitempty"`
+ Title string `json:"title,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+ MimeType string `json:"mime_type,omitempty"`
+ FileSize int64 `json:"file_size,omitempty"`
+ Duration int `json:"duration"`
+}
+
+// Document represents a general file (as opposed to photos, voice messages and audio files).
+type Document struct {
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+ MimeType string `json:"mime_type,omitempty"`
+ FileSize int64 `json:"file_size,omitempty"`
+}
+
+// Video represents a video file.
+type Video struct {
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ FileName string `json:"file_name,omitempty"`
+ MimeType string `json:"mime_type,omitempty"`
+ Qualities []VideoQuality `json:"qualities,omitempty"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Duration int `json:"duration"`
+ FileSize int64 `json:"file_size,omitempty"`
+ Cover []PhotoSize `json:"cover,omitempty"`
+ StartTimestamp int `json:"start_timestamp,omitempty"`
+}
+
+// VideoQuality describes an available quality of a video.
+type VideoQuality struct {
+ Type string `json:"type"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+// VideoNote represents a video message (available in Telegram apps as of v.4.0).
+type VideoNote struct {
+ Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ Length int `json:"length"`
+ Duration int `json:"duration"`
+ FileSize int `json:"file_size,omitempty"`
+}
+
+// Voice represents a voice note.
+type Voice struct {
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ MimeType string `json:"mime_type,omitempty"`
+ Duration int `json:"duration"`
+ FileSize int64 `json:"file_size,omitempty"`
+}
+
+// PaidMediaInfo describes the paid media added to a message.
+type PaidMediaInfo struct {
+ PaidMedia []PaidMedia `json:"paid_media"`
+ StarCount int `json:"star_count"`
+}
+
+// PaidMedia describes paid media.
+type PaidMedia struct {
+ Photo *[]PhotoSize `json:"photo,omitempty"`
+ Video *Video `json:"video,omitempty"`
+ Type string `json:"type"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration int `json:"duration,omitempty"`
+}
+
+// Contact represents a phone contact.
+type Contact struct {
+ PhoneNumber string `json:"phone_number"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name,omitempty"`
+ VCard string `json:"vcard,omitempty"`
+ UserID int `json:"user_id,omitempty"`
+}
+
+// Dice represents an animated emoji that displays a random value.
+type Dice struct {
+ Emoji string `json:"emoji"`
+ Value int `json:"value"`
+}
+
+// PollOption contains information about one answer option in a poll.
+type PollOption struct {
+ Text string `json:"text"`
+ TextEntities []*MessageEntity `json:"text_entities,omitempty"`
+ VoterCount int `json:"voter_count"`
+}
+
+// InputPollOption contains information about one answer option in a poll to send.
+type InputPollOption struct {
+ Text string `json:"text"`
+ TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
+ TextEntities []*MessageEntity `json:"text_entities,omitempty"`
+}
+
+// PollAnswer represents an answer of a user in a non-anonymous poll.
+type PollAnswer struct {
+ PollID string `json:"poll_id"`
+ VoterChat *Chat `json:"chat,omitempty"`
+ User *User `json:"user,omitempty"`
+ OptionIDs []int `json:"option_ids"`
+}
+
+// Poll contains information about a poll.
+type Poll struct {
+ Type string `json:"type"`
+ Question string `json:"question"`
+ Explanation string `json:"explanation,omitempty"`
+ ID string `json:"id"`
+ ExplanationEntities []*MessageEntity `json:"explanation_entities,omitempty"`
+ QuestionEntities []*MessageEntity `json:"question_entities,omitempty"`
+ Options []*PollOption `json:"options"`
+ OpenPeriod int `json:"open_period,omitempty"`
+ TotalVoterCount int `json:"total_voter_count"`
+ CorrectOptionID int `json:"correct_option_id,omitempty"`
+ CloseDate int `json:"close_date,omitempty"`
+ AllowsMultipleAnswers bool `json:"allows_multiple_answers"`
+ IsClosed bool `json:"is_closed"`
+ IsAnonymous bool `json:"is_anonymous"`
+}
+
+// Location represents a point on the map.
+type Location struct {
+ Longitude float64 `json:"longitude"`
+ Latitude float64 `json:"latitude"`
+ HorizontalAccuracy float64 `json:"horizontal_accuracy,omitempty"`
+ LivePeriod int `json:"live_period,omitempty"`
+ Heading int `json:"heading,omitempty"`
+ ProximityAlertRadius int `json:"proximity_alert_radius,omitempty"`
+}
+
+// Venue represents a venue.
+type Venue struct {
+ Location *Location `json:"location"`
+ Title string `json:"title"`
+ Address string `json:"address"`
+ FoursquareID string `json:"foursquare_id,omitempty"`
+ FoursquareType string `json:"foursquare_type,omitempty"`
+ GooglePlaceID string `json:"google_place_id,omitempty"`
+ GooglePlaceType string `json:"google_place_type,omitempty"`
+}
+
+// ProximityAlertTriggered represents the content of a service message, sent whenever a user in the chat triggers a proximity alert set by another user.
+type ProximityAlertTriggered struct {
+ Traveler *User `json:"traveler"`
+ Watcher *User `json:"watcher"`
+ Distance int `json:"distance"`
+}
+
+// MessageAutoDeleteTimerChanged represents a service message about a change in auto-delete timer settings.
+type MessageAutoDeleteTimerChanged struct {
+ MessageAutoDeleteTime int `json:"message_auto_delete_time"`
+}
+
+// VideoChatScheduled represents a service message about a voice chat scheduled in the chat.
+type VideoChatScheduled struct {
+ StartDate int `json:"start_date"`
+}
+
+// VideoChatStarted represents a service message about a voice chat started in the chat.
+type VideoChatStarted struct{}
+
+// VideoChatEnded represents a service message about a voice chat ended in the chat.
+type VideoChatEnded struct {
+ Duration int `json:"duration"`
+}
+
+// VideoChatParticipantsInvited represents a service message about new members invited to a voice chat.
+type VideoChatParticipantsInvited struct {
+ Users []*User `json:"users,omitempty"`
+}
+
+// UserProfilePhotos represents a user's profile pictures.
+type UserProfilePhotos struct {
+ Photos [][]PhotoSize `json:"photos"`
+ TotalCount int `json:"total_count"`
+}
+
+// UserProfileAudios represents a list of audios added to a user's profile.
+type UserProfileAudios struct {
+ Audios []Audio `json:"audios"`
+ TotalCount int `json:"total_count"`
+}
+
+// File represents a file ready to be downloaded.
+type File struct {
+ FileID string `json:"file_id"`
+ FileUniqueID string `json:"file_unique_id"`
+ FilePath string `json:"file_path,omitempty"`
+ FileSize int64 `json:"file_size,omitempty"`
+}
+
+// LoginURL represents a parameter of the inline keyboard button used to automatically authorize a user.
+type LoginURL struct {
+ URL string `json:"url"`
+ ForwardText string `json:"forward_text,omitempty"`
+ BotUsername string `json:"bot_username,omitempty"`
+ RequestWriteAccess bool `json:"request_write_access,omitempty"`
+}
+
+// SwitchInlineQueryChosenChat represents an inline button that switches the current user to inline mode in a chosen chat, with an optional default inline query.
+type SwitchInlineQueryChosenChat struct {
+ Query string `json:"query,omitempty"`
+ AllowUserChats bool `json:"allow_user_chats,omitempty"`
+ AllowBotChats bool `json:"allow_bot_chats,omitempty"`
+ AllowGroupChats bool `json:"allow_group_chats,omitempty"`
+ AllowChannelChats bool `json:"allow_channel_chats,omitempty"`
+}
+
+// CallbackQuery represents an incoming callback query from a callback button in an inline keyboard.
+// If the button that originated the query was attached to a message sent by the bot,
+// the field message will be present. If the button was attached to a message sent via the bot (in inline mode),
+// the field inline_message_id will be present. Exactly one of the fields data or game_short_name will be present.
+type CallbackQuery struct {
+ ID string `json:"id"`
+ From *User `json:"from"`
+ Message *Message `json:"message,omitempty"`
+ InlineMessageID string `json:"inline_message_id,omitempty"`
+ ChatInstance string `json:"chat_instance,omitempty"`
+ Data string `json:"data,omitempty"`
+ GameShortName string `json:"game_short_name,omitempty"`
+}
+
+// ChatPhoto represents a chat photo.
+type ChatPhoto struct {
+ SmallFileID string `json:"small_file_id"`
+ SmallFileUniqueID string `json:"small_file_unique_id"`
+ BigFileID string `json:"big_file_id"`
+ BigFileUniqueID string `json:"big_file_unique_id"`
+}
+
+// ChatInviteLink represents an invite link for a chat.
+type ChatInviteLink struct {
+ Creator *User `json:"creator"`
+ InviteLink string `json:"invite_link"`
+ Name string `json:"name,omitempty"`
+ PendingJoinRequestCount int `json:"pending_join_request_count,omitempty"`
+ ExpireDate int `json:"expire_date,omitempty"`
+ MemberLimit int `json:"member_limit,omitempty"`
+ IsPrimary bool `json:"is_primary"`
+ IsRevoked bool `json:"is_revoked"`
+ CreatesJoinRequest bool `json:"creates_join_request"`
+}
+
+// ChatMember contains information about one member of a chat.
+type ChatMember struct {
+ User *User `json:"user"`
+ Status string `json:"status"`
+ CustomTitle string `json:"custom_title,omitempty"`
+ IsAnonymous bool `json:"is_anonymous,omitempty"`
+ CanBeEdited bool `json:"can_be_edited,omitempty"`
+ CanManageChat bool `json:"can_manage_chat,omitempty"`
+ CanPostMessages bool `json:"can_post_messages,omitempty"`
+ CanEditMessages bool `json:"can_edit_messages,omitempty"`
+ CanDeleteMessages bool `json:"can_delete_messages,omitempty"`
+ CanManageVideoChats bool `json:"can_manage_video_chats,omitempty"`
+ CanRestrictMembers bool `json:"can_restrict_members,omitempty"`
+ CanPromoteMembers bool `json:"can_promote_members,omitempty"`
+ CanChangeInfo bool `json:"can_change_info,omitempty"`
+ CanInviteUsers bool `json:"can_invite_users,omitempty"`
+ CanPinMessages bool `json:"can_pin_messages,omitempty"`
+ IsMember bool `json:"is_member,omitempty"`
+ CanSendMessages bool `json:"can_send_messages,omitempty"`
+ CanSendAudios bool `json:"can_send_audios,omitempty"`
+ CanSendDocuments bool `json:"can_send_documents,omitempty"`
+ CanSendPhotos bool `json:"can_send_photos,omitempty"`
+ CanSendVideos bool `json:"can_send_videos,omitempty"`
+ CanSendVideoNotes bool `json:"can_send_video_notes,omitempty"`
+ CanSendVoiceNotes bool `json:"can_send_voice_notes,omitempty"`
+ CanSendPolls bool `json:"can_send_polls,omitempty"`
+ CanSendOtherMessages bool `json:"can_send_other_messages,omitempty"`
+ CanAddWebPagePreviews bool `json:"can_add_web_page_previews,omitempty"`
+ CanManageTopics bool `json:"can_manage_topics,omitempty"`
+ CanPostStories bool `json:"can_post_stories,omitempty"`
+ CanEditStories bool `json:"can_edit_stories,omitempty"`
+ CanDeleteStories bool `json:"can_delete_stories,omitempty"`
+ UntilDate int `json:"until_date,omitempty"`
+}
+
+// ChatMemberUpdated represents changes in the status of a chat member.
+type ChatMemberUpdated struct {
+ InviteLink *ChatInviteLink `json:"invite_link,omitempty"`
+ Chat Chat `json:"chat"`
+ From User `json:"from"`
+ OldChatMember ChatMember `json:"old_chat_member"`
+ NewChatMember ChatMember `json:"new_chat_member"`
+ Date int `json:"date"`
+ ViaChatFolderInviteLink bool `json:"via_chat_folder_invite_link,omitempty"`
+ ViaJoinRequest bool `json:"via_join_request,omitempty"`
+}
+
+// ChatPermissions describes actions that a non-administrator user is allowed to take in a chat.
+type ChatPermissions struct {
+ CanSendMessages bool `json:"can_send_messages,omitempty"`
+ CanSendAudios bool `json:"can_send_audios,omitempty"`
+ CanSendDocuments bool `json:"can_send_documents,omitempty"`
+ CanSendPhotos bool `json:"can_send_photos,omitempty"`
+ CanSendVideos bool `json:"can_send_videos,omitempty"`
+ CanSendVideoNotes bool `json:"can_send_video_notes,omitempty"`
+ CanSendVoiceNotes bool `json:"can_send_voice_notes,omitempty"`
+ CanSendPolls bool `json:"can_send_polls,omitempty"`
+ CanSendOtherMessages bool `json:"can_send_other_messages,omitempty"`
+ CanAddWebPagePreviews bool `json:"can_add_web_page_previews,omitempty"`
+ CanChangeInfo bool `json:"can_change_info,omitempty"`
+ CanInviteUsers bool `json:"can_invite_users,omitempty"`
+ CanPinMessages bool `json:"can_pin_messages,omitempty"`
+ CanManageTopics bool `json:"can_manage_topics,omitempty"`
+}
+
+// Birthdate
+type Birthdate struct {
+ Day int `json:"day"`
+ Month int `json:"month"`
+ Year int `json:"year"`
+}
+
+// BusinessIntro
+type BusinessIntro struct {
+ Sticker *Sticker `json:"sticker,omitempty"`
+ Title string `json:"title,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// BusinessLocation
+type BusinessLocation struct {
+ Location *Location `json:"location,omitempty"`
+ Address string `json:"address"`
+}
+
+// BusinessOpeningHoursInterval
+type BusinessOpeningHoursInterval struct {
+ OpeningMinute int `json:"opening_minute"`
+ ClosingMinute int `json:"closing_minute"`
+}
+
+// BusinessOpeningHours
+type BusinessOpeningHours struct {
+ TimeZoneName string `json:"time_zone_name"`
+ OpeningHours []BusinessOpeningHoursInterval `json:"opening_hours"`
+}
+
+// ChatLocation represents a location to which a chat is connected.
+type ChatLocation struct {
+ Location *Location `json:"location"`
+ Address string `json:"address"`
+}
+
+// BotCommand represents a bot command.
+type BotCommand struct {
+ Command string `json:"command"`
+ Description string `json:"description"`
+}
+
+// ResponseParameters contains information about why a request was unsuccessful.
+type ResponseParameters struct {
+ MigrateToChatID int `json:"migrate_to_chat_id,omitempty"`
+ RetryAfter int `json:"retry_after,omitempty"`
+}
+
+// InputMediaType is a custom type for the various InputMedia*'s Type field.
+type InputMediaType string
+
+// These are all the possible types for the various InputMedia*'s Type field.
+const (
+ MediaTypePhoto InputMediaType = "photo"
+ MediaTypeVideo = "video"
+ MediaTypeAnimation = "animation"
+ MediaTypeAudio = "audio"
+ MediaTypeDocument = "document"
+)
+
+// InputMedia is an interface for the various media types.
+type InputMedia interface {
+ media() InputFile
+ thumbnail() InputFile
+}
+
+// GroupableInputMedia is an interface for the various groupable media types.
+type GroupableInputMedia interface {
+ InputMedia
+ groupable()
+}
+
+// mediaEnvelope is a generic struct for all the various structs under the InputMedia interface.
+type mediaEnvelope struct {
+ InputMedia
+ media string
+ thumbnail string
+}
+
+// MarshalJSON is a custom marshaler for the mediaEnvelope struct.
+func (i mediaEnvelope) MarshalJSON() (cnt []byte, err error) {
+ var tmp any
+
+ switch o := i.InputMedia.(type) {
+ case InputMediaPhoto:
+ tmp = struct {
+ Media string `json:"media"`
+ InputMediaPhoto
+ }{
+ InputMediaPhoto: o,
+ Media: i.media,
+ }
+
+ case InputMediaVideo:
+ tmp = struct {
+ Media string `json:"media"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ InputMediaVideo
+ }{
+ InputMediaVideo: o,
+ Media: i.media,
+ Thumbnail: i.thumbnail,
+ }
+
+ case InputMediaAnimation:
+ tmp = struct {
+ Media string `json:"media"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ InputMediaAnimation
+ }{
+ InputMediaAnimation: o,
+ Media: i.media,
+ Thumbnail: i.thumbnail,
+ }
+
+ case InputMediaAudio:
+ tmp = struct {
+ Media string `json:"media"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ InputMediaAudio
+ }{
+ InputMediaAudio: o,
+ Media: i.media,
+ Thumbnail: i.thumbnail,
+ }
+
+ case InputMediaDocument:
+ tmp = struct {
+ Media string `json:"media"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ InputMediaDocument
+ }{
+ InputMediaDocument: o,
+ Media: i.media,
+ Thumbnail: i.thumbnail,
+ }
+
+ case InputPaidMediaPhoto:
+ tmp = struct {
+ Media string `json:"media"`
+ InputPaidMediaPhoto
+ }{
+ InputPaidMediaPhoto: o,
+ Media: i.media,
+ }
+
+ case InputPaidMediaVideo:
+ tmp = struct {
+ Media string `json:"media"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ InputPaidMediaVideo
+ }{
+ InputPaidMediaVideo: o,
+ Media: i.media,
+ Thumbnail: i.thumbnail,
+ }
+ }
+
+ return json.Marshal(tmp)
+}
+
+// InputMediaPhoto represents a photo to be sent.
+// Type MUST BE "photo".
+type InputMediaPhoto struct {
+ Type InputMediaType `json:"type"`
+ Media InputFile `json:"-"`
+ Caption string `json:"caption,omitempty"`
+ ParseMode ParseMode `json:"parse_mode,omitempty"`
+ CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
+ HasSpoiler bool `json:"has_spoiler,omitempty"`
+ ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct.
+func (i InputMediaPhoto) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct.
+func (i InputMediaPhoto) thumbnail() InputFile { return InputFile{} }
+
+// groupable is a dummy method which exists to implement the interface GroupableInputMedia.
+func (i InputMediaPhoto) groupable() {}
+
+// InputMediaVideo represents a video to be sent.
+// Type MUST BE "video".
+type InputMediaVideo struct {
+ Type InputMediaType `json:"type"`
+ Media InputFile `json:"-"`
+ Thumbnail InputFile `json:"-"`
+ Caption string `json:"caption,omitempty"`
+ ParseMode ParseMode `json:"parse_mode,omitempty"`
+ CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration int `json:"duration,omitempty"`
+ SupportsStreaming bool `json:"supports_streaming,omitempty"`
+ HasSpoiler bool `json:"has_spoiler,omitempty"`
+ ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
+ Cover string `json:"cover,omitempty"`
+ StartTimestamp int `json:"start_timestamp,omitempty"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct.
+func (i InputMediaVideo) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct.
+func (i InputMediaVideo) thumbnail() InputFile { return i.Thumbnail }
+
+// groupable is a dummy method which exists to implement the interface GroupableInputMedia.
+func (i InputMediaVideo) groupable() {}
+
+// InputMediaAnimation represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent.
+// Type MUST BE "animation".
+type InputMediaAnimation struct {
+ Type InputMediaType `json:"type"`
+ Media InputFile `json:"-"`
+ Thumbnail InputFile `json:"-"`
+ Caption string `json:"caption,omitempty"`
+ ParseMode ParseMode `json:"parse_mode,omitempty"`
+ CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration int `json:"duration,omitempty"`
+ HasSpoiler bool `json:"has_spoiler,omitempty"`
+ ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct.
+func (i InputMediaAnimation) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct.
+func (i InputMediaAnimation) thumbnail() InputFile { return i.Thumbnail }
+
+// InputMediaAudio represents an audio file to be treated as music to be sent.
+// Type MUST BE "audio".
+type InputMediaAudio struct {
+ Type InputMediaType `json:"type"`
+ Performer string `json:"performer,omitempty"`
+ Title string `json:"title,omitempty"`
+ Caption string `json:"caption,omitempty"`
+ ParseMode ParseMode `json:"parse_mode,omitempty"`
+ Media InputFile `json:"-"`
+ Thumbnail InputFile `json:"-"`
+ CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
+ Duration int `json:"duration,omitempty"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct.
+func (i InputMediaAudio) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct.
+func (i InputMediaAudio) thumbnail() InputFile { return i.Thumbnail }
+
+// groupable is a dummy method which exists to implement the interface GroupableInputMedia.
+func (i InputMediaAudio) groupable() {}
+
+// InputMediaDocument represents a general file to be sent.
+// Type MUST BE "document".
+type InputMediaDocument struct {
+ Type InputMediaType `json:"type"`
+ Media InputFile `json:"-"`
+ Thumbnail InputFile `json:"-"`
+ Caption string `json:"caption,omitempty"`
+ ParseMode ParseMode `json:"parse_mode,omitempty"`
+ CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
+ DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct.
+func (i InputMediaDocument) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct.
+func (i InputMediaDocument) thumbnail() InputFile { return i.Thumbnail }
+
+// groupable is a dummy method which exists to implement the interface GroupableInputMedia.
+func (i InputMediaDocument) groupable() {}
+
+// InputPaidMediaType represents the various InputPaidMedia types.
+type InputPaidMediaType string
+
+// These are the various InputPaidMediaType values.
+const (
+ InputPaidMediaTypePhoto InputPaidMediaType = "photo"
+ InputPaidMediaTypeVideo = "video"
+)
+
+// InputPaidMediaPhoto represents a paid photo to send.
+type InputPaidMediaPhoto struct {
+ Type InputPaidMediaType `json:"type"`
+ Media InputFile `json:"-"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputPaidMedia* struct.
+func (i InputPaidMediaPhoto) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputPaidMedia* struct.
+func (i InputPaidMediaPhoto) thumbnail() InputFile { return InputFile{} }
+
+// groupable is a dummy method which exists to implement the interface GroupableInputMedia.
+func (i InputPaidMediaPhoto) groupable() {}
+
+// InputPaidMediaVideo represents a paid video to send.
+type InputPaidMediaVideo struct {
+ Type InputPaidMediaType `json:"type"`
+ Media InputFile `json:"-"`
+ Thumbnail InputFile `json:"-"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration int `json:"duration,omitempty"`
+ SupportsStreaming bool `json:"supports_streaming,omitempty"`
+ Cover string `json:"cover,omitempty"`
+ StartTimestamp int `json:"start_timestamp,omitempty"`
+}
+
+// media is a method which allows to obtain the Media (type InputFile) field from the InputPaidMedia* struct.
+func (i InputPaidMediaVideo) media() InputFile { return i.Media }
+
+// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputPaidMedia* struct.
+func (i InputPaidMediaVideo) thumbnail() InputFile { return i.Thumbnail }
+
+// groupable is a dummy method which exists to implement the interface GroupableInputMedia.
+func (i InputPaidMediaVideo) groupable() {}
+
+// InputProfilePhoto represents an interface that implements all the various input profile photo types.
+type InputProfilePhoto interface {
+ file() InputFile
+ inputProfilePhoto()
+}
+
+// profilePhotoEnvelope is a generic struct for all the various structs under the InputProfilePhoto interface.
+type profilePhotoEnvelope struct {
+ InputProfilePhoto
+ ProfilePhoto string `json:"photo,omitempty"`
+ Animation string `json:"animation,omitempty"`
+}
+
+// MarshalJSON is a custom marshaler for the profilePhotoEnvelope struct.
+func (i profilePhotoEnvelope) MarshalJSON() (cnt []byte, err error) {
+ var tmp any
+
+ switch o := i.InputProfilePhoto.(type) {
+ case InputProfilePhotoStatic:
+ tmp = struct {
+ Photo string `json:"photo"`
+ InputProfilePhotoStatic
+ }{
+ InputProfilePhotoStatic: o,
+ Photo: i.ProfilePhoto,
+ }
+
+ case InputProfilePhotoAnimated:
+ tmp = struct {
+ Animation string `json:"animation"`
+ InputProfilePhotoAnimated
+ }{
+ InputProfilePhotoAnimated: o,
+ Animation: i.Animation,
+ }
+ }
+
+ return json.Marshal(tmp)
+}
+
+// InputProfilePhotoStatic describes a static profile photo to set for the bot.
+// Type must be "static".
+type InputProfilePhotoStatic struct {
+ Type string `json:"type"`
+ Photo InputFile `json:"-"`
+}
+
+func (i InputProfilePhotoStatic) file() InputFile { return i.Photo }
+
+func (i InputProfilePhotoStatic) inputProfilePhoto() {}
+
+// InputProfilePhotoAnimated describes an animated profile photo to set for the bot.
+// Type must be "animation".
+type InputProfilePhotoAnimated struct {
+ Type string `json:"type"`
+ Animation InputFile `json:"-"`
+ MainFrameTimestamp float64 `json:"main_frame_timestamp,omitempty"`
+}
+
+func (i InputProfilePhotoAnimated) file() InputFile { return i.Animation }
+
+func (i InputProfilePhotoAnimated) inputProfilePhoto() {}
+
+// BotCommandScopeType is a custom type for the various bot command scope types.
+type BotCommandScopeType string
+
+// These are all the various bot command scope types.
+const (
+ BCSTDefault BotCommandScopeType = "default"
+ BCSTAllPrivateChats = "all_private_chats"
+ BCSTAllGroupChats = "all_group_chats"
+ BCSTAllChatAdministrators = "all_chat_administrators"
+ BCSTChat = "chat"
+ BCSTChatAdministrators = "chat_administrators"
+ BCSTChatMember = "chat_member"
+)
+
+// BotCommandScope is an optional parameter used in the SetMyCommands, DeleteMyCommands and GetMyCommands methods.
+type BotCommandScope struct {
+ Type BotCommandScopeType `json:"type"`
+ ChatID int64 `json:"chat_id"`
+ UserID int64 `json:"user_id"`
+}
+
+// BotDescription represents the bot's description.
+type BotDescription struct {
+ Description string `json:"description"`
+}
+
+// BotShortDescription represents the bot's short description.
+type BotShortDescription struct {
+ ShortDescription string `json:"short_description"`
+}
+
+// BotName represents the bot's name.
+type BotName struct {
+ Name string `json:"name"`
+}
+
+// ChatJoinRequest represents a join request sent to a chat.
+type ChatJoinRequest struct {
+ InviteLink *ChatInviteLink `json:"invite_link,omitempty"`
+ Bio string `json:"bio,omitempty"`
+ Chat Chat `json:"chat"`
+ From User `json:"user"`
+ Date int `json:"date"`
+ UserChatID int64 `json:"user_chat_id"`
+}
+
+// ChatBoostAdded represents a service message about a user boosting a chat.
+type ChatBoostAdded struct {
+ BoostCount int `json:"boost_count"`
+}
+
+// BackgroundFill describes the way a background is filled based on the selected colors.
+type BackgroundFill interface {
+ ImplementsBackgroundFill()
+}
+
+// BackgroundFillSolid is a background filled using the selected color.
+// Type MUST be "solid".
+type BackgroundFillSolid struct {
+ Type string `json:"type"`
+ Color int `json:"color"`
+}
+
+func (b BackgroundFillSolid) ImplementsBackgroundFill() {}
+
+// BackgroundFillGradient is a background with a gradient fill.
+// Type MUST be "gradient".
+type BackgroundFillGradient struct {
+ Type string `json:"type"`
+ TopColor int `json:"top_color"`
+ BottomColor int `json:"bottom_color"`
+ RotationAngle int `json:"rotation_angle"`
+}
+
+func (b BackgroundFillGradient) ImplementsBackgroundFill() {}
+
+// BackgroundFillFreeformGradient is a background with a freeform gradient that rotates after every message in the chat.
+// Type MUST be "freeform_gradient".
+type BackgroundFillFreeformGradient struct {
+ Type string `json:"type"`
+ Colors []int `json:"colors"`
+}
+
+func (b BackgroundFillFreeformGradient) ImplementsBackgroundFill() {}
+
+// BackgroundType describes the type of a background.
+type BackgroundType interface {
+ ImplementsBackgroundType()
+}
+
+// BackgroundTypeFill is a background which is automatically filled based on the selected colors.
+// Type MUST be "fill".
+type BackgroundTypeFill struct {
+ Fill BackgroundFill `json:"fill"`
+ Type string `json:"type"`
+ DarkThemeDimming int `json:"dark_theme_dimming"`
+}
+
+func (b BackgroundTypeFill) ImplementsBackgroundType() {}
+
+// BackgroundTypeWallpaper is a background which is a wallpaper in the JPEG format.
+// Type MUST be "wallpaper".
+type BackgroundTypeWallpaper struct {
+ Type string `json:"type"`
+ Document Document `json:"document"`
+ DarkThemeDimming int `json:"dark_theme_dimming"`
+ IsBlurred bool `json:"is_blurred,omitempty"`
+ IsMoving bool `json:"is_moving,omitempty"`
+}
+
+func (b BackgroundTypeWallpaper) ImplementsBackgroundType() {}
+
+// BackgroundTypePattern is a PNG or TGV (gzipped subset of SVG with MIME type “application/x-tgwallpattern”) pattern
+// to be combined with the background fill chosen by the user.
+// Type MUST be "pattern".
+type BackgroundTypePattern struct {
+ Fill BackgroundFill `json:"fill"`
+ Type string `json:"type"`
+ Document Document `json:"document"`
+ Intensity int `json:"intensity"`
+ IsInverted bool `json:"is_inverted,omitempty"`
+ IsMoving bool `json:"is_moving,omitempty"`
+}
+
+func (b BackgroundTypePattern) ImplementsBackgroundType() {}
+
+// BackgroundTypeChatTheme is taken directly from a built-in chat theme.
+// Type MUST be "chat_theme".
+type BackgroundTypeChatTheme struct {
+ Type string `json:"type"`
+ ThemeName string `json:"theme_name"`
+}
+
+func (b BackgroundTypeChatTheme) ImplementsBackgroundType() {}
+
+// ForumTopicCreated represents a service message about a new forum topic created in the chat.
+type ForumTopicCreated struct {
+ Name string `json:"name"`
+ IconCustomEmojiID string `json:"icon_custom_emoji_id"`
+ IconColor int `json:"icon_color"`
+}
+
+// ChatBackground represents a chat background.
+type ChatBackground struct {
+ Type BackgroundType `json:"type"`
+}
+
+// ForumTopicClosed represents a service message about a forum topic closed in the chat.
+type ForumTopicClosed struct{}
+
+// ForumTopicEdited represents a service message about an edited forum topic.
+type ForumTopicEdited struct {
+ Name string `json:"name"`
+ IconCustomEmojiID string `json:"icon_custom_emoji_id"`
+}
+
+// ForumTopicReopened represents a service message about a forum topic reopened in the chat.
+type ForumTopicReopened struct{}
+
+// GeneralForumTopicHidden represents a service message about General forum topic hidden in the chat.
+type GeneralForumTopicHidden struct{}
+
+// GeneralForumTopicUnhidden represents a service message about General forum topic unhidden in the chat.
+type GeneralForumTopicUnhidden struct{}
+
+// ChatOwnerLeft represents a service message about the owner of the direct messages chat leaving the chat.
+type ChatOwnerLeft struct{}
+
+// ChatOwnerChanged represents a service message about a change in the owner of the direct messages chat.
+type ChatOwnerChanged struct {
+ OldOwner User `json:"old_owner"`
+ NewOwner User `json:"new_owner"`
+}
+
+// WriteAccessAllowed represents a service message about a user allowing a bot added to the attachment menu to write messages.
+type WriteAccessAllowed struct {
+ WebAppName string `json:"web_app_name,omitempty"`
+ FromRequest bool `json:"from_request,omitempty"`
+ FromAttachmentMenu bool `json:"from_attachment_menu,omitempty"`
+}
+
+// IconColor represents a forum topic icon in RGB format.
+type IconColor int
+
+// These are all the various icon colors.
+const (
+ LightBlue IconColor = 0x6FB9F0
+ Yellow = 0xFFD67E
+ Purple = 0xCB86DB
+ Green = 0x8EEE98
+ Pink = 0xFF93B2
+ Red = 0xFB6F5F
+)
+
+// ForumTopic represents a forum topic.
+type ForumTopic struct {
+ Name string `json:"name"`
+ IconCustomEmojiID string `json:"icon_custom_emoji_id"`
+ IconColor IconColor `json:"icon_color"`
+ MessageThreadID int64 `json:"message_thread_id"`
+}
+
+// UserShared contains information about the user whose identifier was shared with the bot using a KeyboardButtonRequestUser button.
+type UserShared struct {
+ RequestID int `json:"request_id"`
+ UserID int64 `json:"user_id"`
+}
+
+// ChatShared contains information about the chat whose identifier was shared with the bot using a KeyboardButtonRequestChat button.
+type ChatShared struct {
+ Photo *[]PhotoSize `json:"photo,omitempty"`
+ Title string `json:"title,omitempty"`
+ Username string `json:"username,omitempty"`
+ RequestID int `json:"request_id"`
+ ChatID int64 `json:"chat_id"`
+}
+
+// Story represents a story.
+type Story struct {
+ Chat Chat `json:"chat"`
+ ID int64 `json:"id"`
+}
+
+type ReactionType struct {
+ Type string `json:"type"`
+ Emoji string `json:"emoji"`
+ CustomEmoji string `json:"custom_emoji"`
+}
+
+// ReactionCount represents a reaction added to a message along with the number of times it was added.
+type ReactionCount struct {
+ Type ReactionType `json:"type"`
+ TotalCount int `json:"total_count"`
+}
+
+// MessageReactionUpdated represents a change of a reaction on a message performed by a user.
+type MessageReactionUpdated struct {
+ Chat Chat `json:"chat"`
+ ActorChat Chat `json:"actor_chat,omitempty"`
+ OldReaction []ReactionType `json:"old_reaction"`
+ NewReaction []ReactionType `json:"new_reaction"`
+ User User `json:"user,omitempty"`
+ MessageID int `json:"message_id"`
+ Date int `json:"date"`
+}
+
+// MessageReactionCountUpdated represents reaction changes on a message with anonymous reactions.
+type MessageReactionCountUpdated struct {
+ Reactions []ReactionCount `json:"reactions"`
+ Chat Chat `json:"chat"`
+ MessageID int `json:"message_id"`
+ Date int `json:"date"`
+}
+
+// TextQuote contains information about the quoted part of a message that is replied to by the given message.
+type TextQuote struct {
+ Entities *[]MessageEntity `json:"entities,omitempty"`
+ Text string `json:"text"`
+ Position int `json:"position"`
+ IsManual bool `json:"is_manual,omitempty"`
+}
+
+// ExternalReplyInfo contains information about a message that is being replied to, which may come from another chat or forum topic.
+type ExternalReplyInfo struct {
+ Venue Venue `json:"venue,omitempty"`
+ Chat Chat `json:"chat,omitempty"`
+ Document Document `json:"document,omitempty"`
+ PaidMedia PaidMediaInfo `json:"paid_media,omitempty"`
+ Origin MessageOrigin `json:"origin"`
+ Contact Contact `json:"contact,omitempty"`
+ Invoice Invoice `json:"invoice,omitempty"`
+ Dice Dice `json:"dice,omitempty"`
+ LinkPreviewOptions LinkPreviewOptions `json:"link_preview_options,omitempty"`
+ Photo []PhotoSize `json:"photo,omitempty"`
+ Audio Audio `json:"audio,omitempty"`
+ Story Story `json:"story,omitempty"`
+ Voice Voice `json:"voice,omitempty"`
+ VideoNote VideoNote `json:"video_note,omitempty"`
+ Game Game `json:"game,omitempty"`
+ Video Video `json:"video,omitempty"`
+ Animation Animation `json:"animation,omitempty"`
+ Sticker Sticker `json:"sticker,omitempty"`
+ Giveaway Giveaway `json:"giveaway,omitempty"`
+ Poll Poll `json:"poll,omitempty"`
+ GiveawayWinners GiveawayWinners `json:"giveaway_winners,omitempty"`
+ Location Location `json:"location,omitempty"`
+ MessageID int `json:"message_id,omitempty"`
+ HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"`
+}
+
+// MessageOrigin describes the origin of a message.
+type MessageOrigin struct {
+ SenderChat *Chat `json:"sender_chat,omitempty"`
+ SenderUser *User `json:"sender_user,omitempty"`
+ Type string `json:"type"`
+ SenderUserName string `json:"sender_user_name,omitempty"`
+ AuthorSignature string `json:"author_signature,omitempty"`
+ Date int `json:"date"`
+}
+
+// LinkPreviewOptions describes the options used for link preview generation.
+type LinkPreviewOptions struct {
+ URL string `json:"url,omitempty"`
+ IsDisabled bool `json:"is_disabled,omitempty"`
+ PreferSmallMedia bool `json:"prefer_small_media,omitempty"`
+ PreferLargeMedia bool `json:"prefer_large_media,omitempty"`
+ ShowAboveText bool `json:"show_above_text,omitempty"`
+}
+
+// ReplyParameters describes reply parameters for the message that is being sent.
+type ReplyParameters struct {
+ Quote string `json:"quote,omitempty"`
+ QuoteParseMode string `json:"quote_parse_mode,omitempty"`
+ QuoteEntities []MessageEntity `json:"quote_entities,omitempty"`
+ MessageID int `json:"message_id"`
+ ChatID int64 `json:"chat_id,omitempty"`
+ QuotePosition int `json:"quote_position,omitempty"`
+ AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
+}
+
+// SharedUser contains information about a user that was shared with the bot using a KeyboardButtonRequestUser button.
+type SharedUser struct {
+ Photo *[]PhotoSize `json:"photo,omitempty"`
+ FirstName string `json:"firstname,omitempty"`
+ LastName string `json:"lastname,omitempty"`
+ Username string `json:"username,omitempty"`
+ UserID int64 `json:"user_id"`
+}
+
+// UsersShared contains information about the users whose identifiers were shared with the bot using a KeyboardButtonRequestUsers button.
+type UsersShared struct {
+ Users []SharedUser `json:"users"`
+ RequestID int `json:"request_id"`
+}
+
+// ChatBoost contains information about a chat boost.
+type ChatBoost struct {
+ BoostID string `json:"boost_id"`
+ Source ChatBoostSource `json:"source"`
+ AddDate int `json:"add_date"`
+ ExpirationDate int `json:"expiration_date"`
+}
+
+// ChatBoostSourceType is a custom type for the various chat boost sources.
+type ChatBoostSourceType string
+
+// These are all the possible chat boost types.
+const (
+ ChatBoostSourcePremium ChatBoostSourceType = "premium"
+ ChatBoostSourceGiftCode = "gift_code"
+ ChatBoostSourceGiveaway = "giveaway"
+)
+
+// ChatBoostSource describes the source of a chat boost.
+type ChatBoostSource struct {
+ User *User `json:"user,omitempty"`
+ Source ChatBoostSourceType `json:"source"`
+ GiveawayMessageID int `json:"giveaway_message_id,omitempty"`
+ PrizeStarCount int `json:"prize_star_count,omitempty"`
+ IsUnclaimed bool `json:"is_unclaimed,omitempty"`
+}
+
+// ChatBoostUpdated represents a boost added to a chat or changed.
+type ChatBoostUpdated struct {
+ Chat Chat `json:"chat"`
+ Boost ChatBoost `json:"boost"`
+}
+
+// ChatBoostRemoved represents a boost removed from a chat.
+type ChatBoostRemoved struct {
+ BoostID string `json:"boost_id"`
+ Chat Chat `json:"chat"`
+ Source ChatBoostSource `json:"source"`
+ RemoveDate int `json:"remove_date"`
+}
+
+// UserChatBoosts represents a list of boosts added to a chat by a user.
+type UserChatBoosts struct {
+ Boosts []ChatBoost `json:"boosts"`
+}
+
+// BusinessConnection describes the connection of the bot with a business account.
+type BusinessConnection struct {
+ ID string `json:"id"`
+ User User `json:"user"`
+ UserChatID int64 `json:"user_chat_id"`
+ Date int64 `json:"date"`
+ CanReply bool `json:"can_reply"`
+ IsEnabled bool `json:"is_enabled"`
+}
+
+// BusinessMessagesDeleted is received when messages are deleted from a connected business account.
+type BusinessMessagesDeleted struct {
+ BusinessConnectionID string `json:"business_connection_id"`
+ MessageIDs []int `json:"message_ids"`
+ Chat Chat `json:"chat"`
+}
+
+// Giveaway represents a message about a scheduled giveaway.
+type Giveaway struct {
+ CountryCodes *[]string `json:"country_codes,omitempty"`
+ PrizeDescription string `json:"prize_description,omitempty"`
+ Chats []Chat `json:"chats"`
+ PrizeStarCount int `json:"prize_star_count,omitempty"`
+ WinnersSelectionDate int `json:"winners_selection_date"`
+ WinnerCount int `json:"winner_count"`
+ PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"`
+ OnlyNewMembers bool `json:"only_new_members,omitempty"`
+ HasPublicWinners bool `json:"has_public_winners,omitempty"`
+}
+
+// GiveawayCreated represents a service message about the creation of a scheduled giveaway.
+type GiveawayCreated struct {
+ PrizeStarCount int `json:"prize_star_count,omitempty"`
+}
+
+// GiveawayWinners represents a message about the completion of a giveaway with public winners.
+type GiveawayWinners struct {
+ PrizeDescription string `json:"prize_description,omitempty"`
+ Chats []Chat `json:"chats"`
+ Winners []User `json:"winners"`
+ PrizeStarCount int `json:"prize_star_count,omitempty"`
+ GiveawayMessageID int `json:"giveaway_message_id"`
+ WinnersSelectionDate int `json:"winners_selection_date"`
+ WinnerCount int `json:"winner_count"`
+ AdditionalChatCount int `json:"additional_chat_count,omitempty"`
+ PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"`
+ UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"`
+ OnlyNewMembers bool `json:"only_new_members,omitempty"`
+ WasRefunded bool `json:"was_refunded,omitempty"`
+}
+
+// GiveawayCompleted represents a service message about the completion of a giveaway without public winners.
+type GiveawayCompleted struct {
+ GiveawayMessage *Message `json:"giveaway_message,omitempty"`
+ IsStarGiveaway bool `json:"is_star_giveaway,omitempty"`
+ WinnerCount int `json:"winner_count"`
+ UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"`
+}
+
+// Gift represents a gift that can be sent by the bot.
+type Gift struct {
+ ID string `json:"id"`
+ Sticker Sticker `json:"sticker"`
+ StarCount int `json:"star_count"`
+ UpgradeStarCount int `json:"upgrade_star_count,omitempty"`
+ TotalCount int `json:"total_count,omitempty"`
+ RemainingCount int `json:"remaining_count,omitempty"`
+}
+
+// Gifts represents a list of gifts.
+type Gifts struct {
+ Gifts []Gift `json:"gifts"`
+}
+
+// UniqueGiftBackdropColors describes colors of the backdrop of a unique gift.
+type UniqueGiftBackdropColors struct {
+ CenterColor int `json:"center_color"`
+ EdgeColor int `json:"edge_color"`
+ SymbolColor int `json:"symbol_color"`
+ TextColor int `json:"text_color"`
+}
+
+// UniqueGiftBackdrop describes the backdrop of a unique gift.
+type UniqueGiftBackdrop struct {
+ Name string `json:"name"`
+ Colors UniqueGiftBackdropColors `json:"colors"`
+ Rarity int `json:"rarity"`
+}
+
+// UniqueGiftSymbol describes a symbol of a unique gift.
+type UniqueGiftSymbol struct {
+ Name string `json:"name"`
+ Sticker Sticker `json:"sticker"`
+ Rarity int `json:"rarity"`
+}
+
+// UniqueGiftModel describes a model of a unique gift.
+type UniqueGiftModel struct {
+ Name string `json:"name"`
+ Sticker Sticker `json:"sticker"`
+ Number int `json:"number"`
+ Rarity int `json:"rarity"`
+ Model Sticker `json:"model"`
+ Symbol Sticker `json:"symbol"`
+ Backdrop UniqueGiftBackdrop `json:"backdrop"`
+}
+
+// UniqueGift describes an upgraded gift with unique characteristics.
+type UniqueGift struct {
+ Model UniqueGiftModel `json:"model"`
+ Symbol UniqueGiftSymbol `json:"symbol"`
+ Backdrop UniqueGiftBackdrop `json:"backdrop"`
+ PublisherChat *Chat `json:"publisher_chat,omitempty"`
+ OwnerChat *Chat `json:"owner_chat,omitempty"`
+ SellerBot *User `json:"seller_bot,omitempty"`
+ BaseName string `json:"base_name"`
+ Name string `json:"name"`
+ OwnerName string `json:"owner_name,omitempty"`
+ Text string `json:"text,omitempty"`
+ Entities []MessageEntity `json:"entities,omitempty"`
+ Number int `json:"number"`
+ LastResaleStarCount int `json:"last_resale_star_count,omitempty"`
+ SellStarCount int `json:"sell_star_count,omitempty"`
+ TransferStarCount int `json:"transfer_star_count,omitempty"`
+ NextTransferDate int `json:"next_transfer_date,omitempty"`
+ CanBeTransferred bool `json:"can_be_transferred,omitempty"`
+ WasTransferred bool `json:"was_transferred,omitempty"`
+ CanBeUpgraded bool `json:"can_be_upgraded,omitempty"`
+ HasBeenUpgraded bool `json:"has_been_upgraded,omitempty"`
+ IsResellable bool `json:"is_resellable,omitempty"`
+ IsDisplayed bool `json:"is_displayed,omitempty"`
+ IsPublic bool `json:"is_public,omitempty"`
+ IsLimited bool `json:"is_limited,omitempty"`
+ IsSoldOut bool `json:"is_sold_out,omitempty"`
+ IsPermanent bool `json:"is_permanent,omitempty"`
+ IsBanned bool `json:"is_banned,omitempty"`
+ IsBurned bool `json:"is_burned,omitempty"`
+}
diff --git a/shared/echotron/types_test.go b/shared/echotron/types_test.go
new file mode 100644
index 0000000..cefaedc
--- /dev/null
+++ b/shared/echotron/types_test.go
@@ -0,0 +1,248 @@
+package echotron
+
+import "testing"
+
+func TestAPIResponseBase(_ *testing.T) {
+ a := APIResponseBase{}
+ a.Base()
+}
+
+func TestAPIResponseUpdate(_ *testing.T) {
+ a := APIResponseUpdate{}
+ a.Base()
+}
+
+func TestAPIResponseUser(_ *testing.T) {
+ a := APIResponseUser{}
+ a.Base()
+}
+
+func TestAPIResponseMessage(_ *testing.T) {
+ a := APIResponseMessage{}
+ a.Base()
+}
+
+func TestAPIResponseMessageArray(_ *testing.T) {
+ a := APIResponseMessageArray{}
+ a.Base()
+}
+
+func TestAPIResponseMessageID(_ *testing.T) {
+ a := APIResponseMessageID{}
+ a.Base()
+}
+
+func TestAPIResponseCommands(_ *testing.T) {
+ a := APIResponseCommands{}
+ a.Base()
+}
+
+func TestAPIResponseBool(_ *testing.T) {
+ a := APIResponseBool{}
+ a.Base()
+}
+
+func TestAPIResponseString(_ *testing.T) {
+ a := APIResponseString{}
+ a.Base()
+}
+
+func TestAPIResponseChat(_ *testing.T) {
+ a := APIResponseChat{}
+ a.Base()
+}
+
+func TestAPIResponseInviteLink(_ *testing.T) {
+ a := APIResponseInviteLink{}
+ a.Base()
+}
+
+func TestAPIResponseStickerSet(_ *testing.T) {
+ a := APIResponseStickerSet{}
+ a.Base()
+}
+
+func TestAPIResponseUserProfile(_ *testing.T) {
+ a := APIResponseUserProfile{}
+ a.Base()
+}
+
+func TestAPIResponseUserProfileAudios(_ *testing.T) {
+ a := APIResponseUserProfileAudios{}
+ a.Base()
+}
+
+func TestAPIResponseFile(_ *testing.T) {
+ a := APIResponseFile{}
+ a.Base()
+}
+
+func TestAPIResponseAdministrators(_ *testing.T) {
+ a := APIResponseAdministrators{}
+ a.Base()
+}
+
+func TestAPIResponseChatMember(_ *testing.T) {
+ a := APIResponseChatMember{}
+ a.Base()
+}
+
+func TestAPIResponseInteger(_ *testing.T) {
+ a := APIResponseInteger{}
+ a.Base()
+}
+
+func TestAPIResponsePoll(_ *testing.T) {
+ a := APIResponsePoll{}
+ a.Base()
+}
+
+func TestAPIResponseGameHighScore(_ *testing.T) {
+ a := APIResponseGameHighScore{}
+ a.Base()
+}
+
+func TestAPIResponseWebhook(_ *testing.T) {
+ a := APIResponseWebhook{}
+ a.Base()
+}
+
+func TestAPIResponseSentWebAppMessage(_ *testing.T) {
+ a := APIResponseSentWebAppMessage{}
+ a.Base()
+}
+
+func TestAPIResponseMenuButton(_ *testing.T) {
+ a := APIResponseMenuButton{}
+ a.Base()
+}
+
+func TestAPIResponseChatAdministratorRights(_ *testing.T) {
+ a := APIResponseChatAdministratorRights{}
+ a.Base()
+}
+
+func TestAPIResponseBotDescription(_ *testing.T) {
+ a := APIResponseBotDescription{}
+ a.Base()
+}
+
+func TestAPIResponseBotShortDescription(_ *testing.T) {
+ a := APIResponseBotShortDescription{}
+ a.Base()
+}
+
+func TestAPIResponseBusinessConnection(_ *testing.T) {
+ a := APIResponseBusinessConnection{}
+ a.Base()
+}
+
+func TestAPIResponseStarTransactions(_ *testing.T) {
+ a := APIResponseStarTransactions{}
+ a.Base()
+}
+
+func TestAPIResponsePreparedInlineMessage(_ *testing.T) {
+ a := APIResponsePreparedInlineMessage{}
+ a.Base()
+}
+
+func TestAPIResponseGifts(_ *testing.T) {
+ a := APIResponseGifts{}
+ a.Base()
+}
+
+func TestInputMediaPhoto(_ *testing.T) {
+ i := InputMediaPhoto{}
+ i.media()
+ i.thumbnail()
+ i.groupable()
+}
+
+func TestInputMediaVideo(_ *testing.T) {
+ i := InputMediaVideo{}
+ i.media()
+ i.thumbnail()
+ i.groupable()
+}
+
+func TestInputMediaAnimation(_ *testing.T) {
+ i := InputMediaAnimation{}
+ i.media()
+ i.thumbnail()
+}
+
+func TestInputMediaAudio(_ *testing.T) {
+ i := InputMediaAudio{}
+ i.media()
+ i.thumbnail()
+ i.groupable()
+}
+
+func TestInputMediaDocument(_ *testing.T) {
+ i := InputMediaDocument{}
+ i.media()
+ i.thumbnail()
+ i.groupable()
+}
+
+func TestInputPaidMediaPhoto(_ *testing.T) {
+ i := InputPaidMediaPhoto{}
+ i.media()
+ i.groupable()
+ i.thumbnail()
+}
+
+func TestInputPaidMediaVideo(_ *testing.T) {
+ i := InputPaidMediaVideo{}
+ i.media()
+ i.groupable()
+ i.thumbnail()
+}
+
+func TestInputProfilePhotoStatic(_ *testing.T) {
+ i := InputProfilePhotoStatic{}
+ i.file()
+ i.inputProfilePhoto()
+}
+
+func TestInputProfilePhotoAnimated(_ *testing.T) {
+ i := InputProfilePhotoAnimated{}
+ i.file()
+ i.inputProfilePhoto()
+}
+
+func TestBackgroundFillSolid(_ *testing.T) {
+ b := BackgroundFillSolid{}
+ b.ImplementsBackgroundFill()
+}
+
+func TestBackgroundFillGradient(_ *testing.T) {
+ b := BackgroundFillGradient{}
+ b.ImplementsBackgroundFill()
+}
+
+func TestBackgroundFillFreeformGradient(_ *testing.T) {
+ b := BackgroundFillFreeformGradient{}
+ b.ImplementsBackgroundFill()
+}
+
+func TestBackgroundTypeFill(_ *testing.T) {
+ b := BackgroundTypeFill{}
+ b.ImplementsBackgroundType()
+}
+
+func TestBackgroundTypeWallpaper(_ *testing.T) {
+ b := BackgroundTypeWallpaper{}
+ b.ImplementsBackgroundType()
+}
+
+func TestBackgroundTypePattern(_ *testing.T) {
+ b := BackgroundTypePattern{}
+ b.ImplementsBackgroundType()
+}
+
+func TestBackgroundTypeChatTheme(_ *testing.T) {
+ b := BackgroundTypeChatTheme{}
+ b.ImplementsBackgroundType()
+}
diff --git a/shared/echotron/webapp.go b/shared/echotron/webapp.go
new file mode 100644
index 0000000..8b978d3
--- /dev/null
+++ b/shared/echotron/webapp.go
@@ -0,0 +1,57 @@
+/*
+ * Echotron
+ * Copyright (C) 2022 The Echotron Contributors
+ *
+ * Echotron is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Echotron is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package echotron
+
+import (
+ "encoding/json"
+ "net/url"
+)
+
+// WebAppInfo contains information about a Web App.
+type WebAppInfo struct {
+ URL string `json:"url"`
+}
+
+// SentWebAppMessage contains information about an inline message sent
+// by a Web App on behalf of a user.
+type SentWebAppMessage struct {
+ InlineMessageID string `json:"inline_message_id,omitempty"`
+}
+
+// WebAppData contains data sent from a Web App to the bot.
+type WebAppData struct {
+ Data string `json:"data"`
+ ButtonText string `json:"button_text"`
+}
+
+// AnswerWebAppQuery is used to set the result of an interaction with a Web App
+// and send a corresponding message on behalf of the user to the chat from which
+// the query originated.
+func (a API) AnswerWebAppQuery(webAppQueryID string, result InlineQueryResult) (res APIResponseSentWebAppMessage, err error) {
+ var vals = make(url.Values)
+
+ resultJson, err := json.Marshal(result)
+ if err != nil {
+ return res, err
+ }
+
+ vals.Set("web_app_query_id", webAppQueryID)
+ vals.Set("result", string(resultJson))
+ return res, client.get(a.base, "answerWebAppQuery", vals, &res)
+}
diff --git a/shared/jwt_base.py b/shared/jwt_base.py
new file mode 100644
index 0000000..1601ca4
--- /dev/null
+++ b/shared/jwt_base.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel
+
+
+class JWTConfig(BaseModel):
+ SECRET_KEY: str
+ ALGORITHM: str = 'HS256'
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
+
+
+class JWTBase:
+ def __init__(self, config: JWTConfig) -> None:
+ self.config = config
diff --git a/shared/logger/__init__.py b/shared/logger/__init__.py
new file mode 100644
index 0000000..c15588b
--- /dev/null
+++ b/shared/logger/__init__.py
@@ -0,0 +1,3 @@
+__all__ = ['LoggerConfig', 'init']
+
+from .logger import LoggerConfig, init
diff --git a/shared/logger/console_formatter.py b/shared/logger/console_formatter.py
new file mode 100644
index 0000000..16b8982
--- /dev/null
+++ b/shared/logger/console_formatter.py
@@ -0,0 +1,84 @@
+import logging
+from datetime import datetime
+
+
+class ColoredConsoleFormatter(logging.Formatter):
+ TIME_COLOR = '\033[38;2;89;89;89m'
+ RESET = '\033[0m'
+ BOLD = '\033[1m'
+ DARK_CYAN = '\033[36m'
+ DARK_YELLOW = '\033[33m'
+ RED = '\033[91m'
+
+ LEVEL_COLORS = {
+ logging.DEBUG: '\033[36m',
+ logging.INFO: '\033[32m',
+ logging.WARNING: '\033[33m',
+ logging.ERROR: RED,
+ logging.CRITICAL: '\033[35m',
+ }
+
+ LEVEL_NAMES = {
+ logging.DEBUG: 'DBG',
+ logging.INFO: 'INF',
+ logging.WARNING: 'WRN',
+ logging.ERROR: 'ERR',
+ logging.CRITICAL: 'CRT',
+ }
+
+ def format(self, record: logging.LogRecord) -> str:
+ timestamp = f'{self.TIME_COLOR}{datetime.fromtimestamp(record.created).strftime("%H:%M:%S")}{self.RESET}'
+
+ level_name = self.LEVEL_NAMES.get(record.levelno, record.levelname[:3])
+ level_color = self.LEVEL_COLORS.get(record.levelno, '')
+ level = f'{level_color}{level_name}{self.RESET}'
+
+ message_color = self.RED if record.levelno >= logging.ERROR else ''
+ message = f'{message_color}{self.BOLD}{record.getMessage()}{self.RESET}'
+
+ # Add extra fields
+ extra_parts = []
+ for key, value in record.__dict__.items():
+ if key not in [
+ 'name',
+ 'msg',
+ 'args',
+ 'created',
+ 'filename',
+ 'funcName',
+ 'levelname',
+ 'levelno',
+ 'lineno',
+ 'module',
+ 'msecs',
+ 'message',
+ 'pathname',
+ 'process',
+ 'processName',
+ 'relativeCreated',
+ 'thread',
+ 'threadName',
+ 'exc_info',
+ 'exc_text',
+ 'stack_info',
+ 'app_name',
+ 'app_version',
+ 'taskName',
+ 'color_message',
+ ]:
+ value_color = self.RED if key == 'error' else self.DARK_YELLOW
+ extra_parts.append(f'{self.DARK_CYAN}{key}{self.RESET}={value_color}{value}{self.RESET}')
+
+ if extra_parts:
+ message += f' {" ".join(extra_parts)}'
+
+ if record.levelno >= logging.WARNING:
+ location = f'{self.TIME_COLOR}{record.module}:{record.lineno}{self.RESET}'
+ result = f'{timestamp} {level} {location} {message}'
+ else:
+ result = f'{timestamp} {level} {message}'
+
+ if record.exc_info:
+ result += '\n' + self.formatException(record.exc_info)
+
+ return result
diff --git a/shared/logger/json_formatter.py b/shared/logger/json_formatter.py
new file mode 100644
index 0000000..c36fd25
--- /dev/null
+++ b/shared/logger/json_formatter.py
@@ -0,0 +1,55 @@
+import json
+import logging
+from datetime import UTC, datetime
+
+
+class JSONFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ log_data = {
+ 'timestamp': datetime.fromtimestamp(record.created, UTC).isoformat(),
+ 'level': record.levelname,
+ 'message': record.getMessage(),
+ 'module': record.module,
+ 'package': record.name,
+ 'app_name': getattr(record, 'app_name', 'unknown'),
+ 'app_version': getattr(record, 'app_version', 'unknown'),
+ }
+
+ if record.levelno >= logging.ERROR:
+ log_data['location'] = f'{record.pathname}:{record.lineno}'
+
+ if record.exc_info:
+ exc_type = record.exc_info[0]
+ log_data['error_type'] = exc_type.__name__ if exc_type else 'Unknown'
+ log_data['error_message'] = str(record.exc_info[1])
+ log_data['traceback'] = self.formatException(record.exc_info)
+
+ for key, value in record.__dict__.items():
+ if key not in {
+ 'name',
+ 'msg',
+ 'args',
+ 'created',
+ 'filename',
+ 'funcName',
+ 'levelname',
+ 'levelno',
+ 'lineno',
+ 'module',
+ 'msecs',
+ 'message',
+ 'pathname',
+ 'process',
+ 'processName',
+ 'relativeCreated',
+ 'thread',
+ 'threadName',
+ 'exc_info',
+ 'exc_text',
+ 'stack_info',
+ 'app_name',
+ 'app_version',
+ }:
+ log_data[key] = value
+
+ return json.dumps(log_data, ensure_ascii=False)
diff --git a/shared/logger/logger.py b/shared/logger/logger.py
new file mode 100644
index 0000000..0f74279
--- /dev/null
+++ b/shared/logger/logger.py
@@ -0,0 +1,65 @@
+import logging
+import os
+import sys
+
+import pydantic
+
+from .console_formatter import ColoredConsoleFormatter
+from .json_formatter import JSONFormatter
+
+
+class LoggerConfig(pydantic.BaseModel):
+ APP_NAME: str
+ APP_VERSION: str
+ LEVEL: int = logging.INFO
+ PRETTY_CONSOLE: bool = False
+
+ @pydantic.field_validator('APP_VERSION')
+ @classmethod
+ def normalize_app_version(cls, v: str) -> str:
+ parts = v.removeprefix('v').split('.')
+ if len(parts) < 2 or any(not part.isdigit() for part in parts):
+ raise ValueError('APP_VERSION must look like 1.2.0')
+ commit_count = _get_git_commit_count()
+ if not commit_count:
+ return f'v{".".join(parts)}'
+ parts[-1] = commit_count
+ return f'v{".".join(parts)}'
+
+
+def _get_git_commit_count() -> str | None:
+ commit_count = os.getenv('GIT_COMMIT_COUNT')
+ if commit_count is None:
+ return None
+ normalized = commit_count.strip()
+ return normalized or None
+
+
+def init(config: LoggerConfig) -> None:
+ root_logger = logging.getLogger()
+ root_logger.setLevel(config.LEVEL)
+ root_logger.handlers.clear()
+
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setLevel(config.LEVEL)
+
+ formatter = ColoredConsoleFormatter() if config.PRETTY_CONSOLE else JSONFormatter()
+ handler.setFormatter(formatter)
+ root_logger.addHandler(handler)
+
+ for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error']:
+ uvicorn_logger = logging.getLogger(logger_name)
+ uvicorn_logger.handlers = [handler]
+ uvicorn_logger.propagate = False
+
+ old_factory = logging.getLogRecordFactory()
+
+ def record_factory(*args: object, **kwargs: object) -> logging.LogRecord:
+ record = old_factory(*args, **kwargs)
+ record.app_name = config.APP_NAME
+ record.app_version = config.APP_VERSION
+ return record
+
+ logging.setLogRecordFactory(record_factory)
+
+ logging.info('Logger initialized', extra={'app': config.APP_NAME, 'version': config.APP_VERSION})
diff --git a/shared/telegram_base.py b/shared/telegram_base.py
new file mode 100644
index 0000000..2f8d7c6
--- /dev/null
+++ b/shared/telegram_base.py
@@ -0,0 +1,14 @@
+import logging
+
+import pydantic
+from aiogram import Bot
+
+
+class TelegramConfig(pydantic.BaseModel):
+ TOKEN: str
+
+
+class TelegramBase:
+ def __init__(self, config: TelegramConfig) -> None:
+ self.bot: Bot = Bot(token=config.TOKEN)
+ logging.info('Telegram bot initialized')
diff --git a/shared/worker_base.py b/shared/worker_base.py
new file mode 100644
index 0000000..eb4561c
--- /dev/null
+++ b/shared/worker_base.py
@@ -0,0 +1,65 @@
+import asyncio
+import logging
+
+import pydantic
+
+log = logging.getLogger(__name__)
+
+
+class WorkerConfig(pydantic.BaseModel):
+ INTERVAL_SECONDS: int = 60
+
+
+class WorkerBase:
+ def __init__(self, config: WorkerConfig) -> None:
+ self.config = config
+ self._task: asyncio.Task[None] | None = None
+ self._stop_event = asyncio.Event()
+
+ log.info('Worker initialized (interval=%ds)', config.INTERVAL_SECONDS)
+
+ async def _cycle_func(self) -> None:
+ raise NotImplementedError
+
+ async def start(self) -> None:
+ if self._task is not None:
+ log.warning('Worker already running')
+ return
+
+ self._stop_event.clear()
+ self._task = asyncio.create_task(self._run())
+ log.info('Worker started')
+
+ async def stop(self) -> None:
+ if self._task is None:
+ log.warning('Worker not running')
+ return
+
+ self._stop_event.set()
+
+ try:
+ await asyncio.wait_for(self._task, timeout=5.0)
+ except TimeoutError:
+ log.warning('Worker did not stop in time, cancelling')
+ self._task.cancel()
+ try:
+ await self._task
+ except asyncio.CancelledError:
+ pass
+ except asyncio.CancelledError:
+ log.info('Worker task cancelled')
+
+ self._task = None
+ log.info('Worker stopped')
+
+ async def _run(self) -> None:
+ while not self._stop_event.is_set():
+ try:
+ await self._cycle_func()
+ except Exception:
+ log.exception('Error in worker cycle')
+
+ try:
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self.config.INTERVAL_SECONDS)
+ except TimeoutError:
+ pass # Время вышло, продолжаем
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/adapter/jwt.py b/src/adapter/jwt.py
new file mode 100644
index 0000000..7f86391
--- /dev/null
+++ b/src/adapter/jwt.py
@@ -0,0 +1,59 @@
+import datetime
+import uuid
+
+import jwt
+import pydantic
+
+from shared.jwt_base import JWTBase
+from src.usecase import JWTEncoder
+
+
+class JWTPayload(pydantic.BaseModel):
+ user_id: uuid.UUID
+ telegram_id: int
+ username: str | None
+
+
+class JWT(JWTBase, JWTEncoder):
+ def encode_access_token(
+ self,
+ user_id: uuid.UUID,
+ telegram_id: int,
+ username: str | None = None,
+ ) -> str:
+ expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
+ minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES
+ )
+
+ payload = {
+ 'sub': str(user_id),
+ 'telegram_id': telegram_id,
+ 'username': username,
+ 'exp': expire,
+ 'type': 'access',
+ }
+
+ encoded: str = jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
+ return encoded
+
+ def decode_access_token(self, token: str) -> JWTPayload:
+ try:
+ payload = jwt.decode(token, self.config.SECRET_KEY, algorithms=[self.config.ALGORITHM])
+
+ if payload.get('type') != 'access':
+ raise ValueError('Invalid token type')
+
+ user_id = payload.get('sub')
+ if not user_id:
+ raise ValueError('Token missing subject')
+
+ return JWTPayload(
+ user_id=uuid.UUID(user_id),
+ telegram_id=payload['telegram_id'],
+ username=payload.get('username'),
+ )
+
+ except jwt.ExpiredSignatureError as e:
+ raise ValueError('Token has expired') from e
+ except jwt.InvalidTokenError as e:
+ raise ValueError('Invalid token') from e
diff --git a/src/adapter/parser.py b/src/adapter/parser.py
new file mode 100644
index 0000000..ed84005
--- /dev/null
+++ b/src/adapter/parser.py
@@ -0,0 +1,83 @@
+import logging
+from typing import Any
+
+import httpx
+from pydantic import BaseModel
+
+from src.usecase import Parser
+
+log = logging.getLogger(__name__)
+
+
+class FetchChannelResponse(BaseModel):
+ telegram_id: int
+ username: str | None
+ title: str | None
+ access_hash: int | None
+ pts: int | None
+
+
+class ParserClient(Parser):
+ def __init__(self, base_url: str, timeout: float = 5.0):
+ self.base_url = base_url.rstrip('/')
+ self.timeout = timeout
+
+ async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None:
+ username = username.lstrip('@')
+
+ url = f'{self.base_url}/fetch-telegram-channel'
+ params = {'username': username}
+
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ try:
+ response = await client.get(url, params=params)
+
+ if response.status_code == 404:
+ log.info('Channel @%s not found in Telegram', username)
+ return None
+
+ response.raise_for_status()
+ data: dict[str, Any] = response.json()
+ return FetchChannelResponse.model_validate(data)
+
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 404:
+ return None
+ log.error('Parser HTTP error for @%s: %s', username, e)
+ raise
+ except httpx.TimeoutException:
+ log.error('Parser timeout for @%s', username)
+ raise
+ except Exception as e:
+ log.error('Parser unexpected error for @%s: %s', username, e)
+ raise
+
+ async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None:
+ invite_link = invite_link.strip()
+
+ url = f'{self.base_url}/resolve-channel-by-invite'
+ payload = {'invite_link': invite_link}
+
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ try:
+ response = await client.post(url, json=payload)
+
+ if response.status_code == 404:
+ log.info('Channel not found by invite link')
+ return None
+
+ response.raise_for_status()
+ data: dict[str, Any] = response.json()
+ return FetchChannelResponse.model_validate(data)
+
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 404:
+ return None
+ log.error('Parser HTTP error for invite link: %s', e)
+ raise
+ except httpx.TimeoutException:
+ log.error('Parser timeout for invite link')
+ raise
+ except Exception as e:
+ log.error('Parser unexpected error for invite link: %s', e)
+ raise
diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py
new file mode 100644
index 0000000..2543848
--- /dev/null
+++ b/src/adapter/postgres.py
@@ -0,0 +1,1040 @@
+import datetime
+import logging
+import typing
+import uuid
+
+from tortoise import timezone
+from tortoise.transactions import in_transaction
+
+from shared.datebase_base import DatabaseBase
+from src import domain
+
+log = logging.getLogger(__name__)
+
+
+class Postgres(DatabaseBase):
+ @staticmethod
+ def transaction() -> typing.AsyncContextManager[None]:
+ return in_transaction()
+
+ @staticmethod
+ async def create_user(user: domain.User) -> None:
+ await user.save()
+
+ @staticmethod
+ async def get_telegram_user(
+ telegram_user_id: uuid.UUID | None = None, telegram_id: int | None = None
+ ) -> domain.TelegramUser | None:
+ if telegram_user_id:
+ return await domain.TelegramUser.get_or_none(id=telegram_user_id)
+ if telegram_id:
+ return await domain.TelegramUser.get_or_none(telegram_id=telegram_id)
+
+ raise ValueError('Either telegram_user_id or telegram_id must be provided')
+
+ @staticmethod
+ async def create_telegram_user(telegram_user: domain.TelegramUser) -> None:
+ await telegram_user.save()
+
+ @staticmethod
+ async def update_telegram_user(telegram_user: domain.TelegramUser) -> None:
+ await telegram_user.save()
+
+ @staticmethod
+ async def create_workspace(workspace: domain.Workspace) -> None:
+ await workspace.save()
+
+ @staticmethod
+ async def update_workspace(workspace: domain.Workspace) -> None:
+ await workspace.save()
+
+ @staticmethod
+ async def delete_workspace(workspace_id: uuid.UUID) -> None:
+ await domain.Workspace.filter(id=workspace_id).delete()
+
+ @staticmethod
+ async def add_user_to_workspace(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser:
+ return await domain.WorkspaceUser.create(
+ workspace_id=workspace_id,
+ user_id=user_id,
+ status=domain.WorkspaceUserStatus.ACTIVE,
+ )
+
+ @staticmethod
+ async def update_workspace_user(workspace_user: domain.WorkspaceUser) -> None:
+ await workspace_user.save()
+
+ @staticmethod
+ async def get_user_workspaces(user_id: uuid.UUID) -> list[domain.WorkspaceUser]:
+ return (
+ await domain.WorkspaceUser.filter(user_id=user_id)
+ .prefetch_related('workspace')
+ .order_by('created_at')
+ .all()
+ )
+
+ @staticmethod
+ async def get_workspace(workspace_id: uuid.UUID) -> domain.Workspace | None:
+ return await domain.Workspace.get_or_none(id=workspace_id)
+
+ @staticmethod
+ async def get_workspace_for_user(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.Workspace | None:
+ membership = (
+ await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id)
+ .prefetch_related('workspace')
+ .first()
+ )
+ return membership.workspace if membership else None
+
+ @staticmethod
+ async def get_default_workspace_for_user(user_id: uuid.UUID) -> domain.Workspace | None:
+ membership = (
+ await domain.WorkspaceUser.filter(user_id=user_id)
+ .prefetch_related('workspace')
+ .order_by('created_at')
+ .first()
+ )
+ return membership.workspace if membership else None
+
+ @staticmethod
+ async def get_workspace_membership(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser | None:
+ return (
+ await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id)
+ .prefetch_related('workspace', 'user', 'user__telegram_user', 'permissions', 'permission_scopes')
+ .first()
+ )
+
+ @staticmethod
+ async def get_workspace_members(workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]:
+ return (
+ await domain.WorkspaceUser.filter(workspace_id=workspace_id)
+ .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes')
+ .order_by('created_at')
+ .all()
+ )
+
+ @staticmethod
+ async def get_workspace_member(workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None:
+ return (
+ await domain.WorkspaceUser.filter(id=workspace_user_id)
+ .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes')
+ .first()
+ )
+
+ @staticmethod
+ async def create_workspace_invite(invite: domain.WorkspaceInvite) -> None:
+ await invite.save()
+
+ @staticmethod
+ async def update_workspace_invite(invite: domain.WorkspaceInvite) -> None:
+ await invite.save()
+
+ @staticmethod
+ async def get_workspace_invite(invite_id: uuid.UUID) -> domain.WorkspaceInvite | None:
+ return (
+ await domain.WorkspaceInvite.filter(id=invite_id)
+ .prefetch_related('workspace', 'user__telegram_user', 'invited_by__telegram_user')
+ .first()
+ )
+
+ @staticmethod
+ async def get_workspace_invite_by_user(
+ workspace_id: uuid.UUID, user_id: uuid.UUID
+ ) -> domain.WorkspaceInvite | None:
+ return await domain.WorkspaceInvite.get_or_none(workspace_id=workspace_id, user_id=user_id)
+
+ @staticmethod
+ async def get_workspace_invites(workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]:
+ return (
+ await domain.WorkspaceInvite.filter(workspace_id=workspace_id)
+ .prefetch_related('user__telegram_user', 'invited_by__telegram_user')
+ .order_by('created_at')
+ .all()
+ )
+
+ @staticmethod
+ async def set_workspace_user_permissions(
+ workspace_user_id: uuid.UUID,
+ global_permissions: set[domain.PermissionKey],
+ scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]],
+ ) -> None:
+ await domain.WorkspaceUserPermission.filter(workspace_user_id=workspace_user_id).delete()
+ await domain.WorkspaceUserPermissionScope.filter(workspace_user_id=workspace_user_id).delete()
+
+ if global_permissions:
+ await domain.WorkspaceUserPermission.bulk_create(
+ [
+ domain.WorkspaceUserPermission(workspace_user_id=workspace_user_id, permission=permission)
+ for permission in global_permissions
+ ]
+ )
+
+ if scoped_permissions:
+ scope_objects = []
+ for permission, scope_type, scope_id in scoped_permissions:
+ kwargs = {
+ 'workspace_user_id': workspace_user_id,
+ 'permission': permission,
+ }
+ if scope_type == domain.PermissionScopeType.PROJECT:
+ kwargs['project_id'] = scope_id
+ elif scope_type == domain.PermissionScopeType.CREATIVE:
+ kwargs['creative_id'] = scope_id
+ elif scope_type == domain.PermissionScopeType.PLACEMENT:
+ kwargs['placement_id'] = scope_id
+ elif scope_type == domain.PermissionScopeType.CHANNEL:
+ kwargs['channel_id'] = scope_id
+
+ scope_objects.append(domain.WorkspaceUserPermissionScope(**kwargs))
+
+ await domain.WorkspaceUserPermissionScope.bulk_create(scope_objects)
+
+ @staticmethod
+ async def create_login_token(login_token: domain.LoginToken) -> None:
+ await login_token.save()
+
+ @staticmethod
+ async def create_creative(creative: domain.Creative) -> None:
+ await creative.save()
+
+ @staticmethod
+ async def get_user(user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
+ if user_id:
+ return await domain.User.filter(id=user_id).prefetch_related('telegram_user').first()
+ elif telegram_id:
+ return (
+ await domain.User.filter(telegram_user__telegram_id=telegram_id)
+ .prefetch_related('telegram_user')
+ .first()
+ )
+
+ raise ValueError('Either user_id or telegram_id must be provided')
+
+ @staticmethod
+ async def get_user_by_username(username: str) -> domain.User | None:
+ return (
+ await domain.User.filter(telegram_user__username__iexact=username).prefetch_related('telegram_user').first()
+ )
+
+ @staticmethod
+ async def get_login_token(token: str) -> domain.LoginToken | None:
+ return await domain.LoginToken.get_or_none(token=token)
+
+ @staticmethod
+ async def mark_token_as_used(token: str) -> None:
+ updated = await domain.LoginToken.filter(token=token, used_at__isnull=True).update(used_at=timezone.now())
+
+ if updated == 0:
+ raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован
+
+ @staticmethod
+ async def update_login_token_message_id(token: str, message_id: int) -> None:
+ updated = await domain.LoginToken.filter(token=token).update(message_id=message_id)
+ if updated == 0:
+ raise domain.LoginTokenNotFound()
+
+ @staticmethod
+ async def get_channel(
+ channel_id: uuid.UUID | None = None, telegram_id: int | None = None, username: str | None = None
+ ) -> domain.Channel | None:
+ if channel_id:
+ return await domain.Channel.get_or_none(id=channel_id)
+ if telegram_id:
+ return await domain.Channel.get_or_none(telegram_id=telegram_id)
+ if username:
+ return await domain.Channel.filter(username__iexact=username).first()
+ return None
+
+ @staticmethod
+ async def create_channel(channel: domain.Channel) -> None:
+ await channel.save()
+
+ @staticmethod
+ async def update_channel(channel: domain.Channel) -> None:
+ await channel.save()
+
+ @staticmethod
+ async def search_channels(username_query: str | None = None) -> list[domain.Channel]:
+ query = domain.Channel.all()
+
+ if username_query:
+ # Частичный поиск (case-insensitive)
+ query = query.filter(username__icontains=username_query)
+
+ return await query.all()
+
+ @staticmethod
+ async def get_project(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID | None = None,
+ channel_id: uuid.UUID | None = None,
+ include_deleted: bool = False,
+ ) -> domain.Project | None:
+ query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel')
+ if not include_deleted:
+ query = query.filter(deleted_at__isnull=True)
+ if project_id:
+ query = query.filter(id=project_id)
+ if channel_id:
+ query = query.filter(channel_id=channel_id)
+ return await query.first()
+
+ @staticmethod
+ async def get_project_for_user_by_telegram(user_id: uuid.UUID, channel_telegram_id: int) -> domain.Project | None:
+ return (
+ await domain.Project.filter(
+ channel__telegram_id=channel_telegram_id,
+ workspace__workspace_users__user_id=user_id,
+ deleted_at__isnull=True,
+ )
+ .prefetch_related('channel')
+ .first()
+ )
+
+ @staticmethod
+ async def get_project_by_channel_telegram(channel_telegram_id: int) -> domain.Project | None:
+ return (
+ await domain.Project.filter(channel__telegram_id=channel_telegram_id, deleted_at__isnull=True)
+ .prefetch_related('channel')
+ .first()
+ )
+
+ @staticmethod
+ async def create_project(project: domain.Project) -> None:
+ await project.save()
+
+ @staticmethod
+ async def update_project(project: domain.Project) -> None:
+ await project.save()
+
+ @staticmethod
+ async def get_workspace_projects(
+ workspace_id: uuid.UUID,
+ allowed_project_ids: set[uuid.UUID] | None = None,
+ include_archived: bool = False,
+ ) -> list[domain.Project]:
+ query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel')
+
+ if allowed_project_ids is not None:
+ if not allowed_project_ids:
+ return []
+ query = query.filter(id__in=list(allowed_project_ids))
+
+ if not include_archived:
+ query = query.filter(status=domain.ProjectStatus.ACTIVE)
+
+ return await query.order_by('created_at').all()
+
+ @staticmethod
+ async def archive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None:
+ project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
+ if not project:
+ raise domain.ProjectNotFound()
+ project.status = domain.ProjectStatus.ARCHIVED
+ await project.save()
+
+ @staticmethod
+ async def unarchive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None:
+ project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
+ if not project:
+ raise domain.ProjectNotFound()
+ project.status = domain.ProjectStatus.ACTIVE
+ await project.save()
+
+ @staticmethod
+ async def delete_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None:
+ project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
+ if not project:
+ raise domain.ProjectNotFound()
+
+ project.deleted_at = timezone.now()
+ await project.save()
+
+ @staticmethod
+ async def check_channel_exists_in_workspace(channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool:
+ project = await domain.Project.get_or_none(
+ channel_id=channel_id, workspace_id=workspace_id, deleted_at__isnull=True
+ )
+ return project is not None
+
+ @staticmethod
+ async def get_creative(workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
+ return await domain.Creative.get_or_none(id=creative_id, project__workspace_id=workspace_id).prefetch_related(
+ 'project', 'project__channel', 'media_items'
+ )
+
+ @staticmethod
+ async def update_creative(creative: domain.Creative) -> None:
+ await creative.save()
+
+ @staticmethod
+ async def get_workspace_creatives(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID | None = None,
+ include_archived: bool = False,
+ allowed_project_ids: set[uuid.UUID] | None = None,
+ created_by_user_id: uuid.UUID | None = None,
+ tag: domain.CreativeTag | None = None,
+ ) -> list[domain.Creative]:
+ query = domain.Creative.filter(project__workspace_id=workspace_id)
+
+ if project_id:
+ query = query.filter(project_id=project_id)
+ elif allowed_project_ids is not None:
+ if not allowed_project_ids:
+ return []
+ query = query.filter(project_id__in=list(allowed_project_ids))
+
+ if created_by_user_id is not None:
+ query = query.filter(created_by_user_id=created_by_user_id)
+
+ if tag is not None:
+ query = query.filter(tag=tag)
+
+ if not include_archived:
+ query = query.filter(status=domain.CreativeStatus.ACTIVE)
+
+ return await query.prefetch_related('project', 'project__channel', 'media_items').order_by('-created_at').all()
+
+ @staticmethod
+ async def delete_creative(creative_id: uuid.UUID) -> None:
+ await domain.Creative.filter(id=creative_id).delete()
+
+ @staticmethod
+ async def create_placement(placement: domain.Placement) -> None:
+ await placement.save()
+
+ @staticmethod
+ async def get_placement(workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
+ return await domain.Placement.get_or_none(id=placement_id, project__workspace_id=workspace_id).prefetch_related(
+ 'project', 'project__channel', 'channel', 'creative'
+ )
+
+ @staticmethod
+ async def count_placements_by_project_and_channel(
+ project_id: uuid.UUID,
+ channel_id: uuid.UUID,
+ ) -> int:
+ """Count placements for a project in a specific channel."""
+ return await domain.Placement.filter(
+ project_id=project_id,
+ channel_id=channel_id,
+ ).count()
+
+ @staticmethod
+ async def update_placement(placement: domain.Placement) -> None:
+ await placement.save()
+
+ @staticmethod
+ async def delete_placement(placement_id: uuid.UUID) -> None:
+ await domain.Placement.filter(id=placement_id).delete()
+
+ @staticmethod
+ async def get_project_placements(
+ workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False
+ ) -> list[domain.Placement]:
+ query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id)
+
+ if not include_archived:
+ query = query.filter(
+ status__in=[
+ domain.PlacementStatus.NO_STATUS,
+ domain.PlacementStatus.WRITE,
+ domain.PlacementStatus.WAITING_RESPONSE,
+ domain.PlacementStatus.TERMS_APPROVAL,
+ domain.PlacementStatus.TO_PAY,
+ domain.PlacementStatus.PAID,
+ ]
+ )
+
+ return (
+ await query.prefetch_related('project', 'project__channel', 'channel', 'creative')
+ .order_by('-created_at')
+ .all()
+ )
+
+ @staticmethod
+ async def get_placement_post(workspace_id: uuid.UUID, placement_post_id: uuid.UUID) -> domain.PlacementPost | None:
+ return await domain.PlacementPost.get_or_none(
+ id=placement_post_id, placement__project__workspace_id=workspace_id
+ ).prefetch_related(
+ 'placement',
+ 'placement__project',
+ 'placement__project__channel',
+ 'placement__channel',
+ 'placement__creative',
+ 'post',
+ 'post__channel',
+ )
+
+ @staticmethod
+ async def count_placement_posts_by_placement(placement_id: uuid.UUID) -> int:
+ return await domain.PlacementPost.filter(placement_id=placement_id).count()
+
+ @staticmethod
+ async def get_workspace_placement_posts(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID | None = None,
+ placement_channel_id: uuid.UUID | None = None,
+ creative_id: uuid.UUID | None = None,
+ placement_id: uuid.UUID | None = None,
+ include_archived: bool = False,
+ allowed_project_ids: set[uuid.UUID] | None = None,
+ date_from: datetime.datetime | None = None,
+ date_to: datetime.datetime | None = None,
+ has_post: bool = False,
+ ) -> list[domain.PlacementPost]:
+ query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id)
+
+ if project_id:
+ query = query.filter(placement__project_id=project_id)
+ elif allowed_project_ids is not None:
+ if not allowed_project_ids:
+ return []
+ query = query.filter(placement__project_id__in=list(allowed_project_ids))
+ if placement_channel_id:
+ query = query.filter(placement__channel_id=placement_channel_id)
+ if creative_id:
+ query = query.filter(placement__creative_id=creative_id)
+ if placement_id:
+ query = query.filter(placement_id=placement_id)
+
+ if date_from:
+ query = query.filter(created_at__gte=date_from)
+ if date_to:
+ query = query.filter(created_at__lte=date_to)
+
+ if has_post:
+ query = query.filter(post_id__isnull=False)
+
+ return (
+ await query.prefetch_related(
+ 'placement',
+ 'placement__project',
+ 'placement__project__channel',
+ 'placement__channel',
+ 'placement__creative',
+ 'post',
+ 'post__channel',
+ )
+ .order_by('-created_at')
+ .all()
+ )
+
+ @staticmethod
+ async def get_placement_posts_by_placement_ids(
+ workspace_id: uuid.UUID,
+ placement_ids: list[uuid.UUID],
+ include_archived: bool = False,
+ ) -> list[domain.PlacementPost]:
+ if not placement_ids:
+ return []
+
+ query = domain.PlacementPost.filter(
+ placement__project__workspace_id=workspace_id,
+ placement_id__in=placement_ids,
+ )
+
+ return (
+ await query.prefetch_related(
+ 'placement',
+ 'placement__project',
+ 'placement__project__channel',
+ 'placement__channel',
+ 'placement__creative',
+ 'post',
+ 'post__channel',
+ )
+ .order_by('-created_at')
+ .all()
+ )
+
+ @staticmethod
+ async def create_placement_post(placement_post: domain.PlacementPost) -> None:
+ await placement_post.save()
+
+ @staticmethod
+ async def get_placement_post_by_invite_link(invite_link: str) -> domain.PlacementPost | None:
+ return await domain.PlacementPost.get_or_none(placement__invite_link=invite_link).prefetch_related(
+ 'placement',
+ 'placement__project',
+ 'placement__project__channel',
+ 'placement__channel',
+ 'placement__creative',
+ )
+
+ # Subscription methods
+ @staticmethod
+ async def create_subscription(subscription: domain.Subscription) -> None:
+ await subscription.save()
+
+ @staticmethod
+ async def get_subscription_by_subscriber_and_placement_post(
+ telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID
+ ) -> domain.Subscription | None:
+ return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_post_id)
+
+ @staticmethod
+ async def get_subscription_by_subscriber_and_placement(
+ telegram_user_id: uuid.UUID, placement_id: uuid.UUID
+ ) -> domain.Subscription | None:
+ return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_id)
+
+ @staticmethod
+ async def update_subscription(subscription: domain.Subscription) -> None:
+ await subscription.save()
+
+ @staticmethod
+ async def get_subscriptions_for_placement_posts(
+ placement_post_ids: list[uuid.UUID],
+ *,
+ date_from: datetime.datetime | None = None,
+ date_to: datetime.datetime | None = None,
+ ) -> list[domain.Subscription]:
+ """Get subscriptions for placement_posts by finding their parent placements."""
+ if not placement_post_ids:
+ return []
+
+ # Get placement_ids from placement_posts
+ placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all()
+ placement_ids = [pp.placement_id for pp in placement_posts]
+
+ if not placement_ids:
+ return []
+
+ query = domain.Subscription.filter(placement_id__in=placement_ids)
+
+ if date_from:
+ query = query.filter(created_at__gte=date_from)
+ if date_to:
+ query = query.filter(created_at__lte=date_to)
+
+ return await query.all()
+
+ @staticmethod
+ async def get_active_subscriptions_by_subscriber_and_project(
+ telegram_user_id: uuid.UUID, project_id: uuid.UUID
+ ) -> list[domain.Subscription]:
+ return (
+ await domain.Subscription.filter(
+ telegram_user_id=telegram_user_id,
+ placement__project_id=project_id,
+ status=domain.SubscriptionStatus.ACTIVE,
+ )
+ .prefetch_related('placement', 'telegram_user')
+ .all()
+ )
+
+ @staticmethod
+ async def get_active_subscription_by_subscriber_and_project(
+ telegram_user_id: uuid.UUID, project_id: uuid.UUID
+ ) -> domain.Subscription | None:
+ return (
+ await domain.Subscription.filter(
+ telegram_user_id=telegram_user_id,
+ placement__project_id=project_id,
+ status=domain.SubscriptionStatus.ACTIVE,
+ )
+ .prefetch_related('placement', 'telegram_user')
+ .first()
+ )
+
+ @staticmethod
+ async def get_views_history(
+ post_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None
+ ) -> list[domain.PostViewsHistory]:
+ query = domain.PostViewsHistory.filter(post_id=post_id)
+
+ if from_date:
+ query = query.filter(fetched_at__gte=from_date)
+ if to_date:
+ query = query.filter(fetched_at__lte=to_date)
+
+ return await query.order_by('fetched_at').all()
+
+ @staticmethod
+ async def get_latest_views_data_batch(post_ids: list[uuid.UUID]) -> dict[uuid.UUID, tuple[int, datetime.datetime]]:
+ if not post_ids:
+ return {}
+
+ results: dict[uuid.UUID, tuple[int, datetime.datetime]] = {}
+ for post_id in post_ids:
+ latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first()
+ if latest:
+ results[post_id] = (latest.views_count, latest.fetched_at)
+
+ return results
+
+ # Count methods
+ @staticmethod
+ async def count_placement_posts_by_creative(creative_id: uuid.UUID) -> int:
+ return await domain.PlacementPost.filter(placement__creative_id=creative_id).count()
+
+ @staticmethod
+ async def count_subscriptions_by_placement_post(placement_post_id: uuid.UUID) -> int:
+ """Count subscriptions for a placement_post by finding its parent placement."""
+ placement_post = await domain.PlacementPost.get_or_none(id=placement_post_id)
+ if not placement_post:
+ return 0
+ return await domain.Subscription.filter(placement_id=placement_post.placement_id).count()
+
+ @staticmethod
+ async def count_subscriptions_by_placement(placement_id: uuid.UUID) -> int:
+ return await domain.Subscription.filter(placement_id=placement_id).count()
+
+ @staticmethod
+ async def count_placement_posts_by_creative_batch(creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
+ if not creative_ids:
+ return {}
+
+ from tortoise.functions import Count
+
+ results = (
+ await domain.PlacementPost.filter(placement__creative_id__in=creative_ids)
+ .group_by('placement__creative_id')
+ .annotate(count=Count('id'))
+ .values('placement__creative_id', 'count')
+ )
+
+ counts = {row['placement__creative_id']: row['count'] for row in results}
+ return {cid: counts.get(cid, 0) for cid in creative_ids}
+
+ @staticmethod
+ async def count_subscriptions_by_placement_post_batch(placement_post_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
+ """Count subscriptions for placement_posts by finding their parent placements."""
+ if not placement_post_ids:
+ return {}
+
+ from tortoise.functions import Count
+
+ # Get placement_ids from placement_posts
+ placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all()
+ placement_id_to_post_ids: dict[uuid.UUID, list[uuid.UUID]] = {}
+ for pp in placement_posts:
+ placement_id_to_post_ids.setdefault(pp.placement_id, []).append(pp.id)
+
+ placement_ids = list(placement_id_to_post_ids.keys())
+ if not placement_ids:
+ return dict.fromkeys(placement_post_ids, 0)
+
+ # Count subscriptions by placement
+ results = (
+ await domain.Subscription.filter(placement_id__in=placement_ids)
+ .group_by('placement_id')
+ .annotate(count=Count('id'))
+ .values('placement_id', 'count')
+ )
+
+ placement_counts = {row['placement_id']: row['count'] for row in results}
+
+ # Map back to placement_post_ids
+ post_counts: dict[uuid.UUID, int] = {}
+ for placement_id, post_ids in placement_id_to_post_ids.items():
+ count = placement_counts.get(placement_id, 0)
+ for post_id in post_ids:
+ post_counts[post_id] = count
+
+ return {pid: post_counts.get(pid, 0) for pid in placement_post_ids}
+
+ @staticmethod
+ async def count_subscriptions_by_placement_batch(placement_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
+ if not placement_ids:
+ return {}
+
+ from tortoise.functions import Count
+
+ results = (
+ await domain.Subscription.filter(placement_id__in=placement_ids)
+ .group_by('placement_id')
+ .annotate(count=Count('id'))
+ .values('placement_id', 'count')
+ )
+
+ counts = {row['placement_id']: row['count'] for row in results}
+ return {pid: counts.get(pid, 0) for pid in placement_ids}
+
+ @staticmethod
+ async def count_unsubscriptions_by_placement_post_batch(
+ placement_post_ids: list[uuid.UUID],
+ ) -> dict[uuid.UUID, int]:
+ """Count unsubscriptions for placement_posts by finding their parent placements."""
+ if not placement_post_ids:
+ return {}
+
+ from tortoise.functions import Count
+
+ # Get placement_ids from placement_posts
+ placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all()
+ placement_id_to_post_ids: dict[uuid.UUID, list[uuid.UUID]] = {}
+ for pp in placement_posts:
+ placement_id_to_post_ids.setdefault(pp.placement_id, []).append(pp.id)
+
+ placement_ids = list(placement_id_to_post_ids.keys())
+ if not placement_ids:
+ return dict.fromkeys(placement_post_ids, 0)
+
+ # Count unsubscriptions by placement (filter by UNSUBSCRIBED status)
+ results = (
+ await domain.Subscription.filter(
+ placement_id__in=placement_ids, status=domain.SubscriptionStatus.UNSUBSCRIBED
+ )
+ .group_by('placement_id')
+ .annotate(count=Count('id'))
+ .values('placement_id', 'count')
+ )
+
+ placement_counts = {row['placement_id']: row['count'] for row in results}
+
+ # Map back to placement_post_ids
+ post_counts: dict[uuid.UUID, int] = {}
+ for placement_id, post_ids in placement_id_to_post_ids.items():
+ count = placement_counts.get(placement_id, 0)
+ for post_id in post_ids:
+ post_counts[post_id] = count
+
+ return {pid: post_counts.get(pid, 0) for pid in placement_post_ids}
+
+ @staticmethod
+ async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool:
+ return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists()
+
+ @staticmethod
+ async def get_next_post_after(channel_id: uuid.UUID, message_id: int) -> domain.Post | None:
+ """Get first post in channel after the given message_id."""
+ return (
+ await domain.Post.filter(
+ channel_id=channel_id,
+ message_id__gt=message_id,
+ deleted_from_channel_at__isnull=True,
+ )
+ .order_by('message_id')
+ .first()
+ )
+
+ @staticmethod
+ async def get_next_posts_after_batch(
+ channel_message_pairs: list[tuple[uuid.UUID, int]],
+ ) -> dict[tuple[uuid.UUID, int], domain.Post]:
+ """Batch version: get next post for each (channel_id, message_id) pair."""
+ if not channel_message_pairs:
+ return {}
+
+ results: dict[tuple[uuid.UUID, int], domain.Post] = {}
+ for channel_id, message_id in channel_message_pairs:
+ next_post = (
+ await domain.Post.filter(
+ channel_id=channel_id,
+ message_id__gt=message_id,
+ deleted_from_channel_at__isnull=True,
+ )
+ .order_by('message_id')
+ .first()
+ )
+ if next_post:
+ results[(channel_id, message_id)] = next_post
+
+ return results
+
+ @staticmethod
+ async def get_workspace_placements_for_analytics(
+ workspace_id: uuid.UUID,
+ project_ids: list[uuid.UUID] | None = None,
+ channel_ids: list[uuid.UUID] | None = None,
+ creative_ids: list[uuid.UUID] | None = None,
+ status_list: list[str] | None = None,
+ cost_types: list[str] | None = None,
+ placement_types: list[str] | None = None,
+ invite_link_types: list[str] | None = None,
+ cost_min: float | None = None,
+ cost_max: float | None = None,
+ views_min: int | None = None,
+ views_max: int | None = None,
+ subscriptions_min: int | None = None,
+ subscriptions_max: int | None = None,
+ cpm_min: float | None = None,
+ cpm_max: float | None = None,
+ channel_title_contains: str | None = None,
+ creative_name_contains: str | None = None,
+ comment_contains: str | None = None,
+ placement_date_from: datetime.datetime | None = None,
+ placement_date_to: datetime.datetime | None = None,
+ sort_by: str = 'created_at',
+ sort_direction: str = 'desc',
+ offset: int = 0,
+ limit: int = 50,
+ include_archived: bool = False,
+ allowed_project_ids: set[uuid.UUID] | None = None,
+ ) -> list[domain.Placement]:
+ """Get placements for analytics with flexible filtering, sorting, and pagination."""
+ from tortoise.queryset import QuerySet
+
+ query: QuerySet[domain.Placement] = domain.Placement.filter(
+ project__workspace_id=workspace_id,
+ )
+
+ if project_ids:
+ query = query.filter(project_id__in=project_ids)
+ elif allowed_project_ids is not None:
+ if not allowed_project_ids:
+ return []
+ query = query.filter(project_id__in=list(allowed_project_ids))
+
+ if channel_ids:
+ query = query.filter(channel_id__in=channel_ids)
+
+ if creative_ids:
+ query = query.filter(creative_id__in=creative_ids)
+
+ if status_list:
+ query = query.filter(status__in=status_list)
+
+ if cost_types:
+ query = query.filter(cost_type__in=cost_types)
+
+ if placement_types:
+ query = query.filter(placement_type__in=placement_types)
+
+ if invite_link_types:
+ query = query.filter(invite_link_type__in=invite_link_types)
+
+ if cost_min is not None:
+ query = query.filter(cost_value__gte=cost_min)
+ if cost_max is not None:
+ query = query.filter(cost_value__lte=cost_max)
+
+ if placement_date_from:
+ query = query.filter(placement_at__gte=placement_date_from)
+ if placement_date_to:
+ query = query.filter(placement_at__lte=placement_date_to)
+
+ if channel_title_contains:
+ query = query.filter(channel__title__icontains=channel_title_contains)
+
+ if creative_name_contains:
+ query = query.filter(creative__name__icontains=creative_name_contains)
+
+ if comment_contains:
+ query = query.filter(comment__icontains=comment_contains)
+
+ elif not include_archived:
+ query = query.filter(
+ status__in=[
+ domain.PlacementStatus.NO_STATUS,
+ domain.PlacementStatus.WRITE,
+ domain.PlacementStatus.WAITING_RESPONSE,
+ domain.PlacementStatus.TERMS_APPROVAL,
+ domain.PlacementStatus.TO_PAY,
+ domain.PlacementStatus.PAID,
+ ]
+ )
+
+ valid_sort_fields = ['created_at', 'updated_at', 'placement_at', 'cost_value']
+ if sort_by not in valid_sort_fields:
+ sort_by = 'created_at'
+
+ if sort_direction == 'asc':
+ query = query.order_by(sort_by)
+ else:
+ query = query.order_by(f'-{sort_by}')
+
+ return (
+ await query.prefetch_related(
+ 'project',
+ 'project__channel',
+ 'channel',
+ 'creative',
+ 'placement_posts',
+ 'placement_posts__post',
+ 'placement_posts__post__channel',
+ )
+ .offset(offset)
+ .limit(limit)
+ .all()
+ )
+
+ @staticmethod
+ async def count_workspace_placements_for_analytics(
+ workspace_id: uuid.UUID,
+ project_ids: list[uuid.UUID] | None = None,
+ channel_ids: list[uuid.UUID] | None = None,
+ creative_ids: list[uuid.UUID] | None = None,
+ status_list: list[str] | None = None,
+ cost_types: list[str] | None = None,
+ placement_types: list[str] | None = None,
+ invite_link_types: list[str] | None = None,
+ cost_min: float | None = None,
+ cost_max: float | None = None,
+ views_min: int | None = None,
+ views_max: int | None = None,
+ subscriptions_min: int | None = None,
+ subscriptions_max: int | None = None,
+ cpm_min: float | None = None,
+ cpm_max: float | None = None,
+ channel_title_contains: str | None = None,
+ creative_name_contains: str | None = None,
+ comment_contains: str | None = None,
+ placement_date_from: datetime.datetime | None = None,
+ placement_date_to: datetime.datetime | None = None,
+ include_archived: bool = False,
+ allowed_project_ids: set[uuid.UUID] | None = None,
+ ) -> int:
+ """Count placements for analytics with flexible filtering."""
+ query = domain.Placement.filter(project__workspace_id=workspace_id)
+
+ if project_ids:
+ query = query.filter(project_id__in=project_ids)
+ elif allowed_project_ids is not None:
+ if not allowed_project_ids:
+ return 0
+ query = query.filter(project_id__in=list(allowed_project_ids))
+
+ if channel_ids:
+ query = query.filter(channel_id__in=channel_ids)
+
+ if creative_ids:
+ query = query.filter(creative_id__in=creative_ids)
+
+ if status_list:
+ query = query.filter(status__in=status_list)
+
+ if cost_types:
+ query = query.filter(cost_type__in=cost_types)
+
+ if placement_types:
+ query = query.filter(placement_type__in=placement_types)
+
+ if invite_link_types:
+ query = query.filter(invite_link_type__in=invite_link_types)
+
+ if cost_min is not None:
+ query = query.filter(cost_value__gte=cost_min)
+ if cost_max is not None:
+ query = query.filter(cost_value__lte=cost_max)
+
+ if placement_date_from:
+ query = query.filter(placement_at__gte=placement_date_from)
+ if placement_date_to:
+ query = query.filter(placement_at__lte=placement_date_to)
+
+ if channel_title_contains:
+ query = query.filter(channel__title__icontains=channel_title_contains)
+
+ if creative_name_contains:
+ query = query.filter(creative__name__icontains=creative_name_contains)
+
+ if comment_contains:
+ query = query.filter(comment__icontains=comment_contains)
+
+ elif not include_archived:
+ query = query.filter(
+ status__in=[
+ domain.PlacementStatus.NO_STATUS,
+ domain.PlacementStatus.WRITE,
+ domain.PlacementStatus.WAITING_RESPONSE,
+ domain.PlacementStatus.TERMS_APPROVAL,
+ domain.PlacementStatus.TO_PAY,
+ domain.PlacementStatus.PAID,
+ ]
+ )
+
+ return await query.count()
diff --git a/src/adapter/s3.py b/src/adapter/s3.py
new file mode 100644
index 0000000..9b45e8d
--- /dev/null
+++ b/src/adapter/s3.py
@@ -0,0 +1,112 @@
+import logging
+
+import aioboto3 # type: ignore[import-untyped]
+from botocore.config import Config # type: ignore[import-untyped]
+from pydantic import BaseModel
+from types_aiobotocore_s3.client import S3Client
+
+from src.usecase import S3Storage
+
+log = logging.getLogger(__name__)
+
+
+class S3Config(BaseModel):
+ ENDPOINT_URL: str
+ ACCESS_KEY_ID: str
+ SECRET_ACCESS_KEY: str
+ BUCKET_NAME: str
+ REGION: str = 'us-east-1'
+ PUBLIC_BASE_URL: str | None = None
+
+
+class S3(S3Storage):
+ def __init__(self, config: S3Config) -> None:
+ self.config = config
+ self.session = aioboto3.Session()
+ self._client: S3Client | None = None
+ # Конфигурация для S3-совместимых хранилищ (не AWS)
+ # Отключаем строгую проверку контрольных сумм для совместимости с Beget и другими провайдерами
+ # См: https://github.com/open-webui/open-webui/issues/16758
+ self.boto_config = Config(
+ signature_version='s3v4',
+ s3={
+ 'payload_signing_enabled': False,
+ 'addressing_style': 'auto',
+ },
+ # Отключаем строгую проверку контрольных сумм (boto3 >= 1.40.5)
+ request_checksum_calculation='when_required',
+ response_checksum_validation='when_required',
+ )
+
+ async def connect(self) -> None:
+ # Создаем клиента для проверки/создания bucket
+ async with self.session.client(
+ 's3',
+ endpoint_url=self.config.ENDPOINT_URL,
+ aws_access_key_id=self.config.ACCESS_KEY_ID,
+ aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
+ region_name=self.config.REGION,
+ config=self.boto_config,
+ ) as client:
+ # Проверяем существование bucket
+ try:
+ await client.head_bucket(Bucket=self.config.BUCKET_NAME)
+ log.info(f'S3 bucket {self.config.BUCKET_NAME} exists')
+ except Exception:
+ # Bucket не существует, создаем
+ log.info(f'Creating S3 bucket {self.config.BUCKET_NAME}')
+ await client.create_bucket(Bucket=self.config.BUCKET_NAME)
+ log.info(f'S3 bucket {self.config.BUCKET_NAME} created')
+
+ async def close(self) -> None:
+ # aioboto3 использует context manager, не нужно закрывать
+ pass
+
+ async def upload(self, key: str, data: bytes, content_type: str) -> None:
+ async with self.session.client(
+ 's3',
+ endpoint_url=self.config.ENDPOINT_URL,
+ aws_access_key_id=self.config.ACCESS_KEY_ID,
+ aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
+ region_name=self.config.REGION,
+ config=self.boto_config,
+ ) as client:
+ await client.put_object(
+ Bucket=self.config.BUCKET_NAME,
+ Key=key,
+ Body=data,
+ ContentType=content_type,
+ )
+ log.info(f'Uploaded file to S3: {key}')
+
+ async def get(self, key: str) -> bytes:
+ async with self.session.client(
+ 's3',
+ endpoint_url=self.config.ENDPOINT_URL,
+ aws_access_key_id=self.config.ACCESS_KEY_ID,
+ aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
+ region_name=self.config.REGION,
+ config=self.boto_config,
+ ) as client:
+ response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key)
+ data: bytes = await response['Body'].read()
+ log.info(f'Downloaded file from S3: {key}')
+ return data
+
+ async def delete(self, key: str) -> None:
+ async with self.session.client(
+ 's3',
+ endpoint_url=self.config.ENDPOINT_URL,
+ aws_access_key_id=self.config.ACCESS_KEY_ID,
+ aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
+ region_name=self.config.REGION,
+ config=self.boto_config,
+ ) as client:
+ await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key)
+ log.info(f'Deleted file from S3: {key}')
+
+ def public_url(self, key: str) -> str:
+ base_url = self.config.PUBLIC_BASE_URL
+ if not base_url:
+ base_url = f'{self.config.ENDPOINT_URL.rstrip("/")}/{self.config.BUCKET_NAME}'
+ return f'{base_url.rstrip("/")}/{key.lstrip("/")}'
diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py
new file mode 100644
index 0000000..44be1a1
--- /dev/null
+++ b/src/adapter/telegram_bot.py
@@ -0,0 +1,157 @@
+import logging
+from collections.abc import Sequence
+from typing import Any
+
+from aiogram.types import (
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputMediaAudio,
+ InputMediaDocument,
+ InputMediaPhoto,
+ InputMediaVideo,
+ LinkPreviewOptions,
+)
+
+from shared.telegram_base import TelegramBase
+from src.usecase import TelegramBotWriter
+
+log = logging.getLogger(__name__)
+
+
+class TelegramBot(TelegramBase, TelegramBotWriter):
+ async def send_message(
+ self,
+ text: str,
+ chat_id: int,
+ parse_mode: str | None = None,
+ disable_preview: bool = True,
+ reply_to_message_id: int | None = None,
+ ) -> int:
+ message = await self.bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ parse_mode=parse_mode,
+ link_preview_options=LinkPreviewOptions(is_disabled=disable_preview),
+ reply_to_message_id=reply_to_message_id,
+ )
+ return message.message_id
+
+ async def create_chat_invite_link(
+ self, chat_id: int, requires_approval: bool = False, name: str | None = None
+ ) -> str:
+ invite_link = await self.bot.create_chat_invite_link(
+ chat_id=chat_id, creates_join_request=requires_approval, name=name
+ )
+ return invite_link.invite_link
+
+ async def send_message_with_inline_keyboard(
+ self,
+ text: str,
+ chat_id: int,
+ buttons: list[list[InlineKeyboardButton]],
+ parse_mode: str | None = None,
+ disable_preview: bool = True,
+ reply_to_message_id: int | None = None,
+ ) -> int:
+ keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
+ message = await self.bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ reply_markup=keyboard,
+ parse_mode=parse_mode,
+ link_preview_options=LinkPreviewOptions(is_disabled=disable_preview),
+ reply_to_message_id=reply_to_message_id,
+ )
+ return message.message_id
+
+ async def send_media_with_inline_keyboard(
+ self,
+ text: str,
+ chat_id: int,
+ media_type: str,
+ media_file_id: str,
+ buttons: list[list[InlineKeyboardButton]],
+ parse_mode: str | None = None,
+ reply_to_message_id: int | None = None,
+ ) -> int:
+ keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None
+ if media_type == 'photo':
+ message = await self.bot.send_photo(
+ chat_id=chat_id,
+ photo=media_file_id,
+ caption=text,
+ parse_mode=parse_mode,
+ reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
+ )
+ return message.message_id
+ if media_type == 'video':
+ message = await self.bot.send_video(
+ chat_id=chat_id,
+ video=media_file_id,
+ caption=text,
+ parse_mode=parse_mode,
+ reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
+ )
+ return message.message_id
+ if media_type == 'animation':
+ message = await self.bot.send_animation(
+ chat_id=chat_id,
+ animation=media_file_id,
+ caption=text,
+ parse_mode=parse_mode,
+ reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
+ )
+ return message.message_id
+ message = await self.bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ parse_mode=parse_mode,
+ reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
+ )
+ return message.message_id
+
+ async def send_media_group(
+ self,
+ chat_id: int,
+ media_items: Sequence[Any],
+ caption: str | None = None,
+ parse_mode: str | None = None,
+ reply_to_message_id: int | None = None,
+ ) -> int:
+ if not media_items:
+ raise ValueError('No media items to send')
+
+ group: list[InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo] = []
+ for index, item in enumerate(media_items):
+ item_caption = caption if index == 0 else None
+ if item.media_type == 'photo':
+ group.append(InputMediaPhoto(
+ media=item.media_file_id,
+ caption=item_caption,
+ parse_mode=parse_mode,
+ ))
+ elif item.media_type == 'video':
+ group.append(InputMediaVideo(
+ media=item.media_file_id,
+ caption=item_caption,
+ parse_mode=parse_mode,
+ ))
+ else:
+ raise ValueError(f'Unsupported media type for group: {item.media_type}')
+
+ messages = await self.bot.send_media_group(
+ chat_id=chat_id, media=group, reply_to_message_id=reply_to_message_id
+ )
+ # Return message_id of first message (with caption)
+ return messages[0].message_id
+
+ async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None:
+ await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None)
+
+ async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None:
+ await self.bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id, reply_markup=None)
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..799f92f
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,44 @@
+import json
+import typing
+
+from pydantic import BaseModel, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from shared.config_helper import load_settings
+from shared.datebase_base import DatabaseConfig
+from shared.jwt_base import JWTConfig
+from shared.logger import LoggerConfig
+from shared.telegram_base import TelegramConfig
+from src.adapter.s3 import S3Config
+
+
+class ParserConfig(BaseModel):
+ URL: str
+
+
+class AppConfig(BaseModel):
+ ORIGINS: list[str]
+
+ @field_validator('ORIGINS', mode='before')
+ @classmethod
+ def parse_origins(cls, v: str | list[str]) -> list[str]:
+ if isinstance(v, list):
+ return v
+ if isinstance(v, str):
+ return typing.cast(list[str], json.loads(v))
+ raise ValueError('ORIGINS must be a JSON array string')
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file='.env', case_sensitive=False, env_nested_delimiter='__')
+
+ app: AppConfig
+ db: DatabaseConfig
+ logger: LoggerConfig
+ telegram: TelegramConfig
+ jwt: JWTConfig
+ parser: ParserConfig
+ s3: S3Config
+
+
+settings: Settings = load_settings(Settings)
diff --git a/src/controller/http_v1/__init__.py b/src/controller/http_v1/__init__.py
new file mode 100644
index 0000000..bfa1ab8
--- /dev/null
+++ b/src/controller/http_v1/__init__.py
@@ -0,0 +1,32 @@
+from fastapi import APIRouter
+
+from src.controller.http_v1.analytics import analytics_router
+from src.controller.http_v1.auth import auth_router
+from src.controller.http_v1.channels import channels_router
+from src.controller.http_v1.creatives import creatives_router
+from src.controller.http_v1.internal import internal_router
+from src.controller.http_v1.projects import projects_router
+from src.controller.http_v1.purchases import placements_user_router
+from src.controller.http_v1.views import views_router
+from src.controller.http_v1.workspace_invites import workspace_invites_global_router, workspace_invites_router
+from src.controller.http_v1.workspace_members import workspace_members_router
+from src.controller.http_v1.workspaces import workspaces_router
+
+api_router = APIRouter()
+
+# API v1 endpoints
+api_v1_router = APIRouter(prefix='/api/v1')
+api_v1_router.include_router(auth_router)
+api_v1_router.include_router(internal_router)
+api_v1_router.include_router(channels_router)
+api_v1_router.include_router(projects_router)
+api_v1_router.include_router(placements_user_router) # User-managed placements
+api_v1_router.include_router(creatives_router)
+api_v1_router.include_router(views_router)
+api_v1_router.include_router(analytics_router)
+api_v1_router.include_router(workspaces_router)
+api_v1_router.include_router(workspace_members_router)
+api_v1_router.include_router(workspace_invites_router)
+api_v1_router.include_router(workspace_invites_global_router)
+
+api_router.include_router(api_v1_router)
diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py
new file mode 100644
index 0000000..bbce5fe
--- /dev/null
+++ b/src/controller/http_v1/analytics.py
@@ -0,0 +1,177 @@
+import datetime
+import uuid
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, domain, dto
+from src.adapter.jwt import JWTPayload
+
+analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics'])
+
+
+@analytics_router.get('/placements')
+async def get_placements_analytics(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ # Categorical filters - accept both list and comma-separated string
+ project_ids: list[uuid.UUID] | None = None,
+ status_list: str | None = None,
+ placement_channel_ids: list[uuid.UUID] | None = None,
+ creative_ids: list[uuid.UUID] | None = None,
+ cost_types: str | None = None,
+ placement_types: str | None = None,
+ invite_link_types: str | None = None,
+ # Numeric filters
+ cost_min: float | None = None,
+ cost_max: float | None = None,
+ views_min: int | None = None,
+ views_max: int | None = None,
+ subscriptions_min: int | None = None,
+ subscriptions_max: int | None = None,
+ cpm_min: float | None = None,
+ cpm_max: float | None = None,
+ # Text filters
+ channel_title_contains: str | None = None,
+ creative_name_contains: str | None = None,
+ comment_contains: str | None = None,
+ # Date filters
+ placement_date_from: datetime.datetime | None = None,
+ placement_date_to: datetime.datetime | None = None,
+ # Pagination and sorting
+ sort_by: str | None = 'created_at',
+ sort_direction: str = 'desc',
+ page: int = 1,
+ size: int = 50,
+) -> dto.GetPlacementsAnalyticsOutput:
+ # Parse comma-separated lists
+ parsed_status_list = status_list.split(',') if status_list else None
+ parsed_cost_types = cost_types.split(',') if cost_types else None
+ parsed_placement_types = placement_types.split(',') if placement_types else None
+ parsed_invite_link_types = invite_link_types.split(',') if invite_link_types else None
+
+ input = dto.GetPlacementsAnalyticsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_ids=project_ids,
+ status_list=parsed_status_list,
+ placement_channel_ids=placement_channel_ids,
+ creative_ids=creative_ids,
+ cost_types=parsed_cost_types,
+ placement_types=parsed_placement_types,
+ invite_link_types=parsed_invite_link_types,
+ cost_min=cost_min,
+ cost_max=cost_max,
+ views_min=views_min,
+ views_max=views_max,
+ subscriptions_min=subscriptions_min,
+ subscriptions_max=subscriptions_max,
+ cpm_min=cpm_min,
+ cpm_max=cpm_max,
+ channel_title_contains=channel_title_contains,
+ creative_name_contains=creative_name_contains,
+ comment_contains=comment_contains,
+ placement_date_from=placement_date_from,
+ placement_date_to=placement_date_to,
+ sort_by=sort_by,
+ sort_direction=sort_direction,
+ page=page,
+ size=size,
+ )
+ return await deps.get_usecase().get_placements_analytics(input)
+
+
+@analytics_router.get('/creatives')
+async def get_creatives_analytics(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ project_id: uuid.UUID | None = None,
+ tag: domain.CreativeTag | None = None,
+) -> Page[dto.CreativeAnalyticsOutput]:
+ input = dto.GetCreativesAnalyticsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ tag=tag,
+ )
+ result = await deps.get_usecase().get_creatives_analytics(input)
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@analytics_router.get('/channels')
+async def get_channel_analytics(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ project_id: uuid.UUID | None = None,
+) -> Page[dto.ChannelAnalyticsOutput]:
+ input = dto.GetChannelAnalyticsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ )
+ result = await deps.get_usecase().get_channel_analytics(input)
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@analytics_router.get('/spending')
+async def get_spending_analytics(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ project_id: uuid.UUID | None = None,
+ date_from: datetime.datetime | None = None,
+ date_to: datetime.datetime | None = None,
+ grouping: dto.DateGrouping = dto.DateGrouping.DAY,
+) -> dto.GetSpendingAnalyticsOutput:
+ input = dto.GetSpendingAnalyticsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ date_from=date_from,
+ date_to=date_to,
+ grouping=grouping,
+ )
+ return await deps.get_usecase().get_spending_analytics(input)
+
+
+@analytics_router.get('/overview')
+async def get_overview_analytics(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ date_from: datetime.datetime,
+ date_to: datetime.datetime,
+ project_id: uuid.UUID | None = None,
+) -> dto.GetOverviewAnalyticsOutput:
+ input = dto.GetOverviewAnalyticsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ date_from=date_from,
+ date_to=date_to,
+ project_id=project_id,
+ )
+ return await deps.get_usecase().get_overview_analytics(input)
+
+
+@analytics_router.get('/projects')
+async def get_projects_analytics(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ project_ids: list[uuid.UUID] | None = None,
+ date_from: datetime.datetime | None = None,
+ date_to: datetime.datetime | None = None,
+ grouping: dto.DateGrouping = dto.DateGrouping.DAY,
+ date_grouping: dto.DateGroupingType = dto.DateGroupingType.PLACEMENT_DATE,
+ metrics: list[dto.ProjectMetrics] | None = None,
+) -> dto.GetProjectsAnalyticsOutput:
+ input = dto.GetProjectsAnalyticsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_ids=project_ids,
+ date_from=date_from,
+ date_to=date_to,
+ grouping=grouping,
+ date_grouping=date_grouping,
+ metrics=metrics,
+ )
+ return await deps.get_usecase().get_projects_analytics(input)
diff --git a/src/controller/http_v1/auth.py b/src/controller/http_v1/auth.py
new file mode 100644
index 0000000..79d61ba
--- /dev/null
+++ b/src/controller/http_v1/auth.py
@@ -0,0 +1,25 @@
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+auth_router = APIRouter(prefix='/auth', tags=['auth'])
+
+
+@auth_router.get('/complete')
+async def complete_auth(token: str) -> dto.ValidateLoginTokenOutput:
+ return await deps.get_usecase().validate_login_token(
+ input=dto.ValidateLoginTokenInput(
+ token=token,
+ )
+ )
+
+
+@auth_router.get('/me')
+async def get_me(
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.UserOutput:
+ return await deps.get_usecase().get_me(user_id=current_user.user_id)
diff --git a/src/controller/http_v1/channels.py b/src/controller/http_v1/channels.py
new file mode 100644
index 0000000..c353305
--- /dev/null
+++ b/src/controller/http_v1/channels.py
@@ -0,0 +1,35 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+channels_router = APIRouter(prefix='/channels', tags=['channels'])
+
+
+@channels_router.get('')
+async def get_channels(
+ _: Annotated[JWTPayload, Depends(deps.get_current_user)], username: str | None = None
+) -> Page[dto.ChannelOutput]:
+ input = dto.GetChannelsInput(username=username)
+ result = await deps.get_usecase().get_channels(input=input)
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@channels_router.post('')
+async def create_channels(
+ request: dto.CreateChannelsInput, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
+) -> dto.CreateChannelsOutput:
+ return await deps.get_usecase().create_channels(input=request)
+
+
+@channels_router.get('/{channel_id}')
+async def get_channel(
+ channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
+) -> dto.ChannelOutput:
+ input_data = dto.GetChannelInput(channel_id=channel_id)
+ return await deps.get_usecase().get_channel(input=input_data)
diff --git a/src/controller/http_v1/creatives.py b/src/controller/http_v1/creatives.py
new file mode 100644
index 0000000..a32c08d
--- /dev/null
+++ b/src/controller/http_v1/creatives.py
@@ -0,0 +1,84 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends, Query
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+creatives_router = APIRouter(prefix='/workspaces/{workspace_id}/creatives', tags=['creatives'])
+
+
+@creatives_router.get('')
+async def list_creatives(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ project_id: uuid.UUID | None = None,
+ include_archived: bool = False,
+) -> Page[dto.CreativeOutput]:
+ input = dto.GetCreativesInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ include_archived=include_archived,
+ )
+
+ result = await deps.get_usecase().get_creatives(input=input)
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@creatives_router.get('/{creative_id}')
+async def get_creative(
+ creative_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.CreativeOutput:
+ input = dto.GetCreativeInput(
+ creative_id=creative_id,
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ )
+
+ return await deps.get_usecase().get_creative(input=input)
+
+
+@creatives_router.post('')
+async def create_creative(
+ workspace_id: uuid.UUID,
+ request: dto.CreateCreativeInput,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ project_id: uuid.UUID = Query(),
+) -> dto.CreativeOutput:
+ return await deps.get_usecase().create_creative(request, project_id, current_user.user_id, workspace_id)
+
+
+@creatives_router.patch('/{creative_id}')
+async def update_creative(
+ creative_id: uuid.UUID,
+ request: dto.UpdateCreativeInput,
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.CreativeOutput:
+ return await deps.get_usecase().update_creative(
+ creative_id=creative_id,
+ input=request,
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ )
+
+
+@creatives_router.delete('/{creative_id}')
+async def delete_creative(
+ creative_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> None:
+ input = dto.DeleteCreativeInput(
+ creative_id=creative_id,
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ )
+
+ await deps.get_usecase().delete_creative(input)
diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py
new file mode 100644
index 0000000..f53f001
--- /dev/null
+++ b/src/controller/http_v1/internal.py
@@ -0,0 +1,129 @@
+from typing import Annotated, Literal
+
+from fastapi.routing import APIRouter
+from pydantic import BaseModel, Field
+
+from src import deps, dto
+
+internal_router = APIRouter(prefix='/internal', tags=['internal'])
+
+
+@internal_router.post('/auth/login-token')
+async def create_login_token(input: dto.CreateLoginTokenRequest) -> str:
+ return await deps.get_usecase().create_telegram_login_token(telegram_id=input.telegram_id)
+
+
+class AttachLoginTokenMessageRequest(BaseModel):
+ token: str
+ message_id: int
+
+
+@internal_router.post('/auth/login-token/message')
+async def attach_login_token_message(input: AttachLoginTokenMessageRequest) -> None:
+ await deps.get_usecase().attach_login_token_message(token=input.token, message_id=input.message_id)
+
+
+@internal_router.get('/auth/jwt')
+async def get_jwt_by_telegram_id(
+ telegram_id: int,
+ username: str | None = None,
+ first_name: str | None = None,
+ last_name: str | None = None,
+) -> dto.ValidateLoginTokenOutput:
+ return await deps.get_usecase().get_jwt_by_telegram_id(
+ telegram_id=telegram_id,
+ username=username,
+ first_name=first_name,
+ last_name=last_name,
+ )
+
+
+class AttachChannelToWorkspaceRequest(BaseModel):
+ channel_id: str
+ workspace_id: str
+ user_telegram_id: int
+
+
+@internal_router.post('/projects')
+async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) -> dto.ProjectOutput:
+ """Привязать канал к workspace (вызывается из Golang бота после выбора workspace)"""
+ import uuid
+
+ input_data = dto.AttachChannelToWorkspaceInput(
+ channel_id=uuid.UUID(input.channel_id),
+ workspace_id=uuid.UUID(input.workspace_id),
+ user_telegram_id=input.user_telegram_id,
+ )
+
+ return await deps.get_usecase().attach_channel_to_workspace(input=input_data)
+
+
+class SubscriptionEventRequest(BaseModel):
+ type: Literal['subscription']
+ user_telegram_id: int
+ invite_link: str
+ username: str | None = None
+ first_name: str | None = None
+ last_name: str | None = None
+
+
+class UnsubscriptionEventRequest(BaseModel):
+ type: Literal['unsubscription']
+ user_telegram_id: int
+ channel_telegram_id: int
+
+
+class BotAddedEventRequest(dto.ConnectProjectInput):
+ type: Literal['bot_added']
+
+
+class BotRemovedEventRequest(dto.DisconnectProjectByTgIdInput):
+ type: Literal['bot_removed']
+
+
+class BotPermissionsEventRequest(dto.UpdateProjectPermissionsInput):
+ type: Literal['bot_permissions']
+
+
+EventRequest = Annotated[
+ SubscriptionEventRequest
+ | UnsubscriptionEventRequest
+ | BotAddedEventRequest
+ | BotRemovedEventRequest
+ | BotPermissionsEventRequest,
+ Field(discriminator='type'),
+]
+
+
+@internal_router.post('/events')
+async def handle_event(input: EventRequest) -> None:
+ if isinstance(input, SubscriptionEventRequest):
+ await deps.get_usecase().handle_subscription(
+ user_telegram_id=input.user_telegram_id,
+ username=input.username,
+ invite_link=input.invite_link,
+ first_name=input.first_name,
+ last_name=input.last_name,
+ )
+ return
+
+ if isinstance(input, UnsubscriptionEventRequest):
+ await deps.get_usecase().handle_unsubscription(
+ user_telegram_id=input.user_telegram_id,
+ channel_telegram_id=input.channel_telegram_id,
+ )
+ return
+
+ if isinstance(input, BotAddedEventRequest):
+ add_data = dto.ConnectProjectInput(**input.dict(exclude={'type'}))
+ await deps.get_usecase().tg_add_project(input=add_data)
+ return
+
+ if isinstance(input, BotRemovedEventRequest):
+ remove_data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'}))
+ await deps.get_usecase().disconnect_project_by_tg_id(input=remove_data)
+ return
+
+ if isinstance(input, BotPermissionsEventRequest):
+ permissions_data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'}))
+ await deps.get_usecase().update_project_permissions(input=permissions_data)
diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py
new file mode 100644
index 0000000..25c415b
--- /dev/null
+++ b/src/controller/http_v1/projects.py
@@ -0,0 +1,117 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends, Query
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+from src.domain import PermissionKey
+
+projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects'])
+
+
+@projects_router.get('')
+async def get_projects(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ include_archived: bool = Query(default=False),
+) -> Page[dto.ProjectOutput]:
+ input = dto.GetWorkspaceProjectsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ include_archived=include_archived,
+ )
+
+ result = await deps.get_usecase().get_workspace_projects(input=input)
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@projects_router.get('/{project_id}')
+async def get_project(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.ProjectOutput:
+ context = await deps.get_usecase().ensure_workspace_permission(
+ workspace_id, current_user.user_id, PermissionKey.PROJECTS_READ
+ )
+ context.ensure_project_permission(PermissionKey.PROJECTS_READ, project_id)
+
+ input = dto.GetProjectInput(
+ workspace_id=workspace_id,
+ project_id=project_id,
+ )
+ return await deps.get_usecase().get_project(input=input)
+
+
+@projects_router.patch('/{project_id}/invite-link-type')
+async def update_project_invite_link_type(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ input: dto.UpdateProjectInviteLinkTypeInput,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.ProjectOutput:
+ return await deps.get_usecase().update_project_invite_link_type(
+ workspace_id=workspace_id,
+ project_id=project_id,
+ purchase_invite_type_default=input.purchase_invite_type_default,
+ user_id=current_user.user_id,
+ )
+
+
+@projects_router.post('/{project_id}/archive')
+async def archive_project(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.ProjectOutput:
+ input = dto.ArchiveProjectInput(
+ workspace_id=workspace_id,
+ project_id=project_id,
+ user_id=current_user.user_id,
+ )
+ return await deps.get_usecase().archive_project(input=input)
+
+
+@projects_router.post('/{project_id}/unarchive')
+async def unarchive_project(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.ProjectOutput:
+ input = dto.ArchiveProjectInput(
+ workspace_id=workspace_id,
+ project_id=project_id,
+ user_id=current_user.user_id,
+ )
+ return await deps.get_usecase().unarchive_project(input=input)
+
+
+@projects_router.delete('/{project_id}')
+async def delete_project(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> None:
+ await deps.get_usecase().delete_project(
+ workspace_id=workspace_id,
+ project_id=project_id,
+ user_id=current_user.user_id,
+ )
+
+
+@projects_router.put('/{project_id}/move')
+async def move_project(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ request: dto.MoveProjectRequest,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.ProjectOutput:
+ return await deps.get_usecase().move_project_to_workspace(
+ user_id=current_user.user_id,
+ source_workspace_id=workspace_id,
+ project_id=project_id,
+ target_workspace_id=request.target_workspace_id,
+ )
diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py
new file mode 100644
index 0000000..94786b7
--- /dev/null
+++ b/src/controller/http_v1/purchases.py
@@ -0,0 +1,132 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+# Placement router - User-managed planned placements
+placements_user_router = APIRouter(
+ prefix='/workspaces/{workspace_id}/projects/{project_id}/placements',
+ tags=['placements'],
+)
+
+
+@placements_user_router.post('')
+async def create_placements(
+ request: dto.CreatePlacementsInput,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.GetPlacementsOutput:
+ """Create multiple placements for different channels (bulk creation)"""
+ return await deps.get_usecase().create_placements(
+ project_id=project_id,
+ workspace_id=workspace_id,
+ user_id=current_user.user_id,
+ input=request,
+ )
+
+
+@placements_user_router.get('')
+async def get_placements(
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> Page[dto.PlacementWithPostsOutput]:
+ """Get all placements for a project"""
+ input = dto.GetPlacementsInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ )
+ result = await deps.get_usecase().get_placements(input=input)
+ return paginate(result.placements) # type: ignore[no-any-return]
+
+
+@placements_user_router.get('/{placement_id}')
+async def get_placement(
+ placement_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.PlacementWithPostsOutput:
+ """Get single placement by ID"""
+ input = dto.GetPlacementInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ placement_id=placement_id,
+ )
+ return await deps.get_usecase().get_placement_user(input=input)
+
+
+@placements_user_router.patch('/{placement_id}')
+async def update_placement(
+ placement_id: uuid.UUID,
+ request: dto.UpdatePlacementInput,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.PlacementWithPostsOutput:
+ return await deps.get_usecase().update_placement(
+ placement_id=placement_id,
+ input=request,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ user_id=current_user.user_id,
+ )
+
+
+@placements_user_router.post('/{placement_id}/creative')
+async def build_placement_creative(
+ placement_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.CreativePreviewOutput:
+ return await deps.get_usecase().build_placement_creative(
+ placement_id=placement_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ user_id=current_user.user_id,
+ )
+
+
+@placements_user_router.patch('/{placement_id}/posts/{placement_post_id}')
+async def update_placement_post(
+ placement_id: uuid.UUID,
+ placement_post_id: uuid.UUID,
+ request: dto.UpdatePlacementPostInput,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.PlacementWithPostsOutput:
+ """Update placement post status"""
+ return await deps.get_usecase().update_placement_post(
+ placement_id=placement_id,
+ placement_post_id=placement_post_id,
+ input=request,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ user_id=current_user.user_id,
+ )
+
+
+@placements_user_router.delete('/{placement_id}')
+async def delete_placement(
+ placement_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> None:
+ input = dto.DeletePlacementInput(
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ placement_id=placement_id,
+ )
+ await deps.get_usecase().delete_placement(input=input)
diff --git a/src/controller/http_v1/views.py b/src/controller/http_v1/views.py
new file mode 100644
index 0000000..feaf478
--- /dev/null
+++ b/src/controller/http_v1/views.py
@@ -0,0 +1,32 @@
+import datetime
+import uuid
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+views_router = APIRouter(prefix='/workspaces/{workspace_id}/placements/{placement_id}/views', tags=['views'])
+
+
+@views_router.get('/history')
+async def get_views_history(
+ placement_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ from_date: datetime.datetime | None = None,
+ to_date: datetime.datetime | None = None,
+) -> Page[dto.PostViewsHistoryOutput]:
+ input_data = dto.GetViewsHistoryInput(
+ placement_id=placement_id,
+ user_id=current_user.user_id,
+ workspace_id=workspace_id,
+ from_date=from_date,
+ to_date=to_date,
+ )
+
+ result = await deps.get_usecase().get_views_history(input=input_data)
+ return paginate(result) # type: ignore[no-any-return]
diff --git a/src/controller/http_v1/workspace_invites.py b/src/controller/http_v1/workspace_invites.py
new file mode 100644
index 0000000..07ea391
--- /dev/null
+++ b/src/controller/http_v1/workspace_invites.py
@@ -0,0 +1,51 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+workspace_invites_router = APIRouter(prefix='/workspaces/{workspace_id}/invites', tags=['workspace invites'])
+
+
+@workspace_invites_router.get('')
+async def list_workspace_invites(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> Page[dto.WorkspaceInviteOutput]:
+ result = await deps.get_usecase().get_workspace_invites(
+ workspace_id=workspace_id,
+ user_id=current_user.user_id,
+ )
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@workspace_invites_router.post('')
+async def create_workspace_invite(
+ workspace_id: uuid.UUID,
+ request: dto.CreateWorkspaceInviteInput,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.WorkspaceInviteOutput:
+ return await deps.get_usecase().create_workspace_invite(
+ workspace_id=workspace_id,
+ user_id=current_user.user_id,
+ input=request,
+ )
+
+
+# Глобальный роутер для invite endpoints (без workspace_id в пути)
+workspace_invites_global_router = APIRouter(prefix='/invites', tags=['workspace invites'])
+
+
+@workspace_invites_global_router.post('/{invite_id}/accept')
+async def accept_workspace_invite(
+ invite_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.WorkspaceInviteOutput:
+ return await deps.get_usecase().accept_workspace_invite(
+ invite_id=invite_id,
+ user_id=current_user.user_id,
+ )
diff --git a/src/controller/http_v1/workspace_members.py b/src/controller/http_v1/workspace_members.py
new file mode 100644
index 0000000..081a361
--- /dev/null
+++ b/src/controller/http_v1/workspace_members.py
@@ -0,0 +1,50 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+workspace_members_router = APIRouter(prefix='/workspaces/{workspace_id}/members', tags=['workspace members'])
+
+
+@workspace_members_router.get('/me')
+async def get_current_member_permissions(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.WorkspaceMemberOutput:
+ """Get current user's membership and permissions in the workspace."""
+ return await deps.get_usecase().get_current_member_permissions(
+ workspace_id=workspace_id,
+ user_id=current_user.user_id,
+ )
+
+
+@workspace_members_router.get('')
+async def list_workspace_members(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> Page[dto.WorkspaceMemberOutput]:
+ result = await deps.get_usecase().get_workspace_members(
+ workspace_id=workspace_id,
+ user_id=current_user.user_id,
+ )
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@workspace_members_router.put('/{workspace_user_id}/permissions')
+async def put_workspace_member_permissions(
+ workspace_id: uuid.UUID,
+ workspace_user_id: uuid.UUID,
+ request: dto.UpdateWorkspaceMemberPermissionsInput,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.WorkspaceMemberOutput:
+ return await deps.get_usecase().update_workspace_member_permissions(
+ workspace_id=workspace_id,
+ workspace_user_id=workspace_user_id,
+ user_id=current_user.user_id,
+ input=request,
+ )
diff --git a/src/controller/http_v1/workspaces.py b/src/controller/http_v1/workspaces.py
new file mode 100644
index 0000000..7377a01
--- /dev/null
+++ b/src/controller/http_v1/workspaces.py
@@ -0,0 +1,67 @@
+import uuid
+from typing import Annotated
+
+from fastapi import Depends, File, UploadFile
+from fastapi.routing import APIRouter
+from fastapi_pagination import Page, paginate
+
+from src import deps, dto
+from src.adapter.jwt import JWTPayload
+
+workspaces_router = APIRouter(prefix='/workspaces', tags=['workspaces'])
+
+
+@workspaces_router.get('')
+async def list_workspaces(
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> Page[dto.WorkspaceMembershipOutput]:
+ result = await deps.get_usecase().get_workspaces(current_user.user_id)
+ return paginate(result) # type: ignore[no-any-return]
+
+
+@workspaces_router.post('')
+async def create_workspace(
+ request: dto.CreateWorkspaceInput,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.CreateWorkspaceOutput:
+ return await deps.get_usecase().create_workspace(current_user.user_id, request)
+
+
+@workspaces_router.patch('/{workspace_id}')
+async def update_workspace(
+ workspace_id: uuid.UUID,
+ request: dto.UpdateWorkspaceInput,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.WorkspaceMembershipOutput:
+ return await deps.get_usecase().update_workspace(workspace_id, current_user.user_id, request)
+
+
+@workspaces_router.delete('/{workspace_id}')
+async def delete_workspace(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> None:
+ await deps.get_usecase().delete_workspace(workspace_id, current_user.user_id)
+
+
+@workspaces_router.post('/{workspace_id}/avatar')
+async def upload_workspace_avatar(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+ file: UploadFile = File(...),
+) -> dto.WorkspaceMembershipOutput:
+ avatar_data = await file.read()
+ return await deps.get_usecase().update_workspace_avatar(
+ workspace_id,
+ current_user.user_id,
+ avatar_data,
+ file.content_type,
+ )
+
+
+@workspaces_router.delete('/{workspace_id}/avatar')
+async def delete_workspace_avatar(
+ workspace_id: uuid.UUID,
+ current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+) -> dto.WorkspaceMembershipOutput:
+ return await deps.get_usecase().delete_workspace_avatar(workspace_id, current_user.user_id)
diff --git a/src/controller/worker/fetch_placement_post.py b/src/controller/worker/fetch_placement_post.py
new file mode 100644
index 0000000..ac98a53
--- /dev/null
+++ b/src/controller/worker/fetch_placement_post.py
@@ -0,0 +1,17 @@
+import logging
+from typing import TYPE_CHECKING
+
+from shared.worker_base import WorkerBase
+from src import deps
+
+if TYPE_CHECKING:
+ pass
+
+log = logging.getLogger(__name__)
+
+
+class FetchPlacementPostWorker(WorkerBase):
+ """Worker that fetches posts from channels and creates PlacementPosts from Placements"""
+
+ async def _cycle_func(self) -> None:
+ await deps.get_usecase().fetch_placement_post_cycle(self.config.INTERVAL_SECONDS)
diff --git a/src/deps.py b/src/deps.py
new file mode 100644
index 0000000..a991228
--- /dev/null
+++ b/src/deps.py
@@ -0,0 +1,42 @@
+from typing import Annotated
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from src.adapter.jwt import JWT, JWTPayload
+from src.config import settings
+from src.usecase import Usecase
+
+_usecase_instance: Usecase | None = None # Singleton
+
+
+def set_usecase(usecase: Usecase) -> None:
+ global _usecase_instance
+ _usecase_instance = usecase
+
+
+def get_usecase() -> Usecase:
+ if _usecase_instance is None:
+ raise RuntimeError('Usecase not initialized. Call set_usecase() first')
+
+ return _usecase_instance
+
+
+security = HTTPBearer()
+
+
+def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> JWTPayload:
+ token = credentials.credentials
+
+ jwt_decoder = JWT(settings.jwt)
+
+ try:
+ payload: JWTPayload = jwt_decoder.decode_access_token(token)
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=str(e),
+ headers={'WWW-Authenticate': 'Bearer'},
+ ) from e
+
+ return payload
diff --git a/src/domain/__init__.py b/src/domain/__init__.py
new file mode 100644
index 0000000..f521ad3
--- /dev/null
+++ b/src/domain/__init__.py
@@ -0,0 +1,158 @@
+__all__ = (
+ 'User',
+ 'TelegramUser',
+ 'Workspace',
+ 'WorkspaceUser',
+ 'WorkspaceInvite',
+ 'WorkspaceInviteStatus',
+ 'WorkspaceUserStatus',
+ 'WorkspaceUserPermission',
+ 'WorkspaceUserPermissionScope',
+ 'WorkspacePermissions',
+ 'WorkspacePermissionContext',
+ 'build_workspace_permission_context',
+ 'PermissionKey',
+ 'PermissionScopeType',
+ 'Channel',
+ 'Project',
+ 'ProjectStatus',
+ 'Placement',
+ 'PlacementStatus',
+ 'PlacementType',
+ 'parse_format_duration',
+ 'parse_format_string',
+ 'format_display_string',
+ 'get_feed_duration_seconds',
+ 'COMMON_FORMATS',
+ 'PlacementPost',
+ 'PlacementPostStatus',
+ 'Creative',
+ 'CreativeMedia',
+ 'replace_invite_link_with_tag',
+ 'validate_media_size',
+ 'validate_media_items',
+ 'MAX_CREATIVE_MEDIA_BYTES',
+ 'MAX_CREATIVE_MEDIA_ITEMS',
+ 'validate_workspace_avatar_size',
+ 'MAX_WORKSPACE_AVATAR_BYTES',
+ 'Post',
+ 'Subscription',
+ 'PostViewsHistory',
+ 'ChannelNotFound',
+ 'ProjectNotFound',
+ 'CreativeStatus',
+ 'CreativeTag',
+ 'SubscriptionStatus',
+ 'InviteLinkType',
+ 'CostType',
+ 'LoginToken',
+ 'UserNotFound',
+ 'WorkspaceNotFound',
+ 'WorkspaceAccessDenied',
+ 'WorkspaceInviteNotFound',
+ 'WorkspaceInviteAlreadyExists',
+ 'WorkspaceInviteAlreadyProcessed',
+ 'WorkspaceMemberAlreadyExists',
+ 'WorkspaceAvatarTooLarge',
+ 'LoginTokenNotFound',
+ 'LoginTokenExpired',
+ 'LoginTokenAlreadyUsed',
+ 'ProjectNotFound',
+ 'ProjectChannelConflict',
+ 'PlacementNotFound',
+ 'PlacementPostNotFound',
+ 'PlacementHasPosts',
+ 'ChannelNotFound',
+ 'ChannelAlreadyExists',
+ 'ChannelNoAdminRights',
+ 'TelegramChannelNotFound',
+ 'CreativeNotFound',
+ 'CreativeInUse',
+ 'CreativeInviteLinkNotFound',
+ 'CreativeMultipleInviteLinks',
+ 'CreativeMediaTooLarge',
+ 'CreativeMediaTooMany',
+ 'CreativeMediaGroupUnsupported',
+ 'UserByUsernameNotFound',
+)
+
+from .channel import Channel
+from .creative import (
+ MAX_CREATIVE_MEDIA_BYTES,
+ MAX_CREATIVE_MEDIA_ITEMS,
+ Creative,
+ CreativeMedia,
+ CreativeStatus,
+ CreativeTag,
+ replace_invite_link_with_tag,
+ validate_media_items,
+ validate_media_size,
+)
+from .error import (
+ ChannelAlreadyExists,
+ ChannelNoAdminRights,
+ ChannelNotFound,
+ CreativeInUse,
+ CreativeInviteLinkNotFound,
+ CreativeMediaGroupUnsupported,
+ CreativeMediaTooLarge,
+ CreativeMediaTooMany,
+ CreativeMultipleInviteLinks,
+ CreativeNotFound,
+ LoginTokenAlreadyUsed,
+ LoginTokenExpired,
+ LoginTokenNotFound,
+ PlacementHasPosts,
+ PlacementNotFound,
+ PlacementPostNotFound,
+ ProjectChannelConflict,
+ ProjectNotFound,
+ TelegramChannelNotFound,
+ UserByUsernameNotFound,
+ UserNotFound,
+ WorkspaceAccessDenied,
+ WorkspaceAvatarTooLarge,
+ WorkspaceInviteAlreadyExists,
+ WorkspaceInviteAlreadyProcessed,
+ WorkspaceInviteNotFound,
+ WorkspaceMemberAlreadyExists,
+ WorkspaceNotFound,
+)
+from .login_token import LoginToken
+from .placement import (
+ COMMON_FORMATS,
+ CostType,
+ InviteLinkType,
+ Placement,
+ PlacementStatus,
+ PlacementType,
+ format_display_string,
+ get_feed_duration_seconds,
+ parse_format_duration,
+ parse_format_string,
+)
+from .placement_post import PlacementPost, PlacementPostStatus
+from .post import Post
+from .post_views_history import PostViewsHistory
+from .project import Project, ProjectStatus
+from .subscription import Subscription, SubscriptionStatus
+from .telegram_user import TelegramUser
+from .user import User
+from .workspace import (
+ MAX_WORKSPACE_AVATAR_BYTES,
+ PermissionKey,
+ PermissionScopeType,
+ Workspace,
+ WorkspaceInvite,
+ WorkspaceInviteStatus,
+ WorkspaceUser,
+ WorkspaceUserPermission,
+ WorkspaceUserPermissionScope,
+ WorkspaceUserStatus,
+ validate_workspace_avatar_size,
+)
+from .workspace_permissions import (
+ WorkspacePermissionContext,
+ WorkspacePermissions,
+ build_workspace_permission_context,
+)
diff --git a/src/domain/base.py b/src/domain/base.py
new file mode 100644
index 0000000..5e7cbc2
--- /dev/null
+++ b/src/domain/base.py
@@ -0,0 +1,12 @@
+from tortoise import fields
+from tortoise.models import Model
+
+
+class TimestampedModel(Model):
+ id = fields.UUIDField(pk=True)
+ created_at = fields.DatetimeField(auto_now_add=True)
+ updated_at = fields.DatetimeField(auto_now=True)
+ deleted_at = fields.DatetimeField(null=True)
+
+ class Meta:
+ abstract = True
diff --git a/src/domain/channel.py b/src/domain/channel.py
new file mode 100644
index 0000000..0faa491
--- /dev/null
+++ b/src/domain/channel.py
@@ -0,0 +1,17 @@
+from tortoise import fields
+
+from .base import TimestampedModel
+
+
+class Channel(TimestampedModel):
+ telegram_id = fields.BigIntField(unique=True, index=True)
+ title = fields.CharField(max_length=255)
+ username = fields.CharField(max_length=255, unique=True, index=True, null=True)
+
+ access_hash = fields.BigIntField(null=True)
+ pts = fields.IntField(null=True)
+ invite_link = fields.CharField(max_length=1024, null=True)
+ is_accessible = fields.BooleanField(default=True)
+
+ class Meta:
+ table = 'channel'
diff --git a/src/domain/creative.py b/src/domain/creative.py
new file mode 100644
index 0000000..71af5c7
--- /dev/null
+++ b/src/domain/creative.py
@@ -0,0 +1,136 @@
+import enum
+import re
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+from .error import (
+ CreativeInviteLinkNotFound,
+ CreativeMediaGroupUnsupported,
+ CreativeMediaTooLarge,
+ CreativeMediaTooMany,
+)
+
+if TYPE_CHECKING:
+
+ from .project import Project
+ from .user import User
+
+
+class CreativeStatus(str, enum.Enum):
+ ACTIVE = 'active'
+ ARCHIVED = 'archived'
+
+
+class CreativeTag(str, enum.Enum):
+ TESTING = 'testing' # Тестовый
+ PRODUCTION = 'production' # Рабочий
+
+
+class Creative(TimestampedModel):
+ name = fields.CharField(max_length=255)
+ text = fields.TextField()
+ buttons = fields.JSONField(default=list)
+ status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
+ tag = fields.CharEnumField(CreativeTag, default=CreativeTag.TESTING)
+
+ project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
+ 'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
+ )
+ created_by_user: fields.ForeignKeyRelation['User'] | None = fields.ForeignKeyField(
+ 'models.User', related_name='created_creatives', on_delete=fields.SET_NULL, null=True, index=True
+ )
+
+ if TYPE_CHECKING:
+ project_id: UUID
+ created_by_user_id: UUID | None
+ media_items: 'fields.ReverseRelation[CreativeMedia]'
+
+ class Meta:
+ table = 'creative'
+
+
+class CreativeMedia(TimestampedModel):
+ media_type = fields.CharField(max_length=32)
+ media_file_id = fields.CharField(max_length=512)
+ media_s3_key = fields.CharField(max_length=512, null=True)
+ position = fields.IntField()
+
+ creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
+ 'models.Creative', related_name='media_items', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ creative_id: UUID
+
+ class Meta:
+ table = 'creative_media'
+ unique_together = (('creative_id', 'position'),)
+
+
+MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
+MAX_CREATIVE_MEDIA_ITEMS = 10
+
+
+_INVITE_LINK_HTML = re.compile(
+ r'(.*?)',
+ re.IGNORECASE | re.DOTALL,
+)
+_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE)
+_INVITE_LINK_REPLACED = re.compile(r'(.*?)', re.IGNORECASE | re.DOTALL)
+
+
+def replace_invite_link_with_tag(text: str) -> str:
+ """Replace ALL Telegram invite links (t.me/+xxx) with tracking tags."""
+ # Check for any invite links first (excluding tg-link which are already replaced)
+ replaced = _INVITE_LINK_REPLACED.findall(text)
+ html_links = _INVITE_LINK_HTML.findall(text)
+
+ # Remove HTML links and tg-link tags to find plain text links
+ text_without_links = _INVITE_LINK_HTML.sub('', text)
+ text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links)
+ plain_links = _INVITE_LINK_PLAIN.findall(text_without_links)
+
+ total = len(replaced) + len(html_links) + len(plain_links)
+
+ if total == 0:
+ raise CreativeInviteLinkNotFound()
+
+ # Normalize already replaced links (preserve anchor text)
+ def _normalize_replaced(match: re.Match[str]) -> str:
+ inner = match.group(1).strip()
+ if inner == "" or _INVITE_LINK_PLAIN.search(inner):
+ return ''
+ return f'{inner}'
+
+ text = _INVITE_LINK_REPLACED.sub(_normalize_replaced, text)
+
+ # Replace HTML links with tracking tags (preserve anchor text)
+ def _replace_html(match: re.Match[str]) -> str:
+ inner = match.group(1).strip()
+ if _INVITE_LINK_PLAIN.fullmatch(inner):
+ return ''
+ return f'{inner}'
+
+ text = _INVITE_LINK_HTML.sub(_replace_html, text)
+
+ # Replace plain text invite links
+ text = _INVITE_LINK_PLAIN.sub('', text)
+
+ return text
+
+
+def validate_media_size(media_data: bytes | None) -> None:
+ if media_data is None:
+ return
+ if len(media_data) > MAX_CREATIVE_MEDIA_BYTES:
+ raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES)
+
+
+def validate_media_items(media_types: list[str]) -> None:
+ if len(media_types) > MAX_CREATIVE_MEDIA_ITEMS:
+ raise CreativeMediaTooMany(MAX_CREATIVE_MEDIA_ITEMS)
+ if len(media_types) > 1 and "animation" in media_types:
+ raise CreativeMediaGroupUnsupported()
diff --git a/src/domain/error.py b/src/domain/error.py
new file mode 100644
index 0000000..f5f5f51
--- /dev/null
+++ b/src/domain/error.py
@@ -0,0 +1,146 @@
+import uuid
+
+from fastapi import HTTPException, status
+
+
+def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException:
+ if user_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
+
+
+def UserByUsernameNotFound(username: str) -> HTTPException:
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'User @{username} not found')
+
+
+def LoginTokenNotFound() -> HTTPException:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
+
+
+def LoginTokenExpired() -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has expired')
+
+
+def LoginTokenAlreadyUsed() -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used')
+
+
+def ChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
+ if channel_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Channel not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Channel {channel_id} not found')
+
+
+def TelegramChannelNotFound(username: str) -> HTTPException:
+ return HTTPException(
+ status.HTTP_404_NOT_FOUND, f'Telegram channel @{username} not found or is not a public channel'
+ )
+
+
+def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
+ if project_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Project not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
+
+
+def ProjectChannelConflict() -> HTTPException:
+ return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace')
+
+
+def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
+ if placement_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found')
+
+
+def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPException:
+ if placement_post_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'PlacementPost not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found')
+
+
+def PlacementHasPosts(placement_id: uuid.UUID) -> HTTPException:
+ return HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f'Placement {placement_id} has placement_posts and cannot remove creative',
+ )
+
+
+def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
+ return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
+
+
+def ChannelNoAdminRights() -> HTTPException:
+ return HTTPException(
+ status.HTTP_403_FORBIDDEN, 'Bot must be added as administrator with invite link creation rights'
+ )
+
+
+def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
+ if creative_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found')
+
+
+def CreativeInviteLinkNotFound() -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain one invite link (t.me/+xxx)')
+
+
+def CreativeMultipleInviteLinks() -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain only one invite link')
+
+
+def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)')
+
+
+def CreativeMediaTooMany(max_items: int) -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media exceeds max items ({max_items})')
+
+
+def CreativeMediaGroupUnsupported() -> HTTPException:
+ return HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ 'Creative media group supports only photo/video; animation allowed only as a single item',
+ )
+
+
+def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
+ return HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f'Creative {creative_id} is used in active placement_posts and cannot be deleted',
+ )
+
+
+def WorkspaceNotFound(workspace_id: uuid.UUID | None = None) -> HTTPException:
+ if workspace_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace {workspace_id} not found')
+
+
+def WorkspaceAccessDenied(workspace_id: uuid.UUID | None = None) -> HTTPException:
+ if workspace_id is None:
+ return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied')
+ return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} access denied')
+
+
+def WorkspaceInviteNotFound(invite_id: uuid.UUID | None = None) -> HTTPException:
+ if invite_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace invite not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace invite {invite_id} not found')
+
+
+def WorkspaceInviteAlreadyExists() -> HTTPException:
+ return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user')
+
+
+def WorkspaceInviteAlreadyProcessed() -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, 'Workspace invite has already been processed')
+
+
+def WorkspaceMemberAlreadyExists() -> HTTPException:
+ return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member')
+
+
+def WorkspaceAvatarTooLarge(max_bytes: int) -> HTTPException:
+ return HTTPException(status.HTTP_400_BAD_REQUEST, f'Workspace avatar is too large (max {max_bytes} bytes)')
diff --git a/src/domain/login_token.py b/src/domain/login_token.py
new file mode 100644
index 0000000..e9a0e56
--- /dev/null
+++ b/src/domain/login_token.py
@@ -0,0 +1,25 @@
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .user import User
+
+
+class LoginToken(TimestampedModel):
+ token = fields.CharField(max_length=255, unique=True, index=True)
+ user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
+ 'models.User', related_name='login_tokens', on_delete=fields.CASCADE, index=True
+ )
+ expires_at = fields.DatetimeField()
+ used_at = fields.DatetimeField(null=True)
+ message_id = fields.IntField(null=True)
+
+ class Meta:
+ table = 'login_token'
+
+ if TYPE_CHECKING:
+ user_id: UUID
diff --git a/src/domain/placement.py b/src/domain/placement.py
new file mode 100644
index 0000000..80e49dc
--- /dev/null
+++ b/src/domain/placement.py
@@ -0,0 +1,289 @@
+import enum
+import re
+import uuid
+from typing import TYPE_CHECKING
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .channel import Channel
+ from .creative import Creative
+ from .project import Project
+
+__all__ = [
+ 'Placement',
+ 'PlacementStatus',
+ 'PlacementType',
+ 'InviteLinkType',
+ 'CostType',
+ 'parse_format_duration',
+ 'parse_format_string',
+ 'format_display_string',
+ 'get_feed_duration_seconds',
+ 'COMMON_FORMATS',
+]
+
+
+class CostType(enum.StrEnum):
+ FIXED = 'fixed'
+ CPM = 'cpm'
+
+
+class PlacementStatus(enum.StrEnum):
+ NO_STATUS = 'Без статуса'
+ WRITE = 'Написать'
+ WAITING_RESPONSE = 'Ждём ответа'
+ TERMS_APPROVAL = 'Согласование условий'
+ TO_PAY = 'Оплатить'
+ PAID = 'Оплачено'
+ CANCELED = 'Отмена'
+ PRICE_NOT_OK = 'Не подходит цена'
+ NOT_RELEVANT = 'Неактуально'
+ NO_RESPONSE = 'Не отвечает'
+
+
+class PlacementType(enum.StrEnum):
+ SELF_PROMO = 'self_promo'
+ STANDARD = 'standard'
+
+
+class InviteLinkType(enum.StrEnum):
+ PUBLIC = 'public' # открытая ссылка
+ APPROVAL = 'approval' # с одобрением ботом
+
+
+def parse_format_string(format_str: str | None) -> tuple[int | None, int | None]:
+ """
+ Парсит строку формата размещения в числовые значения (минуты).
+
+ Разделяет строку по '/' на 2 части (top, feed).
+ Top: число <= 12 → часы (*60), > 12 → минуты (для миграции старых данных).
+ Feed: "без удаления" → 0; "Xд"/"X дней" → дни (*24*60); число → часы (*60).
+
+ Returns:
+ (top_time_minutes, feed_time_minutes) — (None, None) если не распознано
+ """
+ if not format_str:
+ return None, None
+
+ format_str = format_str.strip().lower()
+
+ if not format_str:
+ return None, None
+
+ # Проверяем наличие разделителя
+ if '/' not in format_str:
+ return None, None
+
+ parts = format_str.split('/', 1)
+ if len(parts) != 2:
+ return None, None
+
+ top_str = parts[0].strip().strip('(').strip(')')
+ feed_str = parts[1].strip().strip('(').strip(')')
+
+ # Парсим top
+ top_minutes = _parse_top_part(top_str)
+ if top_minutes is None:
+ return None, None
+
+ # Парсим feed
+ feed_minutes = _parse_feed_part(feed_str)
+ if feed_minutes is None:
+ return None, None
+
+ return top_minutes, feed_minutes
+
+
+def _parse_top_part(s: str) -> int | None:
+ """Парсит часть top из строки формата."""
+ s = s.strip()
+ # Убираем суффиксы единиц
+ s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s)
+ s = s.strip()
+
+ match = re.match(r'^(\d+)$', s)
+ if not match:
+ return None
+
+ value = int(match.group(1))
+ if value <= 0:
+ return None
+
+ # <= 12 → часы, > 12 → минуты (для обратной совместимости со старыми данными)
+ if value <= 12:
+ return value * 60
+ return value
+
+
+def _parse_feed_part(s: str) -> int | None:
+ """Парсит часть feed из строки формата. Возвращает минуты, 0 = без удаления."""
+ s = s.strip()
+
+ # "без удаления"
+ if 'без удаления' in s:
+ return 0
+
+ # Ищем дни: "7д", "7 дней", "7 дн", "(7 дней)"
+ days_match = re.search(r'(\d+)\s*(?:д(?:н|ней|ня)?)', s)
+ if days_match:
+ days = int(days_match.group(1))
+ if days <= 0:
+ return None
+ return days * 24 * 60
+
+ # Ищем часы: "24ч", "24 часов", или просто число
+ s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s)
+ s = s.strip().strip('(').strip(')')
+
+ match = re.match(r'^(\d+)$', s)
+ if not match:
+ return None
+
+ value = int(match.group(1))
+ if value <= 0:
+ return None
+
+ # Значение — часы, конвертируем в минуты
+ return value * 60
+
+
+def format_display_string(top_minutes: int | None, feed_minutes: int | None) -> str | None:
+ """
+ Формирует строку отображения из числовых значений.
+
+ Returns:
+ Строка вида "1ч / 24ч", "30мин / 7д", "1ч / без удаления", или None
+ """
+ if top_minutes is None and feed_minutes is None:
+ return None
+
+ top_str = _format_duration_top(top_minutes) if top_minutes is not None else '?'
+ feed_str = _format_duration_feed(feed_minutes) if feed_minutes is not None else '?'
+
+ return f'{top_str} / {feed_str}'
+
+
+def _format_duration_top(minutes: int) -> str:
+ """Форматирует время топа: кратно 60 → 'Xч', иначе → 'Xмин'."""
+ if minutes > 0 and minutes % 60 == 0:
+ return f'{minutes // 60}ч'
+ return f'{minutes}мин'
+
+
+def _format_duration_feed(minutes: int) -> str:
+ """Форматирует время ленты: 0 → 'без удаления', ≥7д и кратно дню → 'Xд', кратно 60 → 'Xч', иначе → 'Xмин'."""
+ if minutes == 0:
+ return 'без удаления'
+ # Дни используем только для >= 7 дней (10080 мин), иначе часы (24ч, 48ч, 72ч выглядят привычнее)
+ if minutes >= 7 * 24 * 60 and minutes % (24 * 60) == 0:
+ return f'{minutes // (24 * 60)}д'
+ if minutes % 60 == 0:
+ return f'{minutes // 60}ч'
+ return f'{minutes}мин'
+
+
+def parse_format_duration(format_str: str | None) -> int | None:
+ """
+ Парсит формат размещения и возвращает длительность ленты в секундах.
+ Обёртка для обратной совместимости.
+
+ Примеры форматов:
+ - "1 / 24" -> 86400 сек (24 часа)
+ - "1/48" -> 172800 сек (48 часов)
+ - "1 / 72" -> 259200 сек (72 часа)
+ - "1 / (7 дней)" -> 604800 сек (7 дней)
+ - "1 / (30 дней)" -> 2592000 сек (30 дней)
+ - "1 / (без удаления)" -> None (не удалять)
+ - "2 / 24" -> 86400 сек (24 часа)
+
+ Returns:
+ int | None: Длительность в секундах или None для "без удаления"
+ """
+ _, feed_minutes = parse_format_string(format_str)
+ if feed_minutes is None or feed_minutes == 0:
+ return None
+ return feed_minutes * 60
+
+
+def get_feed_duration_seconds(placement: 'Placement') -> int | None:
+ """
+ Возвращает длительность ленты в секундах для placement.
+
+ Сначала проверяет числовые поля (feed_time_minutes),
+ потом fallback на parse_format_duration(placement.format).
+ 0 = без удаления → None.
+
+ Returns:
+ int | None: Длительность в секундах или None
+ """
+ if placement.feed_time_minutes is not None:
+ if placement.feed_time_minutes == 0:
+ return None
+ return placement.feed_time_minutes * 60
+
+ return parse_format_duration(placement.format)
+
+
+COMMON_FORMATS: list[dict[str, int | str]] = [
+ {'top_time_minutes': 60, 'feed_time_minutes': 1440, 'label': '1ч / 24ч'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 2160, 'label': '1ч / 36ч'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 2880, 'label': '1ч / 48ч'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 4320, 'label': '1ч / 72ч'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 10080, 'label': '1ч / 7д'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 43200, 'label': '1ч / 30д'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 86400, 'label': '1ч / 60д'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 129600, 'label': '1ч / 90д'},
+ {'top_time_minutes': 60, 'feed_time_minutes': 0, 'label': '1ч / без удаления'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 1440, 'label': '2ч / 24ч'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 2160, 'label': '2ч / 36ч'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 2880, 'label': '2ч / 48ч'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 4320, 'label': '2ч / 72ч'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 10080, 'label': '2ч / 7д'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 43200, 'label': '2ч / 30д'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 86400, 'label': '2ч / 60д'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 129600, 'label': '2ч / 90д'},
+ {'top_time_minutes': 120, 'feed_time_minutes': 0, 'label': '2ч / без удаления'},
+]
+
+
+class Placement(TimestampedModel):
+ status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.NO_STATUS)
+ placement_at = fields.DatetimeField(null=True)
+ payment_at = fields.DatetimeField(null=True)
+ cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
+ cost_value = fields.FloatField(null=True)
+ cost_before_bargain_type = fields.CharEnumField(CostType, null=True, max_length=8)
+ cost_before_bargain = fields.FloatField(null=True)
+ placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
+ format = fields.TextField(null=True)
+ top_time_minutes = fields.IntField(null=True)
+ feed_time_minutes = fields.IntField(null=True)
+ comment = fields.TextField(null=True)
+
+ invite_link = fields.CharField(max_length=512, null=True)
+ invite_link_created_at = fields.DatetimeField(null=True)
+ invite_link_type = fields.CharEnumField(InviteLinkType)
+
+ invite_link_name = fields.CharField(max_length=32, null=True)
+
+ project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
+ 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
+ )
+ creative: fields.ForeignKeyRelation['Creative'] | None = fields.ForeignKeyField(
+ 'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
+ )
+ channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
+ 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ project_id: uuid.UUID
+ creative_id: uuid.UUID | None
+ channel_id: uuid.UUID
+
+ class Meta:
+ table = 'placement'
+ indexes = (('project', 'status'),)
diff --git a/src/domain/placement_post.py b/src/domain/placement_post.py
new file mode 100644
index 0000000..a613552
--- /dev/null
+++ b/src/domain/placement_post.py
@@ -0,0 +1,49 @@
+import enum
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .placement import Placement
+ from .post import Post
+
+__all__ = ['PlacementPost', 'PlacementPostStatus']
+
+
+class PlacementPostStatus(enum.StrEnum):
+ # Ручные статусы
+ NO_STATUS = 'Без статуса'
+ SEND_POST = 'Отправить пост'
+ POST_APPROVAL = 'Согласование поста'
+ WAITING_SCHEDULE = 'Ожидание отложки'
+ SCHEDULED = 'Запланирован'
+
+ # Автоматические статусы
+ POST_PUBLISHED = 'Пост вышел'
+ COMPLETED_DELETED = 'Размещение отработало - пост удалён'
+ COMPLETED_NOT_DELETED = 'Размещение отработало - пост не удалён'
+
+ # Проверить (требуют внимания)
+ CHECK_DELETED_EARLY = 'Проверить - пост удалён раньше срока'
+ CHECK_NOT_PUBLISHED = 'Проверить - пост не вышел'
+ CHECK_COMPLETED = 'Размещение отработало' # ручной после проверки
+
+
+class PlacementPost(TimestampedModel):
+ placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
+ 'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True
+ )
+ post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
+ 'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True
+ )
+ status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.NO_STATUS, max_length=64)
+
+ if TYPE_CHECKING:
+ post_id: UUID | None
+ placement_id: UUID
+
+ class Meta:
+ table = 'placement_post'
diff --git a/src/domain/post.py b/src/domain/post.py
new file mode 100644
index 0000000..54b2a73
--- /dev/null
+++ b/src/domain/post.py
@@ -0,0 +1,42 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .channel import Channel
+
+
+class Post(TimestampedModel):
+ message_id = fields.IntField()
+ text = fields.TextField()
+ deleted_from_channel_at = fields.DatetimeField(null=True)
+ published_at = fields.DatetimeField(null=True)
+
+ channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
+ 'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ channel_id: uuid.UUID
+
+ class Meta:
+ table = 'post'
+ unique_together = (('channel_id', 'message_id'),)
+
+ @property
+ def url(self) -> str | None:
+ if self.channel.username:
+ return f'https://t.me/{self.channel.username}/{self.message_id}'
+
+ telegram_id = self.channel.telegram_id
+ if telegram_id is None:
+ return None
+
+ channel_id = telegram_id
+ if telegram_id < 0:
+ channel_id = -telegram_id - 1000000000000
+
+ return f'https://t.me/c/{channel_id}/{self.message_id}'
diff --git a/src/domain/post_views_history.py b/src/domain/post_views_history.py
new file mode 100644
index 0000000..e2ff5aa
--- /dev/null
+++ b/src/domain/post_views_history.py
@@ -0,0 +1,25 @@
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .post import Post
+
+
+class PostViewsHistory(TimestampedModel):
+ views_count = fields.IntField() # Количество просмотров на момент снимка
+ fetched_at = fields.DatetimeField(index=True)
+
+ post: fields.ForeignKeyRelation['Post'] = fields.ForeignKeyField(
+ 'models.Post', related_name='views_histories', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ post_id: UUID
+
+ class Meta:
+ table = 'post_views_history'
+ unique_together = (('post_id', 'fetched_at'),)
diff --git a/src/domain/project.py b/src/domain/project.py
new file mode 100644
index 0000000..5fe405b
--- /dev/null
+++ b/src/domain/project.py
@@ -0,0 +1,39 @@
+import enum
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+from .placement import InviteLinkType
+
+if TYPE_CHECKING:
+ from .channel import Channel
+ from .workspace import Workspace
+
+
+class ProjectStatus(str, enum.Enum):
+ ACTIVE = 'active'
+ INACTIVE = 'inactive'
+ ARCHIVED = 'archived'
+
+
+class Project(TimestampedModel):
+ status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
+ purchase_invite_type_default = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
+
+ channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
+ 'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True
+ )
+
+ workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
+ 'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ channel_id: UUID
+ workspace_id: UUID
+
+ class Meta:
+ table = 'project'
+ unique_together = (('workspace_id', 'channel_id'),)
diff --git a/src/domain/subscription.py b/src/domain/subscription.py
new file mode 100644
index 0000000..aeb367b
--- /dev/null
+++ b/src/domain/subscription.py
@@ -0,0 +1,37 @@
+import enum
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .placement import Placement
+ from .telegram_user import TelegramUser
+
+
+class SubscriptionStatus(str, enum.Enum):
+ ACTIVE = 'active'
+ UNSUBSCRIBED = 'unsubscribed'
+
+
+class Subscription(TimestampedModel):
+ invite_link = fields.CharField(max_length=512, index=True)
+ status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
+ unsubscribed_at = fields.DatetimeField(null=True)
+
+ placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
+ 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
+ )
+ telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField(
+ 'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ placement_id: UUID
+ telegram_user_id: UUID
+
+ class Meta:
+ table = 'subscription'
+ unique_together = (('placement_id', 'telegram_user_id'),)
diff --git a/src/domain/telegram_user.py b/src/domain/telegram_user.py
new file mode 100644
index 0000000..42cf8a0
--- /dev/null
+++ b/src/domain/telegram_user.py
@@ -0,0 +1,13 @@
+from tortoise import fields
+
+from .base import TimestampedModel
+
+
+class TelegramUser(TimestampedModel):
+ telegram_id = fields.BigIntField(unique=True, index=True)
+ username = fields.CharField(max_length=255, null=True)
+ first_name = fields.CharField(max_length=255, null=True)
+ last_name = fields.CharField(max_length=255, null=True)
+
+ class Meta:
+ table = 'telegram_user'
diff --git a/src/domain/user.py b/src/domain/user.py
new file mode 100644
index 0000000..87424c7
--- /dev/null
+++ b/src/domain/user.py
@@ -0,0 +1,21 @@
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from tortoise import fields
+
+from .base import TimestampedModel
+
+if TYPE_CHECKING:
+ from .telegram_user import TelegramUser
+
+
+class User(TimestampedModel):
+ telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.OneToOneField(
+ 'models.TelegramUser', related_name='user', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ telegram_user_id: UUID
+
+ class Meta:
+ table = 'user'
diff --git a/src/domain/workspace.py b/src/domain/workspace.py
new file mode 100644
index 0000000..b5511f8
--- /dev/null
+++ b/src/domain/workspace.py
@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+import enum
+import uuid
+from typing import TYPE_CHECKING
+
+from tortoise import fields
+
+from .base import TimestampedModel
+from .error import WorkspaceAvatarTooLarge
+
+if TYPE_CHECKING:
+ from .creative import Creative
+ from .placement import Placement
+ from .project import Project
+ from .user import User
+
+
+class Workspace(TimestampedModel):
+ id = fields.UUIDField(pk=True)
+ name = fields.CharField(max_length=255)
+ avatar_s3_key = fields.CharField(max_length=512, null=True)
+
+ class Meta:
+ table = 'workspace'
+
+
+class WorkspaceUserStatus(str, enum.Enum):
+ ACTIVE = 'active'
+ INVITED = 'invited'
+ BLOCKED = 'blocked'
+
+
+class WorkspaceUser(TimestampedModel):
+ status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE)
+
+ workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
+ 'models.Workspace', related_name='workspace_users', on_delete=fields.CASCADE, index=True
+ )
+ user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
+ 'models.User', related_name='workspace_users', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ workspace_id: uuid.UUID
+ user_id: uuid.UUID
+
+ class Meta:
+ table = 'workspace_user'
+ unique_together = (('workspace_id', 'user_id'),)
+
+
+class WorkspaceInviteStatus(str, enum.Enum):
+ PENDING = 'pending'
+ ACCEPTED = 'accepted'
+ REVOKED = 'revoked'
+
+
+class WorkspaceInvite(TimestampedModel):
+ status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING)
+
+ workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
+ 'models.Workspace', related_name='invites', on_delete=fields.CASCADE, index=True
+ )
+ invited_by: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
+ 'models.User', related_name='sent_invites', on_delete=fields.CASCADE, index=True
+ )
+ user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
+ 'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ workspace_id: uuid.UUID
+ invited_by_id: uuid.UUID
+ user_id: uuid.UUID
+
+ class Meta:
+ table = 'workspace_invite'
+ unique_together = (('workspace_id', 'user_id'),)
+
+
+class PermissionKey(enum.StrEnum):
+ ADMIN_FULL = 'admin_full'
+
+ PROJECTS_READ = 'projects_read'
+ PROJECTS_WRITE = 'projects_write'
+
+ CREATIVES_READ = 'creatives_read'
+ CREATIVES_WRITE = 'creatives_write'
+
+ PLACEMENTS_READ = 'placements_read'
+ PLACEMENTS_WRITE = 'placements_write'
+
+ ANALYTICS_READ = 'analytics_read'
+ ANALYTICS_WITHOUT_CLICKS = 'analytics_without_clicks'
+ ANALYTICS_OWN_CREATIVES = 'analytics_own_creatives'
+
+ @property
+ def description(self) -> str:
+ return {
+ PermissionKey.ADMIN_FULL: 'полный доступ',
+ PermissionKey.PROJECTS_READ: 'просмотр каталога каналов (проектов)',
+ PermissionKey.PROJECTS_WRITE: 'редактирование каталога каналов',
+ PermissionKey.CREATIVES_READ: 'просмотр креативов',
+ PermissionKey.CREATIVES_WRITE: 'создание/редактирование креативов',
+ PermissionKey.PLACEMENTS_READ: 'просмотр планов закупок',
+ PermissionKey.PLACEMENTS_WRITE: 'создание/редактирование закупок',
+ PermissionKey.ANALYTICS_READ: 'полный доступ к статистике',
+ PermissionKey.ANALYTICS_WITHOUT_CLICKS: 'статистика кроме переходов',
+ PermissionKey.ANALYTICS_OWN_CREATIVES: 'статистика только по своим креативам',
+ }.get(self, self.value)
+
+
+class PermissionScopeType(enum.StrEnum):
+ PROJECT = 'project'
+ CREATIVE = 'creative'
+ PLACEMENT = 'placement'
+
+
+class WorkspaceUserPermission(TimestampedModel):
+ permission = fields.CharEnumField(PermissionKey)
+
+ workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField(
+ 'models.WorkspaceUser', related_name='permissions', on_delete=fields.CASCADE, index=True
+ )
+
+ if TYPE_CHECKING:
+ workspace_user_id: uuid.UUID
+
+ class Meta:
+ table = 'workspace_user_permission'
+ unique_together = (('workspace_user_id', 'permission'),)
+
+
+class WorkspaceUserPermissionScope(TimestampedModel):
+ permission = fields.CharEnumField(PermissionKey)
+
+ workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField(
+ 'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True
+ )
+
+ # Exactly one of these must be set (enforced by CHECK constraint in migration)
+ project: fields.ForeignKeyRelation[Project] | None = fields.ForeignKeyField(
+ 'models.Project', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True
+ )
+ creative: fields.ForeignKeyRelation[Creative] | None = fields.ForeignKeyField(
+ 'models.Creative', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True
+ )
+ placement: fields.ForeignKeyRelation[Placement] | None = fields.ForeignKeyField(
+ 'models.Placement', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True
+ )
+
+ if TYPE_CHECKING:
+ workspace_user_id: uuid.UUID
+ project_id: uuid.UUID | None
+ creative_id: uuid.UUID | None
+ placement_id: uuid.UUID | None
+
+ class Meta:
+ table = 'workspace_user_permission_scope'
+
+
+MAX_WORKSPACE_AVATAR_BYTES = 5 * 1024 * 1024
+
+
+def validate_workspace_avatar_size(avatar_data: bytes | None) -> None:
+ if avatar_data is None:
+ return
+ if len(avatar_data) > MAX_WORKSPACE_AVATAR_BYTES:
+ raise WorkspaceAvatarTooLarge(MAX_WORKSPACE_AVATAR_BYTES)
diff --git a/src/domain/workspace_permissions.py b/src/domain/workspace_permissions.py
new file mode 100644
index 0000000..903e7aa
--- /dev/null
+++ b/src/domain/workspace_permissions.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import UUID
+
+from . import WorkspaceAccessDenied
+from .workspace import (
+ PermissionKey,
+ PermissionScopeType,
+ Workspace,
+ WorkspaceUser,
+)
+
+
+@dataclass
+class WorkspacePermissions:
+ global_permissions: set[PermissionKey]
+ scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]]
+
+ @classmethod
+ def from_membership(cls, membership: WorkspaceUser) -> WorkspacePermissions:
+ global_permissions = {permission.permission for permission in getattr(membership, 'permissions', []) or []}
+
+ scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]] = {}
+ for scope in getattr(membership, 'permission_scopes', []) or []:
+ if scope.project_id:
+ key = (scope.permission, PermissionScopeType.PROJECT)
+ scoped_permissions.setdefault(key, set()).add(scope.project_id)
+ elif scope.creative_id:
+ key = (scope.permission, PermissionScopeType.CREATIVE)
+ scoped_permissions.setdefault(key, set()).add(scope.creative_id)
+ elif scope.placement_id:
+ key = (scope.permission, PermissionScopeType.PLACEMENT)
+ scoped_permissions.setdefault(key, set()).add(scope.placement_id)
+
+ return cls(global_permissions=global_permissions, scoped_permissions=scoped_permissions)
+
+ def has_global(self, permission: PermissionKey) -> bool:
+ return permission in self.global_permissions or PermissionKey.ADMIN_FULL in self.global_permissions
+
+ def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None:
+ if self.has_global(permission):
+ return None
+
+ allowed: set[UUID] = set()
+ for key in (permission, PermissionKey.ADMIN_FULL):
+ project_scope = self.scoped_permissions.get((key, PermissionScopeType.PROJECT))
+ if project_scope:
+ allowed.update(project_scope)
+
+ return allowed
+
+ def allowed_creative_ids(self, permission: PermissionKey) -> set[UUID] | None:
+ if self.has_global(permission):
+ return None
+
+ allowed: set[UUID] = set()
+ for key in (permission, PermissionKey.ADMIN_FULL):
+ creative_scope = self.scoped_permissions.get((key, PermissionScopeType.CREATIVE))
+ if creative_scope:
+ allowed.update(creative_scope)
+
+ return allowed
+
+ def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None:
+ if self.has_global(permission):
+ return None
+
+ allowed: set[UUID] = set()
+ for key in (permission, PermissionKey.ADMIN_FULL):
+ placement_scope = self.scoped_permissions.get((key, PermissionScopeType.PLACEMENT))
+ if placement_scope:
+ allowed.update(placement_scope)
+
+ return allowed
+
+ def has_permission(
+ self,
+ permission: PermissionKey,
+ *,
+ scope_type: PermissionScopeType | None = None,
+ scope_id: UUID | None = None,
+ ) -> bool:
+ if self.has_global(permission):
+ return True
+
+ if scope_type is not None and scope_id is not None:
+ if scope_type == PermissionScopeType.PROJECT:
+ allowed = self.allowed_project_ids(permission)
+ elif scope_type == PermissionScopeType.CREATIVE:
+ allowed = self.allowed_creative_ids(permission)
+ elif scope_type == PermissionScopeType.PLACEMENT:
+ allowed = self.allowed_placement_ids(permission)
+ else:
+ return False
+
+ if allowed is None:
+ return True
+ return scope_id in allowed
+
+ return False
+
+ def has_any(self, permission: PermissionKey) -> bool:
+ allowed = self.allowed_project_ids(permission)
+ if allowed is None:
+ return True
+ return bool(allowed)
+
+ def has_any_analytics_permission(self) -> bool:
+ """Check if user has any analytics permission."""
+ return (
+ self.has_global(PermissionKey.ANALYTICS_READ)
+ or self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS)
+ or self.has_global(PermissionKey.ANALYTICS_OWN_CREATIVES)
+ )
+
+ def should_hide_subscriptions(self) -> bool:
+ """Check if subscription data should be hidden (user has analytics_without_clicks but not analytics_read)."""
+ if self.has_global(PermissionKey.ANALYTICS_READ):
+ return False
+ return self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS)
+
+ def should_filter_own_creatives(self) -> bool:
+ """Check if analytics should be filtered to user's own creatives only."""
+ if self.has_global(PermissionKey.ANALYTICS_READ):
+ return False
+ if self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS):
+ return False
+ return self.has_global(PermissionKey.ANALYTICS_OWN_CREATIVES)
+
+
+@dataclass
+class WorkspacePermissionContext:
+ workspace: Workspace
+ membership: WorkspaceUser
+ permissions: WorkspacePermissions
+
+ def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None:
+ return self.permissions.allowed_project_ids(permission)
+
+ def allowed_creative_ids(self, permission: PermissionKey) -> set[UUID] | None:
+ return self.permissions.allowed_creative_ids(permission)
+
+ def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None:
+ return self.permissions.allowed_placement_ids(permission)
+
+ def ensure_project_permission(self, permission: PermissionKey, project_id: UUID) -> None:
+ if not self.permissions.has_permission(
+ permission,
+ scope_type=PermissionScopeType.PROJECT,
+ scope_id=project_id,
+ ):
+ raise WorkspaceAccessDenied(self.workspace.id)
+
+ def ensure_creative_permission(self, permission: PermissionKey, creative_id: UUID) -> None:
+ if not self.permissions.has_permission(
+ permission,
+ scope_type=PermissionScopeType.CREATIVE,
+ scope_id=creative_id,
+ ):
+ raise WorkspaceAccessDenied(self.workspace.id)
+
+ def ensure_placement_permission(self, permission: PermissionKey, placement_id: UUID) -> None:
+ if not self.permissions.has_permission(
+ permission,
+ scope_type=PermissionScopeType.PLACEMENT,
+ scope_id=placement_id,
+ ):
+ raise WorkspaceAccessDenied(self.workspace.id)
+
+ def should_hide_subscriptions(self) -> bool:
+ """Check if subscription data should be hidden."""
+ return self.permissions.should_hide_subscriptions()
+
+ def should_filter_own_creatives(self) -> bool:
+ """Check if analytics should be filtered to user's own creatives only."""
+ return self.permissions.should_filter_own_creatives()
+
+
+def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext:
+ if membership.workspace is None:
+ raise ValueError('Workspace relation must be loaded for membership permissions')
+
+ permissions = WorkspacePermissions.from_membership(membership)
+
+ return WorkspacePermissionContext(
+ workspace=membership.workspace,
+ membership=membership,
+ permissions=permissions,
+ )
diff --git a/src/dto/__init__.py b/src/dto/__init__.py
new file mode 100644
index 0000000..80be20c
--- /dev/null
+++ b/src/dto/__init__.py
@@ -0,0 +1,208 @@
+__all__ = (
+ 'UpdateProjectInviteLinkTypeInput',
+ 'UpdateProjectPermissionsInput',
+ 'GetWorkspaceProjectsInput',
+ 'GetWorkspaceProjectsOutput',
+ 'GetProjectInput',
+ 'ArchiveProjectInput',
+ 'MoveProjectRequest',
+ 'DisconnectProjectByTgIdInput',
+ 'ConnectProjectInput',
+ 'ProjectOutput',
+ 'ValidateLoginTokenInput',
+ 'ValidateLoginTokenOutput',
+ 'ChannelBotPermissions',
+ 'ChannelOutput',
+ 'GetChannelInput',
+ 'GetChannelsInput',
+ 'CreateChannelInput',
+ 'CreateChannelsInput',
+ 'CreateChannelResult',
+ 'CreateChannelsOutput',
+ 'GetChannelsOutput',
+ 'AttachChannelToWorkspaceInput',
+ 'PlacementOutput',
+ 'PlacementDetails',
+ 'CostInfo',
+ 'CreatePlacementsInput',
+ 'CreatePlacementChannelInput',
+ 'GetPlacementsInput',
+ 'GetPlacementsOutput',
+ 'GetPlacementInput',
+ 'UpdatePlacementInput',
+ 'DeletePlacementInput',
+ 'UpdatePlacementPostInput',
+ 'PlacementPostOutput',
+ 'PostOutput',
+ 'PlacementWithPostsOutput',
+ 'CreativeButton',
+ 'CreativeMediaInput',
+ 'CreativeMediaItem',
+ 'CreativeOutput',
+ 'CreativePreviewOutput',
+ 'GetCreativesInput',
+ 'GetCreativesOutput',
+ 'GetCreativeInput',
+ 'CreateCreativeInput',
+ 'UpdateCreativeInput',
+ 'DeleteCreativeInput',
+ 'PostViewsHistoryOutput',
+ 'GetViewsHistoryInput',
+ 'GetViewsHistoryOutput',
+ 'UpdateViewsManuallyInput',
+ 'UserOutput',
+ 'DateGrouping',
+ 'PlacementAnalyticsOutput',
+ 'ChannelAnalyticsOutput',
+ 'CreativeAnalyticsOutput',
+ 'GetPlacementsAnalyticsInput',
+ 'GetPlacementsAnalyticsOutput',
+ 'GetCreativesAnalyticsInput',
+ 'GetCreativesAnalyticsOutput',
+ 'GetChannelAnalyticsInput',
+ 'GetChannelAnalyticsOutput',
+ 'SpendingDataPoint',
+ 'NumberWithDelta',
+ 'OverviewDailyPoint',
+ 'OverviewChannelPerformance',
+ 'OverviewProjectSpending',
+ 'GetOverviewAnalyticsInput',
+ 'GetOverviewAnalyticsOutput',
+ 'GetSpendingAnalyticsInput',
+ 'GetSpendingAnalyticsOutput',
+ 'DateGroupingType',
+ 'ProjectMetrics',
+ 'ProjectMetricsData',
+ 'ProjectAnalyticsPeriod',
+ 'GetProjectsAnalyticsInput',
+ 'GetProjectsAnalyticsOutput',
+ 'WorkspaceMembershipOutput',
+ 'GetWorkspacesOutput',
+ 'CreateWorkspaceInput',
+ 'CreateWorkspaceOutput',
+ 'UpdateWorkspaceInput',
+ 'WorkspaceMemberOutput',
+ 'WorkspaceMemberUserOutput',
+ 'WorkspacePermissionOutput',
+ 'WorkspacePermissionScopeOutput',
+ 'GetWorkspaceMembersOutput',
+ 'WorkspacePermissionInput',
+ 'WorkspacePermissionScopeInput',
+ 'UpdateWorkspaceMemberPermissionsInput',
+ 'CreateWorkspaceInviteInput',
+ 'WorkspaceInviteOutput',
+ 'GetWorkspaceInvitesOutput',
+)
+
+from pydantic import BaseModel
+
+from .analytics import (
+ ChannelAnalyticsOutput,
+ CreativeAnalyticsOutput,
+ DateGrouping,
+ DateGroupingType,
+ GetChannelAnalyticsInput,
+ GetChannelAnalyticsOutput,
+ GetCreativesAnalyticsInput,
+ GetCreativesAnalyticsOutput,
+ GetOverviewAnalyticsInput,
+ GetOverviewAnalyticsOutput,
+ GetPlacementsAnalyticsInput,
+ GetPlacementsAnalyticsOutput,
+ GetProjectsAnalyticsInput,
+ GetProjectsAnalyticsOutput,
+ GetSpendingAnalyticsInput,
+ GetSpendingAnalyticsOutput,
+ NumberWithDelta,
+ OverviewChannelPerformance,
+ OverviewDailyPoint,
+ OverviewProjectSpending,
+ PlacementAnalyticsOutput,
+ ProjectAnalyticsPeriod,
+ ProjectMetrics,
+ ProjectMetricsData,
+ SpendingDataPoint,
+)
+from .channel import (
+ AttachChannelToWorkspaceInput,
+ ChannelOutput,
+ CreateChannelInput,
+ CreateChannelResult,
+ CreateChannelsInput,
+ CreateChannelsOutput,
+ GetChannelInput,
+ GetChannelsInput,
+ GetChannelsOutput,
+)
+from .creative import (
+ CreateCreativeInput,
+ CreativeButton,
+ CreativeMediaInput,
+ CreativeMediaItem,
+ CreativeOutput,
+ CreativePreviewOutput,
+ DeleteCreativeInput,
+ GetCreativeInput,
+ GetCreativesInput,
+ GetCreativesOutput,
+ UpdateCreativeInput,
+)
+from .project import (
+ ArchiveProjectInput,
+ ChannelBotPermissions,
+ ConnectProjectInput,
+ DisconnectProjectByTgIdInput,
+ GetProjectInput,
+ GetWorkspaceProjectsInput,
+ GetWorkspaceProjectsOutput,
+ MoveProjectRequest,
+ ProjectOutput,
+ UpdateProjectInviteLinkTypeInput,
+ UpdateProjectPermissionsInput,
+)
+from .purchase import (
+ CostInfo,
+ CreatePlacementChannelInput,
+ CreatePlacementsInput,
+ DeletePlacementInput,
+ GetPlacementInput,
+ GetPlacementsInput,
+ GetPlacementsOutput,
+ PlacementDetails,
+ PlacementOutput,
+ PlacementPostOutput,
+ PlacementWithPostsOutput,
+ PostOutput,
+ UpdatePlacementInput,
+ UpdatePlacementPostInput,
+)
+from .user import UserOutput
+from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
+from .views import (
+ GetViewsHistoryInput,
+ GetViewsHistoryOutput,
+ PostViewsHistoryOutput,
+ UpdateViewsManuallyInput,
+)
+from .workspace import (
+ CreateWorkspaceInput,
+ CreateWorkspaceInviteInput,
+ CreateWorkspaceOutput,
+ GetWorkspaceInvitesOutput,
+ GetWorkspaceMembersOutput,
+ GetWorkspacesOutput,
+ UpdateWorkspaceInput,
+ UpdateWorkspaceMemberPermissionsInput,
+ WorkspaceInviteOutput,
+ WorkspaceMemberOutput,
+ WorkspaceMembershipOutput,
+ WorkspaceMemberUserOutput,
+ WorkspacePermissionInput,
+ WorkspacePermissionOutput,
+ WorkspacePermissionScopeInput,
+ WorkspacePermissionScopeOutput,
+)
+
+
+class CreateLoginTokenRequest(BaseModel):
+ telegram_id: int
diff --git a/src/dto/analytics.py b/src/dto/analytics.py
new file mode 100644
index 0000000..437df5b
--- /dev/null
+++ b/src/dto/analytics.py
@@ -0,0 +1,296 @@
+import datetime
+import uuid
+from enum import StrEnum
+
+import pydantic
+
+from src import domain
+
+
+class DateGrouping(StrEnum):
+ DAY = 'day'
+ WEEK = 'week'
+ MONTH = 'month'
+ QUARTER = 'quarter'
+ YEAR = 'year'
+
+
+class DateGroupingType(StrEnum):
+ PURCHASE_DATE = 'purchase_date'
+ LINK_DATE = 'link_date'
+ PLACEMENT_DATE = 'placement_date'
+
+
+class ProjectMetrics(StrEnum):
+ TOTAL_COST = 'total_cost'
+ PURCHASES_COUNT = 'purchases_count'
+ TOTAL_SUBSCRIPTIONS = 'total_subscriptions'
+ TOTAL_VIEWS = 'total_views'
+ AVG_CPF = 'avg_cpf'
+ AVG_CPM = 'avg_cpm'
+ AVG_POST_COST = 'avg_post_cost'
+ CLICKS_COUNT = 'clicks_count'
+ REACH_VOLUME = 'reach_volume'
+ TOTAL_DISCOUNTS = 'total_discounts'
+ AVG_DISCOUNT_PERCENT = 'avg_discount_percent'
+ AVG_CONVERSION = 'avg_conversion'
+
+
+class PlacementAnalyticsOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ project_id: uuid.UUID
+ project_title: str
+ channel_id: uuid.UUID
+ channel_title: str
+ creative_id: uuid.UUID | None = None
+ creative_name: str | None = None
+ cost: float | None = None
+ cost_type: domain.CostType = domain.CostType.FIXED
+ cost_before_bargain: float | None = None
+ payment_at: datetime.datetime | None = None
+ placement_type: domain.PlacementType | None = None
+ comment: str | None = None
+ format: str | None = None
+ invite_link_type: domain.InviteLinkType | None = None
+ placement_date: datetime.datetime | None = None
+ subscriptions_count: int = 0
+ views_count: int | None = None
+ cpf: float | None = None
+ cpm: float | None = None
+ time_on_top: int | None = None
+ time_in_feed: int | None = None
+ invite_link: str | None = None
+ invite_link_created_at: datetime.datetime | None = None
+ post_url: str | None = None
+ post_deleted_at: datetime.datetime | None = None
+ conversion_24h: float | None = None
+ conversion_48h: float | None = None
+ conversion_total: float | None = None
+ unsubscriptions_count: int = 0
+ unsub_percent: float | None = None
+ total_active: int = 0
+
+
+class GetPlacementsAnalyticsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+
+ # Categorical filters (multiple selection)
+ project_ids: list[uuid.UUID] | None = None
+ status_list: list[str] | None = None
+ placement_channel_ids: list[uuid.UUID] | None = None
+ creative_ids: list[uuid.UUID] | None = None
+ cost_types: list[str] | None = None
+ placement_types: list[str] | None = None
+ invite_link_types: list[str] | None = None
+
+ # Numeric filters (ranges)
+ cost_min: float | None = None
+ cost_max: float | None = None
+ views_min: int | None = None
+ views_max: int | None = None
+ subscriptions_min: int | None = None
+ subscriptions_max: int | None = None
+ cpm_min: float | None = None
+ cpm_max: float | None = None
+ cpf_min: float | None = None
+ cpf_max: float | None = None
+ discount_min: float | None = None
+ discount_max: float | None = None
+ conversion_24h_min: float | None = None
+ conversion_24h_max: float | None = None
+ conversion_48h_min: float | None = None
+ conversion_48h_max: float | None = None
+ conversion_total_min: float | None = None
+ conversion_total_max: float | None = None
+ unsub_percent_min: float | None = None
+ unsub_percent_max: float | None = None
+ time_on_top_min: int | None = None
+ time_on_top_max: int | None = None
+ time_in_feed_min: int | None = None
+ time_in_feed_max: int | None = None
+
+ # Text filters (substring)
+ channel_title_contains: str | None = None
+ creative_name_contains: str | None = None
+ comment_contains: str | None = None
+
+ # Date filters
+ placement_date_from: datetime.datetime | None = None
+ placement_date_to: datetime.datetime | None = None
+ payment_date_from: datetime.datetime | None = None
+ payment_date_to: datetime.datetime | None = None
+
+ # Pagination and sorting
+ sort_by: str | None = 'created_at'
+ sort_direction: str = 'desc'
+ page: int = 1
+ size: int = 50
+
+
+class GetPlacementsAnalyticsOutput(pydantic.BaseModel):
+ items: list[PlacementAnalyticsOutput]
+ total: int
+ page: int
+ size: int
+ pages: int
+
+
+class CreativeAnalyticsOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ name: str
+ tag: domain.CreativeTag
+ placements_count: int
+ total_cost: float
+ total_subscriptions: int
+ total_views: int
+ avg_cpf: float | None
+ avg_cpm: float | None
+
+
+class ChannelAnalyticsOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ title: str
+ username: str | None
+ placements_count: int
+ total_cost: float
+ total_subscriptions: int
+ total_views: int
+ avg_cpf: float | None
+ avg_cpm: float | None
+
+
+class GetCreativesAnalyticsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID | None = None
+ tag: domain.CreativeTag | None = None
+
+
+class GetCreativesAnalyticsOutput(pydantic.BaseModel):
+ creatives: list[CreativeAnalyticsOutput]
+
+
+class GetChannelAnalyticsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID | None = None
+
+
+class GetChannelAnalyticsOutput(pydantic.BaseModel):
+ channels: list[ChannelAnalyticsOutput]
+
+
+class SpendingDataPoint(pydantic.BaseModel):
+ period: str
+ cost: float
+ subscriptions: int
+ views: int
+ cpf: float | None
+ cpm: float | None
+
+
+class GetSpendingAnalyticsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID | None = None
+ date_from: datetime.datetime | None = None
+ date_to: datetime.datetime | None = None
+ grouping: DateGrouping = DateGrouping.DAY
+
+
+class GetSpendingAnalyticsOutput(pydantic.BaseModel):
+ total_cost: float
+ total_subscriptions: int
+ total_views: int
+ avg_cpf: float | None
+ avg_cpm: float | None
+ chart_data: list[SpendingDataPoint]
+ placements_count: int = 0
+
+
+class NumberWithDelta(pydantic.BaseModel):
+ value: float | int | None
+ delta_percent: float | None
+
+
+class OverviewDailyPoint(pydantic.BaseModel):
+ date: datetime.date
+ cost: float
+ subscriptions: int
+ subscriptions_delta: int | None = None
+ cpf: float | None
+
+
+class OverviewChannelPerformance(pydantic.BaseModel):
+ channel_id: uuid.UUID
+ title: str
+ username: str | None
+ cpf: float | None
+ total_cost: float
+ subscriptions: int
+
+
+class OverviewProjectSpending(pydantic.BaseModel):
+ project_id: uuid.UUID
+ project_title: str
+ project_username: str | None
+ total_cost: float
+
+
+class GetOverviewAnalyticsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ date_from: datetime.datetime
+ date_to: datetime.datetime
+ project_id: uuid.UUID | None = None
+
+
+class GetOverviewAnalyticsOutput(pydantic.BaseModel):
+ total_cost: NumberWithDelta
+ total_reach: NumberWithDelta
+ placements_count: NumberWithDelta
+ subscriptions_count: NumberWithDelta
+ avg_cpm: NumberWithDelta
+ avg_cpf: NumberWithDelta
+ daily_stats: list[OverviewDailyPoint]
+ top_channels_by_cpf: list[OverviewChannelPerformance]
+ worst_channels_by_cpf: list[OverviewChannelPerformance]
+ project_spending: list[OverviewProjectSpending]
+
+
+class ProjectMetricsData(pydantic.BaseModel):
+ total_cost: float | None = None
+ purchases_count: int | None = None
+ total_subscriptions: int | None = None
+ total_views: int | None = None
+ avg_cpf: float | None = None
+ avg_cpm: float | None = None
+ avg_post_cost: float | None = None
+ clicks_count: int | None = None
+ reach_volume: int | None = None
+ total_discounts: float | None = None
+ avg_discount_percent: float | None = None
+ avg_conversion: float | None = None
+
+
+class ProjectAnalyticsPeriod(pydantic.BaseModel):
+ period: str
+ period_label: str
+ metrics: ProjectMetricsData
+
+
+class GetProjectsAnalyticsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_ids: list[uuid.UUID] | None = None
+ date_from: datetime.datetime | None = None
+ date_to: datetime.datetime | None = None
+ grouping: DateGrouping = DateGrouping.DAY
+ date_grouping: DateGroupingType = DateGroupingType.PLACEMENT_DATE
+ metrics: list[ProjectMetrics] | None = None
+
+
+class GetProjectsAnalyticsOutput(pydantic.BaseModel):
+ periods: list[ProjectAnalyticsPeriod]
+ totals: ProjectMetricsData
diff --git a/src/dto/channel.py b/src/dto/channel.py
new file mode 100644
index 0000000..b19de98
--- /dev/null
+++ b/src/dto/channel.py
@@ -0,0 +1,80 @@
+import uuid
+from typing import Literal
+
+import pydantic
+
+
+class ChannelOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ telegram_id: int | None
+ title: str | None
+ username: str | None
+
+
+class GetChannelsInput(pydantic.BaseModel):
+ username: str | None = None
+
+
+class GetChannelsOutput(pydantic.BaseModel):
+ channels: list[ChannelOutput]
+
+
+class GetChannelInput(pydantic.BaseModel):
+ channel_id: uuid.UUID
+
+
+class AttachChannelToWorkspaceInput(pydantic.BaseModel):
+ channel_id: uuid.UUID
+ workspace_id: uuid.UUID
+ user_telegram_id: int
+
+
+class CreateChannelInput(pydantic.BaseModel):
+ username: str | None = None
+ invite_link: str | None = None
+
+ @pydantic.field_validator('username')
+ @classmethod
+ def normalize_username(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ username = value.strip()
+ if username.startswith('@'):
+ username = username[1:]
+ username = username.strip()
+ if not username:
+ return None
+ return username
+
+ @pydantic.field_validator('invite_link')
+ @classmethod
+ def normalize_invite_link(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ link = value.strip()
+ if not link:
+ return None
+ return link
+
+ @pydantic.model_validator(mode='after')
+ def validate_input(self) -> 'CreateChannelInput':
+ has_username = bool(self.username)
+ has_invite = bool(self.invite_link)
+ if has_username == has_invite:
+ raise ValueError('Specify exactly one of username or invite_link')
+ return self
+
+
+class CreateChannelsInput(pydantic.BaseModel):
+ channels: list[CreateChannelInput]
+
+
+class CreateChannelResult(pydantic.BaseModel):
+ index: int
+ status: Literal['created', 'updated', 'failed']
+ channel: ChannelOutput | None = None
+ error: str | None = None
+
+
+class CreateChannelsOutput(pydantic.BaseModel):
+ results: list[CreateChannelResult]
diff --git a/src/dto/creative.py b/src/dto/creative.py
new file mode 100644
index 0000000..d45590b
--- /dev/null
+++ b/src/dto/creative.py
@@ -0,0 +1,86 @@
+import datetime
+import uuid
+
+import pydantic
+
+from src import domain
+
+
+class CreativeButton(pydantic.BaseModel):
+ text: str
+ url: str
+
+
+class CreativeMediaItem(pydantic.BaseModel):
+ media_type: str
+ media_file_id: str
+ position: int
+ s3_url: str | None = None
+
+
+class CreativeMediaInput(pydantic.BaseModel):
+ media_type: str
+ media_file_id: str
+ media_data: bytes | None = None
+
+
+class CreativeOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ name: str
+ text: str
+ media_items: list[CreativeMediaItem]
+ buttons: list[CreativeButton]
+ project_id: uuid.UUID
+ project_channel_title: str
+ created_at: datetime.datetime
+ status: domain.CreativeStatus
+ tag: domain.CreativeTag
+ placements_count: int
+
+
+class CreativePreviewOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ name: str
+ text: str
+ media_items: list[CreativeMediaItem]
+ buttons: list[CreativeButton]
+
+
+class GetCreativesInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID | None = None
+ include_archived: bool = False
+
+
+class GetCreativesOutput(pydantic.BaseModel):
+ creatives: list[CreativeOutput]
+
+
+class GetCreativeInput(pydantic.BaseModel):
+ creative_id: uuid.UUID
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+
+
+class CreateCreativeInput(pydantic.BaseModel):
+ name: str
+ text: str
+ media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list)
+ buttons: list[CreativeButton] = pydantic.Field(default_factory=list)
+ tag: domain.CreativeTag | None = None
+
+
+class UpdateCreativeInput(pydantic.BaseModel):
+ name: str | None = None
+ text: str | None = None
+ media_items: list[CreativeMediaInput] | None = None
+ buttons: list[CreativeButton] | None = None
+ status: domain.CreativeStatus | None = None
+ tag: domain.CreativeTag | None = None
+
+
+class DeleteCreativeInput(pydantic.BaseModel):
+ creative_id: uuid.UUID
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
diff --git a/src/dto/project.py b/src/dto/project.py
new file mode 100644
index 0000000..94c6a1f
--- /dev/null
+++ b/src/dto/project.py
@@ -0,0 +1,78 @@
+import uuid
+
+import pydantic
+
+from src import domain
+from src.domain.project import ProjectStatus
+from .channel import ChannelOutput
+
+
+class ChannelBotPermissions(pydantic.BaseModel):
+ is_admin: bool
+ can_invite_users: bool
+ can_restrict_members: bool
+ can_manage_chat: bool | None = None
+ can_delete_messages: bool | None = None
+ can_manage_video_chats: bool | None = None
+ can_post_messages: bool | None = None
+ can_edit_messages: bool | None = None
+ can_pin_messages: bool | None = None
+
+
+class ConnectProjectInput(pydantic.BaseModel):
+ telegram_id: int
+ title: str
+ username: str | None
+ user_telegram_id: int
+ bot_permissions: ChannelBotPermissions
+
+
+class ProjectOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ telegram_id: int
+ title: str
+ username: str | None
+ status: ProjectStatus
+ purchase_invite_type_default: domain.InviteLinkType
+ channel: ChannelOutput
+
+
+class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
+ purchase_invite_type_default: domain.InviteLinkType
+
+
+class GetWorkspaceProjectsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ include_archived: bool = False
+
+
+class GetWorkspaceProjectsOutput(pydantic.BaseModel):
+ projects: list[ProjectOutput]
+
+
+class DisconnectProjectByTgIdInput(pydantic.BaseModel):
+ telegram_id: int
+ user_telegram_id: int
+
+
+class UpdateProjectPermissionsInput(pydantic.BaseModel):
+ telegram_id: int
+ permissions: ChannelBotPermissions
+ chat_title: str
+ user_telegram_id: int
+
+
+class GetProjectInput(pydantic.BaseModel):
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID
+
+
+class ArchiveProjectInput(pydantic.BaseModel):
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID
+ user_id: uuid.UUID
+
+
+class MoveProjectRequest(pydantic.BaseModel):
+ target_workspace_id: uuid.UUID
diff --git a/src/dto/purchase.py b/src/dto/purchase.py
new file mode 100644
index 0000000..6241ed2
--- /dev/null
+++ b/src/dto/purchase.py
@@ -0,0 +1,127 @@
+import datetime
+import uuid
+
+import pydantic
+
+from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType
+from src.domain.placement_post import PlacementPostStatus
+
+from .channel import ChannelOutput
+from .project import ProjectOutput
+
+
+class CostInfo(pydantic.BaseModel):
+ type: CostType
+ value: float
+
+
+class PlacementDetails(pydantic.BaseModel):
+ placement_at: datetime.datetime | None = None
+ payment_at: datetime.datetime | None = None
+ cost: CostInfo | None = None
+ cost_before_bargain: CostInfo | None = None
+ placement_type: PlacementType | None = None
+ format: str | None = None
+ top_time_minutes: int | None = None
+ feed_time_minutes: int | None = None
+ comment: str | None = None
+ creative_id: uuid.UUID | None = None
+ invite_link_type: InviteLinkType | None = None
+
+
+class PlacementOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ status: PlacementStatus
+ creative_id: uuid.UUID | None = None
+ creative_name: str | None = None
+ comment: str | None = None
+ invite_link: str | None
+ invite_link_created_at: datetime.datetime | None = None
+ invite_link_type: InviteLinkType
+ channel: ChannelOutput
+ project: ProjectOutput | None = None
+ short_id: str
+ details: PlacementDetails | None = None
+ created_at: datetime.datetime
+
+
+class PostOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ message_id: int
+ text: str
+ url: str | None
+ deleted_from_channel_at: datetime.datetime | None
+ created_at: datetime.datetime
+ updated_at: datetime.datetime
+
+
+class PlacementPostOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ status: PlacementPostStatus
+ subscriptions_count: int
+ views_count: int | None
+ created_at: datetime.datetime
+ time_on_top: int | None = None
+ post: PostOutput
+
+
+class PlacementWithPostsOutput(PlacementOutput):
+ placement_post: PlacementPostOutput | None = None
+
+
+class CreatePlacementChannelInput(pydantic.BaseModel):
+ """Input для создания одного placement (channel + детали)"""
+
+ channel_id: uuid.UUID
+ status: PlacementStatus | None = None
+ comment: str | None = None
+ details: PlacementDetails | None = None
+
+
+class CreatePlacementsInput(pydantic.BaseModel):
+ """Input для создания нескольких placements (бывший CreatePurchaseInput)"""
+
+ creative_id: uuid.UUID | None = None
+ channels: list[CreatePlacementChannelInput]
+
+
+class GetPlacementsInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID
+
+
+class GetPlacementsOutput(pydantic.BaseModel):
+ placements: list[PlacementWithPostsOutput]
+
+
+class GetPlacementInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID
+ placement_id: uuid.UUID
+
+
+class UpdatePlacementInput(pydantic.BaseModel):
+ status: PlacementStatus | None = None
+ comment: str | None = None
+ creative_id: uuid.UUID | None = None
+ placement_at: datetime.datetime | None = None
+ payment_at: datetime.datetime | None = None
+ cost: CostInfo | None = None
+ cost_before_bargain: CostInfo | None = None
+ placement_type: PlacementType | None = None
+ format: str | None = None
+ top_time_minutes: int | None = None
+ feed_time_minutes: int | None = None
+
+
+class DeletePlacementInput(pydantic.BaseModel):
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ project_id: uuid.UUID
+ placement_id: uuid.UUID
+
+
+class UpdatePlacementPostInput(pydantic.BaseModel):
+ status: PlacementPostStatus | None = None
diff --git a/src/dto/user.py b/src/dto/user.py
new file mode 100644
index 0000000..ac29953
--- /dev/null
+++ b/src/dto/user.py
@@ -0,0 +1,11 @@
+import uuid
+
+import pydantic
+
+
+class UserOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ telegram_id: int
+ username: str | None
+ first_name: str | None
+ last_name: str | None
diff --git a/src/dto/validate_login_token.py b/src/dto/validate_login_token.py
new file mode 100644
index 0000000..28b8bd5
--- /dev/null
+++ b/src/dto/validate_login_token.py
@@ -0,0 +1,9 @@
+import pydantic
+
+
+class ValidateLoginTokenInput(pydantic.BaseModel):
+ token: str
+
+
+class ValidateLoginTokenOutput(pydantic.BaseModel):
+ access_token: str
diff --git a/src/dto/views.py b/src/dto/views.py
new file mode 100644
index 0000000..9bc1602
--- /dev/null
+++ b/src/dto/views.py
@@ -0,0 +1,39 @@
+import datetime
+import uuid
+
+import pydantic
+
+
+class PostViewsHistoryOutput(pydantic.BaseModel):
+ """История просмотров поста."""
+
+ id: uuid.UUID
+ post_id: uuid.UUID
+ views_count: int
+ fetched_at: datetime.datetime
+ created_at: datetime.datetime
+
+
+class GetViewsHistoryInput(pydantic.BaseModel):
+ """Получить историю просмотров для placement."""
+
+ placement_id: uuid.UUID
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ from_date: datetime.datetime | None = None # Фильтр: с какой даты
+ to_date: datetime.datetime | None = None # Фильтр: по какую дату
+
+
+class GetViewsHistoryOutput(pydantic.BaseModel):
+ """История просмотров."""
+
+ histories: list[PostViewsHistoryOutput]
+
+
+class UpdateViewsManuallyInput(pydantic.BaseModel):
+ """Ручное обновление просмотров."""
+
+ placement_id: uuid.UUID
+ user_id: uuid.UUID
+ workspace_id: uuid.UUID
+ views_count: int
diff --git a/src/dto/workspace.py b/src/dto/workspace.py
new file mode 100644
index 0000000..e235ce1
--- /dev/null
+++ b/src/dto/workspace.py
@@ -0,0 +1,196 @@
+import uuid
+
+import pydantic
+
+from src import domain
+
+
+class WorkspaceMembershipOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ name: str
+ avatar_url: str | None = None
+
+
+class CreateWorkspaceOutput(WorkspaceMembershipOutput): ...
+
+
+class GetWorkspacesOutput(pydantic.BaseModel):
+ workspaces: list[WorkspaceMembershipOutput]
+
+
+class CreateWorkspaceInput(pydantic.BaseModel):
+ name: str
+
+
+class UpdateWorkspaceInput(pydantic.BaseModel):
+ name: str | None = None
+
+
+class WorkspaceMemberUserOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ telegram_id: int
+ username: str | None
+
+
+class WorkspacePermissionScopeOutput(pydantic.BaseModel):
+ type: domain.PermissionScopeType
+ id: uuid.UUID
+
+
+class WorkspacePermissionOutput(pydantic.BaseModel):
+ key: domain.PermissionKey
+ scopes: list[WorkspacePermissionScopeOutput]
+
+
+class WorkspaceMemberOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ status: domain.WorkspaceUserStatus
+ user: WorkspaceMemberUserOutput
+ permissions: list[WorkspacePermissionOutput]
+
+ @classmethod
+ def from_domain(cls, member: domain.WorkspaceUser) -> 'WorkspaceMemberOutput':
+ if member.user is None or member.user.telegram_user is None:
+ raise ValueError('Workspace member user relation is not fully loaded')
+
+ telegram_user = member.user.telegram_user
+
+ permissions_map: dict[domain.PermissionKey, list[WorkspacePermissionScopeOutput]] = {}
+
+ for permission in getattr(member, 'permissions', []) or []:
+ permissions_map.setdefault(permission.permission, [])
+
+ for scope in getattr(member, 'permission_scopes', []) or []:
+ # Определяем тип и ID scope на основе заполненных полей
+ scope_type = None
+ scope_id = None
+
+ if scope.project_id:
+ scope_type = domain.PermissionScopeType.PROJECT
+ scope_id = scope.project_id
+ elif scope.creative_id:
+ scope_type = domain.PermissionScopeType.CREATIVE
+ scope_id = scope.creative_id
+ elif scope.placement_id:
+ scope_type = domain.PermissionScopeType.PLACEMENT
+ scope_id = scope.placement_id
+
+ if scope_type and scope_id:
+ permissions_map.setdefault(scope.permission, []).append(
+ WorkspacePermissionScopeOutput(type=scope_type, id=scope_id)
+ )
+
+ return cls(
+ id=member.id,
+ status=member.status,
+ user=WorkspaceMemberUserOutput(
+ id=member.user.id,
+ telegram_id=telegram_user.telegram_id,
+ username=telegram_user.username,
+ ),
+ permissions=[
+ WorkspacePermissionOutput(key=key, scopes=scopes)
+ for key, scopes in sorted(permissions_map.items(), key=lambda item: item[0].value)
+ ],
+ )
+
+
+class GetWorkspaceMembersOutput(pydantic.BaseModel):
+ members: list[WorkspaceMemberOutput]
+
+
+class WorkspacePermissionScopeInput(pydantic.BaseModel):
+ type: domain.PermissionScopeType = pydantic.Field(
+ description='Тип области действия. Сейчас используется только "project".'
+ )
+ id: uuid.UUID = pydantic.Field(description='Идентификатор сущности в указанной области (например, project_id).')
+
+
+class WorkspacePermissionInput(pydantic.BaseModel):
+ key: domain.PermissionKey = pydantic.Field(
+ description=(
+ 'Ключ права. Доступные значения: '
+ + ', '.join(f'"{k.value}" - {k.description}' for k in domain.PermissionKey)
+ )
+ )
+ scopes: list[WorkspacePermissionScopeInput] = pydantic.Field(
+ default_factory=list,
+ description='Ограничения по областям. Пустой список означает глобальное право.',
+ )
+
+
+class UpdateWorkspaceMemberPermissionsInput(pydantic.BaseModel):
+ permissions: list[WorkspacePermissionInput] = pydantic.Field(
+ default_factory=list,
+ description='Список прав, который полностью заменит текущий набор участника.',
+ )
+
+ model_config = pydantic.ConfigDict(
+ json_schema_extra={
+ 'examples': [
+ {
+ 'permissions': [
+ {'key': 'admin_full'},
+ {
+ 'key': 'projects_write',
+ 'scopes': [
+ {'type': 'project', 'id': '11111111-1111-1111-1111-111111111111'},
+ {'type': 'project', 'id': '22222222-2222-2222-2222-222222222222'},
+ ],
+ },
+ ]
+ }
+ ]
+ }
+ )
+
+
+class CreateWorkspaceInviteInput(pydantic.BaseModel):
+ username: str = pydantic.Field(min_length=1)
+
+ @pydantic.field_validator('username')
+ @classmethod
+ def normalize_username(cls, value: str) -> str:
+ username = value.strip()
+ if username.startswith('@'):
+ username = username[1:]
+ username = username.strip()
+ if not username:
+ raise ValueError('Username must not be empty')
+ return username
+
+
+class WorkspaceInviteOutput(pydantic.BaseModel):
+ id: uuid.UUID
+ status: domain.WorkspaceInviteStatus
+ user: WorkspaceMemberUserOutput
+ invited_by: WorkspaceMemberUserOutput
+
+ @classmethod
+ def from_domain(cls, invite: domain.WorkspaceInvite) -> 'WorkspaceInviteOutput':
+ if (
+ invite.user is None
+ or invite.invited_by is None
+ or invite.user.telegram_user is None
+ or invite.invited_by.telegram_user is None
+ ):
+ raise ValueError('Workspace invite relations are not fully loaded')
+
+ return cls(
+ id=invite.id,
+ status=invite.status,
+ user=WorkspaceMemberUserOutput(
+ id=invite.user.id,
+ telegram_id=invite.user.telegram_user.telegram_id,
+ username=invite.user.telegram_user.username,
+ ),
+ invited_by=WorkspaceMemberUserOutput(
+ id=invite.invited_by.id,
+ telegram_id=invite.invited_by.telegram_user.telegram_id,
+ username=invite.invited_by.telegram_user.username,
+ ),
+ )
+
+
+class GetWorkspaceInvitesOutput(pydantic.BaseModel):
+ invites: list[WorkspaceInviteOutput]
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..1de6fce
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,73 @@
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi_pagination import add_pagination
+from fastapi_pagination.utils import disable_installed_extensions_check
+
+from shared import logger
+from shared.worker_base import WorkerConfig
+from src import deps
+from src.adapter.jwt import JWT
+from src.adapter.parser import ParserClient
+from src.adapter.postgres import Postgres
+from src.adapter.s3 import S3
+from src.adapter.telegram_bot import TelegramBot
+from src.config import settings
+from src.controller.http_v1 import api_router
+from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker
+from src.usecase import Usecase
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
+ await postgres.connect()
+ await s3.connect()
+ await fetch_placement_post_worker.start()
+
+ yield
+
+ await fetch_placement_post_worker.stop()
+ await s3.close()
+ await postgres.close()
+
+
+logger.init(settings.logger)
+disable_installed_extensions_check()
+
+postgres = Postgres(settings.db)
+telegram = TelegramBot(settings.telegram)
+jwt_encoder = JWT(settings.jwt)
+parser_client = ParserClient(base_url=settings.parser.URL)
+s3 = S3(settings.s3)
+
+usecase = Usecase(
+ database=postgres,
+ telegram_bot=telegram,
+ jwt_encoder=jwt_encoder,
+ parser=parser_client,
+ s3=s3,
+)
+deps.set_usecase(usecase)
+
+fetch_placement_post_worker = FetchPlacementPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5))
+
+app = FastAPI(
+ lifespan=lifespan,
+ version=settings.logger.APP_VERSION,
+ # Сворачиваем Schemas
+ swagger_ui_parameters={'defaultModelsExpandDepth': 0},
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.app.ORIGINS,
+ allow_credentials=True,
+ allow_methods=['*'],
+ allow_headers=['*'],
+)
+
+add_pagination(app)
+
+app.include_router(api_router)
diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py
new file mode 100644
index 0000000..a152dc4
--- /dev/null
+++ b/src/usecase/__init__.py
@@ -0,0 +1,275 @@
+import typing
+from collections.abc import Sequence
+from dataclasses import dataclass
+from uuid import UUID
+
+if typing.TYPE_CHECKING:
+ from aiogram.types import InlineKeyboardButton
+
+ from src.adapter.postgres import Postgres
+
+from src import domain
+
+from .analytics.get_channel_analytics import get_channel_analytics
+from .analytics.get_creatives_analytics import get_creatives_analytics
+from .analytics.get_overview_analytics import get_overview_analytics
+from .analytics.get_placements_analytics import get_placements_analytics
+from .analytics.get_projects_analytics import get_projects_analytics
+from .analytics.get_spending_analytics import get_spending_analytics
+from .auth.attach_login_token_message import attach_login_token_message
+from .auth.create_telegram_login_token import create_telegram_login_token
+from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
+from .auth.get_me import get_me
+from .auth.validate_login_token import validate_login_token
+from .channel.attach_channel_to_workspace import attach_channel_to_workspace
+from .channel.create_channels import create_channels
+from .channel.get_channel import get_channel
+from .channel.get_channels import get_channels
+from .creative.create_creative import create_creative
+from .creative.delete_creative import delete_creative
+from .creative.get_creative import get_creative
+from .creative.get_creatives import get_creatives
+from .creative.update_creative import update_creative
+from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
+from .placement.update_post_status_cycle import update_post_status_cycle
+from .project.archive_project import archive_project, unarchive_project
+from .project.delete_project import delete_project
+from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
+from .project.get_project import get_project
+from .project.get_workspace_projects import get_workspace_projects
+from .project.move_project_to_workspace import move_project_to_workspace
+from .project.tg_add_project import tg_add_project
+from .project.update_project_invite_link_type import update_project_invite_link_type
+from .project.update_project_permissions import update_project_permissions
+from .purchase.build_placement_creative import build_placement_creative
+from .purchase.create_placements import create_placements
+from .purchase.delete_placement import delete_placement
+from .purchase.get_placement import get_placement_user
+from .purchase.get_placements import get_placements
+from .purchase.update_placement import update_placement
+from .purchase.update_placement_post import update_placement_post
+from .subscription.handle_subscription import handle_subscription
+from .subscription.handle_unsubscription import handle_unsubscription
+from .views.get_views_history import get_views_history
+from .workspace.accept_workspace_invite import accept_workspace_invite
+from .workspace.create_workspace import create_workspace
+from .workspace.create_workspace_invite import create_workspace_invite
+from .workspace.delete_workspace import delete_workspace
+from .workspace.delete_workspace_avatar import delete_workspace_avatar
+from .workspace.get_workspace_invites import get_workspace_invites
+from .workspace.get_workspace_members import get_workspace_members, get_current_member_permissions
+from .workspace.get_workspaces import get_workspaces
+from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite
+from .workspace.update_workspace import update_workspace
+from .workspace.update_workspace_avatar import update_workspace_avatar
+from .workspace.update_workspace_member_permissions import update_workspace_member_permissions
+
+
+class MediaItem(typing.Protocol):
+ media_type: str
+ media_file_id: str
+
+
+class TelegramBotWriter(typing.Protocol):
+ async def send_message(
+ self,
+ text: str,
+ chat_id: int,
+ parse_mode: str | None = None,
+ disable_preview: bool = False,
+ reply_to_message_id: int | None = None,
+ ) -> int: ...
+
+ async def send_message_with_inline_keyboard(
+ self,
+ text: str,
+ chat_id: int,
+ buttons: list[list['InlineKeyboardButton']],
+ parse_mode: str | None = None,
+ disable_preview: bool = False,
+ reply_to_message_id: int | None = None,
+ ) -> int: ...
+
+ async def send_media_with_inline_keyboard(
+ self,
+ text: str,
+ chat_id: int,
+ media_type: str,
+ media_file_id: str,
+ buttons: list[list['InlineKeyboardButton']],
+ parse_mode: str | None = None,
+ reply_to_message_id: int | None = None,
+ ) -> int: ...
+
+ async def send_media_group(
+ self,
+ chat_id: int,
+ media_items: Sequence[MediaItem],
+ caption: str | None = None,
+ parse_mode: str | None = None,
+ reply_to_message_id: int | None = None,
+ ) -> int: ...
+
+ async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ...
+
+ async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ...
+
+ async def create_chat_invite_link(
+ self, chat_id: int, requires_approval: bool = False, name: str | None = None
+ ) -> str: ...
+
+
+class JWTEncoder(typing.Protocol):
+ def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ...
+
+
+class FetchChannelResponse(typing.Protocol):
+ telegram_id: int
+ username: str | None
+ title: str | None
+ access_hash: int | None
+ pts: int | None
+
+
+class Parser(typing.Protocol):
+ async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ...
+ async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None: ...
+
+
+class S3Storage(typing.Protocol):
+ async def upload(self, key: str, data: bytes, content_type: str) -> None: ...
+
+ async def get(self, key: str) -> bytes: ...
+
+ async def delete(self, key: str) -> None: ...
+
+ def public_url(self, key: str) -> str: ...
+
+
+@dataclass
+class Usecase:
+ database: 'Postgres'
+ telegram_bot: TelegramBotWriter
+ jwt_encoder: JWTEncoder
+ parser: Parser
+ s3: S3Storage
+
+ async def ensure_workspace_permission(
+ self, workspace_id: UUID, user_id: UUID, permission: domain.PermissionKey, *, for_project_id: UUID | None = None
+ ) -> domain.WorkspacePermissionContext:
+ membership = await self.database.get_workspace_membership(workspace_id, user_id)
+ if not membership or not membership.workspace:
+ raise domain.WorkspaceNotFound(workspace_id)
+
+ permissions = domain.WorkspacePermissions.from_membership(membership)
+
+ if for_project_id is not None:
+ has_permission = permissions.has_permission(
+ permission,
+ scope_type=domain.PermissionScopeType.PROJECT,
+ scope_id=for_project_id,
+ )
+ else:
+ has_permission = permissions.has_any(permission)
+
+ if not has_permission:
+ raise domain.WorkspaceAccessDenied(workspace_id)
+
+ return domain.build_workspace_permission_context(membership)
+
+ async def ensure_analytics_permission(
+ self, workspace_id: UUID, user_id: UUID, *, for_project_id: UUID | None = None
+ ) -> domain.WorkspacePermissionContext:
+ """Ensure user has any analytics permission."""
+ membership = await self.database.get_workspace_membership(workspace_id, user_id)
+ if not membership or not membership.workspace:
+ raise domain.WorkspaceNotFound(workspace_id)
+
+ permissions = domain.WorkspacePermissions.from_membership(membership)
+
+ if not permissions.has_any_analytics_permission():
+ raise domain.WorkspaceAccessDenied(workspace_id)
+
+ return domain.build_workspace_permission_context(membership)
+
+ async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace:
+ workspace = await self.database.get_default_workspace_for_user(user.id)
+ if workspace:
+ return workspace
+
+ if not user.telegram_user:
+ raise domain.UserNotFound(user.id)
+
+ telegram_user = user.telegram_user
+ workspace_name = telegram_user.username or f'Workspace {telegram_user.telegram_id}'
+ workspace = domain.Workspace(name=workspace_name)
+
+ async with self.database.transaction():
+ await self.database.create_workspace(workspace)
+ membership = await self.database.add_user_to_workspace(workspace.id, user.id)
+ await self.database.set_workspace_user_permissions(
+ membership.id,
+ global_permissions={domain.PermissionKey.ADMIN_FULL},
+ scoped_permissions=[],
+ )
+
+ return workspace
+
+ validate_login_token = validate_login_token
+ create_telegram_login_token = create_telegram_login_token
+ attach_login_token_message = attach_login_token_message
+ get_jwt_by_telegram_id = get_jwt_by_telegram_id
+ get_me = get_me
+ tg_add_project = tg_add_project
+ get_workspace_projects = get_workspace_projects
+ get_project = get_project
+ archive_project = archive_project
+ unarchive_project = unarchive_project
+ delete_project = delete_project
+ move_project_to_workspace = move_project_to_workspace
+ disconnect_project_by_tg_id = disconnect_project_by_tg_id
+ update_project_permissions = update_project_permissions
+ update_project_invite_link_type = update_project_invite_link_type
+ get_channels = get_channels
+ create_channels = create_channels
+ get_channel = get_channel
+ attach_channel_to_workspace = attach_channel_to_workspace
+ # Placement (user-managed) use cases
+ create_placements = create_placements
+ get_placements = get_placements
+ get_placement_user = get_placement_user
+ build_placement_creative = build_placement_creative
+ update_placement = update_placement
+ update_placement_post = update_placement_post
+ delete_placement = delete_placement
+ # Creative use cases
+ get_creatives = get_creatives
+ get_creative = get_creative
+ create_creative = create_creative
+ update_creative = update_creative
+ delete_creative = delete_creative
+ # PlacementPost (system-managed) use cases
+ fetch_placement_post_cycle = fetch_placement_post_cycle
+ update_post_status_cycle = update_post_status_cycle
+ handle_subscription = handle_subscription
+ handle_unsubscription = handle_unsubscription
+ get_views_history = get_views_history
+ get_placements_analytics = get_placements_analytics
+ get_creatives_analytics = get_creatives_analytics
+ get_channel_analytics = get_channel_analytics
+ get_projects_analytics = get_projects_analytics
+ get_spending_analytics = get_spending_analytics
+ get_overview_analytics = get_overview_analytics
+ get_workspaces = get_workspaces
+ create_workspace = create_workspace
+ update_workspace = update_workspace
+ delete_workspace = delete_workspace
+ update_workspace_avatar = update_workspace_avatar
+ delete_workspace_avatar = delete_workspace_avatar
+ get_workspace_members = get_workspace_members
+ get_current_member_permissions = get_current_member_permissions
+ update_workspace_member_permissions = update_workspace_member_permissions
+ create_workspace_invite = create_workspace_invite
+ get_workspace_invites = get_workspace_invites
+ accept_workspace_invite = accept_workspace_invite
+ tg_accept_workspace_invite = tg_accept_workspace_invite
diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py
new file mode 100644
index 0000000..467211d
--- /dev/null
+++ b/src/usecase/analytics/get_channel_analytics.py
@@ -0,0 +1,111 @@
+import logging
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+def _get_cost(placement_post: domain.PlacementPost) -> float:
+ placement = placement_post.placement
+ return placement.cost_value if placement and placement.cost_value is not None else 0.0
+
+
+async def get_channel_analytics(
+ self: 'Usecase', input: dto.GetChannelAnalyticsInput
+) -> list[dto.ChannelAnalyticsOutput]:
+ context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
+ hide_subscriptions = context.should_hide_subscriptions()
+
+ if input.project_id:
+ project = await self.database.get_project(input.workspace_id, input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+ allowed_project_filter = None
+ else:
+ allowed_project_filter = allowed_project_ids
+
+ placements = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ input.project_id,
+ include_archived=False,
+ allowed_project_ids=allowed_project_filter,
+ )
+
+ # Collect unique channels from placements
+ channel_map: dict[UUID, domain.Channel] = {}
+ for placement_post in placements:
+ placement = placement_post.placement
+ if placement and placement.channel:
+ channel_map[placement.channel_id] = placement.channel
+ channels = list(channel_map.values())
+
+ @dataclass
+ class ChannelStats:
+ total_cost: float = 0.0
+ total_subscriptions: int = 0
+ total_views: int = 0
+ placements_count: int = 0
+
+ channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in channels}
+
+ # Batch fetch views data for all posts
+ post_ids = [p.post.id for p in placements if p.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ # Batch fetch subscriptions counts
+ placement_ids = [p.id for p in placements]
+ subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
+
+ for placement_post in placements:
+ placement = placement_post.placement
+ if not placement:
+ continue
+ stats = channel_stats.get(placement.channel_id)
+ if stats is None:
+ continue
+
+ stats.total_cost += _get_cost(placement_post)
+ stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
+
+ # Get views from batch data
+ if placement_post.post and placement_post.post.id in views_map:
+ views_count = views_map[placement_post.post.id][0]
+ stats.total_views += views_count
+
+ stats.placements_count += 1
+
+ result = []
+ for ch in channels:
+ stats = channel_stats[ch.id]
+
+ total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions
+ avg_cpf = None
+ if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0:
+ avg_cpf = stats.total_cost / stats.total_subscriptions
+ avg_cpm = (
+ (stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None
+ )
+
+ result.append(
+ dto.ChannelAnalyticsOutput(
+ id=ch.id,
+ title=ch.title,
+ username=ch.username,
+ placements_count=stats.placements_count,
+ total_cost=stats.total_cost,
+ total_subscriptions=total_subscriptions,
+ total_views=stats.total_views,
+ avg_cpf=avg_cpf,
+ avg_cpm=avg_cpm,
+ )
+ )
+
+ return result
diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py
new file mode 100644
index 0000000..d28e0f8
--- /dev/null
+++ b/src/usecase/analytics/get_creatives_analytics.py
@@ -0,0 +1,115 @@
+import logging
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+def _get_cost(placement_post: domain.PlacementPost) -> float:
+ placement = placement_post.placement
+ return placement.cost_value if placement and placement.cost_value is not None else 0.0
+
+
+async def get_creatives_analytics(
+ self: 'Usecase', input: dto.GetCreativesAnalyticsInput
+) -> list[dto.CreativeAnalyticsOutput]:
+ context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
+ hide_subscriptions = context.should_hide_subscriptions()
+ filter_own_creatives = context.should_filter_own_creatives()
+
+ # Get user_id for own creatives filter
+ created_by_filter: UUID | None = None
+ if filter_own_creatives:
+ created_by_filter = context.membership.user_id
+
+ if input.project_id:
+ project = await self.database.get_project(input.workspace_id, input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+ allowed_project_ids = None
+
+ creatives = await self.database.get_workspace_creatives(
+ input.workspace_id,
+ input.project_id,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ created_by_user_id=created_by_filter,
+ tag=input.tag,
+ )
+ placements = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ input.project_id,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ )
+
+ @dataclass
+ class CreativeStats:
+ total_cost: float = 0.0
+ total_subscriptions: int = 0
+ total_views: int = 0
+ placements_count: int = 0
+
+ creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives}
+
+ # Batch fetch views data for all posts
+ post_ids = [p.post.id for p in placements if p.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ # Batch fetch subscriptions counts
+ placement_ids = [p.id for p in placements]
+ subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
+
+ for placement_post in placements:
+ placement = placement_post.placement
+ if not placement or not placement.creative_id:
+ continue
+ stats = creative_stats.get(placement.creative_id)
+ if stats is None:
+ continue
+
+ stats.total_cost += _get_cost(placement_post)
+ stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
+
+ # Get views from batch data
+ if placement_post.post and placement_post.post.id in views_map:
+ views_count = views_map[placement_post.post.id][0]
+ stats.total_views += views_count
+
+ stats.placements_count += 1
+
+ result = []
+ for cr in creatives:
+ stats = creative_stats[cr.id]
+
+ total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions
+ avg_cpf = None
+ if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0:
+ avg_cpf = stats.total_cost / stats.total_subscriptions
+ avg_cpm = (
+ (stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None
+ )
+
+ result.append(
+ dto.CreativeAnalyticsOutput(
+ id=cr.id,
+ name=cr.name,
+ tag=cr.tag,
+ placements_count=stats.placements_count,
+ total_cost=stats.total_cost,
+ total_subscriptions=total_subscriptions,
+ total_views=stats.total_views,
+ avg_cpf=avg_cpf,
+ avg_cpm=avg_cpm,
+ )
+ )
+
+ return result
diff --git a/src/usecase/analytics/get_overview_analytics.py b/src/usecase/analytics/get_overview_analytics.py
new file mode 100644
index 0000000..2100d5f
--- /dev/null
+++ b/src/usecase/analytics/get_overview_analytics.py
@@ -0,0 +1,253 @@
+import datetime
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from fastapi import HTTPException, status
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+def _calc_delta(current: float | None, previous: float | None) -> float | None:
+ if current is None or previous is None or previous == 0:
+ return None
+ return (current - previous) / previous * 100
+
+
+def _as_number_with_delta(value: float | None, previous: float | None) -> dto.NumberWithDelta:
+ return dto.NumberWithDelta(value=value, delta_percent=_calc_delta(value, previous))
+
+
+@dataclass
+class ChannelAggregate:
+ channel: domain.Channel | None
+ total_cost: float = 0.0
+ total_subs: int = 0
+
+
+def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
+ placement = placement_post.placement
+ if placement and placement.placement_at:
+ return placement.placement_at
+ if placement_post.post and placement_post.post.created_at:
+ return placement_post.post.created_at
+ return placement_post.created_at
+
+
+def _get_cost(placement_post: domain.PlacementPost) -> float:
+ placement = placement_post.placement
+ return placement.cost_value if placement and placement.cost_value is not None else 0.0
+
+
+async def get_overview_analytics(
+ self: 'Usecase', input: dto.GetOverviewAnalyticsInput
+) -> dto.GetOverviewAnalyticsOutput:
+ if input.date_from > input.date_to:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
+
+ context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
+ hide_subscriptions = context.should_hide_subscriptions()
+
+ if input.project_id:
+ project = await self.database.get_project(input.workspace_id, input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+ allowed_project_ids = None
+
+ raw_duration = input.date_to - input.date_from
+ if raw_duration.total_seconds() < 0:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
+
+ period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1)
+ previous_period_start = input.date_from - period_length
+
+ placements = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ project_id=input.project_id,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ date_from=previous_period_start,
+ date_to=input.date_to,
+ )
+
+ current_placements: list[domain.PlacementPost] = []
+ previous_placements: list[domain.PlacementPost] = []
+
+ for placement_post in placements:
+ placement_date = _get_placement_date(placement_post)
+ if placement_date >= input.date_from and placement_date <= input.date_to:
+ current_placements.append(placement_post)
+ elif placement_date >= previous_period_start and placement_date < input.date_from:
+ previous_placements.append(placement_post)
+
+ placement_post_ids = [p.id for p in placements]
+ subscriptions = await self.database.get_subscriptions_for_placement_posts(
+ placement_post_ids, date_from=previous_period_start, date_to=input.date_to
+ )
+
+ # Map placement_post_id to placement_id for subscription aggregation
+ # Since subscriptions link to placement, we need to map back to placement_post
+ placement_post_to_placement: dict[UUID, UUID] = {p.id: p.placement_id for p in placements}
+
+ subs_per_placement_post_current: dict[UUID, int] = defaultdict(int)
+ subs_per_placement_post_previous: dict[UUID, int] = defaultdict(int)
+ subs_per_day_current: dict[datetime.date, int] = defaultdict(int)
+
+ for sub in subscriptions:
+ created_at = sub.created_at
+ if created_at is None:
+ continue
+
+ # Find which placement_post(s) this subscription belongs to via placement_id
+ # A placement can have multiple placement_posts, so we count it for each
+ for pp_id, p_id in placement_post_to_placement.items():
+ if p_id == sub.placement_id:
+ if created_at >= input.date_from and created_at <= input.date_to:
+ subs_per_placement_post_current[pp_id] += 1
+ elif created_at >= previous_period_start and created_at < input.date_from:
+ subs_per_placement_post_previous[pp_id] += 1
+
+ # Count each subscription only once for daily stats
+ if created_at >= input.date_from and created_at <= input.date_to:
+ subs_per_day_current[created_at.date()] += 1
+
+ post_ids = [p.post.id for p in placements if p.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ def _aggregate_totals(
+ placement_list: list[domain.PlacementPost], subs_per_placement_post: dict[UUID, int]
+ ) -> tuple[float, int, int]:
+ total_cost = 0.0
+ total_subscriptions = 0
+ total_views = 0
+
+ for placement_post in placement_list:
+ total_cost += _get_cost(placement_post)
+
+ subs = subs_per_placement_post.get(placement_post.id, 0)
+ total_subscriptions += subs
+
+ if placement_post.post and placement_post.post.id in views_map:
+ total_views += views_map[placement_post.post.id][0]
+
+ return total_cost, total_subscriptions, total_views
+
+ current_cost, current_subs, current_views = _aggregate_totals(
+ current_placements, subs_per_placement_post_current
+ )
+ previous_cost, previous_subs, previous_views = _aggregate_totals(
+ previous_placements, subs_per_placement_post_previous
+ )
+
+ # Apply hide_subscriptions filter
+ if hide_subscriptions:
+ current_subs = 0
+ previous_subs = 0
+ current_avg_cpf = None
+ previous_avg_cpf = None
+ else:
+ current_avg_cpf = current_cost / current_subs if current_cost > 0 and current_subs > 0 else None
+ previous_avg_cpf = previous_cost / previous_subs if previous_cost > 0 and previous_subs > 0 else None
+
+ current_avg_cpm = (current_cost / current_views * 1000) if current_cost > 0 and current_views > 0 else None
+ previous_avg_cpm = (previous_cost / previous_views * 1000) if previous_cost > 0 and previous_views > 0 else None
+
+ cost_per_day: dict[datetime.date, float] = defaultdict(float)
+ channel_stats: dict[UUID, ChannelAggregate] = {}
+ project_spending_map: dict[UUID, float] = defaultdict(float)
+ project_meta: dict[UUID, domain.Project] = {}
+
+ for placement_post in current_placements:
+ placement_day = _get_placement_date(placement_post).date()
+ placement = placement_post.placement
+ if not placement:
+ continue
+ if placement.project:
+ project_meta[placement.project_id] = placement.project
+
+ cost_value = _get_cost(placement_post)
+ cost_per_day[placement_day] += cost_value
+ project_spending_map[placement.project_id] += cost_value
+ subs = subs_per_placement_post_current.get(placement_post.id, 0)
+
+ channel_id = placement.channel_id
+ if channel_id not in channel_stats:
+ channel_stats[channel_id] = ChannelAggregate(channel=placement.channel)
+ channel_stats[channel_id].total_cost += cost_value
+ channel_stats[channel_id].total_subs += subs
+
+ start_date = input.date_from.date()
+ end_date = input.date_to.date()
+ days_span = (end_date - start_date).days
+
+ daily_stats: list[dto.OverviewDailyPoint] = []
+ previous_day_subs: int | None = None
+ for day_offset in range(days_span + 1):
+ day = start_date + datetime.timedelta(days=day_offset)
+ cost = cost_per_day.get(day, 0.0)
+ subs = 0 if hide_subscriptions else subs_per_day_current.get(day, 0)
+ delta = None if hide_subscriptions else ((subs - previous_day_subs) if previous_day_subs is not None else subs)
+ cpf = None if hide_subscriptions else (cost / subs if subs > 0 and cost > 0 else None)
+ daily_stats.append(
+ dto.OverviewDailyPoint(date=day, cost=cost, subscriptions=subs, subscriptions_delta=delta, cpf=cpf)
+ )
+ previous_day_subs = subs
+
+ channel_performance = []
+ for stats in channel_stats.values():
+ subs = 0 if hide_subscriptions else stats.total_subs
+ cost_value = stats.total_cost
+ cpf = None if hide_subscriptions else (cost_value / stats.total_subs if stats.total_subs > 0 else None)
+ channel = stats.channel
+
+ if not channel:
+ continue
+ # Skip channels without CPF only if we're not hiding subscriptions
+ if not hide_subscriptions and cpf is None:
+ continue
+
+ channel_performance.append(
+ dto.OverviewChannelPerformance(
+ channel_id=channel.id,
+ title=channel.title,
+ username=channel.username,
+ cpf=cpf,
+ total_cost=cost_value,
+ subscriptions=subs,
+ )
+ )
+
+ top_channels = sorted(channel_performance, key=lambda c: c.cpf or float('inf'))[:5]
+ worst_channels = sorted(channel_performance, key=lambda c: c.cpf or float('inf'), reverse=True)[:5]
+
+ project_spending = []
+ for project_id, total_cost in project_spending_map.items():
+ project = project_meta.get(project_id)
+ project_spending.append(
+ dto.OverviewProjectSpending(
+ project_id=project_id,
+ project_title=project.channel.title if project and project.channel else 'Unnamed project',
+ project_username=project.channel.username if project and project.channel else None,
+ total_cost=total_cost,
+ )
+ )
+ project_spending = sorted(project_spending, key=lambda p: p.total_cost, reverse=True)
+
+ return dto.GetOverviewAnalyticsOutput(
+ total_cost=_as_number_with_delta(current_cost, previous_cost),
+ total_reach=_as_number_with_delta(current_views, previous_views),
+ placements_count=_as_number_with_delta(len(current_placements), len(previous_placements)),
+ subscriptions_count=_as_number_with_delta(current_subs, previous_subs),
+ avg_cpm=_as_number_with_delta(current_avg_cpm, previous_avg_cpm),
+ avg_cpf=_as_number_with_delta(current_avg_cpf, previous_avg_cpf),
+ daily_stats=daily_stats,
+ top_channels_by_cpf=top_channels,
+ worst_channels_by_cpf=worst_channels,
+ project_spending=project_spending,
+ )
diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py
new file mode 100644
index 0000000..5614899
--- /dev/null
+++ b/src/usecase/analytics/get_placements_analytics.py
@@ -0,0 +1,257 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from tortoise import timezone
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None:
+ if cost is None or subscriptions == 0:
+ return None
+ return cost / subscriptions
+
+
+def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
+ if cost is None or views is None or views == 0:
+ return None
+ return (cost / views) * 1000
+
+
+def _calculate_discount_percent(cost: float | None, cost_before: float | None) -> float | None:
+ if cost is None or cost_before is None or cost_before == 0:
+ return None
+ return ((cost_before - cost) / cost_before) * 100
+
+
+async def get_placements_analytics(
+ self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
+) -> dto.GetPlacementsAnalyticsOutput:
+ context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
+ hide_subscriptions = context.should_hide_subscriptions()
+
+ # Filter by project permissions
+ project_ids = input.project_ids
+ if project_ids:
+ # Verify all requested projects exist and user has access
+ for pid in project_ids:
+ project = await self.database.get_project(input.workspace_id, pid)
+ if not project:
+ raise domain.ProjectNotFound(pid)
+ allowed_project_ids = None
+
+ placements = await self.database.get_workspace_placements_for_analytics(
+ workspace_id=input.workspace_id,
+ project_ids=project_ids or input.project_ids,
+ channel_ids=input.placement_channel_ids,
+ creative_ids=input.creative_ids,
+ status_list=input.status_list,
+ cost_types=input.cost_types,
+ placement_types=input.placement_types,
+ invite_link_types=input.invite_link_types,
+ cost_min=input.cost_min,
+ cost_max=input.cost_max,
+ views_min=input.views_min,
+ views_max=input.views_max,
+ subscriptions_min=input.subscriptions_min,
+ subscriptions_max=input.subscriptions_max,
+ cpm_min=input.cpm_min,
+ cpm_max=input.cpm_max,
+ channel_title_contains=input.channel_title_contains,
+ creative_name_contains=input.creative_name_contains,
+ comment_contains=input.comment_contains,
+ placement_date_from=input.placement_date_from,
+ placement_date_to=input.placement_date_to,
+ sort_by=input.sort_by or 'created_at',
+ sort_direction=input.sort_direction or 'desc',
+ offset=(input.page - 1) * input.size,
+ limit=input.size,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ )
+
+ total = await self.database.count_workspace_placements_for_analytics(
+ workspace_id=input.workspace_id,
+ project_ids=project_ids or input.project_ids,
+ channel_ids=input.placement_channel_ids,
+ creative_ids=input.creative_ids,
+ status_list=input.status_list,
+ cost_types=input.cost_types,
+ placement_types=input.placement_types,
+ invite_link_types=input.invite_link_types,
+ cost_min=input.cost_min,
+ cost_max=input.cost_max,
+ views_min=input.views_min,
+ views_max=input.views_max,
+ subscriptions_min=input.subscriptions_min,
+ subscriptions_max=input.subscriptions_max,
+ cpm_min=input.cpm_min,
+ cpm_max=input.cpm_max,
+ channel_title_contains=input.channel_title_contains,
+ creative_name_contains=input.creative_name_contains,
+ comment_contains=input.comment_contains,
+ placement_date_from=input.placement_date_from,
+ placement_date_to=input.placement_date_to,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ )
+
+ # Extract placement_post_ids and post_ids from placement.placement_posts
+ placement_post_ids = [pp.id for p in placements for pp in p.placement_posts]
+ post_ids = [
+ pp.post.id
+ for p in placements
+ for pp in p.placement_posts
+ if pp.post
+ ]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+ subscriptions_counts = (
+ await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
+ if placement_post_ids
+ else {}
+ )
+ unsubscriptions_counts = (
+ await self.database.count_unsubscriptions_by_placement_post_batch(placement_post_ids)
+ if placement_post_ids
+ else {}
+ )
+
+ # Collect (channel_id, message_id) pairs for batch next post lookup
+ channel_message_pairs = [
+ (pp.post.channel_id, pp.post.message_id)
+ for p in placements
+ for pp in p.placement_posts
+ if pp.post and pp.post.published_at
+ ]
+ next_posts_map = (
+ await self.database.get_next_posts_after_batch(channel_message_pairs)
+ if channel_message_pairs
+ else {}
+ )
+
+ # Calculate time_on_top for each placement_post
+ time_on_top_map: dict[uuid.UUID, int] = {}
+ now = timezone.now()
+ for placement in placements:
+ for pp in placement.placement_posts:
+ post = pp.post
+ if not post or not post.published_at:
+ continue
+ published_at = post.published_at
+ key = (post.channel_id, post.message_id)
+ next_post = next_posts_map.get(key)
+ if next_post and next_post.published_at:
+ time_on_top_map[pp.id] = int((next_post.published_at - published_at).total_seconds())
+ else:
+ time_on_top_map[pp.id] = int((now - published_at).total_seconds())
+
+ results: list[dto.PlacementAnalyticsOutput] = []
+ for placement in placements:
+ placement_post = placement.placement_posts[0] if placement.placement_posts else None
+
+ project = placement.project
+ channel = placement.channel
+ creative = placement.creative
+
+ if not project or not channel:
+ log.warning('Placement %s missing project or channel', placement.id)
+ continue
+
+ cost_value = placement.cost_value
+ cost_type = placement.cost_type if placement.cost_type else domain.CostType.FIXED
+ cost_before = placement.cost_before_bargain
+
+ subs_count = 0
+ if placement_post and not hide_subscriptions:
+ subs_count = subscriptions_counts.get(placement_post.id, 0)
+
+ unsubs_count = 0
+ if placement_post and not hide_subscriptions:
+ unsubs_count = unsubscriptions_counts.get(placement_post.id, 0)
+
+ views_count = None
+ if placement_post and placement_post.post:
+ post_id = placement_post.post.id
+ if post_id in views_map:
+ views_count = views_map[post_id][0]
+
+ cpm_value = _calculate_cpm(cost_value, views_count)
+ cpf_value = None if hide_subscriptions else _calculate_cpf(cost_value, subs_count)
+ discount_percent = _calculate_discount_percent(cost_value, cost_before)
+
+ conversion_24h = None
+ conversion_48h = None
+ conversion_total = None
+ if placement_post and views_count and views_count > 0 and not hide_subscriptions:
+ if subs_count > 0:
+ conversion_total = (subs_count / views_count) * 100
+
+ total_subs = subs_count + unsubs_count
+ unsub_percent = None if total_subs == 0 else (unsubs_count / total_subs) * 100
+
+ total_active = subs_count if not hide_subscriptions else 0
+
+ post_url = None
+ if placement_post and placement_post.post and placement_post.post.channel.username:
+ post_url = f'https://t.me/{placement_post.post.channel.username}/{placement_post.post.message_id}'
+
+ post_deleted_at = (
+ placement_post.post.deleted_from_channel_at if placement_post and placement_post.post else None
+ )
+
+ results.append(
+ dto.PlacementAnalyticsOutput(
+ id=placement.id,
+ project_id=project.id,
+ project_title=getattr(project, 'title', None) or getattr(project.channel, 'title', '')
+ if project and project.channel
+ else '',
+ channel_id=channel.id,
+ channel_title=channel.title,
+ creative_id=placement.creative_id,
+ creative_name=creative.name if creative else None,
+ cost=cost_value,
+ cost_type=cost_type,
+ cost_before_bargain=cost_before,
+ payment_at=placement.payment_at,
+ placement_type=placement.placement_type,
+ comment=placement.comment,
+ format=placement.format,
+ invite_link_type=placement.invite_link_type,
+ placement_date=placement.placement_at,
+ subscriptions_count=subs_count,
+ views_count=views_count,
+ cpf=cpf_value,
+ cpm=cpm_value,
+ time_on_top=time_on_top_map.get(placement_post.id) if placement_post else None,
+ time_in_feed=None,
+ invite_link=placement.invite_link,
+ invite_link_created_at=placement.invite_link_created_at,
+ post_url=post_url,
+ post_deleted_at=post_deleted_at,
+ conversion_24h=conversion_24h,
+ conversion_48h=conversion_48h,
+ conversion_total=conversion_total,
+ unsubscriptions_count=unsubs_count,
+ unsub_percent=unsub_percent,
+ total_active=total_active,
+ )
+ )
+
+ pages = (total + input.size - 1) // input.size if total > 0 else 0
+
+ return dto.GetPlacementsAnalyticsOutput(
+ items=results,
+ total=total,
+ page=input.page,
+ size=input.size,
+ pages=pages,
+ )
diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py
new file mode 100644
index 0000000..24b359e
--- /dev/null
+++ b/src/usecase/analytics/get_projects_analytics.py
@@ -0,0 +1,315 @@
+import datetime
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from fastapi import HTTPException, status
+from tortoise import timezone
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+def _format_period(dt: datetime.datetime, grouping: dto.DateGrouping) -> str:
+ match grouping:
+ case dto.DateGrouping.DAY:
+ return dt.strftime('%Y-%m-%d')
+ case dto.DateGrouping.WEEK:
+ # ISO week
+ year, week, _ = dt.isocalendar()
+ return f'{year}-W{week:02d}'
+ case dto.DateGrouping.MONTH:
+ return dt.strftime('%Y-%m')
+ case dto.DateGrouping.QUARTER:
+ quarter = (dt.month - 1) // 3 + 1
+ return f'{dt.year}-Q{quarter}'
+ case _:
+ raise ValueError('Invalid date grouping')
+
+
+def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> str:
+ match grouping:
+ case dto.DateGrouping.DAY:
+ # "1 дек" или "1 дек 2024"
+ day = dt.day
+ month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
+ month = month_names[dt.month - 1]
+ return f'{day} {month}'
+ case dto.DateGrouping.WEEK:
+ # "49 нед. 2024" или "1-7 дек"
+ year, week, _ = dt.isocalendar()
+ # Находим первый день недели (понедельник)
+ days_since_monday = dt.weekday()
+ week_start = dt - datetime.timedelta(days=days_since_monday)
+ week_end = week_start + datetime.timedelta(days=6)
+ month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
+ if week_start.month == week_end.month:
+ return f'{week_start.day}-{week_end.day} {month_names[week_start.month - 1]}'
+ else:
+ return f'{week_start.day} {month_names[week_start.month - 1]}-{week_end.day} {month_names[week_end.month - 1]}' # noqa: E501
+ case dto.DateGrouping.MONTH:
+ # "дек 2024"
+ month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
+ return f'{month_names[dt.month - 1]} {dt.year}'
+ case dto.DateGrouping.QUARTER:
+ # "Q4 2024"
+ quarter = (dt.month - 1) // 3 + 1
+ return f'Q{quarter} {dt.year}'
+ case _:
+ raise ValueError('Invalid date grouping')
+
+
+def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
+ placement = placement_post.placement
+ if placement and placement.placement_at:
+ return placement.placement_at
+ if placement_post.post and placement_post.post.created_at:
+ return placement_post.post.created_at
+ return placement_post.created_at
+
+
+def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime:
+ match date_grouping:
+ case dto.DateGroupingType.PLACEMENT_DATE:
+ return _get_placement_date(placement_post)
+ case dto.DateGroupingType.PURCHASE_DATE:
+ if placement_post.placement:
+ return placement_post.placement.created_at
+ return _get_placement_date(placement_post) # Fallback
+ case dto.DateGroupingType.LINK_DATE:
+ if placement_post.placement:
+ return placement_post.placement.created_at
+ return _get_placement_date(placement_post) # Fallback
+ case _:
+ return _get_placement_date(placement_post)
+
+
+@dataclass
+class PeriodMetrics:
+ total_cost: float = 0.0
+ purchases_count: int = 0
+ total_subscriptions: int = 0
+ total_views: int = 0
+ clicks_count: int = 0
+ reach_volume: int = 0
+ total_discounts: float = 0.0
+ discount_count: int = 0
+ discount_sum: float = 0.0
+
+
+def _calculate_metrics(
+ period_metrics: PeriodMetrics,
+ requested_metrics: list[dto.ProjectMetrics] | None,
+ hide_subscriptions: bool = False,
+) -> dto.ProjectMetricsData:
+ all_metrics = requested_metrics is None or len(requested_metrics) == 0
+
+ def should_include(metric: dto.ProjectMetrics) -> bool:
+ return all_metrics or (requested_metrics is not None and metric in requested_metrics)
+
+ metrics = dto.ProjectMetricsData()
+
+ if should_include(dto.ProjectMetrics.TOTAL_COST):
+ metrics.total_cost = period_metrics.total_cost
+
+ if should_include(dto.ProjectMetrics.PURCHASES_COUNT):
+ metrics.purchases_count = period_metrics.purchases_count
+
+ if should_include(dto.ProjectMetrics.TOTAL_SUBSCRIPTIONS):
+ metrics.total_subscriptions = 0 if hide_subscriptions else period_metrics.total_subscriptions
+
+ if should_include(dto.ProjectMetrics.TOTAL_VIEWS):
+ metrics.total_views = period_metrics.total_views
+
+ if should_include(dto.ProjectMetrics.CLICKS_COUNT):
+ metrics.clicks_count = 0 if hide_subscriptions else period_metrics.clicks_count
+
+ if should_include(dto.ProjectMetrics.REACH_VOLUME):
+ metrics.reach_volume = period_metrics.reach_volume
+
+ if should_include(dto.ProjectMetrics.TOTAL_DISCOUNTS):
+ metrics.total_discounts = period_metrics.total_discounts
+
+ # Средние значения
+ if should_include(dto.ProjectMetrics.AVG_CPF):
+ if hide_subscriptions:
+ metrics.avg_cpf = None
+ else:
+ metrics.avg_cpf = (
+ period_metrics.total_cost / period_metrics.total_subscriptions
+ if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0
+ else None
+ )
+
+ if should_include(dto.ProjectMetrics.AVG_CPM):
+ metrics.avg_cpm = (
+ (period_metrics.total_cost / period_metrics.total_views) * 1000
+ if period_metrics.total_views > 0 and period_metrics.total_cost > 0
+ else None
+ )
+
+ if should_include(dto.ProjectMetrics.AVG_POST_COST):
+ metrics.avg_post_cost = (
+ period_metrics.total_cost / period_metrics.purchases_count
+ if period_metrics.purchases_count > 0 and period_metrics.total_cost > 0
+ else None
+ )
+
+ if should_include(dto.ProjectMetrics.AVG_DISCOUNT_PERCENT):
+ if period_metrics.discount_count > 0:
+ metrics.avg_discount_percent = period_metrics.discount_sum / period_metrics.discount_count
+ else:
+ metrics.avg_discount_percent = 0.0
+
+ if should_include(dto.ProjectMetrics.AVG_CONVERSION):
+ # Конверсия = подписки / просмотры * 100
+ if hide_subscriptions:
+ metrics.avg_conversion = None
+ else:
+ metrics.avg_conversion = (
+ (period_metrics.total_subscriptions / period_metrics.total_views) * 100
+ if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0
+ else 0.0
+ )
+
+ return metrics
+
+
+async def get_projects_analytics(
+ self: 'Usecase', input: dto.GetProjectsAnalyticsInput
+) -> dto.GetProjectsAnalyticsOutput:
+ if input.date_from and input.date_to and input.date_from > input.date_to:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to')
+
+ context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
+ hide_subscriptions = context.should_hide_subscriptions()
+
+ # Фильтрация по project_ids если указаны
+ if input.project_ids:
+ if allowed_project_ids is not None:
+ # Пересечение разрешенных и запрошенных
+ filtered_ids = [pid for pid in input.project_ids if pid in allowed_project_ids]
+ if not filtered_ids:
+ return dto.GetProjectsAnalyticsOutput(periods=[], totals=dto.ProjectMetricsData())
+ allowed_project_ids = set(filtered_ids)
+ else:
+ allowed_project_ids = set(input.project_ids)
+
+ placements = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ project_id=None,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ date_from=input.date_from,
+ date_to=input.date_to,
+ )
+
+ # Фильтрация по датам на основе date_grouping
+ filtered_placements = []
+ for placement_post in placements:
+ grouping_date = _get_grouping_date(placement_post, input.date_grouping)
+ if input.date_from and grouping_date < input.date_from:
+ continue
+ if input.date_to and grouping_date > input.date_to:
+ continue
+ filtered_placements.append(placement_post)
+
+ # Batch fetch views data
+ post_ids = [p.post.id for p in filtered_placements if p.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ # Batch fetch subscriptions counts
+ placement_post_ids = [p.id for p in filtered_placements]
+ subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
+
+ # Группировка по периодам
+ period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
+ total_metrics = PeriodMetrics()
+
+ for placement_post in filtered_placements:
+ grouping_date = _get_grouping_date(placement_post, input.date_grouping)
+ period = _format_period(grouping_date, input.grouping)
+ pd = period_data[period]
+
+ # Обновляем метрики периода
+ pd.purchases_count += 1
+ total_metrics.purchases_count += 1
+
+ placement = placement_post.placement
+ cost = placement.cost_value if placement and placement.cost_value is not None else 0.0
+ pd.total_cost += cost
+ total_metrics.total_cost += cost
+
+ subs_count = subscriptions_counts.get(placement_post.id, 0)
+ pd.total_subscriptions += subs_count
+ pd.clicks_count += subs_count
+ total_metrics.total_subscriptions += subs_count
+ total_metrics.clicks_count += subs_count
+
+ if placement_post.post and placement_post.post.id in views_map:
+ views_count = views_map[placement_post.post.id][0]
+ pd.total_views += views_count
+ pd.reach_volume += views_count
+ total_metrics.total_views += views_count
+ total_metrics.reach_volume += views_count
+
+ # Расчет скидок
+ if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
+ discount = placement.cost_before_bargain - cost
+ discount_percent = (
+ (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0
+ )
+
+ pd.total_discounts += discount
+ pd.discount_count += 1
+ pd.discount_sum += discount_percent
+
+ total_metrics.total_discounts += discount
+ total_metrics.discount_count += 1
+ total_metrics.discount_sum += discount_percent
+
+ # Формируем периоды с метриками
+ periods: list[dto.ProjectAnalyticsPeriod] = []
+ for period_key in sorted(period_data.keys()):
+ pd = period_data[period_key]
+
+ # Определяем дату для period_label (берем первую дату периода)
+ if input.grouping == dto.DateGrouping.DAY:
+ period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d').replace(tzinfo=datetime.UTC)
+ elif input.grouping == dto.DateGrouping.WEEK:
+ # ISO week format: YYYY-Www
+ year, week_str = period_key.split('-W')
+ week = int(week_str)
+ # Находим первый день недели (понедельник) для данной ISO недели
+ jan4 = datetime.datetime(int(year), 1, 4, tzinfo=datetime.UTC)
+ jan4_weekday = jan4.weekday() # 0=Monday, 6=Sunday
+ days_since_monday = (jan4_weekday + 1) % 7
+ jan4_monday = jan4 - datetime.timedelta(days=days_since_monday)
+ period_dt = jan4_monday + datetime.timedelta(weeks=week - 1)
+ elif input.grouping == dto.DateGrouping.MONTH:
+ period_dt = datetime.datetime.strptime(period_key, '%Y-%m').replace(tzinfo=datetime.UTC)
+ elif input.grouping == dto.DateGrouping.QUARTER:
+ year, quarter = period_key.split('-Q')
+ month = (int(quarter) - 1) * 3 + 1
+ period_dt = datetime.datetime(int(year), month, 1, tzinfo=datetime.UTC)
+ else:
+ period_dt = timezone.now()
+
+ period_label = _format_period_label(period_dt, input.grouping)
+ metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions)
+
+ periods.append(
+ dto.ProjectAnalyticsPeriod(
+ period=period_key,
+ period_label=period_label,
+ metrics=metrics,
+ )
+ )
+
+ totals = _calculate_metrics(total_metrics, input.metrics, hide_subscriptions)
+
+ return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals)
diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py
new file mode 100644
index 0000000..ff80d38
--- /dev/null
+++ b/src/usecase/analytics/get_spending_analytics.py
@@ -0,0 +1,159 @@
+import datetime
+import logging
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str:
+ match grouping:
+ case dto.DateGrouping.DAY:
+ return str(dt.strftime('%Y-%m-%d'))
+ case dto.DateGrouping.WEEK:
+ # ISO week
+ return str(dt.strftime('%Y-W%W'))
+ case dto.DateGrouping.MONTH:
+ return str(dt.strftime('%Y-%m'))
+ case dto.DateGrouping.QUARTER:
+ quarter = (dt.month - 1) // 3 + 1
+ return f'{dt.year}-Q{quarter}'
+ case dto.DateGrouping.YEAR:
+ return str(dt.year)
+
+ raise ValueError('Invalid date grouping')
+
+
+def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime:
+ placement = placement_post.placement
+ if placement and placement.placement_at:
+ return placement.placement_at
+ if placement_post.post and placement_post.post.created_at:
+ return placement_post.post.created_at
+ return placement_post.created_at
+
+
+def _get_cost(placement_post: domain.PlacementPost) -> float | None:
+ placement = placement_post.placement
+ return placement.cost_value if placement else None
+
+
+async def get_spending_analytics(
+ self: 'Usecase', input: dto.GetSpendingAnalyticsInput
+) -> dto.GetSpendingAnalyticsOutput:
+ context = await self.ensure_analytics_permission(input.workspace_id, input.user_id)
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
+ hide_subscriptions = context.should_hide_subscriptions()
+
+ if input.project_id:
+ project = await self.database.get_project(input.workspace_id, input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+ allowed_project_ids = None
+
+ placements = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ input.project_id,
+ include_archived=False,
+ allowed_project_ids=allowed_project_ids,
+ )
+
+ filtered: list[tuple[domain.PlacementPost, datetime.datetime]] = []
+ for placement_post in placements:
+ placement_date = _get_placement_date(placement_post)
+ if input.date_from and placement_date < input.date_from:
+ continue
+ if input.date_to and placement_date > input.date_to:
+ continue
+ filtered.append((placement_post, placement_date))
+
+ total_cost = 0.0
+ total_subs = 0
+ total_views = 0
+
+ @dataclass
+ class PeriodData:
+ cost: float = 0.0
+ subscriptions: int = 0
+ views: int = 0
+
+ # Batch fetch views data for all posts
+ post_ids = [p.post.id for p, _ in filtered if p.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ # Batch fetch subscriptions counts
+ placement_ids = [p.id for p, _ in filtered]
+ subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
+
+ # Группировка по периодам
+ period_data: dict[str, PeriodData] = defaultdict(PeriodData)
+
+ for p, placement_date in filtered:
+ period = _format_period(placement_date, input.grouping)
+ pd = period_data[period]
+
+ cost = _get_cost(p)
+ if cost is not None:
+ total_cost += cost
+ pd.cost += cost
+
+ subs_count = subscriptions_counts.get(p.id, 0)
+ total_subs += subs_count
+ pd.subscriptions += subs_count
+
+ # Get views from batch data
+ if p.post and p.post.id in views_map:
+ views_count = views_map[p.post.id][0]
+ total_views += views_count
+ pd.views += views_count
+
+ output_total_subs = 0 if hide_subscriptions else total_subs
+ avg_cpf = None if hide_subscriptions else (total_cost / total_subs if total_subs > 0 and total_cost > 0 else None)
+ avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None
+
+ # Count unique placements
+ unique_placements = set()
+ for p, _ in filtered:
+ if p.placement:
+ unique_placements.add(p.placement.id)
+ placements_count = len(unique_placements)
+
+ # Данные для графика
+ chart_data = []
+ for period in sorted(period_data.keys()):
+ d = period_data[period]
+
+ cost = d.cost
+ subs = 0 if hide_subscriptions else d.subscriptions
+ views = d.views
+
+ cpf = None if hide_subscriptions else (cost / d.subscriptions if d.subscriptions > 0 and cost > 0 else None)
+ cpm = (cost / views * 1000) if views > 0 and cost > 0 else None
+
+ chart_data.append(
+ dto.SpendingDataPoint(
+ period=period,
+ cost=cost,
+ subscriptions=subs,
+ views=views,
+ cpf=cpf,
+ cpm=cpm,
+ )
+ )
+
+ return dto.GetSpendingAnalyticsOutput(
+ total_cost=total_cost,
+ total_subscriptions=output_total_subs,
+ total_views=total_views,
+ avg_cpf=avg_cpf,
+ avg_cpm=avg_cpm,
+ chart_data=chart_data,
+ placements_count=placements_count,
+ )
diff --git a/src/usecase/auth/attach_login_token_message.py b/src/usecase/auth/attach_login_token_message.py
new file mode 100644
index 0000000..d6f09ba
--- /dev/null
+++ b/src/usecase/auth/attach_login_token_message.py
@@ -0,0 +1,8 @@
+import typing
+
+if typing.TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def attach_login_token_message(self: 'Usecase', token: str, message_id: int) -> None:
+ await self.database.update_login_token_message_id(token=token, message_id=message_id)
diff --git a/src/usecase/auth/create_telegram_login_token.py b/src/usecase/auth/create_telegram_login_token.py
new file mode 100644
index 0000000..8f3c1f1
--- /dev/null
+++ b/src/usecase/auth/create_telegram_login_token.py
@@ -0,0 +1,34 @@
+import datetime
+import secrets
+import typing
+
+from tortoise import timezone
+
+from src import domain
+
+if typing.TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def create_telegram_login_token(self: 'Usecase', telegram_id: int) -> str:
+ telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id)
+ if not telegram_user:
+ telegram_user = domain.TelegramUser(telegram_id=telegram_id)
+ await self.database.create_telegram_user(telegram_user)
+
+ user = await self.database.get_user(telegram_id=telegram_id)
+ if not user:
+ user = domain.User(telegram_user=telegram_user)
+ await self.database.create_user(user)
+
+ token = secrets.token_urlsafe(32)
+ expires_at = timezone.now() + datetime.timedelta(minutes=10)
+
+ login_token = domain.LoginToken(
+ token=token,
+ user=user,
+ expires_at=expires_at,
+ )
+ await self.database.create_login_token(login_token)
+
+ return token
diff --git a/src/usecase/auth/get_jwt_by_telegram_id.py b/src/usecase/auth/get_jwt_by_telegram_id.py
new file mode 100644
index 0000000..531b8f8
--- /dev/null
+++ b/src/usecase/auth/get_jwt_by_telegram_id.py
@@ -0,0 +1,52 @@
+import typing
+
+from src import domain, dto
+
+if typing.TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_jwt_by_telegram_id(
+ self: 'Usecase',
+ telegram_id: int,
+ username: str | None = None,
+ first_name: str | None = None,
+ last_name: str | None = None,
+) -> dto.ValidateLoginTokenOutput:
+ telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id)
+ if not telegram_user:
+ telegram_user = domain.TelegramUser(
+ telegram_id=telegram_id,
+ username=username,
+ first_name=first_name,
+ last_name=last_name,
+ )
+ await self.database.create_telegram_user(telegram_user)
+ else:
+ updated = False
+ if username is not None and telegram_user.username != username:
+ telegram_user.username = username
+ updated = True
+ if first_name is not None and telegram_user.first_name != first_name:
+ telegram_user.first_name = first_name
+ updated = True
+ if last_name is not None and telegram_user.last_name != last_name:
+ telegram_user.last_name = last_name
+ updated = True
+ if updated:
+ await self.database.update_telegram_user(telegram_user)
+
+ user = await self.database.get_user(telegram_id=telegram_id)
+ if not user:
+ user = domain.User(telegram_user=telegram_user)
+ await self.database.create_user(user)
+
+ access_token = self.jwt_encoder.encode_access_token(
+ user_id=user.id,
+ telegram_id=telegram_user.telegram_id,
+ username=telegram_user.username,
+ )
+
+ return dto.ValidateLoginTokenOutput(
+ access_token=access_token,
+ )
diff --git a/src/usecase/auth/get_me.py b/src/usecase/auth/get_me.py
new file mode 100644
index 0000000..6d752b4
--- /dev/null
+++ b/src/usecase/auth/get_me.py
@@ -0,0 +1,27 @@
+import typing
+import uuid
+
+from src import domain, dto
+
+if typing.TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_me(self: 'Usecase', user_id: uuid.UUID) -> dto.UserOutput:
+ user = await self.database.get_user(user_id=user_id)
+
+ if not user:
+ raise domain.UserNotFound(user_id)
+
+ if not user.telegram_user:
+ raise domain.UserNotFound(user_id)
+
+ telegram_user = user.telegram_user
+
+ return dto.UserOutput(
+ id=user.id,
+ telegram_id=telegram_user.telegram_id,
+ username=telegram_user.username,
+ first_name=telegram_user.first_name,
+ last_name=telegram_user.last_name,
+ )
diff --git a/src/usecase/auth/validate_login_token.py b/src/usecase/auth/validate_login_token.py
new file mode 100644
index 0000000..de7fc8d
--- /dev/null
+++ b/src/usecase/auth/validate_login_token.py
@@ -0,0 +1,52 @@
+import logging
+import typing
+
+from tortoise import timezone
+
+from src import domain, dto
+
+if typing.TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInput) -> dto.ValidateLoginTokenOutput:
+ login_token = await self.database.get_login_token(input.token)
+
+ if not login_token:
+ raise domain.LoginTokenNotFound()
+
+ if login_token.used_at:
+ raise domain.LoginTokenAlreadyUsed()
+
+ if login_token.expires_at < timezone.now():
+ raise domain.LoginTokenExpired()
+
+ user = await self.database.get_user(user_id=login_token.user_id)
+ if not user:
+ raise domain.UserNotFound(login_token.user_id)
+
+ await self.database.mark_token_as_used(input.token)
+
+ telegram_user = user.telegram_user
+ if telegram_user is None:
+ raise domain.UserNotFound(login_token.user_id)
+
+ if login_token.message_id is not None:
+ try:
+ await self.telegram_bot.edit_message_text(
+ text='✅ Вы успешно авторизованы',
+ chat_id=telegram_user.telegram_id,
+ message_id=login_token.message_id,
+ )
+ except Exception:
+ logging.getLogger(__name__).exception('Failed to update login message')
+
+ access_token = self.jwt_encoder.encode_access_token(
+ user_id=user.id,
+ telegram_id=telegram_user.telegram_id,
+ username=telegram_user.username,
+ )
+
+ return dto.ValidateLoginTokenOutput(
+ access_token=access_token,
+ )
diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py
new file mode 100644
index 0000000..ab5b377
--- /dev/null
+++ b/src/usecase/channel/attach_channel_to_workspace.py
@@ -0,0 +1,55 @@
+import logging
+from typing import TYPE_CHECKING
+
+from fastapi import HTTPException
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelToWorkspaceInput) -> dto.ProjectOutput:
+ """Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)"""
+
+ channel = await self.database.get_channel(channel_id=input.channel_id)
+ if not channel:
+ raise HTTPException(status_code=404, detail='Channel not found')
+
+ workspace = await self.database.get_workspace(input.workspace_id)
+ if not workspace:
+ raise HTTPException(status_code=404, detail='Workspace not found')
+
+ # Проверяем что проект еще не существует
+ project = await self.database.get_project(workspace.id, channel_id=channel.id)
+ if project:
+ # Проект уже существует - просто активируем
+ project.status = domain.ProjectStatus.ACTIVE
+ await self.database.update_project(project)
+ log.info('Project %s reactivated in workspace %s', project.id, workspace.id)
+ else:
+ # Создаем новый проект
+ project = domain.Project(
+ workspace_id=workspace.id,
+ channel_id=channel.id,
+ status=domain.ProjectStatus.ACTIVE,
+ )
+ await self.database.create_project(project)
+ log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ ),
+ )
diff --git a/src/usecase/channel/create_channels.py b/src/usecase/channel/create_channels.py
new file mode 100644
index 0000000..50ed819
--- /dev/null
+++ b/src/usecase/channel/create_channels.py
@@ -0,0 +1,95 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dto.CreateChannelsOutput:
+ results: list[dto.CreateChannelResult] = []
+
+ for index, channel_input in enumerate(input.channels):
+ try:
+ if channel_input.username:
+ parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
+ if not parser_response:
+ raise domain.TelegramChannelNotFound(channel_input.username)
+ else:
+ parser_response = await self.parser.resolve_telegram_channel_by_invite(channel_input.invite_link or '')
+ if not parser_response:
+ raise ValueError('Telegram channel not found by invite link')
+
+ parsed_username = parser_response.username or None
+ is_private = channel_input.invite_link != ''
+
+ channel = await self.database.get_channel(telegram_id=parser_response.telegram_id)
+ if not channel and parsed_username:
+ channel = await self.database.get_channel(username=parsed_username)
+
+ status = 'created'
+ if channel:
+ status = 'updated'
+ updated = False
+ # Канал стал публичным (появился username)
+ if parsed_username is not None and channel.username != parsed_username:
+ channel.username = parsed_username
+ channel.invite_link = None # Очищаем invite_link у публичных каналов
+ updated = True
+ # Канал остаётся приватным или обновляется
+ elif parsed_username is None and channel_input.invite_link and channel.invite_link != channel_input.invite_link:
+ channel.invite_link = channel_input.invite_link
+ updated = True
+
+ if parser_response.title is not None and channel.title != parser_response.title:
+ channel.title = parser_response.title
+ updated = True
+ if parser_response.telegram_id is not None and channel.telegram_id != parser_response.telegram_id:
+ channel.telegram_id = parser_response.telegram_id
+ updated = True
+ if parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash:
+ channel.access_hash = parser_response.access_hash
+ updated = True
+ if parser_response.pts is not None and channel.pts != parser_response.pts:
+ channel.pts = parser_response.pts
+ updated = True
+ if updated:
+ await self.database.update_channel(channel)
+ else:
+ channel = domain.Channel(
+ username=parsed_username,
+ telegram_id=parser_response.telegram_id,
+ title=parser_response.title,
+ access_hash=parser_response.access_hash,
+ pts=0 if is_private else parser_response.pts,
+ # Для публичных каналов (есть username) invite_link не храним
+ invite_link=None if parsed_username else channel_input.invite_link,
+ )
+ await self.database.create_channel(channel)
+
+ results.append(
+ dto.CreateChannelResult(
+ index=index,
+ status=status,
+ channel=dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ ),
+ )
+ )
+ except Exception as exc:
+ log.warning('Failed to create channel at index %s: %s', index, exc)
+ results.append(
+ dto.CreateChannelResult(
+ index=index,
+ status='failed',
+ error=str(exc),
+ )
+ )
+
+ return dto.CreateChannelsOutput(results=results)
diff --git a/src/usecase/channel/get_channel.py b/src/usecase/channel/get_channel.py
new file mode 100644
index 0000000..7996921
--- /dev/null
+++ b/src/usecase/channel/get_channel.py
@@ -0,0 +1,22 @@
+from typing import TYPE_CHECKING
+
+from fastapi import HTTPException
+
+from src import dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_channel(self: 'Usecase', input: dto.GetChannelInput) -> dto.ChannelOutput:
+ channel = await self.database.get_channel(channel_id=input.channel_id)
+
+ if not channel:
+ raise HTTPException(status_code=404, detail='Channel not found')
+
+ return dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ )
diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py
new file mode 100644
index 0000000..ba97f67
--- /dev/null
+++ b/src/usecase/channel/get_channels.py
@@ -0,0 +1,25 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
+ channels = await self.database.search_channels(username_query=input.username)
+
+ log.debug('Found %s channels for username query: %s', len(channels), input.username)
+
+ return [
+ dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ )
+ for channel in channels
+ ]
diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py
new file mode 100644
index 0000000..fec25e6
--- /dev/null
+++ b/src/usecase/creative/create_creative.py
@@ -0,0 +1,102 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def create_creative(
+ self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
+) -> dto.CreativeOutput:
+ await self.ensure_workspace_permission(
+ workspace_id, user_id, domain.PermissionKey.CREATIVES_WRITE, for_project_id=project_id
+ )
+
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
+ raise domain.ProjectNotFound(project_id)
+
+ creative_text = domain.replace_invite_link_with_tag(input.text)
+ media_items = input.media_items or []
+ domain.validate_media_items([item.media_type for item in media_items])
+ for item in media_items:
+ domain.validate_media_size(item.media_data)
+
+ creative = domain.Creative(
+ name=input.name,
+ text=creative_text,
+ buttons=[button.model_dump() for button in input.buttons],
+ status=domain.CreativeStatus.ACTIVE,
+ tag=input.tag or domain.CreativeTag.TESTING,
+ project_id=project.id,
+ created_by_user_id=user_id,
+ )
+ await self.database.create_creative(creative)
+
+ created_media = await _replace_media_items(self, creative.id, workspace_id, media_items)
+
+ return dto.CreativeOutput(
+ id=creative.id,
+ name=creative.name,
+ text=creative.text,
+ media_items=created_media,
+ buttons=creative.buttons,
+ project_id=project.id,
+ project_channel_title=project.channel.title,
+ created_at=creative.created_at,
+ status=creative.status,
+ tag=creative.tag,
+ placements_count=0,
+ )
+
+
+def _get_content_type(media_type: str | None) -> str:
+ """Map Telegram media type to MIME content type."""
+ if not media_type:
+ return 'application/octet-stream'
+
+ mapping = {
+ 'photo': 'image/jpeg',
+ 'video': 'video/mp4',
+ 'animation': 'image/gif',
+ }
+ return mapping.get(media_type, 'application/octet-stream')
+
+
+async def _replace_media_items(
+ self: 'Usecase',
+ creative_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ media_items: list[dto.CreativeMediaInput],
+) -> list[dto.CreativeMediaItem]:
+ created: list[dto.CreativeMediaItem] = []
+ for position, item in enumerate(media_items):
+ media_s3_key: str | None = None
+ if item.media_data:
+ file_id = uuid.uuid4()
+ media_s3_key = f'creatives/{workspace_id}/{file_id}'
+ content_type = _get_content_type(item.media_type)
+ await self.s3.upload(media_s3_key, item.media_data, content_type)
+ log.info('Uploaded creative media to S3: %s', media_s3_key)
+ media = await domain.CreativeMedia.create(
+ creative_id=creative_id,
+ media_type=item.media_type,
+ media_file_id=item.media_file_id,
+ media_s3_key=media_s3_key,
+ position=position,
+ )
+ created.append(
+ dto.CreativeMediaItem(
+ media_type=media.media_type,
+ media_file_id=media.media_file_id,
+ position=media.position,
+ s3_url=self.s3.public_url(media.media_s3_key) if media.media_s3_key else None,
+ )
+ )
+ return created
diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py
new file mode 100644
index 0000000..c8a75d5
--- /dev/null
+++ b/src/usecase/creative/delete_creative.py
@@ -0,0 +1,44 @@
+import asyncio
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_WRITE
+ )
+
+ creative = await self.database.get_creative(input.workspace_id, input.creative_id)
+ if not creative:
+ log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
+ raise domain.CreativeNotFound(input.creative_id)
+
+ context.ensure_project_permission(domain.PermissionKey.CREATIVES_WRITE, creative.project_id)
+
+ has_placement_posts = await self.database.has_placement_posts_for_creative(creative.id)
+ if has_placement_posts:
+ log.warning('Creative %s is used in placement_posts and cannot be deleted', input.creative_id)
+ raise domain.CreativeInUse(input.creative_id)
+
+ media_items = await creative.media_items.all()
+ media_keys = [item.media_s3_key for item in media_items if item.media_s3_key]
+ if media_keys:
+
+ async def delete_old_media() -> None:
+ for key in media_keys:
+ try:
+ await self.s3.delete(key)
+ log.info('Deleted old creative media from S3: %s', key)
+ except Exception as e:
+ log.warning('Failed to delete old creative media from S3: %s', e)
+
+ asyncio.create_task(delete_old_media())
+
+ await self.database.delete_creative(input.creative_id)
diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py
new file mode 100644
index 0000000..b6926c3
--- /dev/null
+++ b/src/usecase/creative/get_creative.py
@@ -0,0 +1,50 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_READ
+ )
+
+ creative = await self.database.get_creative(input.workspace_id, input.creative_id)
+ if not creative:
+ raise domain.CreativeNotFound(input.creative_id)
+
+ context.ensure_project_permission(domain.PermissionKey.CREATIVES_READ, creative.project_id)
+
+ placements_count = await self.database.count_placement_posts_by_creative(creative.id)
+ media_rel = creative.media_items
+ if hasattr(media_rel, 'all'):
+ media_items = await media_rel.all().order_by('position')
+ else:
+ media_items = sorted(media_rel, key=lambda item: item.position)
+
+ return dto.CreativeOutput(
+ id=creative.id,
+ name=creative.name,
+ text=creative.text,
+ media_items=[
+ dto.CreativeMediaItem(
+ media_type=item.media_type,
+ media_file_id=item.media_file_id,
+ position=item.position,
+ s3_url=self.s3.public_url(item.media_s3_key) if item.media_s3_key else None,
+ )
+ for item in media_items
+ ],
+ buttons=creative.buttons,
+ project_id=creative.project_id,
+ project_channel_title=creative.project.channel.title,
+ created_at=creative.created_at,
+ status=creative.status,
+ tag=creative.tag,
+ placements_count=placements_count,
+ )
diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py
new file mode 100644
index 0000000..5e36112
--- /dev/null
+++ b/src/usecase/creative/get_creatives.py
@@ -0,0 +1,60 @@
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[dto.CreativeOutput]:
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_READ
+ )
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.CREATIVES_READ)
+
+ if input.project_id is not None:
+ context.ensure_project_permission(domain.PermissionKey.CREATIVES_READ, input.project_id)
+ allowed_project_ids = None
+
+ creatives = await self.database.get_workspace_creatives(
+ input.workspace_id,
+ input.project_id,
+ input.include_archived,
+ allowed_project_ids=allowed_project_ids,
+ )
+
+ creative_ids = [c.id for c in creatives]
+ placements_counts = await self.database.count_placement_posts_by_creative_batch(creative_ids)
+
+ results: list[dto.CreativeOutput] = []
+ for creative in creatives:
+ media_rel = creative.media_items
+ if hasattr(media_rel, 'all'):
+ media_items = await media_rel.all().order_by('position')
+ else:
+ media_items = sorted(media_rel, key=lambda item: item.position)
+ results.append(
+ dto.CreativeOutput(
+ id=creative.id,
+ name=creative.name,
+ text=creative.text,
+ media_items=[
+ dto.CreativeMediaItem(
+ media_type=item.media_type,
+ media_file_id=item.media_file_id,
+ position=item.position,
+ s3_url=self.s3.public_url(item.media_s3_key) if item.media_s3_key else None,
+ )
+ for item in media_items
+ ],
+ buttons=creative.buttons,
+ project_id=creative.project_id,
+ project_channel_title=creative.project.channel.title,
+ created_at=creative.created_at,
+ status=creative.status,
+ tag=creative.tag,
+ placements_count=placements_counts.get(creative.id, 0),
+ )
+ )
+ return results
diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py
new file mode 100644
index 0000000..19d34ab
--- /dev/null
+++ b/src/usecase/creative/update_creative.py
@@ -0,0 +1,136 @@
+import asyncio
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import S3Storage, Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def update_creative(
+ self: 'Usecase',
+ creative_id: uuid.UUID,
+ input: dto.UpdateCreativeInput,
+ user_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+) -> dto.CreativeOutput:
+ context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.CREATIVES_WRITE)
+
+ creative = await self.database.get_creative(workspace_id, creative_id)
+ if not creative:
+ log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
+ raise domain.CreativeNotFound(creative_id)
+
+ context.ensure_project_permission(domain.PermissionKey.CREATIVES_WRITE, creative.project_id)
+
+ if input.name:
+ creative.name = input.name
+ if input.text:
+ creative.text = domain.replace_invite_link_with_tag(input.text)
+ media_items: list[dto.CreativeMediaInput] | None = None
+ if input.media_items is not None:
+ media_items = input.media_items
+ domain.validate_media_items([item.media_type for item in media_items])
+ for item in media_items:
+ domain.validate_media_size(item.media_data)
+ if input.buttons is not None:
+ creative.buttons = [button.model_dump() for button in input.buttons]
+ if input.status:
+ creative.status = input.status
+ if input.tag:
+ creative.tag = input.tag
+
+ await self.database.update_creative(creative)
+
+ if media_items is not None:
+ await _replace_media_items(self, creative.id, workspace_id, media_items)
+
+ placements_count = await self.database.count_placement_posts_by_creative(creative.id)
+ creative_media = await _get_media_items(creative, self.s3)
+
+ return dto.CreativeOutput(
+ id=creative.id,
+ name=creative.name,
+ text=creative.text,
+ media_items=creative_media,
+ buttons=creative.buttons,
+ project_id=creative.project_id,
+ project_channel_title=creative.project.channel.title,
+ created_at=creative.created_at,
+ status=creative.status,
+ tag=creative.tag,
+ placements_count=placements_count,
+ )
+
+
+def _get_content_type(media_type: str | None) -> str:
+ if not media_type:
+ return 'application/octet-stream'
+
+ mapping = {
+ 'photo': 'image/jpeg',
+ 'video': 'video/mp4',
+ 'animation': 'image/gif',
+ }
+ return mapping.get(media_type, 'application/octet-stream')
+
+
+async def _replace_media_items(
+ self: 'Usecase',
+ creative_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ media_items: list[dto.CreativeMediaInput],
+) -> None:
+ existing_items = await domain.CreativeMedia.filter(creative_id=creative_id).all()
+ if existing_items:
+ old_media_keys = [item.media_s3_key for item in existing_items if item.media_s3_key]
+ await domain.CreativeMedia.filter(creative_id=creative_id).delete()
+
+ if old_media_keys:
+
+ async def delete_old_media() -> None:
+ for key in old_media_keys:
+ try:
+ await self.s3.delete(key)
+ log.info('Deleted old creative media from S3: %s', key)
+ except Exception as e:
+ log.warning('Failed to delete old creative media from S3: %s', e)
+
+ asyncio.create_task(delete_old_media())
+
+ for position, item in enumerate(media_items):
+ media_s3_key: str | None = None
+ if item.media_data:
+ file_id = uuid.uuid4()
+ media_s3_key = f'creatives/{workspace_id}/{file_id}'
+ content_type = _get_content_type(item.media_type)
+ await self.s3.upload(media_s3_key, item.media_data, content_type)
+ log.info('Uploaded new creative media to S3: %s', media_s3_key)
+ await domain.CreativeMedia.create(
+ creative_id=creative_id,
+ media_type=item.media_type,
+ media_file_id=item.media_file_id,
+ media_s3_key=media_s3_key,
+ position=position,
+ )
+
+
+async def _get_media_items(creative: domain.Creative, s3_storage: 'S3Storage') -> list[dto.CreativeMediaItem]:
+ media_rel = creative.media_items
+ if hasattr(media_rel, 'all'):
+ items = await media_rel.all().order_by('position')
+ else:
+ items = sorted(media_rel, key=lambda item: item.position)
+ return [
+ dto.CreativeMediaItem(
+ media_type=item.media_type,
+ media_file_id=item.media_file_id,
+ position=item.position,
+ s3_url=s3_storage.public_url(item.media_s3_key) if item.media_s3_key else None,
+ )
+ for item in items
+ ]
diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py
new file mode 100644
index 0000000..35ed203
--- /dev/null
+++ b/src/usecase/placement/fetch_placement_post_cycle.py
@@ -0,0 +1,77 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
+ """Находит посты в каналах по invite_link из Placement и создает PlacementPost"""
+ log.debug('Starting fetch_placement_post_cycle')
+
+ # Получаем все approved Placements с invite_link
+ placements = (
+ await domain.Placement.filter(
+ status__in=[
+ domain.PlacementStatus.NO_STATUS,
+ domain.PlacementStatus.WRITE,
+ domain.PlacementStatus.WAITING_RESPONSE,
+ domain.PlacementStatus.TERMS_APPROVAL,
+ domain.PlacementStatus.TO_PAY,
+ domain.PlacementStatus.PAID,
+ ],
+ invite_link__isnull=False,
+ )
+ .prefetch_related('channel', 'project')
+ .all()
+ )
+
+ if not placements:
+ log.debug('No active placements found')
+ return
+
+ created_count = 0
+
+ for placement in placements:
+ if placement.channel is None or placement.invite_link is None:
+ log.warning('Placement %s missing channel or invite_link, skipping', placement.id)
+ continue
+ if placement.creative_id is None:
+ continue
+ existing_for_placement = await domain.PlacementPost.filter(placement_id=placement.id).first()
+ if existing_for_placement:
+ log.debug('Placement %s already has placement_post %s, skipping', placement.id, existing_for_placement.id)
+ continue
+
+ # Ищем посты в канале, содержащие invite_link из placement
+ posts = await domain.Post.filter(
+ channel_id=placement.channel_id,
+ text__contains=placement.invite_link,
+ deleted_from_channel_at__isnull=True,
+ ).all()
+
+ for post in posts:
+ # Проверяем, не создана ли уже публикация для этого поста
+ existing = await domain.PlacementPost.filter(post_id=post.id).first()
+ if existing:
+ continue
+
+ placement_post = domain.PlacementPost(
+ placement_id=placement.id,
+ post_id=post.id,
+ )
+
+ await self.database.create_placement_post(placement_post)
+ created_count += 1
+ log.info(
+ 'Created placement_post %s for placement %s from post %s',
+ placement_post.id,
+ placement.id,
+ post.id,
+ )
+
+ log.debug('Fetch placement_post post cycle completed. Created %s placement_posts', created_count)
diff --git a/src/usecase/placement/update_post_status_cycle.py b/src/usecase/placement/update_post_status_cycle.py
new file mode 100644
index 0000000..35c766f
--- /dev/null
+++ b/src/usecase/placement/update_post_status_cycle.py
@@ -0,0 +1,141 @@
+import datetime
+import logging
+from typing import TYPE_CHECKING
+
+from tortoise import timezone
+
+from src import domain
+from src.domain.placement_post import PlacementPostStatus
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+# Статусы, которые не должны обновляться автоматически (финальные или ручные)
+FINAL_STATUSES = {
+ PlacementPostStatus.COMPLETED_DELETED,
+ PlacementPostStatus.COMPLETED_NOT_DELETED,
+ PlacementPostStatus.CHECK_COMPLETED,
+}
+
+# Статусы, при которых нужно проверять условия для автоматического обновления
+AUTO_UPDATE_STATUSES = {
+ PlacementPostStatus.NO_STATUS,
+ PlacementPostStatus.SEND_POST,
+ PlacementPostStatus.POST_APPROVAL,
+ PlacementPostStatus.WAITING_SCHEDULE,
+ PlacementPostStatus.SCHEDULED,
+ PlacementPostStatus.POST_PUBLISHED,
+ PlacementPostStatus.CHECK_DELETED_EARLY,
+ PlacementPostStatus.CHECK_NOT_PUBLISHED,
+}
+
+
+async def update_post_status_cycle(self: 'Usecase', interval_seconds: int) -> None:
+ """Автоматически обновляет статусы PlacementPost на основе состояния постов"""
+ log.debug('Starting update_post_status_cycle')
+
+ # Получаем все PlacementPost со статусами, которые могут быть обновлены
+ placement_posts = (
+ await domain.PlacementPost.filter(
+ status__in=list(AUTO_UPDATE_STATUSES),
+ )
+ .prefetch_related('placement', 'post', 'post__channel')
+ .all()
+ )
+
+ if not placement_posts:
+ log.debug('No placement_posts to update')
+ return
+
+ updated_count = 0
+ now = timezone.now()
+
+ for placement_post in placement_posts:
+ placement = placement_post.placement
+ post = placement_post.post
+
+ if not placement:
+ log.warning('PlacementPost %s missing placement', placement_post.id)
+ continue
+
+ new_status = _determine_status(placement, placement_post, post, now)
+
+ if new_status and new_status != placement_post.status:
+ old_status = placement_post.status
+ placement_post.status = new_status
+ await placement_post.save()
+ updated_count += 1
+ log.info(
+ 'Updated PlacementPost %s status: %s -> %s',
+ placement_post.id,
+ old_status,
+ new_status,
+ )
+
+ log.debug('Update post status cycle completed. Updated %s placement_posts', updated_count)
+
+
+def _determine_status(
+ placement: domain.Placement,
+ placement_post: domain.PlacementPost,
+ post: domain.Post | None,
+ now: datetime.datetime,
+) -> PlacementPostStatus | None:
+ """Определяет новый статус PlacementPost на основе текущего состояния"""
+
+ # Если поста нет и прошло время размещения - "Пост не вышел"
+ if post is None:
+ if placement.placement_at and placement.placement_at < now:
+ return PlacementPostStatus.CHECK_NOT_PUBLISHED
+ return None
+
+ # Если пост есть, но не опубликован - оставляем как есть
+ if not post.published_at:
+ return None
+
+ # Пост опубликован - проверяем условия для перехода статусов
+ published_at = post.published_at
+ deleted_at = post.deleted_from_channel_at
+
+ # Вычисляем время в топе
+ if deleted_at:
+ time_on_top = int((deleted_at - published_at).total_seconds())
+ else:
+ time_on_top = int((now - published_at).total_seconds())
+
+ # Получаем требуемую длительность из формата
+ required_duration = domain.get_feed_duration_seconds(placement)
+
+ # Если формат "без удаления" - размещение отработало, если прошло 24 часа
+ if required_duration is None:
+ # По умолчанию считаем что "без удаления" = 24 часа минимум
+ required_duration = 24 * 3600
+
+ # Проверяем условия для автоматических статусов
+
+ # Пост удалён раньше срока
+ if deleted_at and time_on_top < required_duration:
+ return PlacementPostStatus.CHECK_DELETED_EARLY
+
+ # Размещение отработало - пост удалён
+ if deleted_at and time_on_top >= required_duration:
+ return PlacementPostStatus.COMPLETED_DELETED
+
+ # Размещение отработало - пост не удалён
+ if not deleted_at and time_on_top >= required_duration:
+ return PlacementPostStatus.COMPLETED_NOT_DELETED
+
+ # Пост вышел, но ещё не отработал
+ if placement_post.status in {
+ PlacementPostStatus.NO_STATUS,
+ PlacementPostStatus.SEND_POST,
+ PlacementPostStatus.POST_APPROVAL,
+ PlacementPostStatus.WAITING_SCHEDULE,
+ PlacementPostStatus.SCHEDULED,
+ }:
+ return PlacementPostStatus.POST_PUBLISHED
+
+ return None
diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py
new file mode 100644
index 0000000..e7304b7
--- /dev/null
+++ b/src/usecase/project/archive_project.py
@@ -0,0 +1,66 @@
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dto.ProjectOutput:
+ await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=input.project_id
+ )
+
+ async with self.database.transaction():
+ await self.database.archive_project(input.workspace_id, input.project_id)
+ project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
+
+ if not project:
+ raise domain.ProjectNotFound()
+
+ await project.fetch_related('channel')
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
+
+
+async def unarchive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dto.ProjectOutput:
+ await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=input.project_id
+ )
+
+ async with self.database.transaction():
+ await self.database.unarchive_project(input.workspace_id, input.project_id)
+ project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
+
+ if not project:
+ raise domain.ProjectNotFound()
+
+ await project.fetch_related('channel')
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
diff --git a/src/usecase/project/delete_project.py b/src/usecase/project/delete_project.py
new file mode 100644
index 0000000..3e28fed
--- /dev/null
+++ b/src/usecase/project/delete_project.py
@@ -0,0 +1,16 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def delete_project(self: 'Usecase', workspace_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) -> None:
+ await self.ensure_workspace_permission(
+ workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
+ )
+
+ async with self.database.transaction():
+ await self.database.delete_project(workspace_id, project_id)
diff --git a/src/usecase/project/disconnect_project_by_tg_id.py b/src/usecase/project/disconnect_project_by_tg_id.py
new file mode 100644
index 0000000..d9cfb17
--- /dev/null
+++ b/src/usecase/project/disconnect_project_by_tg_id.py
@@ -0,0 +1,29 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def disconnect_project_by_tg_id(self: 'Usecase', input: dto.DisconnectProjectByTgIdInput) -> None:
+ user = await self.database.get_user(telegram_id=input.user_telegram_id)
+ if not user:
+ log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel')
+ return
+ if user.telegram_user is None:
+ log.warning('User %s missing telegram profile when disconnecting channel', user.id)
+ return
+
+ project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
+ if not project:
+ log.warning(f'Project channel {input.telegram_id} not found')
+ raise domain.ProjectNotFound()
+
+ project.status = domain.ProjectStatus.ARCHIVED
+ await self.database.update_project(project)
+
+ log.info('Project %s archived for channel %s', project.id, input.telegram_id)
diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py
new file mode 100644
index 0000000..5c3824c
--- /dev/null
+++ b/src/usecase/project/get_project.py
@@ -0,0 +1,30 @@
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.ProjectOutput:
+ project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
+
+ if not project:
+ raise domain.ProjectNotFound()
+
+ await project.fetch_related('channel')
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py
new file mode 100644
index 0000000..f6c2c42
--- /dev/null
+++ b/src/usecase/project/get_workspace_projects.py
@@ -0,0 +1,36 @@
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]:
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
+ )
+
+ allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ)
+
+ projects = await self.database.get_workspace_projects(
+ input.workspace_id, allowed_project_ids=allowed_project_ids, include_archived=input.include_archived
+ )
+
+ return [
+ dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
+ for project in projects
+ ]
diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py
new file mode 100644
index 0000000..f90d2cf
--- /dev/null
+++ b/src/usecase/project/move_project_to_workspace.py
@@ -0,0 +1,76 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def move_project_to_workspace(
+ self: 'Usecase',
+ user_id: uuid.UUID,
+ source_workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ target_workspace_id: uuid.UUID,
+) -> dto.ProjectOutput:
+ # Получаем project из source workspace
+ project = await self.database.get_project(source_workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
+
+ await project.fetch_related('channel')
+
+ # Проверяем что target workspace существует
+ target_workspace = await self.database.get_workspace(target_workspace_id)
+ if not target_workspace:
+ raise domain.WorkspaceNotFound(target_workspace_id)
+
+ # Проверяем права владельца в source workspace
+ await self.ensure_workspace_permission(source_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ # Проверяем права владельца в target workspace
+ await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ # Проверяем что канал не существует в target workspace (UNIQUE constraint)
+ if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id):
+ raise domain.ProjectChannelConflict()
+
+ # Выполняем перенос в транзакции
+ async with self.database.transaction():
+ # Удаляем project-scoped permissions (они теряют смысл в новом workspace)
+ await domain.WorkspaceUserPermissionScope.filter(project_id=project_id).delete()
+
+ # Обновляем workspace_id
+ project.workspace_id = target_workspace_id
+ await self.database.update_project(project)
+
+ # Логирование операции
+ log.info(
+ 'Project moved to another workspace',
+ extra={
+ 'project_id': str(project_id),
+ 'channel_id': str(project.channel.id),
+ 'source_workspace_id': str(source_workspace_id),
+ 'target_workspace_id': str(target_workspace_id),
+ 'user_id': str(user_id),
+ },
+ )
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py
new file mode 100644
index 0000000..4fab33a
--- /dev/null
+++ b/src/usecase/project/tg_add_project.py
@@ -0,0 +1,251 @@
+import logging
+from typing import TYPE_CHECKING
+
+from aiogram.types import InlineKeyboardButton
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None:
+ permissions = input.bot_permissions
+
+ if not permissions.is_admin:
+ log.warning(f'Bot is not admin in channel {input.telegram_id}. Attempted by user {input.user_telegram_id}')
+ await self.telegram_bot.send_message(
+ f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n'
+ 'Пожалуйста, сделайте бота администратором канала.',
+ input.user_telegram_id,
+ )
+ raise domain.ChannelNoAdminRights()
+
+ missing_permissions = []
+ if not permissions.can_invite_users:
+ missing_permissions.append('Создание инвайт-ссылок')
+ # if not permissions.can_restrict_members:
+ # missing_permissions.append('Управление пользователями (видеть вступления)')
+
+ if missing_permissions:
+ log.warning(
+ f'Bot lacks required permissions in channel {input.telegram_id}: {missing_permissions}. '
+ f'Attempted by user {input.user_telegram_id}'
+ )
+ permissions_text = '\n'.join(f'• {p}' for p in missing_permissions)
+ await self.telegram_bot.send_message(
+ f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n'
+ f'Отсутствующие права:\n{permissions_text}\n\n'
+ 'Пожалуйста, предоставьте эти права боту в настройках канала.',
+ input.user_telegram_id,
+ )
+ raise domain.ChannelNoAdminRights()
+
+ user = await self.database.get_user(telegram_id=input.user_telegram_id)
+ if not user:
+ log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}')
+ await self.telegram_bot.send_message(
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.',
+ input.user_telegram_id,
+ )
+ raise domain.UserNotFound()
+ if user.telegram_user is None:
+ log.warning('User %s missing telegram profile when connecting channel', user.id)
+ await self.telegram_bot.send_message(
+ '⚠️ Произошла ошибка при обработке вашего профиля. Пожалуйста, повторите авторизацию.',
+ input.user_telegram_id,
+ )
+ raise domain.UserNotFound()
+
+ if user.telegram_user is None:
+ raise domain.UserNotFound(user.id)
+
+ telegram_user = user.telegram_user
+
+ memberships = await self.database.get_user_workspaces(user.id)
+ workspaces: list[domain.Workspace] = []
+ for membership in memberships:
+ workspace: domain.Workspace | None = membership.workspace
+ if not workspace:
+ workspace = await self.database.get_workspace(membership.workspace_id)
+
+ if workspace:
+ workspaces.append(workspace)
+
+ if not workspaces:
+ workspace = await self.get_or_create_personal_workspace(user)
+ workspaces = [workspace]
+
+ invite_link = ""
+ parser_response = None
+ is_private = input.username is None
+ if is_private:
+ try:
+ invite_link = await self.telegram_bot.create_chat_invite_link(input.telegram_id)
+ parser_response = await self.parser.resolve_telegram_channel_by_invite(invite_link)
+ except Exception as exc:
+ log.warning('Failed to resolve private channel %s: %s', input.telegram_id, exc)
+ await self.telegram_bot.send_message(
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'Не удалось создать или проверить инвайт-ссылку.',
+ input.user_telegram_id,
+ )
+ return None
+
+ def build_channel_title() -> str:
+ if parser_response and parser_response.title:
+ return parser_response.title
+ return input.title
+
+ def build_channel_username() -> str | None:
+ if parser_response and parser_response.username:
+ return parser_response.username
+ return input.username
+
+ def build_channel_telegram_id() -> int:
+ if parser_response and parser_response.telegram_id:
+ return parser_response.telegram_id
+ return input.telegram_id
+
+ def update_channel_meta(channel: domain.Channel) -> bool:
+ updated = False
+ title = build_channel_title()
+ username = build_channel_username()
+ telegram_id = build_channel_telegram_id()
+ if channel.title != title:
+ channel.title = title
+ updated = True
+ if username is not None and channel.username != username:
+ channel.username = username
+ updated = True
+ if channel.telegram_id != telegram_id:
+ channel.telegram_id = telegram_id
+ updated = True
+ if (
+ parser_response
+ and parser_response.access_hash is not None
+ and channel.access_hash != parser_response.access_hash
+ ):
+ channel.access_hash = parser_response.access_hash
+ updated = True
+ if parser_response and parser_response.pts is not None and channel.pts != parser_response.pts:
+ channel.pts = parser_response.pts
+ updated = True
+ if is_private and channel.pts != 0:
+ channel.pts = 0
+ updated = True
+ if invite_link and channel.invite_link != invite_link:
+ channel.invite_link = invite_link
+ updated = True
+ return updated
+
+ if len(workspaces) == 1:
+ workspace = workspaces[0]
+
+ channel = await self.database.get_channel(telegram_id=input.telegram_id)
+ if channel:
+ if update_channel_meta(channel):
+ await self.database.update_channel(channel)
+ else:
+ username = build_channel_username()
+ if username is None and invite_link == "":
+ log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
+ await self.telegram_bot.send_message(
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'У канала отсутствует публичный username и не удалось получить invite link.',
+ input.user_telegram_id,
+ )
+ return None
+
+ channel = domain.Channel(
+ telegram_id=build_channel_telegram_id(),
+ title=build_channel_title(),
+ username=username,
+ access_hash=(parser_response.access_hash if parser_response else None),
+ pts=(0 if is_private else (parser_response.pts if parser_response else None)),
+ invite_link=invite_link or None,
+ )
+ await self.database.create_channel(channel)
+
+ project = await self.database.get_project(workspace.id, channel_id=channel.id, include_deleted=True)
+ if project:
+ project.status = domain.ProjectStatus.ACTIVE
+ project.deleted_at = None
+ await self.database.update_project(project)
+ else:
+ project = domain.Project(
+ workspace_id=workspace.id,
+ channel_id=channel.id,
+ status=domain.ProjectStatus.ACTIVE,
+ )
+ await self.database.create_project(project)
+
+ log.info(
+ 'Project for channel %s connected/updated successfully in workspace %s by user %s',
+ input.telegram_id,
+ workspace.id,
+ input.user_telegram_id,
+ )
+
+ await self.telegram_bot.send_message(
+ f'✅ Канал "{input.title}" добавлен в рабочее пространство "{workspace.name}".', input.user_telegram_id
+ )
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ ),
+ )
+
+ # Если >1 workspace - создаем/обновляем канал и отправляем уведомление
+ channel = await self.database.get_channel(telegram_id=input.telegram_id)
+ if channel:
+ if update_channel_meta(channel):
+ await self.database.update_channel(channel)
+ else:
+ username = build_channel_username()
+ if username is None and invite_link == "":
+ log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
+ await self.telegram_bot.send_message(
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'У канала отсутствует публичный username и не удалось получить invite link.',
+ input.user_telegram_id,
+ )
+ return None
+
+ channel = domain.Channel(
+ telegram_id=build_channel_telegram_id(),
+ title=build_channel_title(),
+ username=username,
+ access_hash=(parser_response.access_hash if parser_response else None),
+ pts=(0 if is_private else (parser_response.pts if parser_response else None)),
+ invite_link=invite_link or None,
+ )
+ await self.database.create_channel(channel)
+
+ # Callback будет обработан Golang ботом который покажет экран выбора workspace
+ buttons = [[InlineKeyboardButton(text='Выбрать workspace', callback_data=f'pending_channel:{channel.id}')]]
+
+ await self.telegram_bot.send_message_with_inline_keyboard(
+ f'Бот добавлен в канал "{input.title}".\n\n'
+ f'У вас {len(workspaces)} рабочих пространств. '
+ 'Нажмите кнопку ниже чтобы выбрать, где создать проект.',
+ telegram_user.telegram_id,
+ buttons,
+ )
+
+ log.info('Pending channel notification sent to user %s for channel %s', telegram_user.telegram_id, channel.id)
+
+ return None
diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py
new file mode 100644
index 0000000..01dd1ab
--- /dev/null
+++ b/src/usecase/project/update_project_invite_link_type.py
@@ -0,0 +1,48 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def update_project_invite_link_type(
+ self: 'Usecase',
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ purchase_invite_type_default: domain.InviteLinkType,
+ user_id: uuid.UUID,
+) -> dto.ProjectOutput:
+ await self.ensure_workspace_permission(
+ workspace_id,
+ user_id,
+ domain.PermissionKey.PROJECTS_WRITE,
+ for_project_id=project_id,
+ )
+
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
+
+ project.purchase_invite_type_default = purchase_invite_type_default
+
+ await self.database.update_project(project)
+
+ return dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
diff --git a/src/usecase/project/update_project_permissions.py b/src/usecase/project/update_project_permissions.py
new file mode 100644
index 0000000..3841387
--- /dev/null
+++ b/src/usecase/project/update_project_permissions.py
@@ -0,0 +1,61 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPermissionsInput) -> None:
+ missing_permissions = []
+ if not input.permissions.can_invite_users:
+ missing_permissions.append('Создание инвайт-ссылок')
+ if not input.permissions.can_restrict_members:
+ missing_permissions.append('Управление пользователями')
+
+ # Получаем user_id по telegram_id
+ user = await self.database.get_user(telegram_id=input.user_telegram_id)
+ if not user:
+ log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
+ return
+ if user.telegram_user is None:
+ log.warning('User %s missing telegram profile when updating permissions', user.id)
+ return
+
+ project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
+ if not project:
+ log.warning(f'Project channel {input.telegram_id} not found when permissions changed')
+ raise domain.ProjectNotFound()
+
+ if not missing_permissions:
+ if project.status != domain.ProjectStatus.ACTIVE:
+ project.status = domain.ProjectStatus.ACTIVE
+ await self.database.update_project(project)
+
+ await self.telegram_bot.send_message(
+ f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
+ user.telegram_user.telegram_id,
+ )
+ log.info(f'Project channel {input.telegram_id} reactivated - all permissions granted')
+ return
+
+ if project.status == domain.ProjectStatus.ACTIVE:
+ project.status = domain.ProjectStatus.INACTIVE
+ await self.database.update_project(project)
+
+ missed_permissions = '\n'.join(f'• {p}' for p in missing_permissions)
+ await self.telegram_bot.send_message(
+ (
+ f'⚠️ Канал "{input.chat_title}" был деактивирован.\n\n'
+ f'Боту убрали необходимые права:\n{missed_permissions}'
+ ),
+ user.telegram_user.telegram_id,
+ )
+ log.warning(
+ 'Project channel %s deactivated due to missing permissions: %s',
+ input.telegram_id,
+ missing_permissions,
+ )
diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py
new file mode 100644
index 0000000..9c05ee4
--- /dev/null
+++ b/src/usecase/purchase/build_placement_creative.py
@@ -0,0 +1,244 @@
+import logging
+import re
+import uuid
+from typing import TYPE_CHECKING
+
+from aiogram.types import InlineKeyboardButton
+from tortoise import timezone
+
+from src import domain, dto
+
+from .create_placements import generate_invite_link_name, uuid_to_short_id
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+_INVITE_LINK_TAG = re.compile(r'(.*?)', re.IGNORECASE | re.DOTALL)
+_INVITE_LINK_PLACEHOLDER = '{{invite_link}}'
+
+
+def _inject_invite_link(text: str, invite_link: str) -> str:
+ if not text:
+ return text
+
+ def _replace(match: re.Match[str]) -> str:
+ inner = match.group(1).strip()
+ if inner:
+ return f'{inner}'
+ return f'{invite_link}'
+
+ return _INVITE_LINK_TAG.sub(_replace, text)
+
+
+def _build_buttons(buttons: list[dict[str, str]], invite_link: str) -> list[dto.CreativeButton]:
+ result: list[dto.CreativeButton] = []
+ for raw in buttons:
+ text = raw.get('text')
+ url = raw.get('url')
+ if not text or not url:
+ continue
+ if url == _INVITE_LINK_PLACEHOLDER:
+ url = invite_link
+ result.append(dto.CreativeButton(text=text, url=url))
+ return result
+
+
+def _format_channel_name(channel: domain.Channel) -> str:
+ if channel.title:
+ return channel.title
+ if channel.username:
+ return f'@{channel.username}'
+ return 'Без названия'
+
+
+def _format_channel_link(channel: domain.Channel) -> str:
+ """Format channel as HTML link if possible, otherwise return plain text."""
+ name = _format_channel_name(channel)
+
+ # Try invite_link first
+ if channel.invite_link:
+ return f'{name}'
+
+ # Try username
+ if channel.username:
+ return f'{name}'
+
+ # No link available, return plain name
+ return name
+
+
+def _build_info_message(
+ placement: domain.Placement,
+ project: domain.Project,
+) -> str:
+ """Build informational message about placement."""
+ lines = []
+
+ # Header
+ lines.append('Ссылка зашита с помощью @smartpost_tg_bot')
+ lines.append('')
+
+ # Placement channel (always present)
+ placement_channel_link = _format_channel_link(placement.channel)
+ lines.append(f'Размещение в канале: {placement_channel_link}')
+
+ # Project channel (always present)
+ project_channel_link = _format_channel_link(project.channel)
+ lines.append(f'Рекламируемый проект: {project_channel_link}')
+
+ # Invite link type (always present)
+ link_type_text = 'Открытая' if placement.invite_link_type == domain.InviteLinkType.PUBLIC else 'С заявками'
+ lines.append(f'Тип ссылки: {link_type_text}')
+
+ # Cost / CPM (optional)
+ if placement.cost_value is not None and placement.cost_value > 0:
+ if placement.cost_type == domain.CostType.CPM:
+ lines.append(f'Ставка CPM: {placement.cost_value:.0f} ₽')
+ else:
+ lines.append(f'Стоимость: {placement.cost_value:.0f} ₽')
+
+ # Date and time (optional)
+ if placement.placement_at:
+ # Check if time is 00:00 (midnight) - then show only date
+ if placement.placement_at.hour == 0 and placement.placement_at.minute == 0:
+ date_str = placement.placement_at.strftime('%d.%m.%Y')
+ lines.append(f'Дата: {date_str}')
+ else:
+ datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M')
+ lines.append(f'Дата и время: {datetime_str}')
+
+ # Format (optional) — prefer display string from numeric fields
+ display_format = domain.format_display_string(placement.top_time_minutes, placement.feed_time_minutes)
+ if display_format:
+ lines.append(f'Формат: {display_format}')
+ elif placement.format:
+ lines.append(f'Формат: {placement.format}')
+
+ # Add empty line before footer
+ lines.append('')
+ lines.append('Пост ниже 👇')
+
+ return '\n'.join(lines)
+
+
+def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]:
+ return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons]
+
+
+async def build_placement_creative(
+ self: 'Usecase',
+ placement_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> dto.CreativePreviewOutput:
+ context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
+
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
+
+ placement = await self.database.get_placement(workspace_id, placement_id)
+ if not placement or placement.project_id != project.id:
+ raise domain.PlacementNotFound(placement_id)
+
+ if placement.creative_id is None:
+ raise domain.CreativeNotFound()
+
+ creative = await self.database.get_creative(workspace_id, placement.creative_id)
+ if not creative or creative.project_id != project.id:
+ raise domain.CreativeNotFound(placement.creative_id)
+
+ if not placement.invite_link:
+ if project.channel.telegram_id is None:
+ raise domain.ChannelNotFound(project.channel.id)
+
+ # Generate invite_link_name if not set
+ if not placement.invite_link_name:
+ short_id = uuid_to_short_id(placement.id)
+ placement.invite_link_name = generate_invite_link_name(short_id, placement.channel.title)
+
+ requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL
+ invite_link = await self.telegram_bot.create_chat_invite_link(
+ project.channel.telegram_id, requires_approval, name=placement.invite_link_name
+ )
+ placement.invite_link = invite_link
+ placement.invite_link_created_at = timezone.now()
+ await self.database.update_placement(placement)
+
+ invite_link = placement.invite_link
+ if not invite_link:
+ raise domain.PlacementNotFound(placement.id)
+
+ media_rel = creative.media_items
+ if hasattr(media_rel, 'all'):
+ media_items = await media_rel.all().order_by('position')
+ else:
+ media_items = sorted(media_rel, key=lambda item: item.position)
+ preview = dto.CreativePreviewOutput(
+ id=creative.id,
+ name=creative.name,
+ text=_inject_invite_link(creative.text, invite_link),
+ media_items=[
+ dto.CreativeMediaItem(
+ media_type=item.media_type,
+ media_file_id=item.media_file_id,
+ position=item.position,
+ s3_url=self.s3.public_url(item.media_s3_key) if item.media_s3_key else None,
+ )
+ for item in media_items
+ ],
+ buttons=_build_buttons(creative.buttons, invite_link),
+ )
+
+ user = await self.database.get_user(user_id=user_id)
+ if not user or not user.telegram_user:
+ raise domain.UserNotFound(user_id)
+
+ chat_id = user.telegram_user.telegram_id
+
+ # Build and send informational message
+ info_message = _build_info_message(placement, project)
+ info_message_id = await self.telegram_bot.send_message(info_message, chat_id, parse_mode='HTML')
+
+ # Send creative as reply to informational message
+ keyboard_buttons = _build_keyboard_buttons(preview.buttons)
+ if preview.media_items:
+ if len(preview.media_items) == 1:
+ media_item = preview.media_items[0]
+ await self.telegram_bot.send_media_with_inline_keyboard(
+ text=preview.text,
+ chat_id=chat_id,
+ media_type=media_item.media_type,
+ media_file_id=media_item.media_file_id,
+ buttons=keyboard_buttons,
+ parse_mode='HTML',
+ reply_to_message_id=info_message_id,
+ )
+ else:
+ await self.telegram_bot.send_media_group(
+ chat_id=chat_id,
+ media_items=preview.media_items,
+ caption=preview.text,
+ parse_mode='HTML',
+ reply_to_message_id=info_message_id,
+ )
+ elif keyboard_buttons:
+ await self.telegram_bot.send_message_with_inline_keyboard(
+ preview.text,
+ chat_id,
+ keyboard_buttons,
+ parse_mode='HTML',
+ disable_preview=True,
+ reply_to_message_id=info_message_id,
+ )
+ else:
+ await self.telegram_bot.send_message(
+ preview.text, chat_id, parse_mode='HTML', disable_preview=True, reply_to_message_id=info_message_id
+ )
+
+ return preview
diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py
new file mode 100644
index 0000000..9dc3cdd
--- /dev/null
+++ b/src/usecase/purchase/create_placements.py
@@ -0,0 +1,293 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+# Base62 alphabet for encoding
+BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+
+
+def uuid_to_short_id(placement_uuid: uuid.UUID) -> str:
+ """
+ Конвертирует UUID в короткий ID (8 символов base62).
+
+ Берёт первые 8 байт UUID (64 бита) и кодирует в base62.
+ """
+ # Берём первые 8 байт UUID
+ uuid_bytes = placement_uuid.bytes[:8]
+
+ # Конвертируем байты в integer
+ num = int.from_bytes(uuid_bytes, byteorder='big')
+
+ # Кодируем в base62
+ if num == 0:
+ return BASE62_ALPHABET[0]
+
+ result = []
+ base = len(BASE62_ALPHABET)
+ while num > 0:
+ num, remainder = divmod(num, base)
+ result.append(BASE62_ALPHABET[remainder])
+
+ # Разворачиваем и ограничиваем до 8 символов
+ short_id = ''.join(reversed(result))[:8]
+ return short_id.lower()
+
+
+def generate_invite_link_name(short_id: str, project_channel_title: str | None) -> str:
+ """
+ Генерирует название для invite link с умным сокращением.
+
+ Формат: "{short_id} {channel_name}"
+ Приоритет: short_id всегда полный, channel_name сокращается.
+
+ Args:
+ short_id: Короткий ID размещения
+ project_channel_title: Название канала проекта
+
+ Returns:
+ Строка до 32 символов
+ """
+ channel_name = project_channel_title or 'Unknown'
+
+ # Базовый формат: "{short_id} {name}"
+ base_name = f'{short_id} {channel_name}'
+
+ # Если помещается, возвращаем как есть
+ if len(base_name) <= 32:
+ return base_name
+
+ # Считаем доступное место для канала
+ # Формат: "{short_id} " занимает len(short_id) + 1 символов
+ prefix_len = len(short_id) + 1 # например: "a3b9x2m " = 8 символов
+ max_channel_len = 32 - prefix_len
+
+ # Если места слишком мало, возвращаем только short_id
+ if max_channel_len < 3:
+ return short_id
+
+ # Сокращаем название канала (с многоточием если нужно)
+ truncated_channel = channel_name[: max_channel_len - 3]
+ if len(truncated_channel) < len(channel_name):
+ truncated_channel = truncated_channel + '...'
+
+ return f'{short_id} {truncated_channel}'
+
+
+def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
+ if cost_type is None or cost_value is None:
+ return None
+ return dto.CostInfo(type=cost_type, value=cost_value)
+
+
+def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetails | None:
+ details = dto.PlacementDetails(
+ placement_at=placement.placement_at,
+ payment_at=placement.payment_at,
+ cost=_build_cost_info(placement.cost_type, placement.cost_value),
+ cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain),
+ placement_type=placement.placement_type,
+ format=placement.format,
+ top_time_minutes=placement.top_time_minutes,
+ feed_time_minutes=placement.feed_time_minutes,
+ comment=placement.comment,
+ )
+ if details.model_dump(exclude_none=True):
+ return details
+ return None
+
+
+def _sync_format_fields(
+ format_str: str | None,
+ top_time_minutes: int | None,
+ feed_time_minutes: int | None,
+) -> tuple[str | None, int | None, int | None]:
+ """Синхронизирует format string и числовые поля.
+
+ Если есть числовые поля — вычисляем display string.
+ Если только format string — парсим числовые поля.
+ """
+ if top_time_minutes is not None or feed_time_minutes is not None:
+ display = domain.format_display_string(top_time_minutes, feed_time_minutes)
+ if display:
+ format_str = display
+ elif format_str:
+ parsed_top, parsed_feed = domain.parse_format_string(format_str)
+ if parsed_top is not None:
+ top_time_minutes = parsed_top
+ if parsed_feed is not None:
+ feed_time_minutes = parsed_feed
+
+ return format_str, top_time_minutes, feed_time_minutes
+
+
+def _build_placement_output(
+ placement: domain.Placement,
+ project: domain.Project | None = None,
+ creative_name: str | None = None,
+) -> dto.PlacementOutput:
+ channel = placement.channel
+ if channel is None:
+ log.error('Placement %s has no channel prefetched', placement.id)
+ raise ValueError(f'Placement {placement.id} has no channel')
+
+ # Generate short_id from UUID
+ short_id = uuid_to_short_id(placement.id)
+
+ # Build project output if project is provided
+ project_output: dto.ProjectOutput | None = None
+ if project is not None:
+ project_channel = project.channel
+ if project_channel is None:
+ log.warning('Project %s has no channel prefetched, skipping project output', project.id)
+ else:
+ project_output = dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
+ log.debug('Built project output for project %s', project.id)
+
+ return dto.PlacementOutput(
+ id=placement.id,
+ status=placement.status,
+ creative_id=placement.creative_id,
+ creative_name=creative_name,
+ comment=placement.comment,
+ invite_link=placement.invite_link,
+ invite_link_created_at=placement.invite_link_created_at,
+ invite_link_type=placement.invite_link_type,
+ channel=dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ ),
+ project=project_output,
+ short_id=short_id,
+ details=_build_placement_details(placement),
+ created_at=placement.created_at,
+ )
+
+
+async def create_placements(
+ self: 'Usecase',
+ project_id: uuid.UUID,
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID,
+ input: dto.CreatePlacementsInput,
+) -> dto.GetPlacementsOutput:
+ """Create multiple placements for different channels (bulk creation, бывший create_purchase)"""
+ context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
+
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
+
+ if project.channel.telegram_id is None:
+ raise domain.ChannelNotFound(project.channel.id)
+
+ placements: list[domain.Placement] = []
+ creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
+
+ for channel_input in input.channels:
+ channel = await self.database.get_channel(channel_id=channel_input.channel_id)
+ if not channel:
+ raise domain.ChannelNotFound(channel_input.channel_id)
+
+ channel_details = channel_input.details
+ creative_id = (
+ channel_details.creative_id if channel_details and channel_details.creative_id else None
+ ) or input.creative_id
+
+ # Если creative_id передан, ищем и валидируем креатив
+ creative: domain.Creative | None = None
+ if creative_id:
+ creative = creatives_by_id.get(creative_id)
+ if not creative:
+ creative = await self.database.get_creative(workspace_id, creative_id)
+ if not creative or creative.project_id != project.id:
+ raise domain.CreativeNotFound(creative_id)
+ creatives_by_id[creative_id] = creative
+
+ raw_format = channel_details.format if channel_details else None
+ raw_top = channel_details.top_time_minutes if channel_details else None
+ raw_feed = channel_details.feed_time_minutes if channel_details else None
+ synced_format, synced_top, synced_feed = _sync_format_fields(raw_format, raw_top, raw_feed)
+
+ placement = domain.Placement(
+ project_id=project.id,
+ creative_id=creative.id if creative else None,
+ channel_id=channel.id,
+ invite_link=None,
+ invite_link_type=(
+ channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None
+ )
+ or project.purchase_invite_type_default,
+ status=channel_input.status or domain.PlacementStatus.NO_STATUS,
+ comment=channel_input.comment or (channel_details.comment if channel_details else None),
+ placement_at=channel_details.placement_at if channel_details else None,
+ payment_at=channel_details.payment_at if channel_details else None,
+ cost_type=channel_details.cost.type if channel_details and channel_details.cost else None,
+ cost_value=channel_details.cost.value if channel_details and channel_details.cost else None,
+ cost_before_bargain_type=(
+ channel_details.cost_before_bargain.type
+ if channel_details and channel_details.cost_before_bargain
+ else None
+ ),
+ cost_before_bargain=(
+ channel_details.cost_before_bargain.value
+ if channel_details and channel_details.cost_before_bargain
+ else None
+ ),
+ placement_type=channel_details.placement_type if channel_details else None,
+ format=synced_format,
+ top_time_minutes=synced_top,
+ feed_time_minutes=synced_feed,
+ )
+
+ await self.database.create_placement(placement)
+ placement.channel = channel
+
+ # Generate invite_link_name after placement has UUID
+ short_id = uuid_to_short_id(placement.id)
+ placement.invite_link_name = generate_invite_link_name(short_id, channel.title)
+ await self.database.update_placement(placement)
+
+ placements.append(placement)
+
+ log.info(
+ 'Created %s placements for project %s (creatives %s)',
+ len(placements),
+ project.id,
+ list(creatives_by_id.keys()),
+ )
+
+ placement_outputs = []
+ for placement in placements:
+ placement_output = _build_placement_output(placement, project=project)
+ placement_outputs.append(
+ dto.PlacementWithPostsOutput(
+ **placement_output.model_dump(),
+ placement_post=None,
+ )
+ )
+
+ return dto.GetPlacementsOutput(placements=placement_outputs)
diff --git a/src/usecase/purchase/delete_placement.py b/src/usecase/purchase/delete_placement.py
new file mode 100644
index 0000000..bdee320
--- /dev/null
+++ b/src/usecase/purchase/delete_placement.py
@@ -0,0 +1,28 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None:
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE
+ )
+
+ project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
+
+ placement = await self.database.get_placement(input.workspace_id, input.placement_id)
+ if not placement or placement.project_id != project.id:
+ log.warning('User %s attempted to delete unavailable placement %s', input.user_id, input.placement_id)
+ raise domain.PlacementNotFound(input.placement_id)
+
+ await self.database.delete_placement(placement.id)
diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py
new file mode 100644
index 0000000..460f3cc
--- /dev/null
+++ b/src/usecase/purchase/get_placement.py
@@ -0,0 +1,135 @@
+import logging
+from typing import TYPE_CHECKING
+
+from tortoise import timezone
+
+from src import domain, dto
+from src.usecase.purchase.create_placements import _build_placement_output
+
+log = logging.getLogger(__name__)
+
+
+def _build_post_output(post: domain.Post) -> dto.PostOutput:
+ channel = post.channel
+ if not channel:
+ raise ValueError(f'Post {post.id} has no channel')
+
+ return dto.PostOutput(
+ id=post.id,
+ message_id=post.message_id,
+ text=post.text,
+ url=post.url,
+ deleted_from_channel_at=post.deleted_from_channel_at,
+ created_at=post.created_at,
+ updated_at=post.updated_at,
+ )
+
+
+def _build_placement_post_output(
+ placement_post: domain.PlacementPost,
+ subscriptions_count: int,
+ views_count: int | None,
+ time_on_top: int | None = None,
+) -> dto.PlacementPostOutput | None:
+ if not placement_post.post:
+ log.warning('PlacementPost %s missing post', placement_post.id)
+ return None
+
+ try:
+ post_output = _build_post_output(placement_post.post)
+ except ValueError:
+ log.warning('PlacementPost %s post missing channel data', placement_post.id)
+ return None
+
+ return dto.PlacementPostOutput(
+ id=placement_post.id,
+ status=placement_post.status,
+ subscriptions_count=subscriptions_count,
+ views_count=views_count,
+ created_at=placement_post.created_at,
+ time_on_top=time_on_top,
+ post=post_output,
+ )
+
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementWithPostsOutput:
+ """Get single placement by ID (user-managed)"""
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
+ )
+
+ project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
+
+ placement = await self.database.get_placement(input.workspace_id, input.placement_id)
+ if not placement or placement.project_id != project.id:
+ raise domain.PlacementNotFound(input.placement_id)
+
+ # Prefetch channel for output building
+ if placement.channel is None:
+ channel = await self.database.get_channel(channel_id=placement.channel_id)
+ placement.channel = channel
+
+ # Prefetch project channel for output building
+ if project.channel is None:
+ project_channel = await self.database.get_channel(channel_id=project.channel_id)
+ project.channel = project_channel
+
+ # Get creative name if exists
+ creative_name = None
+ if placement.creative_id:
+ creative = await self.database.get_creative(input.workspace_id, placement.creative_id)
+ if creative:
+ creative_name = creative.name
+
+ placement_posts = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ placement_id=placement.id,
+ include_archived=True,
+ )
+ # Подсчёт подписок по placement_id (один Placement = одна ссылка = один счётчик подписок)
+ subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)
+ post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ placement_post_output = None
+ if placement_posts:
+ if len(placement_posts) > 1:
+ log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts))
+ for placement_post in placement_posts:
+ views_count = None
+ time_on_top = None
+ if placement_post.post:
+ views_count = views_map.get(placement_post.post.id, (None,))[0]
+ # Calculate time_on_top
+ post = placement_post.post
+ next_post = await self.database.get_next_post_after(post.channel_id, post.message_id)
+ if next_post and next_post.published_at and post.published_at:
+ time_on_top = int((next_post.published_at - post.published_at).total_seconds())
+ elif post.published_at:
+ time_on_top = int((timezone.now() - post.published_at).total_seconds())
+ placement_post_output = _build_placement_post_output(
+ placement_post,
+ subscriptions_count,
+ views_count,
+ time_on_top,
+ )
+ if placement_post_output is not None:
+ break
+
+ placement_output = _build_placement_output(
+ placement,
+ project=project,
+ creative_name=creative_name,
+ )
+ return dto.PlacementWithPostsOutput(
+ **placement_output.model_dump(),
+ placement_post=placement_post_output,
+ )
diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py
new file mode 100644
index 0000000..c3d36bd
--- /dev/null
+++ b/src/usecase/purchase/get_placements.py
@@ -0,0 +1,113 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from tortoise import timezone
+
+from src import domain, dto
+
+from .create_placements import _build_placement_output
+from .get_placement import _build_placement_post_output
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
+ """Get all placements for a project (formerly get_purchases)"""
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
+ )
+
+ project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
+ if not project:
+ raise domain.ProjectNotFound(input.project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
+
+ # Check analytics permissions to determine if subscription data should be hidden
+ hide_subscriptions = context.should_hide_subscriptions()
+
+ # Prefetch project channel for output building
+ if project.channel is None:
+ project_channel = await self.database.get_channel(channel_id=project.channel_id)
+ project.channel = project_channel
+
+ placements = await self.database.get_project_placements(input.workspace_id, project.id)
+ log.debug('Fetched %s placements for project %s', len(placements), project.id)
+
+ placement_ids = [placement.id for placement in placements]
+ placement_posts = await self.database.get_placement_posts_by_placement_ids(
+ input.workspace_id,
+ placement_ids,
+ include_archived=True,
+ )
+ # Подсчёт подписок по placement_id (один Placement = одна ссылка = один счётчик подписок)
+ subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
+ post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post]
+ views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
+
+ # Collect (channel_id, message_id) pairs for batch next post lookup
+ channel_message_pairs = [
+ (placement_post.post.channel_id, placement_post.post.message_id)
+ for placement_post in placement_posts
+ if placement_post.post
+ ]
+ next_posts_map = await self.database.get_next_posts_after_batch(channel_message_pairs)
+
+ # Calculate time_on_top for each placement_post
+ time_on_top_map: dict[uuid.UUID, int] = {}
+ now = timezone.now()
+ for placement_post in placement_posts:
+ post = placement_post.post
+ if not post or not post.published_at:
+ continue
+ published_at = post.published_at
+ key = (post.channel_id, post.message_id)
+ next_post = next_posts_map.get(key)
+ if next_post and next_post.published_at:
+ time_on_top_map[placement_post.id] = int((next_post.published_at - published_at).total_seconds())
+ else:
+ time_on_top_map[placement_post.id] = int((now - published_at).total_seconds())
+
+ placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {}
+ for placement_post in placement_posts:
+ views_count = None
+ time_on_top = time_on_top_map.get(placement_post.id)
+ if placement_post.post:
+ views_count = views_map.get(placement_post.post.id, (None,))[0]
+ # Hide subscriptions if user doesn't have analytics_read permission
+ subscriptions_count = 0 if hide_subscriptions else subscriptions_counts.get(placement_post.placement_id, 0)
+ placement_post_output = _build_placement_post_output(
+ placement_post,
+ subscriptions_count,
+ views_count,
+ time_on_top,
+ )
+ if placement_post_output is None:
+ continue
+ placement_posts_by_placement_id.setdefault(placement_post.placement_id, []).append(placement_post_output)
+
+ placement_outputs = []
+ for placement in placements:
+ placement_output = _build_placement_output(placement, project=project)
+ placement_post_output = None
+ placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, [])
+ if placement_posts_for_placement:
+ if len(placement_posts_for_placement) > 1:
+ log.warning(
+ 'Placement %s has %s placement_posts, returning latest',
+ placement.id,
+ len(placement_posts_for_placement),
+ )
+ placement_post_output = placement_posts_for_placement[0]
+ placement_outputs.append(
+ dto.PlacementWithPostsOutput(
+ **placement_output.model_dump(),
+ placement_post=placement_post_output,
+ )
+ )
+
+ return dto.GetPlacementsOutput(placements=placement_outputs)
diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py
new file mode 100644
index 0000000..b6079dc
--- /dev/null
+++ b/src/usecase/purchase/update_placement.py
@@ -0,0 +1,103 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def update_placement(
+ self: 'Usecase',
+ placement_id: uuid.UUID,
+ input: dto.UpdatePlacementInput,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> dto.PlacementWithPostsOutput:
+ context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
+
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
+
+ placement = await self.database.get_placement(workspace_id, placement_id)
+ if not placement or placement.project_id != project.id:
+ raise domain.PlacementNotFound(placement_id)
+
+ fields_set = input.model_fields_set
+
+ if 'creative_id' in fields_set:
+ if input.creative_id is None:
+ placement_posts_count = await self.database.count_placement_posts_by_placement(placement.id)
+ if placement_posts_count > 0:
+ log.warning('Placement %s has placement_posts and cannot remove creative', placement.id)
+ raise domain.PlacementHasPosts(placement.id)
+ placement.creative_id = None
+ else:
+ creative = await self.database.get_creative(workspace_id, input.creative_id)
+ if not creative or creative.project_id != project.id:
+ raise domain.CreativeNotFound(input.creative_id)
+ placement.creative_id = creative.id
+
+ if 'status' in fields_set and input.status is not None:
+ placement.status = input.status
+ if 'comment' in fields_set:
+ placement.comment = input.comment
+ if 'placement_at' in fields_set:
+ placement.placement_at = input.placement_at
+ if 'payment_at' in fields_set:
+ placement.payment_at = input.payment_at
+ if 'cost' in fields_set:
+ if input.cost is None:
+ placement.cost_type = None
+ placement.cost_value = None
+ else:
+ placement.cost_type = input.cost.type
+ placement.cost_value = input.cost.value
+ if 'cost_before_bargain' in fields_set:
+ if input.cost_before_bargain is None:
+ placement.cost_before_bargain_type = None
+ placement.cost_before_bargain = None
+ else:
+ placement.cost_before_bargain_type = input.cost_before_bargain.type
+ placement.cost_before_bargain = input.cost_before_bargain.value
+ if 'placement_type' in fields_set:
+ placement.placement_type = input.placement_type
+
+ # Синхронизация format / top_time_minutes / feed_time_minutes
+ format_changed = 'format' in fields_set
+ top_changed = 'top_time_minutes' in fields_set
+ feed_changed = 'feed_time_minutes' in fields_set
+
+ if format_changed:
+ placement.format = input.format
+ if top_changed:
+ placement.top_time_minutes = input.top_time_minutes
+ if feed_changed:
+ placement.feed_time_minutes = input.feed_time_minutes
+
+ if format_changed or top_changed or feed_changed:
+ from .create_placements import _sync_format_fields
+
+ synced_format, synced_top, synced_feed = _sync_format_fields(
+ placement.format, placement.top_time_minutes, placement.feed_time_minutes
+ )
+ placement.format = synced_format
+ placement.top_time_minutes = synced_top
+ placement.feed_time_minutes = synced_feed
+
+ await self.database.update_placement(placement)
+
+ placement_input = dto.GetPlacementInput(
+ user_id=user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ placement_id=placement.id,
+ )
+ return await self.get_placement_user(input=placement_input)
diff --git a/src/usecase/purchase/update_placement_post.py b/src/usecase/purchase/update_placement_post.py
new file mode 100644
index 0000000..3ca457d
--- /dev/null
+++ b/src/usecase/purchase/update_placement_post.py
@@ -0,0 +1,72 @@
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+# Статусы Placement, при которых статус PlacementPost не может быть изменён
+LOCKED_PLACEMENT_STATUSES = {
+ domain.PlacementStatus.NO_STATUS,
+ domain.PlacementStatus.WRITE,
+ domain.PlacementStatus.CANCELED,
+ domain.PlacementStatus.PRICE_NOT_OK,
+ domain.PlacementStatus.NOT_RELEVANT,
+ domain.PlacementStatus.NO_RESPONSE,
+}
+
+
+async def update_placement_post(
+ self: 'Usecase',
+ placement_id: uuid.UUID,
+ placement_post_id: uuid.UUID,
+ input: dto.UpdatePlacementPostInput,
+ workspace_id: uuid.UUID,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> dto.PlacementWithPostsOutput:
+ context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
+
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
+
+ placement = await self.database.get_placement(workspace_id, placement_id)
+ if not placement or placement.project_id != project.id:
+ raise domain.PlacementNotFound(placement_id)
+
+ placement_post = await self.database.get_placement_post(workspace_id, placement_post_id)
+ if not placement_post or placement_post.placement_id != placement.id:
+ raise domain.PlacementPostNotFound(placement_post_id)
+
+ fields_set = input.model_fields_set
+
+ if 'status' in fields_set and input.status is not None:
+ # Проверяем, можно ли менять статус поста при текущем статусе Placement
+ if placement.status in LOCKED_PLACEMENT_STATUSES:
+ log.warning(
+ 'Cannot change PlacementPost status when Placement status is %s',
+ placement.status,
+ )
+ # Статус поста должен оставаться "Без статуса"
+ if input.status != domain.PlacementPostStatus.NO_STATUS:
+ raise ValueError(
+ f'Статус поста недоступен для изменения при статусе взаимодействия "{placement.status}"'
+ )
+ placement_post.status = input.status
+
+ await placement_post.save()
+
+ placement_input = dto.GetPlacementInput(
+ user_id=user_id,
+ workspace_id=workspace_id,
+ project_id=project_id,
+ placement_id=placement.id,
+ )
+ return await self.get_placement_user(input=placement_input)
diff --git a/src/usecase/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py
new file mode 100644
index 0000000..7b2430b
--- /dev/null
+++ b/src/usecase/subscription/handle_subscription.py
@@ -0,0 +1,93 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def handle_subscription(
+ self: 'Usecase',
+ user_telegram_id: int,
+ username: str | None,
+ invite_link: str,
+ first_name: str | None = None,
+ last_name: str | None = None,
+) -> None:
+ placement_post = await self.database.get_placement_post_by_invite_link(invite_link)
+ if not placement_post or not placement_post.placement:
+ log.warning('PlacementPost not found for invite_link: %s', invite_link)
+ return
+
+ placement = placement_post.placement
+
+ subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
+ if not subscriber:
+ subscriber = domain.TelegramUser(
+ telegram_id=user_telegram_id,
+ username=username,
+ first_name=first_name,
+ last_name=last_name,
+ )
+ await self.database.create_telegram_user(subscriber)
+ else:
+ subscriber.username = username or subscriber.username
+ subscriber.first_name = first_name or subscriber.first_name
+ subscriber.last_name = last_name or subscriber.last_name
+ await self.database.update_telegram_user(subscriber)
+
+ active_subscription = await self.database.get_active_subscription_by_subscriber_and_project(
+ subscriber.id, placement.project_id
+ )
+
+ if active_subscription:
+ # Пользователь уже подписан на канал через другой placement
+ # Это не должно случиться (Telegram не даст подписаться дважды),
+ # но если случилось - логируем и игнорируем
+ log.warning(
+ 'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, '
+ 'ignoring new subscription attempt via placement %s',
+ subscriber.id,
+ user_telegram_id,
+ placement.project_id,
+ active_subscription.placement_id,
+ placement.id,
+ )
+ return
+
+ # Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации)
+ # Note: Subscription now links to placement directly, not placement_post
+ existing_sub = await self.database.get_subscription_by_subscriber_and_placement(
+ subscriber.id, placement.id
+ )
+
+ if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
+ existing_sub.status = domain.SubscriptionStatus.ACTIVE
+ existing_sub.unsubscribed_at = None
+ await self.database.update_subscription(existing_sub)
+
+ log.info(
+ 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s',
+ subscriber.id,
+ user_telegram_id,
+ placement.id,
+ )
+ return
+
+ subscription = domain.Subscription(
+ placement_id=placement.id,
+ telegram_user_id=subscriber.id,
+ invite_link=invite_link,
+ )
+ await self.database.create_subscription(subscription)
+
+ log.info(
+ 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)',
+ subscriber.id,
+ user_telegram_id,
+ placement.id,
+ invite_link,
+ )
diff --git a/src/usecase/subscription/handle_unsubscription.py b/src/usecase/subscription/handle_unsubscription.py
new file mode 100644
index 0000000..0f25a46
--- /dev/null
+++ b/src/usecase/subscription/handle_unsubscription.py
@@ -0,0 +1,46 @@
+import logging
+from typing import TYPE_CHECKING
+
+from tortoise import timezone
+
+from src import domain
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None:
+ subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
+ if not subscriber:
+ log.warning('Subscriber not found for telegram_id: %s', user_telegram_id)
+ return
+
+ project = await self.database.get_project_by_channel_telegram(channel_telegram_id)
+ if not project:
+ log.warning('Project not found for channel telegram_id: %s', channel_telegram_id)
+ return
+
+ subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_project(subscriber.id, project.id)
+
+ if not subscriptions:
+ log.info(
+ 'No active subscriptions found for subscriber %s (telegram_id: %s) in channel %s',
+ subscriber.id,
+ user_telegram_id,
+ channel_telegram_id,
+ )
+ return
+
+ for subscription in subscriptions:
+ subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED
+ subscription.unsubscribed_at = timezone.now()
+ await self.database.update_subscription(subscription)
+
+ log.info(
+ 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s',
+ subscriber.id,
+ user_telegram_id,
+ subscription.placement_id,
+ )
diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py
new file mode 100644
index 0000000..c78b31c
--- /dev/null
+++ b/src/usecase/views/get_views_history.py
@@ -0,0 +1,59 @@
+import logging
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> list[dto.PostViewsHistoryOutput]:
+ context = await self.ensure_workspace_permission(
+ input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
+ )
+
+ placement = await self.database.get_placement(input.workspace_id, input.placement_id)
+ if not placement:
+ log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
+ raise domain.PlacementNotFound(input.placement_id)
+
+ context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
+
+ # Получаем PlacementPost для этого Placement (для получения связанного Post)
+ placement_posts = await self.database.get_workspace_placement_posts(
+ input.workspace_id,
+ placement_id=placement.id,
+ include_archived=True,
+ )
+
+ if not placement_posts:
+ return []
+
+ # Берём первый PlacementPost с постом
+ placement_post = None
+ for pp in placement_posts:
+ if pp.post:
+ placement_post = pp
+ break
+
+ if not placement_post or not placement_post.post:
+ return []
+
+ histories = await self.database.get_views_history(
+ placement_post.post.id,
+ from_date=input.from_date,
+ to_date=input.to_date,
+ )
+
+ return [
+ dto.PostViewsHistoryOutput(
+ id=history.id,
+ post_id=history.post_id,
+ views_count=history.views_count,
+ fetched_at=history.fetched_at,
+ created_at=history.created_at,
+ )
+ for history in histories
+ ]
diff --git a/src/usecase/workspace/accept_workspace_invite.py b/src/usecase/workspace/accept_workspace_invite.py
new file mode 100644
index 0000000..8f29475
--- /dev/null
+++ b/src/usecase/workspace/accept_workspace_invite.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def accept_workspace_invite(
+ self: Usecase,
+ invite_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> dto.WorkspaceInviteOutput:
+ invite = await self.database.get_workspace_invite(invite_id)
+ if not invite or invite.user_id != user_id:
+ raise domain.WorkspaceInviteNotFound()
+
+ if invite.status != domain.WorkspaceInviteStatus.PENDING:
+ raise domain.WorkspaceInviteAlreadyProcessed()
+
+ async with self.database.transaction():
+ invite.status = domain.WorkspaceInviteStatus.ACCEPTED
+ await self.database.update_workspace_invite(invite)
+
+ membership = await self.database.get_workspace_membership(invite.workspace_id, user_id)
+ if membership:
+ if membership.status != domain.WorkspaceUserStatus.ACTIVE:
+ membership.status = domain.WorkspaceUserStatus.ACTIVE
+ await self.database.update_workspace_user(membership)
+ else:
+ await self.database.add_user_to_workspace(invite.workspace_id, user_id)
+
+ return dto.WorkspaceInviteOutput.from_domain(invite)
diff --git a/src/usecase/workspace/create_workspace.py b/src/usecase/workspace/create_workspace.py
new file mode 100644
index 0000000..7974571
--- /dev/null
+++ b/src/usecase/workspace/create_workspace.py
@@ -0,0 +1,34 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def create_workspace(
+ self: 'Usecase', user_id: uuid.UUID, input: dto.CreateWorkspaceInput
+) -> dto.CreateWorkspaceOutput:
+ user = await self.database.get_user(user_id=user_id)
+ if not user:
+ raise domain.UserNotFound(user_id)
+
+ workspace = domain.Workspace(
+ name=input.name,
+ )
+
+ async with self.database.transaction():
+ await self.database.create_workspace(workspace)
+ membership = await self.database.add_user_to_workspace(workspace.id, user_id)
+ await self.database.set_workspace_user_permissions(
+ membership.id,
+ global_permissions={domain.PermissionKey.ADMIN_FULL},
+ scoped_permissions=[],
+ )
+
+ return dto.CreateWorkspaceOutput(
+ id=workspace.id,
+ name=workspace.name,
+ avatar_url=self.s3.public_url(workspace.avatar_s3_key) if workspace.avatar_s3_key else None,
+ )
diff --git a/src/usecase/workspace/create_workspace_invite.py b/src/usecase/workspace/create_workspace_invite.py
new file mode 100644
index 0000000..8ef0855
--- /dev/null
+++ b/src/usecase/workspace/create_workspace_invite.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING
+
+from aiogram.types import InlineKeyboardButton
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+INVITE_ACCEPT_CALLBACK_PREFIX = 'workspace_invite_accept'
+
+
+async def create_workspace_invite(
+ self: Usecase,
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID,
+ input: dto.CreateWorkspaceInviteInput,
+) -> dto.WorkspaceInviteOutput:
+ context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ username = input.username
+ normalized_username = username.lower()
+
+ invited_user = await self.database.get_user_by_username(normalized_username)
+ if not invited_user:
+ raise domain.UserByUsernameNotFound(username)
+
+ existing_membership = await self.database.get_workspace_membership(workspace_id, invited_user.id)
+ if existing_membership and existing_membership.status != domain.WorkspaceUserStatus.BLOCKED:
+ raise domain.WorkspaceMemberAlreadyExists()
+
+ existing_invite = await self.database.get_workspace_invite_by_user(workspace_id, invited_user.id)
+ if existing_invite:
+ if existing_invite.status == domain.WorkspaceInviteStatus.ACCEPTED:
+ raise domain.WorkspaceMemberAlreadyExists()
+ raise domain.WorkspaceInviteAlreadyExists()
+
+ invite = domain.WorkspaceInvite(
+ workspace_id=workspace_id,
+ invited_by_id=user_id,
+ user_id=invited_user.id,
+ )
+
+ async with self.database.transaction():
+ await self.database.create_workspace_invite(invite)
+
+ invited_by_user = context.membership.user
+ if invited_by_user is None:
+ invited_by_user = await self.database.get_user(user_id=user_id)
+ if invited_by_user is None:
+ raise domain.UserNotFound(user_id)
+
+ if invited_user.telegram_user is None:
+ raise domain.UserNotFound(invited_user.id)
+ if invited_by_user.telegram_user is None:
+ raise domain.UserNotFound(invited_by_user.id)
+
+ invite.user = invited_user
+ invite.invited_by = invited_by_user
+
+ invite_message = (
+ f'✨ Вас пригласили в рабочее пространство «{context.workspace.name}».\n\n'
+ 'Нажмите кнопку ниже, чтобы принять приглашение.'
+ )
+
+ accept_button = InlineKeyboardButton(
+ text='Принять приглашение',
+ callback_data=f'{INVITE_ACCEPT_CALLBACK_PREFIX}:{invite.id}',
+ )
+ await self.telegram_bot.send_message_with_inline_keyboard(
+ invite_message,
+ chat_id=invited_user.telegram_user.telegram_id,
+ buttons=[[accept_button]],
+ )
+
+ return dto.WorkspaceInviteOutput.from_domain(invite)
diff --git a/src/usecase/workspace/delete_workspace.py b/src/usecase/workspace/delete_workspace.py
new file mode 100644
index 0000000..7a65a51
--- /dev/null
+++ b/src/usecase/workspace/delete_workspace.py
@@ -0,0 +1,13 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ await self.database.delete_workspace(workspace_id)
diff --git a/src/usecase/workspace/delete_workspace_avatar.py b/src/usecase/workspace/delete_workspace_avatar.py
new file mode 100644
index 0000000..c85a6e7
--- /dev/null
+++ b/src/usecase/workspace/delete_workspace_avatar.py
@@ -0,0 +1,42 @@
+import asyncio
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def delete_workspace_avatar(
+ self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID
+) -> dto.WorkspaceMembershipOutput:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ membership = await self.database.get_workspace_membership(workspace_id, user_id)
+ if not membership or not membership.workspace:
+ raise domain.WorkspaceNotFound(workspace_id)
+
+ workspace = membership.workspace
+ old_key = workspace.avatar_s3_key
+ if old_key:
+ workspace.avatar_s3_key = None
+ await self.database.update_workspace(workspace)
+
+ async def delete_old_avatar() -> None:
+ try:
+ await self.s3.delete(old_key)
+ log.info('Deleted workspace avatar from S3: %s', old_key)
+ except Exception as exc:
+ log.warning('Failed to delete workspace avatar from S3: %s', exc)
+
+ asyncio.create_task(delete_old_avatar())
+
+ return dto.WorkspaceMembershipOutput(
+ id=workspace.id,
+ name=workspace.name,
+ avatar_url=None,
+ )
diff --git a/src/usecase/workspace/get_workspace_invites.py b/src/usecase/workspace/get_workspace_invites.py
new file mode 100644
index 0000000..572424e
--- /dev/null
+++ b/src/usecase/workspace/get_workspace_invites.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_workspace_invites(
+ self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID
+) -> list[dto.WorkspaceInviteOutput]:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ invites = await self.database.get_workspace_invites(workspace_id)
+
+ return [dto.WorkspaceInviteOutput.from_domain(invite) for invite in invites]
diff --git a/src/usecase/workspace/get_workspace_members.py b/src/usecase/workspace/get_workspace_members.py
new file mode 100644
index 0000000..6b62846
--- /dev/null
+++ b/src/usecase/workspace/get_workspace_members.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_current_member_permissions(
+ self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID
+) -> dto.WorkspaceMemberOutput:
+ """Get current user's membership and permissions in the workspace.
+
+ This endpoint does NOT require ADMIN_FULL - any workspace member can access their own permissions.
+ """
+ membership = await self.database.get_workspace_membership(workspace_id, user_id)
+
+ if not membership:
+ raise domain.WorkspaceNotFound(workspace_id)
+
+ return dto.WorkspaceMemberOutput.from_domain(membership)
+
+
+async def get_workspace_members(
+ self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID
+) -> list[dto.WorkspaceMemberOutput]:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ members = await self.database.get_workspace_members(workspace_id)
+
+ return [dto.WorkspaceMemberOutput.from_domain(member) for member in members]
diff --git a/src/usecase/workspace/get_workspaces.py b/src/usecase/workspace/get_workspaces.py
new file mode 100644
index 0000000..89195f8
--- /dev/null
+++ b/src/usecase/workspace/get_workspaces.py
@@ -0,0 +1,26 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from src import dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def get_workspaces(self: 'Usecase', user_id: uuid.UUID) -> list[dto.WorkspaceMembershipOutput]:
+ memberships = await self.database.get_user_workspaces(user_id)
+
+ return [
+ dto.WorkspaceMembershipOutput(
+ id=membership.workspace_id,
+ name=membership.workspace.name,
+ avatar_url=_build_avatar_url(self, membership.workspace.avatar_s3_key),
+ )
+ for membership in memberships
+ ]
+
+
+def _build_avatar_url(self: 'Usecase', avatar_key: str | None) -> str | None:
+ if not avatar_key:
+ return None
+ return self.s3.public_url(avatar_key)
diff --git a/src/usecase/workspace/tg_accept_workspace_invite.py b/src/usecase/workspace/tg_accept_workspace_invite.py
new file mode 100644
index 0000000..7c95f41
--- /dev/null
+++ b/src/usecase/workspace/tg_accept_workspace_invite.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain
+from src.usecase.workspace.create_workspace_invite import INVITE_ACCEPT_CALLBACK_PREFIX
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def tg_accept_workspace_invite(
+ self: Usecase,
+ telegram_id: int,
+ chat_id: int,
+ callback_data: str,
+ message_id: int,
+) -> None:
+ user = await self.database.get_user(telegram_id=telegram_id)
+ if not user:
+ await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id)
+ return
+ if user.telegram_user is None:
+ await self.telegram_bot.send_message('❌ Ваш Telegram профиль не найден. Авторизуйтесь заново.', chat_id)
+ return
+
+ parts = callback_data.split(':', 1)
+ if len(parts) != 2 or parts[0] != INVITE_ACCEPT_CALLBACK_PREFIX:
+ log.warning('Unexpected callback data for workspace invite: %s', callback_data)
+ await self.telegram_bot.send_message('❌ Приглашение не найдено.', chat_id)
+ return
+
+ try:
+ invite_id = uuid.UUID(parts[1])
+ except ValueError:
+ log.warning('Invalid workspace invite id: %s', parts[1])
+ await self.telegram_bot.send_message('❌ Приглашение больше недоступно.', chat_id)
+ return
+
+ invite = await self.database.get_workspace_invite(invite_id)
+ if not invite or invite.user_id != user.id:
+ await self.telegram_bot.send_message('❌ Приглашение не найдено или вы не можете его принять.', chat_id)
+ if message_id:
+ await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id)
+ return
+
+ if invite.status != domain.WorkspaceInviteStatus.PENDING:
+ await self.telegram_bot.send_message('⚠️ Это приглашение уже обработано.', chat_id)
+ if message_id:
+ await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id)
+ return
+
+ async with self.database.transaction():
+ invite.status = domain.WorkspaceInviteStatus.ACCEPTED
+ await self.database.update_workspace_invite(invite)
+
+ membership = await self.database.get_workspace_membership(invite.workspace_id, user.id)
+ if membership:
+ if membership.status != domain.WorkspaceUserStatus.ACTIVE:
+ membership.status = domain.WorkspaceUserStatus.ACTIVE
+ await self.database.update_workspace_user(membership)
+ else:
+ await self.database.add_user_to_workspace(invite.workspace_id, user.id)
+
+ workspace_name = invite.workspace.name if invite.workspace else 'рабочее пространство'
+ await self.telegram_bot.send_message(
+ f'✅ Вы присоединились к рабочему пространству «{workspace_name}».\n\n'
+ 'Администратор сможет выдать вам необходимые права в веб-интерфейсе.',
+ user.telegram_user.telegram_id,
+ )
+
+ if message_id:
+ await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id)
diff --git a/src/usecase/workspace/update_workspace.py b/src/usecase/workspace/update_workspace.py
new file mode 100644
index 0000000..e2bdafd
--- /dev/null
+++ b/src/usecase/workspace/update_workspace.py
@@ -0,0 +1,29 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def update_workspace(
+ self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput
+) -> dto.WorkspaceMembershipOutput:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ membership = await self.database.get_workspace_membership(workspace_id, user_id)
+ if not membership:
+ raise domain.WorkspaceNotFound(workspace_id)
+
+ workspace = membership.workspace
+
+ if input.name is not None:
+ workspace.name = input.name
+ await self.database.update_workspace(workspace)
+
+ return dto.WorkspaceMembershipOutput(
+ id=workspace.id,
+ name=workspace.name,
+ avatar_url=self.s3.public_url(workspace.avatar_s3_key) if workspace.avatar_s3_key else None,
+ )
diff --git a/src/usecase/workspace/update_workspace_avatar.py b/src/usecase/workspace/update_workspace_avatar.py
new file mode 100644
index 0000000..3289800
--- /dev/null
+++ b/src/usecase/workspace/update_workspace_avatar.py
@@ -0,0 +1,53 @@
+import asyncio
+import logging
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+log = logging.getLogger(__name__)
+
+
+async def update_workspace_avatar(
+ self: 'Usecase',
+ workspace_id: uuid.UUID,
+ user_id: uuid.UUID,
+ avatar_data: bytes,
+ content_type: str | None,
+) -> dto.WorkspaceMembershipOutput:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ membership = await self.database.get_workspace_membership(workspace_id, user_id)
+ if not membership or not membership.workspace:
+ raise domain.WorkspaceNotFound(workspace_id)
+
+ domain.validate_workspace_avatar_size(avatar_data)
+
+ file_id = uuid.uuid4()
+ avatar_key = f'workspaces/{workspace_id}/avatars/{file_id}'
+ await self.s3.upload(avatar_key, avatar_data, content_type or 'application/octet-stream')
+
+ workspace = membership.workspace
+ old_key = workspace.avatar_s3_key
+ workspace.avatar_s3_key = avatar_key
+ await self.database.update_workspace(workspace)
+
+ if old_key:
+
+ async def delete_old_avatar() -> None:
+ try:
+ await self.s3.delete(old_key)
+ log.info('Deleted old workspace avatar from S3: %s', old_key)
+ except Exception as exc:
+ log.warning('Failed to delete old workspace avatar from S3: %s', exc)
+
+ asyncio.create_task(delete_old_avatar())
+
+ return dto.WorkspaceMembershipOutput(
+ id=workspace.id,
+ name=workspace.name,
+ avatar_url=self.s3.public_url(avatar_key),
+ )
diff --git a/src/usecase/workspace/update_workspace_member_permissions.py b/src/usecase/workspace/update_workspace_member_permissions.py
new file mode 100644
index 0000000..596b90c
--- /dev/null
+++ b/src/usecase/workspace/update_workspace_member_permissions.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import uuid
+from typing import TYPE_CHECKING
+
+from src import domain, dto
+
+if TYPE_CHECKING:
+ from .. import Usecase
+
+
+async def update_workspace_member_permissions(
+ self: Usecase,
+ workspace_id: uuid.UUID,
+ workspace_user_id: uuid.UUID,
+ user_id: uuid.UUID,
+ input: dto.UpdateWorkspaceMemberPermissionsInput,
+) -> dto.WorkspaceMemberOutput:
+ await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
+
+ member = await self.database.get_workspace_member(workspace_user_id)
+ if not member or member.workspace_id != workspace_id:
+ raise domain.WorkspaceAccessDenied(workspace_id)
+
+ global_permissions: set[domain.PermissionKey] = set()
+ scoped_assignments: set[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]] = set()
+
+ for permission in input.permissions:
+ if permission.scopes:
+ for scope in permission.scopes:
+ scoped_assignments.add((permission.key, scope.type, scope.id))
+ else:
+ global_permissions.add(permission.key)
+
+ await self.database.set_workspace_user_permissions(
+ member.id,
+ global_permissions=global_permissions,
+ scoped_permissions=list(scoped_assignments),
+ )
+
+ updated_member = await self.database.get_workspace_member(member.id)
+ if not updated_member:
+ raise domain.WorkspaceAccessDenied(workspace_id)
+
+ return dto.WorkspaceMemberOutput.from_domain(updated_member)
diff --git a/tests/test_format_parsing.py b/tests/test_format_parsing.py
new file mode 100644
index 0000000..17e2cf2
--- /dev/null
+++ b/tests/test_format_parsing.py
@@ -0,0 +1,162 @@
+import pytest
+
+from src.domain.placement import format_display_string, parse_format_duration, parse_format_string
+
+
+class TestParseFormatString:
+ """Тесты для parse_format_string — парсинг строки формата в (top_minutes, feed_minutes)."""
+
+ def test_standard_format_1_24(self) -> None:
+ assert parse_format_string('1 / 24') == (60, 1440)
+
+ def test_standard_format_no_spaces(self) -> None:
+ assert parse_format_string('1/48') == (60, 2880)
+
+ def test_standard_format_1_72(self) -> None:
+ assert parse_format_string('1 / 72') == (60, 4320)
+
+ def test_days_format_7(self) -> None:
+ assert parse_format_string('1 / (7 дней)') == (60, 10080)
+
+ def test_days_format_30(self) -> None:
+ assert parse_format_string('1 / (30 дней)') == (60, 43200)
+
+ def test_no_deletion(self) -> None:
+ assert parse_format_string('1 / (без удаления)') == (60, 0)
+
+ def test_no_deletion_no_parens(self) -> None:
+ assert parse_format_string('1 / без удаления') == (60, 0)
+
+ def test_top_2_hours(self) -> None:
+ assert parse_format_string('2 / 24') == (120, 1440)
+
+ def test_top_2_no_space(self) -> None:
+ assert parse_format_string('2/48') == (120, 2880)
+
+ def test_top_2_72(self) -> None:
+ assert parse_format_string('2/72') == (120, 4320)
+
+ def test_top_minutes_value_30(self) -> None:
+ """Значение > 12 интерпретируется как минуты."""
+ assert parse_format_string('30 / 24') == (30, 1440)
+
+ def test_none_input(self) -> None:
+ assert parse_format_string(None) == (None, None)
+
+ def test_empty_string(self) -> None:
+ assert parse_format_string('') == (None, None)
+
+ def test_whitespace_string(self) -> None:
+ assert parse_format_string(' ') == (None, None)
+
+ def test_unparseable_string(self) -> None:
+ assert parse_format_string('пост') == (None, None)
+
+ def test_no_slash(self) -> None:
+ assert parse_format_string('24') == (None, None)
+
+ def test_days_short_form(self) -> None:
+ assert parse_format_string('1 / 7д') == (60, 10080)
+
+ def test_days_short_form_dn(self) -> None:
+ assert parse_format_string('1 / 7 дн') == (60, 10080)
+
+ def test_with_unit_suffixes(self) -> None:
+ assert parse_format_string('1ч / 24ч') == (60, 1440)
+
+ def test_with_unit_suffixes_days(self) -> None:
+ assert parse_format_string('1ч / 7д') == (60, 10080)
+
+
+class TestFormatDisplayString:
+ """Тесты для format_display_string — формирование строки из числовых значений."""
+
+ def test_hours_hours(self) -> None:
+ assert format_display_string(60, 1440) == '1ч / 24ч'
+
+ def test_minutes_hours(self) -> None:
+ assert format_display_string(30, 1440) == '30мин / 24ч'
+
+ def test_no_deletion(self) -> None:
+ assert format_display_string(60, 0) == '1ч / без удаления'
+
+ def test_days(self) -> None:
+ assert format_display_string(60, 10080) == '1ч / 7д'
+
+ def test_none_none(self) -> None:
+ assert format_display_string(None, None) is None
+
+ def test_2h_48h(self) -> None:
+ assert format_display_string(120, 2880) == '2ч / 48ч'
+
+ def test_2h_30d(self) -> None:
+ assert format_display_string(120, 43200) == '2ч / 30д'
+
+ def test_top_only(self) -> None:
+ assert format_display_string(60, None) == '1ч / ?'
+
+ def test_feed_only(self) -> None:
+ assert format_display_string(None, 1440) == '? / 24ч'
+
+ def test_36h(self) -> None:
+ assert format_display_string(60, 2160) == '1ч / 36ч'
+
+ def test_72h(self) -> None:
+ assert format_display_string(60, 4320) == '1ч / 72ч'
+
+ def test_90d(self) -> None:
+ assert format_display_string(60, 129600) == '1ч / 90д'
+
+
+class TestParseFormatDuration:
+ """Тесты обратной совместимости parse_format_duration — возвращает feed time в секундах."""
+
+ def test_1_24(self) -> None:
+ assert parse_format_duration('1 / 24') == 86400
+
+ def test_1_48(self) -> None:
+ assert parse_format_duration('1/48') == 172800
+
+ def test_1_72(self) -> None:
+ assert parse_format_duration('1 / 72') == 259200
+
+ def test_7_days(self) -> None:
+ assert parse_format_duration('1 / (7 дней)') == 604800
+
+ def test_30_days(self) -> None:
+ assert parse_format_duration('1 / (30 дней)') == 2592000
+
+ def test_no_deletion(self) -> None:
+ assert parse_format_duration('1 / (без удаления)') is None
+
+ def test_none(self) -> None:
+ assert parse_format_duration(None) is None
+
+ def test_empty(self) -> None:
+ assert parse_format_duration('') is None
+
+ def test_2_24(self) -> None:
+ assert parse_format_duration('2 / 24') == 86400
+
+
+class TestRoundtrip:
+ """Тесты round-trip: parse → format → parse."""
+
+ @pytest.mark.parametrize(
+ 'top,feed',
+ [
+ (60, 1440),
+ (60, 2880),
+ (60, 10080),
+ (60, 0),
+ (120, 1440),
+ (120, 43200),
+ (30, 1440),
+ ],
+ )
+ def test_roundtrip(self, top: int, feed: int) -> None:
+ display = format_display_string(top, feed)
+ assert display is not None
+ parsed_top, parsed_feed = parse_format_string(display)
+ assert parsed_top == top
+ assert parsed_feed == feed
diff --git a/tg_bot/Dockerfile b/tg_bot/Dockerfile
new file mode 100644
index 0000000..8e62791
--- /dev/null
+++ b/tg_bot/Dockerfile
@@ -0,0 +1,19 @@
+FROM golang:1.25-alpine AS build
+
+WORKDIR /app/tg_bot
+
+# Modules layer
+COPY tg_bot/go.mod tg_bot/go.sum ./
+COPY pkg /app/pkg
+COPY shared/echotron /app/shared/echotron
+RUN go mod download
+
+# Build layer
+COPY tg_bot /app/tg_bot
+RUN CGO_ENABLED=0 GOOS=linux go build -o /tg_bot .
+
+FROM alpine:latest AS run
+
+COPY --from=build /tg_bot /tg_bot
+
+CMD ["/tg_bot"]
diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go
new file mode 100644
index 0000000..1b0275d
--- /dev/null
+++ b/tg_bot/backend/backend_client.go
@@ -0,0 +1,821 @@
+package backend
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+var ErrNotFound = errors.New("not found")
+
+type Config struct {
+ BaseURL string `envconfig:"BACKEND__BASE_URL" default:"http_v1://localhost:8000"`
+ LoginURL string `envconfig:"LOGIN_URL" required:"true"`
+}
+
+type Client struct {
+ http *http.Client
+ baseURL string
+ loginURL string
+}
+
+func New(cfg Config) *Client {
+ return &Client{
+ http: &http.Client{
+ Timeout: 60 * time.Second,
+ },
+ baseURL: cfg.BaseURL,
+ loginURL: cfg.LoginURL,
+ }
+}
+
+func withBearer(token string) func(*http.Request) {
+ return func(r *http.Request) {
+ r.Header.Set("Authorization", "Bearer "+token)
+ }
+}
+
+func (c *Client) do(ctx context.Context, method string, path string, in any, out any, opts ...func(*http.Request)) error {
+ var body io.Reader
+
+ if in != nil {
+ b, err := json.Marshal(in)
+ if err != nil {
+ return fmt.Errorf("json.Marshal: %w", err)
+ }
+ body = bytes.NewReader(b)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/"+path, body)
+ if err != nil {
+ return fmt.Errorf("http_v1.NewRequest: %w", err)
+ }
+
+ if in != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ for _, opt := range opts {
+ opt(req)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("client.Do: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return ErrNotFound
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
+ return fmt.Errorf("request failed: %s: %s", resp.Status, b)
+ }
+
+ if out != nil {
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ return fmt.Errorf("json.Decode: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (c *Client) LoginURL(token string) string {
+ return c.loginURL + token
+}
+
+func (c *Client) CreateLoginToken(ctx context.Context, telegramID int64) (string, error) {
+ req := struct {
+ TelegramID int64 `json:"telegram_id"`
+ }{
+ TelegramID: telegramID,
+ }
+
+ var token string
+ err := c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token)
+
+ return token, err
+}
+
+func (c *Client) AttachLoginTokenMessage(ctx context.Context, token string, messageID int) error {
+ req := struct {
+ Token string `json:"token"`
+ MessageID int `json:"message_id"`
+ }{
+ Token: token,
+ MessageID: messageID,
+ }
+
+ return c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token/message", req, nil)
+}
+
+func (c *Client) GetJWTByTelegramID(
+ ctx context.Context,
+ telegramID int64,
+) (string, error) {
+ q := url.Values{}
+ q.Set("telegram_id", fmt.Sprint(telegramID))
+
+ path := "api/v1/internal/auth/jwt?" + q.Encode()
+
+ var resp struct {
+ AccessToken string `json:"access_token"`
+ }
+
+ err := c.do(ctx, http.MethodGet, path, nil, &resp)
+ return resp.AccessToken, err
+}
+
+func (c *Client) GetJWTByTelegramUser(
+ ctx context.Context,
+ telegramID int64,
+ username *string,
+ firstName *string,
+ lastName *string,
+) (string, error) {
+ q := url.Values{}
+ q.Set("telegram_id", fmt.Sprint(telegramID))
+ if username != nil && *username != "" {
+ q.Set("username", *username)
+ }
+ if firstName != nil && *firstName != "" {
+ q.Set("first_name", *firstName)
+ }
+ if lastName != nil && *lastName != "" {
+ q.Set("last_name", *lastName)
+ }
+
+ path := "api/v1/internal/auth/jwt?" + q.Encode()
+
+ var resp struct {
+ AccessToken string `json:"access_token"`
+ }
+
+ err := c.do(ctx, http.MethodGet, path, nil, &resp)
+ return resp.AccessToken, err
+}
+
+type Workspace struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type Project struct {
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Title string `json:"title"`
+ Username *string `json:"username"`
+ Status string `json:"status"`
+ PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
+}
+
+type Page struct {
+ Items []Project `json:"items"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ Size int `json:"size"`
+ Pages int `json:"pages"`
+}
+
+func (c *Client) GetWorkspaces(
+ ctx context.Context,
+ jwt string,
+) ([]Workspace, error) {
+ var resp struct {
+ Items []Workspace `json:"items"`
+ }
+
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ "api/v1/workspaces",
+ nil,
+ &resp,
+ withBearer(jwt),
+ )
+
+ return resp.Items, err
+}
+
+func (c *Client) CreateWorkspace(ctx context.Context, jwt string, name string) (Workspace, error) {
+ req := struct {
+ Name string `json:"name"`
+ }{
+ Name: name,
+ }
+
+ var workspace Workspace
+ err := c.do(ctx, http.MethodPost, "api/v1/workspaces", req, &workspace, withBearer(jwt))
+
+ return workspace, err
+}
+
+func (c *Client) GetProjects(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ page, size int,
+) (*Page, error) {
+ q := url.Values{}
+ q.Set("page", fmt.Sprint(page))
+ q.Set("size", fmt.Sprint(size))
+
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects?%s", workspaceID, q.Encode())
+
+ var resp Page
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &resp,
+ withBearer(jwt),
+ )
+
+ return &resp, err
+}
+
+func (c *Client) GetProject(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+) (*Project, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s", workspaceID, projectID)
+
+ var project Project
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &project,
+ withBearer(jwt),
+ )
+
+ return &project, err
+}
+
+func (c *Client) UpdateProjectInviteLinkType(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+ inviteLinkType string, // "public" или "approval"
+) (*Project, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID)
+
+ payload := map[string]string{
+ "purchase_invite_type_default": inviteLinkType,
+ }
+
+ var project Project
+ err := c.do(
+ ctx,
+ http.MethodPatch,
+ path,
+ payload,
+ &project,
+ withBearer(jwt),
+ )
+
+ return &project, err
+}
+
+func (c *Client) SendEvent(ctx context.Context, payload any) error {
+ return c.do(
+ ctx,
+ http.MethodPost,
+ "api/v1/internal/events",
+ payload,
+ nil,
+ )
+}
+
+func (c *Client) SearchChannels(
+ ctx context.Context,
+ jwt string,
+ username string,
+) ([]Channel, error) {
+ path := fmt.Sprintf("api/v1/channels?username=%s", username)
+
+ var response struct {
+ Items []Channel `json:"items"`
+ }
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &response,
+ withBearer(jwt),
+ )
+
+ return response.Items, err
+}
+
+func (c *Client) AttachChannelToWorkspace(
+ ctx context.Context,
+ channelID string,
+ workspaceID string,
+ userTelegramID int64,
+) (*Project, error) {
+ input := map[string]any{
+ "channel_id": channelID,
+ "workspace_id": workspaceID,
+ "user_telegram_id": userTelegramID,
+ }
+
+ var project Project
+ err := c.do(
+ ctx,
+ http.MethodPost,
+ "api/v1/internal/projects",
+ input,
+ &project,
+ )
+
+ return &project, err
+}
+
+func (c *Client) AcceptWorkspaceInvite(
+ ctx context.Context,
+ jwt string,
+ inviteID string,
+) error {
+ path := fmt.Sprintf("api/v1/invites/%s/accept", inviteID)
+
+ return c.do(
+ ctx,
+ http.MethodPost,
+ path,
+ nil,
+ nil,
+ withBearer(jwt),
+ )
+}
+
+// ============================================================================
+// Creatives
+// ============================================================================
+
+type Creative struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ MediaItems []CreativeMediaItem `json:"media_items"`
+ Buttons []CreativeButton `json:"buttons"`
+ ProjectID string `json:"project_id"`
+ ProjectChannelTitle string `json:"project_channel_title"`
+ CreatedAt string `json:"created_at"`
+ Status string `json:"status"`
+ Tag string `json:"tag"`
+ PlacementsCount int `json:"placements_count"`
+}
+
+type CreativeButton struct {
+ Text string `json:"text"`
+ URL string `json:"url"`
+}
+
+type CreativeMediaItem struct {
+ MediaType string `json:"media_type"`
+ MediaFileID string `json:"media_file_id"`
+ Position int `json:"position"`
+ S3URL *string `json:"s3_url,omitempty"`
+}
+
+type CreativeMediaInput struct {
+ MediaType string `json:"media_type"`
+ MediaFileID string `json:"media_file_id"`
+ MediaData []byte `json:"media_data,omitempty"`
+}
+
+type CreativesPage struct {
+ Items []Creative `json:"items"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ Size int `json:"size"`
+ Pages int `json:"pages"`
+}
+
+func (c *Client) GetCreatives(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID *string,
+ includeArchived bool,
+ page, size int,
+) (*CreativesPage, error) {
+ q := url.Values{}
+ if projectID != nil {
+ q.Set("project_id", *projectID)
+ }
+ q.Set("include_archived", fmt.Sprint(includeArchived))
+ q.Set("page", fmt.Sprint(page))
+ q.Set("size", fmt.Sprint(size))
+
+ path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode())
+
+ var resp CreativesPage
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &resp,
+ withBearer(jwt),
+ )
+
+ return &resp, err
+}
+
+func (c *Client) GetCreative(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ creativeID string,
+) (*Creative, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID)
+
+ var creative Creative
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &creative,
+ withBearer(jwt),
+ )
+
+ return &creative, err
+}
+
+type CreateCreativeInput struct {
+ Name string `json:"name"`
+ Text string `json:"text"`
+ MediaItems []CreativeMediaInput `json:"media_items,omitempty"`
+ Buttons []CreativeButton `json:"buttons,omitempty"`
+ Tag *string `json:"tag,omitempty"`
+}
+
+func (c *Client) CreateCreative(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+ input CreateCreativeInput,
+) (*Creative, error) {
+ q := url.Values{}
+ q.Set("project_id", projectID)
+
+ path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode())
+
+ var creative Creative
+ err := c.do(
+ ctx,
+ http.MethodPost,
+ path,
+ input,
+ &creative,
+ withBearer(jwt),
+ )
+
+ return &creative, err
+}
+
+type UpdateCreativeInput struct {
+ Name *string `json:"name,omitempty"`
+ Text *string `json:"text,omitempty"`
+ MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"`
+ Buttons *[]CreativeButton `json:"buttons,omitempty"`
+ Status *string `json:"status,omitempty"`
+ Tag *string `json:"tag,omitempty"`
+}
+
+func (c *Client) UpdateCreative(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ creativeID string,
+ input UpdateCreativeInput,
+) (*Creative, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID)
+
+ var creative Creative
+ err := c.do(
+ ctx,
+ http.MethodPatch,
+ path,
+ input,
+ &creative,
+ withBearer(jwt),
+ )
+
+ return &creative, err
+}
+
+func (c *Client) DeleteCreative(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ creativeID string,
+) error {
+ path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID)
+
+ return c.do(
+ ctx,
+ http.MethodDelete,
+ path,
+ nil,
+ nil,
+ withBearer(jwt),
+ )
+}
+
+// ============================================================================
+// Placements (Размещения)
+// ============================================================================
+
+type Channel struct {
+ ID string `json:"id"`
+ TelegramID *int64 `json:"telegram_id"`
+ Title *string `json:"title"`
+ Username *string `json:"username"`
+ InviteLink *string `json:"invite_link"`
+}
+
+type CreateChannelInput struct {
+ Username *string `json:"username,omitempty"`
+ InviteLink *string `json:"invite_link,omitempty"`
+}
+
+type CreateChannelsInput struct {
+ Channels []CreateChannelInput `json:"channels"`
+}
+
+type CreateChannelResult struct {
+ Index int `json:"index"`
+ Status string `json:"status"`
+ Channel *Channel `json:"channel,omitempty"`
+ Error *string `json:"error,omitempty"`
+}
+
+type ProjectOutput struct {
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Title string `json:"title"`
+ Username *string `json:"username"`
+ Status string `json:"status"`
+ PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
+ Channel Channel `json:"channel"`
+}
+
+type CreateChannelsOutput struct {
+ Results []CreateChannelResult `json:"results"`
+}
+
+type CostInfo struct {
+ Type string `json:"type"`
+ Value float64 `json:"value"`
+}
+
+type PlacementDetails struct {
+ PlacementAt *string `json:"placement_at,omitempty"`
+ PaymentAt *string `json:"payment_at,omitempty"`
+ Cost *CostInfo `json:"cost,omitempty"`
+ CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
+ PlacementType *string `json:"placement_type,omitempty"`
+ Format *string `json:"format,omitempty"`
+ TopTimeMinutes *int `json:"top_time_minutes,omitempty"`
+ FeedTimeMinutes *int `json:"feed_time_minutes,omitempty"`
+ Comment *string `json:"comment,omitempty"`
+ CreativeID *string `json:"creative_id,omitempty"`
+ CreativeName *string `json:"creative_name,omitempty"`
+ InviteLinkType *string `json:"invite_link_type,omitempty"`
+}
+
+type PlacementOutput struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreativeID *string `json:"creative_id,omitempty"`
+ CreativeName *string `json:"creative_name,omitempty"`
+ Comment *string `json:"comment,omitempty"`
+ InviteLink *string `json:"invite_link,omitempty"`
+ InviteLinkType string `json:"invite_link_type"`
+ Channel Channel `json:"channel"`
+ Project *ProjectOutput `json:"project"`
+ ShortID string `json:"short_id"`
+ Details *PlacementDetails `json:"details,omitempty"`
+ PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
+ CreatedAt string `json:"created_at"`
+}
+
+type PlacementPostOutput struct {
+ SubscriptionsCount int `json:"subscriptions_count"`
+ ViewsCount *int `json:"views_count,omitempty"`
+ CreatedAt string `json:"created_at"`
+ TimeOnTop *int `json:"time_on_top,omitempty"`
+ Post PostOutput `json:"post"`
+}
+
+type PostOutput struct {
+ ID string `json:"id"`
+ MessageID int `json:"message_id"`
+ Text string `json:"text"`
+ URL *string `json:"url,omitempty"`
+ DeletedFromChannelAt *string `json:"deleted_from_channel_at,omitempty"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+type CreatePlacementChannelInput struct {
+ ChannelID string `json:"channel_id"`
+ Status *string `json:"status,omitempty"`
+ Comment *string `json:"comment,omitempty"`
+ Details *PlacementDetails `json:"details,omitempty"`
+}
+
+type CreatePlacementsInput struct {
+ CreativeID *string `json:"creative_id,omitempty"`
+ Channels []CreatePlacementChannelInput `json:"channels"`
+}
+
+type GetPlacementsOutput struct {
+ Placements []PlacementOutput `json:"placements"`
+}
+
+type PlacementsPage struct {
+ Items []PlacementOutput `json:"items"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ Size int `json:"size"`
+ Pages int `json:"pages"`
+}
+
+type CreativePreviewOutput struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ MediaItems []CreativeMediaItem `json:"media_items"`
+ Buttons []CreativeButton `json:"buttons"`
+}
+
+func (c *Client) CreatePlacements(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+ input CreatePlacementsInput,
+) (*GetPlacementsOutput, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID)
+
+ var placements GetPlacementsOutput
+ err := c.do(
+ ctx,
+ http.MethodPost,
+ path,
+ input,
+ &placements,
+ withBearer(jwt),
+ )
+
+ return &placements, err
+}
+
+func (c *Client) CreateChannels(
+ ctx context.Context,
+ jwt string,
+ input CreateChannelsInput,
+) (*CreateChannelsOutput, error) {
+ var response CreateChannelsOutput
+ err := c.do(
+ ctx,
+ http.MethodPost,
+ "api/v1/channels",
+ input,
+ &response,
+ withBearer(jwt),
+ )
+
+ return &response, err
+}
+
+func (c *Client) GetPlacements(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+ page, size int,
+) (*PlacementsPage, error) {
+ q := url.Values{}
+ q.Set("page", fmt.Sprint(page))
+ q.Set("size", fmt.Sprint(size))
+
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements?%s", workspaceID, projectID, q.Encode())
+
+ var resp PlacementsPage
+
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &resp,
+ withBearer(jwt),
+ )
+
+ return &resp, err
+}
+
+func (c *Client) GetPlacement(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+ placementID string,
+) (*PlacementOutput, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements/%s", workspaceID, projectID, placementID)
+
+ var placement PlacementOutput
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &placement,
+ withBearer(jwt),
+ )
+
+ return &placement, err
+}
+
+func (c *Client) BuildPlacementCreative(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+ projectID string,
+ placementID string,
+) (*CreativePreviewOutput, error) {
+ path := fmt.Sprintf(
+ "api/v1/workspaces/%s/projects/%s/placements/%s/creative",
+ workspaceID,
+ projectID,
+ placementID,
+ )
+
+ var resp CreativePreviewOutput
+ err := c.do(
+ ctx,
+ http.MethodPost,
+ path,
+ nil,
+ &resp,
+ withBearer(jwt),
+ )
+
+ return &resp, err
+}
+
+// ============================================================================
+// Workspace Members
+// ============================================================================
+
+type WorkspaceMember struct {
+ ID string `json:"id"`
+ WorkspaceID string `json:"workspace_id"`
+ UserID string `json:"user_id"`
+ Username string `json:"username"`
+}
+
+func (c *Client) GetWorkspaceMembers(
+ ctx context.Context,
+ jwt string,
+ workspaceID string,
+) ([]WorkspaceMember, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/members", workspaceID)
+
+ var resp struct {
+ Items []WorkspaceMember `json:"items"`
+ }
+
+ err := c.do(
+ ctx,
+ http.MethodGet,
+ path,
+ nil,
+ &resp,
+ withBearer(jwt),
+ )
+
+ return resp.Items, err
+}
diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go
new file mode 100644
index 0000000..47d3332
--- /dev/null
+++ b/tg_bot/bot/bot.go
@@ -0,0 +1,427 @@
+package bot
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/rs/zerolog/log"
+)
+
+type exec struct {
+ handled bool // Событие обработано, дальше не идём
+ transitioned bool // Был SetState
+}
+
+var botUsername string
+
+func SetUsername(username string) { botUsername = username }
+
+func Username() string { return botUsername }
+
+type lastRender struct {
+ textHash string
+ kbHash string
+ isMedia bool
+}
+
+type Bot struct {
+ echotron.API
+ mu sync.Mutex
+ ChatID int64
+ exec *exec
+ CurrentState State
+ Session *Session
+ LastMessageID int // ID последнего сообщения с inline кнопками
+ lastRender lastRender
+ commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
+ globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
+ Backend *backend.Client
+}
+
+func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot {
+ if commandRouter == nil {
+ panic("bot: commandRouter cannot be nil")
+ }
+ if globalCallbackRouter == nil {
+ panic("bot: globalCallbackRouter cannot be nil")
+ }
+ if commandRouter("/start") == nil {
+ panic("bot: commandRouter requires /start state answer")
+ }
+ if backendClient == nil {
+ panic("bot: backendClient cannot be nil")
+ }
+
+ api := echotron.NewAPI(token)
+
+ return &Bot{
+ ChatID: chatID,
+ API: api,
+ Session: &Session{},
+ commandRouter: commandRouter,
+ Backend: backendClient,
+ globalCallbackRouter: globalCallbackRouter,
+ }
+}
+
+// GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update
+func (b *Bot) GetOrCreateJWT(u *echotron.Update) error {
+ // Извлекаем данные пользователя из update
+ var user *echotron.User
+ if u.Message != nil && u.Message.From != nil {
+ user = u.Message.From
+ } else if u.CallbackQuery != nil && u.CallbackQuery.From != nil {
+ user = u.CallbackQuery.From
+ }
+
+ var username, firstName, lastName *string
+ if user != nil {
+ if user.Username != "" {
+ username = &user.Username
+ }
+ if user.FirstName != "" {
+ firstName = &user.FirstName
+ }
+ if user.LastName != "" {
+ lastName = &user.LastName
+ }
+ }
+
+ jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName)
+ if err != nil {
+ return err
+ }
+
+ b.Session.JWT = jwt
+
+ if firstName != nil {
+ b.Session.FirstName = *firstName
+ }
+
+ return nil
+}
+
+func (b *Bot) SetState(s State, mode RenderMode) {
+ log.Info().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s))
+
+ if b.CurrentState != nil {
+ b.CurrentState.Exit()
+ }
+
+ b.CurrentState = s
+
+ if b.exec != nil {
+ b.exec.transitioned = true
+ b.exec.handled = true
+ }
+
+ s.Enter(b, mode)
+}
+
+func (b *Bot) MarkHandled() {
+ if b.exec != nil {
+ b.exec.handled = true
+ }
+}
+
+func isChannelChat(chatType string) bool {
+ return chatType == "channel" || chatType == "supergroup"
+}
+
+func stringPtr(value string) *string {
+ if value == "" {
+ return nil
+ }
+ return &value
+}
+
+func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
+ switch mode {
+ case EditMessage:
+ b.Edit(text, keyboard)
+ case NewMessage:
+ b.SendNew(text, keyboard)
+ default:
+ panic("unknown RenderMode")
+ }
+}
+
+func (b *Bot) SetLastMessageIsMedia(isMedia bool) {
+ b.lastRender.isMedia = isMedia
+}
+
+func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
+ res, err := b.GetFile(fileID)
+ if err != nil {
+ return nil, err
+ }
+ if res.Result == nil || res.Result.FilePath == "" {
+ return nil, fmt.Errorf("telegram file not available")
+ }
+ return b.DownloadFile(res.Result.FilePath)
+}
+
+func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
+ if b.exec != nil {
+ defer func() { b.exec.handled = true }()
+ }
+
+ if b.LastMessageID != 0 {
+ log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
+ b.cleanupKeyboard(b.LastMessageID)
+ }
+
+ res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
+ ReplyMarkup: keyboard,
+ ParseMode: echotron.HTML,
+ LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
+ })
+ if err != nil {
+ log.Error().Err(err).Msg("SendMessage failed")
+ return
+ }
+
+ if res.Result == nil {
+ return
+ }
+
+ b.LastMessageID = res.Result.ID
+ b.lastRender.isMedia = false
+ b.lastRender.textHash = hashString(text)
+ b.lastRender.kbHash = keyboardHash(keyboard)
+ log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
+}
+
+func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
+ if b.exec != nil {
+ defer func() { b.exec.handled = true }()
+ }
+
+ if b.LastMessageID == 0 {
+ b.SendNew(text, keyboard)
+ return
+ }
+
+ newTextHash := hashString(text)
+ newKBHash := keyboardHash(keyboard)
+
+ textChanged := newTextHash != b.lastRender.textHash
+ kbChanged := newKBHash != b.lastRender.kbHash
+
+ if !textChanged && !kbChanged {
+ log.Info().Msg("Edit skipped: message not modified")
+ return
+ }
+
+ var err error
+ msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID)
+
+ if b.lastRender.isMedia {
+ _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{
+ Caption: text,
+ ParseMode: echotron.HTML,
+ ReplyMarkup: keyboard,
+ })
+ } else {
+ _, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{
+ ReplyMarkup: keyboard,
+ ParseMode: echotron.HTML,
+ LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
+ })
+ }
+
+ if err == nil {
+ if textChanged {
+ b.lastRender.textHash = newTextHash
+ }
+ if kbChanged {
+ b.lastRender.kbHash = newKBHash
+ }
+ return
+ }
+
+ switch {
+ case strings.Contains(err.Error(), "message is not modified"):
+ log.Err(err).Msg("EditMessageText ignored: message is not modified")
+ case strings.Contains(err.Error(), "no text in the message to edit"):
+ b.lastRender.isMedia = true
+ log.Err(err).Msg("EditMessageText ignored: message has no text")
+ default:
+ log.Error().Err(err).Msg("EditMessageText")
+ }
+
+}
+
+func keyboardHash(kb echotron.InlineKeyboardMarkup) string {
+ b, err := json.Marshal(kb)
+ if err != nil {
+ return ""
+ }
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+
+func hashString(v string) string {
+ sum := sha256.Sum256([]byte(v))
+ return hex.EncodeToString(sum[:])
+}
+
+func (b *Bot) Update(u *echotron.Update) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ log.Info().Int64("chat_id", b.ChatID).Msg("Update received")
+
+ // 1. Системные события -> отправляем доменные события в backend
+ if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
+ go b.forwardSystemEvent(u)
+ return
+ }
+
+ // 2. Игнорируем события не из private чатов
+ chatType := ""
+ switch {
+ case u.Message != nil:
+ chatType = u.Message.Chat.Type
+ case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
+ chatType = u.CallbackQuery.Message.Chat.Type
+ case u.EditedMessage != nil:
+ chatType = u.EditedMessage.Chat.Type
+ case u.ChannelPost != nil || u.EditedChannelPost != nil:
+ return // Каналы игнорируем сразу
+ }
+ if chatType != "" && chatType != "private" {
+ log.Debug().Str("chat_type", chatType).Msg("Ignoring non-private chat")
+ return
+ }
+
+ // 3. Игнорируем edited messages
+ if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil {
+ log.Debug().Msg("Ignoring edited message")
+ return
+ }
+
+ err := b.GetOrCreateJWT(u)
+ if err != nil || b.Session.JWT == "" {
+ b.Edit(
+ "❌ Ошибка авторизации",
+ echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}},
+ )
+ b.cleanupCallbackUI(u)
+ return
+ }
+
+ e := &exec{}
+ b.exec = e
+ defer func() { b.exec = nil }()
+
+ isStartCommand := u.Message != nil && (u.Message.Text == "/start" ||
+ u.Message.Text == "/start login" ||
+ strings.HasPrefix(u.Message.Text, "/start project_"))
+
+ if b.CurrentState == nil && !isStartCommand {
+ b.Session.WasRestarted = true
+ b.SetState(b.commandRouter("/start"), EditMessage)
+ return
+ }
+
+ // === Обработка входящего события ===
+
+ // 1. Команды (приоритетный маршрут)
+ if u.Message != nil {
+ if strings.HasPrefix(u.Message.Text, "/") {
+ b.SetState(b.commandRouter(u.Message.Text), NewMessage)
+ return
+ }
+
+ b.CurrentState.HandleMessage(b, u)
+ if e.handled || e.transitioned {
+ return
+ }
+ }
+
+ // 2. Callback запросы
+ if u.CallbackQuery != nil {
+ b.cleanupCallbackUI(u)
+
+ switch u.CallbackQuery.Data {
+ case "empty":
+ return
+ case "refresh":
+ if b.CurrentState != nil {
+ b.CurrentState.Enter(b, EditMessage)
+ e.handled = true
+ }
+ return
+ }
+
+ if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil {
+ b.SetState(newState, NewMessage)
+ return
+ }
+
+ b.CurrentState.HandleCallback(b, u)
+ if e.handled || e.transitioned {
+ return
+ }
+ }
+
+ // 3. Универсальный обработчик состояния (если ничего не подошло)
+ b.CurrentState.Handle(b, u)
+ if e.handled || e.transitioned {
+ return
+ }
+
+ // 4. Fallback - ререндер текущего экрана, чтобы не терять прогресс
+ if b.CurrentState != nil {
+ mode := NewMessage
+ if u.CallbackQuery != nil {
+ mode = EditMessage
+ }
+ b.CurrentState.Enter(b, mode)
+ if e.handled || e.transitioned {
+ return
+ }
+ }
+
+ // 5. Крайний fallback - сброс в начальное состояние
+ b.SetState(b.commandRouter("/start"), NewMessage)
+}
+
+func (b *Bot) cleanupKeyboard(messageID int) {
+ var emptyKeyboard = echotron.InlineKeyboardMarkup{
+ InlineKeyboard: [][]echotron.InlineKeyboardButton{},
+ }
+ _, err := b.EditMessageReplyMarkup(
+ echotron.NewMessageID(b.ChatID, messageID),
+ &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard},
+ )
+ if err != nil {
+ log.Error().Err(err).Int("message_id", messageID).Msg("Failed to remove keyboard")
+ }
+}
+
+func (b *Bot) cleanupCallbackUI(u *echotron.Update) {
+ _, err := b.AnswerCallbackQuery(u.CallbackQuery.ID, nil)
+ if err != nil {
+ log.Error().Err(err).Msg("b.AnswerCallbackQuery error")
+ }
+
+ if u.CallbackQuery.Message != nil {
+ callbackMessageID := u.CallbackQuery.Message.ID
+
+ // Если это сообщение последнее, не удаляем клавиатуру
+ if b.LastMessageID == callbackMessageID {
+ return
+ }
+
+ b.cleanupKeyboard(callbackMessageID)
+ }
+}
diff --git a/tg_bot/bot/forward_system_event.go b/tg_bot/bot/forward_system_event.go
new file mode 100644
index 0000000..a3808d6
--- /dev/null
+++ b/tg_bot/bot/forward_system_event.go
@@ -0,0 +1,196 @@
+package bot
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/rs/zerolog/log"
+)
+
+func (b *Bot) forwardSystemEvent(u *echotron.Update) {
+ ctx := context.Background()
+
+ switch {
+ case u.ChatJoinRequest != nil:
+ req := u.ChatJoinRequest
+ if !isChannelChat(req.Chat.Type) {
+ return
+ }
+
+ inviteLink := ""
+ if req.InviteLink != nil {
+ inviteLink = req.InviteLink.InviteLink
+ }
+ if inviteLink == "" {
+ log.Debug().Msg("No invite link in chat join request")
+ return
+ }
+
+ userID := req.From.ID
+ username := stringPtr(req.From.Username)
+ firstName := stringPtr(req.From.FirstName)
+ lastName := stringPtr(req.From.LastName)
+ if userID == 0 && req.UserChatID != 0 {
+ userID = req.UserChatID
+ }
+ if userID == 0 {
+ log.Error().Msg("No user id in chat join request")
+ return
+ }
+
+ payload := map[string]any{
+ "type": "subscription",
+ "user_telegram_id": userID,
+ "invite_link": inviteLink,
+ "username": username,
+ "first_name": firstName,
+ "last_name": lastName,
+ }
+ if err := b.Backend.SendEvent(ctx, payload); err != nil {
+ log.Error().Err(err).Msg("Failed to send subscription event")
+ }
+
+ case u.ChatMember != nil:
+ event := u.ChatMember
+ if !isChannelChat(event.Chat.Type) {
+ return
+ }
+
+ memberUser := event.NewChatMember.User
+ if memberUser == nil {
+ log.Error().Msg("No user in chat member update")
+ return
+ }
+
+ oldStatus := event.OldChatMember.Status
+ newStatus := event.NewChatMember.Status
+
+ wasNotMember := oldStatus == "left" || oldStatus == "kicked"
+ isNowMember := newStatus == "member" || newStatus == "administrator" || newStatus == "creator"
+ userJoined := wasNotMember && isNowMember
+
+ wasMember := oldStatus == "member" || oldStatus == "administrator" || oldStatus == "creator" || oldStatus == "restricted"
+ isNowNotMember := newStatus == "left" || newStatus == "kicked"
+ userLeft := wasMember && isNowNotMember
+
+ if userJoined {
+ inviteLink := ""
+ if event.InviteLink != nil {
+ inviteLink = event.InviteLink.InviteLink
+ }
+ if inviteLink == "" {
+ log.Debug().Msg("No invite link in chat member update")
+ return
+ }
+
+ username := stringPtr(memberUser.Username)
+ firstName := stringPtr(memberUser.FirstName)
+ lastName := stringPtr(memberUser.LastName)
+ payload := map[string]any{
+ "type": "subscription",
+ "user_telegram_id": memberUser.ID,
+ "invite_link": inviteLink,
+ "username": username,
+ "first_name": firstName,
+ "last_name": lastName,
+ }
+ if err := b.Backend.SendEvent(ctx, payload); err != nil {
+ log.Error().Err(err).Msg("Failed to send subscription event")
+ }
+ return
+ }
+
+ if userLeft {
+ payload := map[string]any{
+ "type": "unsubscription",
+ "user_telegram_id": memberUser.ID,
+ "channel_telegram_id": event.Chat.ID,
+ }
+ if err := b.Backend.SendEvent(ctx, payload); err != nil {
+ log.Error().Err(err).Msg("Failed to send unsubscription event")
+ }
+ }
+
+ case u.MyChatMember != nil:
+ event := u.MyChatMember
+ if !isChannelChat(event.Chat.Type) {
+ return
+ }
+
+ actorID := event.From.ID
+ if actorID == 0 {
+ log.Error().Msg("No user in my chat member update")
+ return
+ }
+
+ oldStatus := event.OldChatMember.Status
+ newStatus := event.NewChatMember.Status
+
+ wasMember := oldStatus == "administrator" || oldStatus == "member"
+ isNowNotMember := newStatus == "left" || newStatus == "kicked"
+ botRemoved := wasMember && isNowNotMember
+
+ permissionsChanged := oldStatus == "administrator" && newStatus == "administrator"
+
+ if botRemoved {
+ payload := map[string]any{
+ "type": "bot_removed",
+ "telegram_id": event.Chat.ID,
+ "user_telegram_id": actorID,
+ }
+ if err := b.Backend.SendEvent(ctx, payload); err != nil {
+ log.Error().Err(err).Msg("Failed to send bot removed event")
+ }
+ return
+ }
+
+ if permissionsChanged {
+ title := event.Chat.Title
+ if title == "" {
+ title = fmt.Sprintf("Channel %d", event.Chat.ID)
+ }
+ payload := map[string]any{
+ "type": "bot_permissions",
+ "telegram_id": event.Chat.ID,
+ "chat_title": title,
+ "user_telegram_id": actorID,
+ "permissions": map[string]any{
+ "is_admin": newStatus == "administrator",
+ "can_invite_users": event.NewChatMember.CanInviteUsers,
+ "can_restrict_members": event.NewChatMember.CanRestrictMembers,
+ },
+ }
+ if err := b.Backend.SendEvent(ctx, payload); err != nil {
+ log.Error().Err(err).Msg("Failed to send bot permissions event")
+ }
+ return
+ }
+
+ wasNotMember := oldStatus == "left" || oldStatus == "kicked"
+ isNowMember := newStatus == "administrator" || newStatus == "member"
+ botAdded := wasNotMember && isNowMember
+
+ if botAdded {
+ title := event.Chat.Title
+ if title == "" {
+ title = fmt.Sprintf("Channel %d", event.Chat.ID)
+ }
+ payload := map[string]any{
+ "type": "bot_added",
+ "telegram_id": event.Chat.ID,
+ "title": title,
+ "username": stringPtr(event.Chat.Username),
+ "user_telegram_id": actorID,
+ "bot_permissions": map[string]any{
+ "is_admin": newStatus == "administrator",
+ "can_invite_users": event.NewChatMember.CanInviteUsers,
+ "can_restrict_members": event.NewChatMember.CanRestrictMembers,
+ },
+ }
+ if err := b.Backend.SendEvent(ctx, payload); err != nil {
+ log.Error().Err(err).Msg("Failed to send bot added event")
+ }
+ }
+ }
+}
diff --git a/tg_bot/bot/state.go b/tg_bot/bot/state.go
new file mode 100644
index 0000000..b5fec0d
--- /dev/null
+++ b/tg_bot/bot/state.go
@@ -0,0 +1,25 @@
+package bot
+
+import "github.com/NicoNex/echotron/v3"
+
+type RenderMode int
+
+const (
+ NewMessage RenderMode = iota
+ EditMessage
+)
+
+type State interface {
+ Enter(*Bot, RenderMode)
+ HandleCallback(*Bot, *echotron.Update)
+ HandleMessage(*Bot, *echotron.Update)
+ Handle(*Bot, *echotron.Update)
+ Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин)
+}
+
+type Session struct {
+ FirstName string
+ JWT string
+ WorkspaceID string
+ WasRestarted bool
+}
diff --git a/tg_bot/go.mod b/tg_bot/go.mod
new file mode 100644
index 0000000..ed58e83
--- /dev/null
+++ b/tg_bot/go.mod
@@ -0,0 +1,24 @@
+module github.com/TelegramExchange/tgex-backend/tg_bot
+
+go 1.24.4
+
+require (
+ github.com/NicoNex/echotron/v3 v3.43.0
+ github.com/rs/zerolog v1.34.0
+ golang.org/x/image v0.31.0
+)
+
+require (
+ github.com/AlekSi/pointer v1.0.0 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/olebedev/when v1.1.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ golang.org/x/sys v0.39.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
+ golang.org/x/time v0.5.0 // indirect
+)
+
+replace github.com/TelegramExchange/pkg => ../pkg
+
+replace github.com/NicoNex/echotron/v3 => ../shared/echotron
diff --git a/tg_bot/go.sum b/tg_bot/go.sum
new file mode 100644
index 0000000..d400350
--- /dev/null
+++ b/tg_bot/go.sum
@@ -0,0 +1,31 @@
+github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
+github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
+github.com/NicoNex/echotron/v3 v3.43.0 h1:efE2spw3mfU0Ev20m0PqqvgMSm0xHzgSxlWAEmC9RC4=
+github.com/NicoNex/echotron/v3 v3.43.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
+github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
+golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
diff --git a/tg_bot/main.go b/tg_bot/main.go
new file mode 100644
index 0000000..dca5d9c
--- /dev/null
+++ b/tg_bot/main.go
@@ -0,0 +1,152 @@
+package main
+
+import (
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens"
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+var botCommands = []echotron.BotCommand{
+ {Command: "start", Description: "Главное меню"},
+ {Command: "projects", Description: "Мои проекты"},
+ {Command: "placements", Description: "Размещения"},
+ {Command: "platform", Description: "Веб-платформа"},
+ {Command: "help", Description: "Помощь"},
+}
+
+func main() {
+ initLogger()
+
+ botToken := mustEnv("TELEGRAM__TOKEN")
+
+ backendClient := backend.New(backend.Config{
+ BaseURL: mustEnv("BACKEND__BASE_URL"),
+ LoginURL: mustEnv("LOGIN_URL"),
+ })
+
+ // Регистрируем команды бота в Telegram
+ api := echotron.NewAPI(botToken)
+ if me, err := api.GetMe(); err != nil {
+ log.Error().Err(err).Msg("Failed to get bot profile")
+ } else if me.Result != nil {
+ bot.SetUsername(me.Result.Username)
+ log.Info().Str("username", me.Result.Username).Msg("Telegram bot authorized")
+ }
+
+ if _, err := api.SetMyCommands(nil, botCommands...); err != nil {
+ log.Error().Err(err).Msg("Failed to set bot commands")
+ } else {
+ log.Info().Msg("Bot commands registered successfully")
+ }
+
+ var commandRouter = func(command string) bot.State {
+ createCreativeRe := regexp.MustCompile(`^/start project_(.+)_createcreative$`)
+
+ switch {
+ case command == "/start":
+ return &screens.MainMenu{}
+ case command == "/start login":
+ return &screens.Login{}
+ case command == "/projects":
+ return &screens.MyProjects{BackState: &screens.MainMenu{}}
+ case command == "/placements":
+ return &screens.MyProjects{BackState: &screens.MainMenu{}, OpenPlacements: true}
+ case command == "/platform":
+ return &screens.PlatformLink{}
+ case command == "/help":
+ return &screens.Help{}
+ case createCreativeRe.MatchString(command):
+ projectID := createCreativeRe.FindStringSubmatch(command)[1]
+ return &screens.AddCreativeStart{Ctx: &screens.AddCreativeCtx{
+ ProjectID: projectID,
+ BackState: &screens.MainMenu{},
+ }}
+ }
+ panic("Unknown command: " + command)
+ }
+
+ var handleGlobalCallback = func(callbackData string) bot.State {
+ id, ok := strings.CutPrefix(callbackData, "pending_channel:")
+ if ok {
+ return &screens.SelectWorkspace{ChannelID: id, BackState: &screens.MainMenu{}}
+ }
+
+ id, ok = strings.CutPrefix(callbackData, "workspace_invite_accept:")
+ if ok {
+ return &screens.AcceptWorkspaceInvite{InviteID: id}
+ }
+
+ return nil
+ }
+
+ newBot := func(chatID int64) echotron.Bot {
+ return bot.NewBot(chatID, botToken, commandRouter, backendClient, handleGlobalCallback)
+ }
+
+ dsp := echotron.NewDispatcher(botToken, newBot)
+
+ updateOpts := echotron.UpdateOptions{
+ AllowedUpdates: []echotron.UpdateType{
+ echotron.MessageUpdate,
+ echotron.CallbackQueryUpdate,
+ echotron.MyChatMemberUpdate,
+ echotron.ChatMemberUpdate,
+ echotron.UpdateType("chat_join_request"),
+ },
+ }
+
+ echotron.SetChatRequestLimit(0, 0)
+
+ for {
+ err := dsp.PollOptions(false, updateOpts)
+ if err != nil {
+ log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...")
+ time.Sleep(5 * time.Second)
+ continue
+ }
+ break
+ }
+}
+
+func initLogger() {
+ zerolog.TimeFieldFormat = time.RFC3339
+
+ level := zerolog.InfoLevel
+ if parsedLevel, err := zerolog.ParseLevel(os.Getenv("LOGGER__LEVEL")); err == nil {
+ level = parsedLevel
+ }
+ zerolog.SetGlobalLevel(level)
+
+ log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger().Level(level)
+
+ prettyConsole := os.Getenv("LOGGER__PRETTY_CONSOLE")
+ if prettyConsole == "" {
+ prettyConsole = "true"
+ }
+ if prettyConsole == "true" {
+ log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}).
+ With().
+ Timestamp().
+ Logger().
+ Level(level)
+ }
+
+ log.Info().Msg("Logger initialized")
+}
+
+func mustEnv(key string) string {
+ v := os.Getenv(key)
+ if v == "" {
+ log.Fatal().Str("env", key).Msg("missing required environment variable")
+ }
+
+ return v
+}
diff --git a/tg_bot/screens/accept_workspace_invite.go b/tg_bot/screens/accept_workspace_invite.go
new file mode 100644
index 0000000..1c70009
--- /dev/null
+++ b/tg_bot/screens/accept_workspace_invite.go
@@ -0,0 +1,45 @@
+package screens
+
+import (
+ "context"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+)
+
+type AcceptWorkspaceInvite struct {
+ InviteID string
+}
+
+const msgAcceptWorkspaceInviteSuccessfully = `
+✅ Вы присоединились к рабочему пространству!
+Администратор сможет выдать вам необходимые права в веб-интерфейсе.
+`
+
+func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
+ jwt := b.Session.JWT
+ if jwt == "" {
+ b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", emptyKeyboard)
+ return
+ }
+
+ err := b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID)
+ if err != nil {
+ b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", emptyKeyboard)
+ return
+ }
+
+ kb := Keyboard(Row(Button("Главное меню", "main_menu")))
+
+ b.SendNew(msgAcceptWorkspaceInviteSuccessfully, kb)
+}
+
+func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) {}
+
+func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) {
+ b.SetState(&MainMenu{}, bot.EditMessage)
+}
+
+func (s *AcceptWorkspaceInvite) Exit() {}
diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go
new file mode 100644
index 0000000..802337a
--- /dev/null
+++ b/tg_bot/screens/add_creative.go
@@ -0,0 +1,489 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+type AddCreativeCtx struct {
+ CreativeEditorFields
+
+ ProjectID string
+ BackState bot.State
+ WaitMessageSent bool
+}
+
+type AddCreativeStart struct{ Ctx *AddCreativeCtx }
+
+type AddCreativeEdit struct{ ctx *AddCreativeCtx }
+
+type AddCreativeInput struct{ ctx *AddCreativeCtx }
+
+const msgWaitCreative = `
++ Добавить креатив
+
+📄 Пришлите креатив который хотите добавить
+
+• Обрабатываем любое сообщение Telegram
+• Отправляйте сразу оформленный пост
+• На следующем шаге можно добавить кнопки или изменить контент
+
+`
+const msgConfirmCreativeFormat = `
+➕ Добавляем этот креатив?
+
+Название: %s
+
+Чтобы изменить название, отправь мне сообщение 👇:
+
+`
+const msgDownloadMediaError = `
+❌ Не удалось загрузить медиа
+
+Попробуйте удалить медиа и добавить заново.
+
+`
+const msgInviteLinkRequired = `
+❌ Ошибка валидации
+
+Текст должен содержать хотя бы одну инвайт-ссылку вашего канала!
+
+Формат ссылки:
+https://t.me/+xxx
+
+Пример правильного текста:
+Присоединяйтесь к нашему каналу!
+https://t.me/+AbCdEfGhIjKlMn
+
+Нажмите "✎ Текст" ниже чтобы исправить.
+
+`
+const msgInviteLinkTooMany = `
+❌ Слишком много ссылок
+
+Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку.
+У вас их несколько. Удалите лишние.
+
+Нажмите "✎ Текст" ниже чтобы исправить.
+
+`
+const msgCreateError = `
+❌ Ошибка создания
+
+Не удалось создать креатив.
+Попробуйте ещё раз или вернитесь назад.
+
+`
+const msgCreativeCreated = `
+✅ Креатив успешно создан!
+
+📄 Название: %s
+
+`
+const msgCreativeCreatedButtons = `
+🔘 Кнопок добавлено: %d
+
+`
+
+func filterCreateButtons(rows [][]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
+ filtered := make([][]echotron.InlineKeyboardButton, 0, len(rows))
+ for _, row := range rows {
+ skip := false
+ for _, btn := range row {
+ switch btn.CallbackData {
+ case "edit_text", "add_media", "delete_media":
+ skip = true
+ }
+ }
+ if !skip {
+ filtered = append(filtered, row)
+ }
+ }
+ return filtered
+}
+
+func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
+ if s.Ctx.WaitMessageSent {
+ return
+ }
+
+ kb := Keyboard(Row(Button("« Отмена", "cancel")))
+ b.Render(msgWaitCreative, kb, mode)
+ s.Ctx.WaitMessageSent = true
+}
+
+func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+ if u.CallbackQuery.Data == "cancel" && s.Ctx.BackState != nil {
+ b.SetState(s.Ctx.BackState, bot.EditMessage)
+ }
+}
+
+func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if u.Message == nil {
+ return
+ }
+
+ s.Ctx.extractCreativeFromMessage(u.Message)
+ if s.Ctx.Name == nil {
+ name := s.Ctx.generateCreativeName()
+ s.Ctx.Name = &name
+ }
+
+ if u.Message.MediaGroupID != "" {
+ groupID := u.Message.MediaGroupID
+ s.Ctx.scheduleMediaGroupAction(groupID, func() {
+ b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
+ })
+ b.MarkHandled()
+ return
+ }
+
+ b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
+}
+
+func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *AddCreativeStart) Exit() {}
+
+func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) {
+ s.ctx.showCreativeConfirmation(b)
+}
+
+func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "cancel":
+ if s.ctx.BackState != nil {
+ b.SetState(s.ctx.BackState, bot.EditMessage)
+ }
+ case data == "edit_text":
+ s.ctx.InputMode = inputModeText
+ b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
+ case data == "add_button":
+ s.ctx.InputMode = inputModeButtonText
+ s.ctx.PendingButtonType = "invite"
+ b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
+ case data == "add_media":
+ s.ctx.InputMode = inputModeMedia
+ b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
+ case data == "delete_media":
+ s.ctx.MediaItems = nil
+ s.ctx.MediaChanged = true
+ s.ctx.UpdateCreativePreview(b)
+ s.Enter(b, bot.EditMessage)
+ case data == "edit_tag":
+ s.ctx.ShowTagPanel(b)
+ case data == "tag_testing":
+ tag := "testing"
+ s.ctx.Tag = &tag
+ s.Enter(b, bot.EditMessage)
+ case data == "tag_production":
+ tag := "production"
+ s.ctx.Tag = &tag
+ s.Enter(b, bot.EditMessage)
+ case data == "cancel_edit":
+ s.Enter(b, bot.EditMessage)
+ case data == "confirm_create":
+ s.ctx.createCreative(b)
+ case strings.HasPrefix(data, "delete_button:"):
+ indexStr := strings.TrimPrefix(data, "delete_button:")
+ index, err := strconv.Atoi(indexStr)
+ if err != nil || index < 0 || index >= len(s.ctx.Buttons) {
+ return
+ }
+ s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...)
+ s.ctx.UpdateCreativePreview(b)
+ s.Enter(b, bot.EditMessage)
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if u.Message == nil {
+ return
+ }
+
+ if u.Message.Text == "" {
+ if s.ctx.SetMediaFromMessage(u.Message) {
+ if u.Message.MediaGroupID != "" {
+ groupID := u.Message.MediaGroupID
+ s.ctx.scheduleMediaGroupAction(groupID, func() {
+ s.ctx.UpdateCreativePreview(b)
+ s.Enter(b, bot.EditMessage)
+ })
+ b.MarkHandled()
+ return
+ }
+ s.ctx.UpdateCreativePreview(b)
+ s.Enter(b, bot.EditMessage)
+ }
+ return
+ }
+
+ newName := u.Message.Text
+ runes := []rune(newName)
+ if len(runes) > 200 {
+ newName = string(runes[:200])
+ }
+
+ s.ctx.Name = &newName
+ s.ctx.DeleteUserMessage(b, u.Message.ID)
+ s.Enter(b, bot.EditMessage)
+}
+
+func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *AddCreativeEdit) Exit() {}
+
+func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) {
+ switch s.ctx.InputMode {
+ case inputModeText:
+ s.ctx.ShowTextEditPanel(b)
+ case inputModeButtonText:
+ s.ctx.ShowAddButtonPanel(b)
+ case inputModeButtonURL:
+ s.ctx.ShowButtonURLPanel(b)
+ case inputModeMedia:
+ s.ctx.ShowMediaPanel(b)
+ }
+}
+
+func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ switch u.CallbackQuery.Data {
+ case "button_type_invite":
+ s.ctx.PendingButtonType = "invite"
+ s.ctx.ShowAddButtonPanel(b)
+ case "button_type_custom":
+ s.ctx.PendingButtonType = "custom"
+ s.ctx.ShowAddButtonPanel(b)
+ case "cancel_add_button":
+ s.ctx.CancelPendingButton(b)
+ b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
+ case "cancel_edit", "cancel_media":
+ s.ctx.ClearInputMode()
+ b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
+ }
+}
+
+func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if u.Message == nil {
+ return
+ }
+
+ switch s.ctx.InputMode {
+ case inputModeText:
+ if u.Message.Text == "" {
+ return
+ }
+ text := ui.FormatMessageHTML(u.Message)
+ s.ctx.Text = &text
+ s.ctx.DeleteUserMessage(b, u.Message.ID)
+ s.ctx.ClearInputMode()
+ b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
+ case inputModeButtonText:
+ if u.Message.Text == "" {
+ return
+ }
+ buttonText := u.Message.Text
+ s.ctx.DeleteUserMessage(b, u.Message.ID)
+
+ if s.ctx.PendingButtonType == "custom" {
+ s.ctx.AddCustomButtonPlaceholder(buttonText)
+ s.ctx.UpdateCreativePreview(b)
+ s.ctx.InputMode = inputModeButtonURL
+ b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
+ return
+ }
+
+ s.ctx.AddInviteButton(buttonText)
+ s.ctx.UpdateCreativePreview(b)
+ s.ctx.PendingButtonType = ""
+ s.ctx.ClearInputMode()
+ b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
+ case inputModeButtonURL:
+ if u.Message.Text == "" {
+ return
+ }
+ url := strings.TrimSpace(u.Message.Text)
+ s.ctx.DeleteUserMessage(b, u.Message.ID)
+
+ if !s.ctx.IsValidButtonURL(url, true) {
+ s.ctx.ShowInvalidButtonURLPanel(b)
+ return
+ }
+
+ s.ctx.UpdateLastButtonURL(url)
+ s.ctx.UpdateCreativePreview(b)
+ s.ctx.PendingButtonType = ""
+ s.ctx.ClearInputMode()
+ b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
+ case inputModeMedia:
+ if !s.ctx.SetMediaFromMessage(u.Message) {
+ return
+ }
+ if u.Message.MediaGroupID != "" {
+ groupID := u.Message.MediaGroupID
+ s.ctx.scheduleMediaGroupAction(groupID, func() {
+ s.ctx.UpdateCreativePreview(b)
+ s.ctx.ShowMediaPanel(b)
+ b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
+ })
+ b.MarkHandled()
+ return
+ }
+ s.ctx.UpdateCreativePreview(b)
+ s.ctx.ShowMediaPanel(b)
+ b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
+ }
+}
+
+func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *AddCreativeInput) Exit() {}
+
+func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) {
+ if message.Text != "" || message.Caption != "" {
+ text := ui.FormatMessageHTML(message)
+ if text != "" {
+ s.Text = &text
+ }
+ }
+
+ s.SetMediaFromMessage(message)
+
+ if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 {
+ for _, row := range message.ReplyMarkup.InlineKeyboard {
+ for _, button := range row {
+ if button.URL != "" {
+ s.Buttons = append(s.Buttons, InlineButton{
+ Text: button.Text,
+ URL: button.URL,
+ })
+ }
+ }
+ }
+ }
+}
+
+func (s *AddCreativeCtx) generateCreativeName() string {
+ if s.Text != nil && *s.Text != "" {
+ re := regexp.MustCompile(`<[^>]*>`)
+ cleanText := re.ReplaceAllString(*s.Text, "")
+
+ runes := []rune(strings.TrimSpace(cleanText))
+ if len(runes) > 10 {
+ return string(runes[:10]) + "..."
+ }
+ if len(runes) > 0 {
+ return string(runes)
+ }
+ }
+
+ return "Новый креатив"
+}
+
+func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) {
+ if s.CreativeMessageID == nil {
+ s.SendCreativePreview(b)
+ } else {
+ s.UpdateCreativePreview(b)
+ }
+
+ escapedName := ui.EscapeHTML(*s.Name)
+ confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
+ buttons := filterCreateButtons(s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel"))
+ s.ShowControlPanel(b, confirmText, buttons)
+}
+
+func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
+ var err error
+ var mediaItems []backend.CreativeMediaInput
+ if len(s.MediaItems) > 0 {
+ mediaItems, err = s.BuildMediaInputs(b)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to download creative media")
+ buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
+ s.ShowControlPanel(b, msgDownloadMediaError, buttons)
+ return
+ }
+ }
+
+ buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
+ for _, button := range s.Buttons {
+ buttons = append(buttons, backend.CreativeButton{
+ Text: button.Text,
+ URL: button.URL,
+ })
+ }
+
+ log.Info().Str("text", *s.Text).Msg("Creative text")
+
+ tag := "testing"
+ if s.Tag != nil {
+ tag = *s.Tag
+ }
+
+ input := backend.CreateCreativeInput{
+ Name: *s.Name,
+ Text: *s.Text,
+ Buttons: buttons,
+ Tag: &tag,
+ }
+ if len(mediaItems) > 0 {
+ input.MediaItems = mediaItems
+ }
+
+ creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, input)
+
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to create creative")
+
+ errMsg := err.Error()
+ var userMsg string
+
+ if strings.Contains(errMsg, "Creative text must contain one invite link") {
+ userMsg = msgInviteLinkRequired
+ } else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
+ userMsg = msgInviteLinkTooMany
+ } else {
+ userMsg = msgCreateError
+ }
+
+ buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
+ s.ShowControlPanel(b, userMsg, buttons)
+ return
+ }
+
+ successText := fmt.Sprintf(msgCreativeCreated, creative.Name)
+ if len(s.Buttons) > 0 {
+ successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons))
+ }
+
+ b.SendNew(successText, Keyboard())
+
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.NewMessage)
+ }
+}
diff --git a/tg_bot/screens/add_project.go b/tg_bot/screens/add_project.go
new file mode 100644
index 0000000..e984410
--- /dev/null
+++ b/tg_bot/screens/add_project.go
@@ -0,0 +1,59 @@
+package screens
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+)
+
+type AddProject struct {
+ BackState bot.State
+}
+
+const addProjectInstructionText = `Как добавить канал в проект
+
+Назначьте @%s администратором канала.
+
+Необходимые права:
+
+➔ Управление сообщениями
+➔ Добавление участников
+
+После добавления бота:
+
+• Если у вас 1 рабочее пространство — канал добавится автоматически
+• Если у вас несколько рабочих пространств — вы получите уведомление с кнопкой выбора
`
+
+func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
+ botUsername := strings.TrimPrefix(bot.Username(), "@")
+ addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername)
+
+ buttons := [][]echotron.InlineKeyboardButton{
+ Row(Stylish(URLButton("Назначить администратором", addBotURL), echotron.DangerButtonStyle)),
+ Row(Button("← Назад", "back")),
+ }
+
+ keyboard := Keyboard(buttons...)
+ b.Render(fmt.Sprintf(addProjectInstructionText, botUsername), keyboard, mode)
+}
+
+func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ switch u.CallbackQuery.Data {
+ case "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+ }
+}
+
+func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *AddProject) Exit() {}
diff --git a/tg_bot/screens/add_purchase.go b/tg_bot/screens/add_purchase.go
new file mode 100644
index 0000000..a0f3720
--- /dev/null
+++ b/tg_bot/screens/add_purchase.go
@@ -0,0 +1,325 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+const addPurchaseCreativesPerPage = 5
+const addPurchaseProjectsPerPage = 6
+
+type AddPurchase struct {
+ ProjectID string
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+ ProjectDefaultLinkType string
+ CreativeID string
+ CreativeTitle string
+ ProjectPage int
+ CreativePage int
+ ActivePicker string // "" | "project_list" | "creative_list"
+ BackState bot.State
+ lastProjects map[string]projectInfo
+ lastCreatives map[string]creativeInfo
+}
+
+type projectInfo struct {
+ Title string
+ TelegramID int64
+ Username string
+ Status string
+ DefaultInviteType string
+}
+
+type creativeInfo struct {
+ Title string
+}
+
+func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
+ s.renderSelection(b, mode)
+}
+
+func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) {
+ text := "Создание закупа\n\n"
+ text += "Выберите проект и креатив.\n\n"
+
+ var buttons [][]echotron.InlineKeyboardButton
+
+ projectLabel := "Проект: не выбрано"
+ if s.ProjectTitle != "" {
+ projectLabel = fmt.Sprintf("Проект: %s", s.ProjectTitle)
+ }
+ creativeLabel := "Креатив: не выбрано"
+ if s.CreativeTitle != "" {
+ creativeLabel = fmt.Sprintf("Креатив: %s", s.CreativeTitle)
+ }
+
+ if s.ActivePicker == "" {
+ buttons = append(buttons, Row(
+ Button(projectLabel, "pick_project"),
+ ))
+ buttons = append(buttons, Row(
+ Button(creativeLabel, "pick_creative"),
+ ))
+ }
+
+ switch s.ActivePicker {
+ case "project_list":
+ page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectPage+1, addPurchaseProjectsPerPage)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get projects")
+ b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
+ return
+ }
+
+ s.lastProjects = make(map[string]projectInfo)
+ for _, project := range page.Items {
+ username := ""
+ if project.Username != nil {
+ username = *project.Username
+ }
+ s.lastProjects[project.ID] = projectInfo{
+ Title: project.Title,
+ TelegramID: project.TelegramID,
+ Username: username,
+ Status: project.Status,
+ DefaultInviteType: project.PurchaseInviteTypeDefault,
+ }
+ }
+
+ if len(page.Items) == 0 {
+ text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
+ } else {
+ text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages))
+
+ buttons = append(buttons, ui.BuildGrid(
+ page.Items,
+ 2,
+ addPurchaseProjectsPerPage,
+ page.Pages,
+ func(project backend.Project) (string, string) {
+ return project.Title, "project:" + project.ID
+ },
+ )...)
+
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.ProjectPage,
+ TotalPages: page.Pages,
+ }); navRow != nil {
+ buttons = append(buttons, navRow)
+ }
+ }
+
+ buttons = append(buttons, Row(
+ Button("← Назад", "back_to_selection"),
+ ))
+
+ case "creative_list":
+ if s.ProjectID == "" {
+ text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n"
+ buttons = append(buttons, Row(
+ Button("← Назад", "back_to_selection"),
+ ))
+ break
+ }
+
+ page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CreativePage+1, addPurchaseCreativesPerPage)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get creatives")
+ b.SendNew("❌ Не удалось загрузить креативы", Keyboard())
+ return
+ }
+
+ s.lastCreatives = make(map[string]creativeInfo)
+ for _, creative := range page.Items {
+ s.lastCreatives[creative.ID] = creativeInfo{
+ Title: creative.Name,
+ }
+ }
+
+ if len(page.Items) == 0 {
+ text += `Для создания закупа сначала нужно создать креатив.
+
+Вернитесь назад и создайте креатив в разделе Креативы`
+
+ buttons = append(buttons, Row(
+ Button("🎨 Перейти к креативам", "go_to_creatives"),
+ ))
+ } else {
+ text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages))
+
+ buttons = append(buttons, ui.BuildGrid(
+ page.Items,
+ 2,
+ addPurchaseCreativesPerPage,
+ page.Pages,
+ func(creative backend.Creative) (string, string) {
+ return creative.Name, "creative:" + creative.ID
+ },
+ )...)
+
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CreativePage,
+ TotalPages: page.Pages,
+ }); navRow != nil {
+ buttons = append(buttons, navRow)
+ }
+ }
+
+ buttons = append(buttons, Row(
+ Button("← Назад", "back_to_selection"),
+ ))
+ }
+
+ if s.ActivePicker == "" {
+ if s.ProjectID != "" && s.CreativeID != "" {
+ buttons = append(buttons, Row(
+ Button("← Отмена", "back"),
+ Button("→ Далее", "next"),
+ ))
+ } else {
+ buttons = append(buttons, Row(
+ Button("← Отмена", "back"),
+ ))
+ }
+ }
+
+ keyboard := Keyboard(buttons...)
+ b.Render(text, keyboard, mode)
+
+ if s.ActivePicker == "" && s.ProjectTelegramID != 0 {
+ messageID := b.LastMessageID
+ updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
+ }
+}
+
+func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch data {
+ case "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+
+ case "pick_project":
+ s.ActivePicker = "project_list"
+ s.Enter(b, bot.EditMessage)
+
+ case "pick_creative":
+ s.ActivePicker = "creative_list"
+ s.Enter(b, bot.EditMessage)
+
+ case "back_to_selection":
+ s.ActivePicker = ""
+ s.Enter(b, bot.EditMessage)
+
+ case "go_to_creatives":
+ b.SetState(&Creatives{
+ CurrentPage: 0,
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ BackState: s,
+ }, bot.EditMessage)
+
+ case "prev":
+ if s.ActivePicker == "project_list" {
+ if s.ProjectPage > 0 {
+ s.ProjectPage--
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if s.ActivePicker == "creative_list" {
+ if s.CreativePage > 0 {
+ s.CreativePage--
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+
+ case "next":
+ if s.ActivePicker == "project_list" {
+ s.ProjectPage++
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if s.ActivePicker == "creative_list" {
+ s.CreativePage++
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if s.ProjectID != "" && s.CreativeID != "" {
+ b.SetState(&SelectChannelsForPurchase{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ ProjectDefaultLinkType: s.ProjectDefaultLinkType,
+ CreativeID: s.CreativeID,
+ CreativeTitle: s.CreativeTitle,
+ Channels: []PurchaseChannelInput{},
+ Duplicates: []string{},
+ ParsingErrors: []ParseError{},
+ BackState: s.BackState,
+ }, bot.EditMessage)
+ }
+
+ default:
+ if len(data) > 8 && data[:8] == "project:" {
+ projectID := data[8:]
+ if s.lastProjects != nil {
+ if info, ok := s.lastProjects[projectID]; ok {
+ s.ProjectTitle = info.Title
+ s.ProjectTelegramID = info.TelegramID
+ s.ProjectUsername = info.Username
+ s.ProjectStatus = info.Status
+ s.ProjectDefaultLinkType = info.DefaultInviteType
+ }
+ }
+ s.ProjectID = projectID
+ s.CreativeID = ""
+ s.CreativeTitle = ""
+ s.CreativePage = 0
+ s.ActivePicker = ""
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if len(data) > 9 && data[:9] == "creative:" {
+ creativeID := data[9:]
+ if s.lastCreatives != nil {
+ if info, ok := s.lastCreatives[creativeID]; ok {
+ s.CreativeTitle = info.Title
+ }
+ }
+ s.CreativeID = creativeID
+ s.ActivePicker = ""
+ s.Enter(b, bot.EditMessage)
+ return
+ } else {
+ s.Enter(b, bot.NewMessage)
+ }
+ }
+}
+
+func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *AddPurchase) Exit() {}
diff --git a/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf b/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf
new file mode 100644
index 0000000..8c93043
Binary files /dev/null and b/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf differ
diff --git a/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf b/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf
new file mode 100644
index 0000000..dff66cc
Binary files /dev/null and b/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf differ
diff --git a/tg_bot/screens/channel_parser.go b/tg_bot/screens/channel_parser.go
new file mode 100644
index 0000000..a76525f
--- /dev/null
+++ b/tg_bot/screens/channel_parser.go
@@ -0,0 +1,380 @@
+package screens
+
+import (
+ "net/url"
+ "regexp"
+ "strings"
+)
+
+// ChannelInput представляет результат парсинга ввода пользователя
+type ChannelInput struct {
+ Input string // оригинальный ввод пользователя
+ Username string // извлеченный username (если применимо)
+ InviteLink string // извлеченный invite link (если применимо)
+ Source string // источник: telegram, tgstat, telemetr
+ Type string // тип: username, invite, deeplink, stat
+ Valid bool // валидный ли формат
+}
+
+// Парсеры для разных форматов
+var (
+ // Username без @: 4-32 символа, начинается с буквы
+ channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,31}$`)
+
+ // Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx
+ inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`)
+ inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`)
+
+ // TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username
+ tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{3,31})`)
+
+ // Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username
+ telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{3,31})`)
+)
+
+// ParseChannelInput парсит ввод пользователя и определяет тип канала
+func ParseChannelInput(input string) ChannelInput {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return ChannelInput{Valid: false}
+ }
+
+ result := ChannelInput{Input: input}
+
+ // 1. Проверяем Telegram deeplink (tg://...)
+ if strings.HasPrefix(input, "tg://") {
+ return parseTelegramDeeplink(input)
+ }
+
+ // 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx)
+ if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) {
+ result.Type = "invite"
+ result.Source = "telegram"
+ result.InviteLink = normalizeInviteLink(input)
+ result.Valid = true
+ return result
+ }
+
+ // 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/)
+ if isURLFormat(input) {
+ return parseURLFormat(input)
+ }
+
+ // 4. Проверяем @username
+ if strings.HasPrefix(input, "@") {
+ username := strings.TrimPrefix(input, "@")
+ username = strings.TrimSpace(username)
+ if channelUsernameRe.MatchString(username) {
+ result.Username = username
+ result.Type = "username"
+ result.Source = "telegram"
+ result.Valid = true
+ return result
+ }
+ result.Valid = false
+ return result
+ }
+
+ // 5. Проверяем username (без @)
+ if channelUsernameRe.MatchString(input) {
+ result.Username = input
+ result.Type = "username"
+ result.Source = "telegram"
+ result.Valid = true
+ return result
+ }
+
+ // 6. Ничего не подошло
+ result.Valid = false
+ return result
+}
+
+// parseTelegramDeeplink парсит tg:// ссылки
+func parseTelegramDeeplink(input string) ChannelInput {
+ result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"}
+
+ // tg://resolve?domain=username&post=123
+ if strings.HasPrefix(input, "tg://resolve?domain=") {
+ parsed, err := url.Parse(input)
+ if err != nil {
+ result.Valid = false
+ return result
+ }
+ query := parsed.Query()
+ domain := query.Get("domain")
+ if domain != "" && channelUsernameRe.MatchString(domain) {
+ result.Username = domain
+ result.Type = "username"
+ result.Valid = true
+ return result
+ }
+ }
+
+ // tg://join?invite=abcdef
+ if strings.HasPrefix(input, "tg://join?invite=") {
+ parsed, err := url.Parse(input)
+ if err != nil {
+ result.Valid = false
+ return result
+ }
+ query := parsed.Query()
+ invite := query.Get("invite")
+ if invite != "" {
+ result.InviteLink = "t.me/+" + invite
+ result.Type = "invite"
+ result.Valid = true
+ return result
+ }
+ }
+
+ result.Valid = false
+ return result
+}
+
+// isURLFormat проверяет, является ли ввод URL или ссылкой
+func isURLFormat(input string) bool {
+ return strings.HasPrefix(input, "http://") ||
+ strings.HasPrefix(input, "https://") ||
+ strings.HasPrefix(input, "t.me/") ||
+ strings.HasPrefix(input, "telegram.me/") ||
+ strings.Contains(input, "t.me/") ||
+ strings.Contains(input, "telegram.me/")
+}
+
+// parseURLFormat парсит URL-форматы ссылок
+func parseURLFormat(input string) ChannelInput {
+ result := ChannelInput{Input: input}
+
+ // Нормализуем: убираем протокол
+ normalized := input
+ normalized = strings.TrimPrefix(normalized, "https://")
+ normalized = strings.TrimPrefix(normalized, "http://")
+ normalized = strings.TrimPrefix(normalized, "www.")
+
+ // Проверяем TGStat
+ if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 {
+ username := strings.TrimSuffix(matches[1], "_")
+ result.Username = username
+ result.Type = "stat"
+ result.Source = "tgstat"
+ result.Valid = true
+ return result
+ }
+
+ // Проверяем Telemetr
+ if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 {
+ username := strings.TrimSuffix(matches[1], "_")
+ result.Username = username
+ result.Type = "stat"
+ result.Source = "telemetr"
+ result.Valid = true
+ return result
+ }
+
+ // Проверяем telegram.me или t.me
+ if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") {
+ return parseTelegramLink(normalized)
+ }
+
+ result.Valid = false
+ return result
+}
+
+// parseTelegramLink парсит ссылки t.me/... и telegram.me/...
+func parseTelegramLink(link string) ChannelInput {
+ result := ChannelInput{Source: "telegram"}
+
+ // Убираем t.me/ или telegram.me/
+ path := link
+ if strings.HasPrefix(path, "telegram.me/") {
+ path = strings.TrimPrefix(path, "telegram.me/")
+ } else {
+ path = strings.TrimPrefix(path, "t.me/")
+ }
+
+ // Проверяем invite link: +xxxxx или joinchat/xxxxx
+ if strings.HasPrefix(path, "+") {
+ inviteHash := strings.TrimPrefix(path, "+")
+ inviteHash = extractBeforeChar(inviteHash, '?', '/')
+ if inviteHash != "" {
+ result.InviteLink = "t.me/+" + inviteHash
+ result.Type = "invite"
+ result.Valid = true
+ return result
+ }
+ }
+
+ if strings.HasPrefix(path, "joinchat/") {
+ inviteHash := strings.TrimPrefix(path, "joinchat/")
+ inviteHash = extractBeforeChar(inviteHash, '?', '/')
+ if inviteHash != "" {
+ result.InviteLink = "t.me/joinchat/" + inviteHash
+ result.Type = "invite"
+ result.Valid = true
+ return result
+ }
+ }
+
+ // Извлекаем username из t.me/username или t.me/username/12345
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) > 0 {
+ username := parts[0]
+ // Убираем @ если есть
+ username = strings.TrimPrefix(username, "@")
+
+ // Валидируем username
+ if channelUsernameRe.MatchString(username) {
+ result.Username = username
+ result.Type = "username"
+ result.Valid = true
+ return result
+ }
+ }
+
+ result.Valid = false
+ return result
+}
+
+// normalizeInviteLink нормализует invite ссылку
+func normalizeInviteLink(link string) string {
+ link = strings.TrimSpace(link)
+
+ // Добавляем протокол если нужно
+ if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") {
+ if strings.HasPrefix(link, "t.me/") {
+ // Уже в нужном формате
+ return link
+ }
+ }
+
+ // Нормализуем t.me/+xxxxx
+ if strings.HasPrefix(link, "t.me/+") {
+ return link
+ }
+
+ // Нормализуем t.me/joinchat/xxxxx
+ if strings.HasPrefix(link, "t.me/joinchat/") {
+ return link
+ }
+
+ return link
+}
+
+// extractBeforeChar извлекает часть строки до первого встреченного символа
+func extractBeforeChar(s string, chars ...rune) string {
+ for _, c := range chars {
+ if idx := strings.IndexRune(s, c); idx != -1 {
+ return s[:idx]
+ }
+ }
+ return s
+}
+
+// SuggestFix предлагает исправление для некорректного ввода
+func SuggestFix(input string) (string, bool) {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return "", false
+ }
+
+ // 1. Исправляем опечатки в протоколе
+ if strings.HasPrefix(input, "htp://") {
+ fixed := strings.Replace(input, "htp://", "https://", 1)
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+ if strings.HasPrefix(input, "ttps://") {
+ fixed := strings.Replace(input, "ttps://", "https://", 1)
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+ if strings.HasPrefix(input, "ttp://") {
+ fixed := strings.Replace(input, "ttp://", "http://", 1)
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+
+ // 2. Исправляем tme/ → t.me/
+ if strings.HasPrefix(input, "tme/") {
+ fixed := strings.Replace(input, "tme/", "t.me/", 1)
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+ if strings.Contains(input, "tme/") {
+ fixed := strings.Replace(input, "tme/", "t.me/", 1)
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+
+ // 3. Исправляем @@ или @@
+ if strings.HasPrefix(input, "@@") {
+ fixed := strings.TrimPrefix(input, "@")
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+
+ // 4. Пробуем добавить @ в начало username
+ if !strings.HasPrefix(input, "@") && !isURLFormat(input) {
+ fixed := "@" + input
+ if parsed := ParseChannelInput(fixed); parsed.Valid {
+ return fixed, true
+ }
+ }
+
+ // 5. Пробуем убрать лишние символы в конце
+ cleaned := input
+ cleaned = strings.TrimSuffix(cleaned, ".")
+ cleaned = strings.TrimSuffix(cleaned, ",")
+ cleaned = strings.TrimSuffix(cleaned, ";")
+ cleaned = strings.TrimSpace(cleaned)
+ if cleaned != input {
+ if parsed := ParseChannelInput(cleaned); parsed.Valid {
+ return cleaned, true
+ }
+ }
+
+ // 6. Пробуем извлечь username из сложной ссылки
+ if strings.Contains(input, "/") {
+ parts := strings.Split(input, "/")
+ for i := len(parts) - 1; i >= 0; i-- {
+ part := strings.TrimSpace(parts[i])
+ part = strings.TrimPrefix(part, "@")
+ if channelUsernameRe.MatchString(part) {
+ return "@" + part, true
+ }
+ }
+ }
+
+ return "", false
+}
+
+// FormatChannelLabel форматирует метку канала для отображения
+func FormatChannelLabel(input ChannelInput) string {
+ if input.Username != "" {
+ return "@" + input.Username
+ }
+ if input.InviteLink != "" {
+ return formatInviteLabel(input.InviteLink)
+ }
+ return input.Input
+}
+
+// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username)
+func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool {
+ for _, ch := range channels {
+ // Case-insensitive сравнение username (@MyChannel == @mychannel)
+ if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) {
+ return true
+ }
+ if input.InviteLink != "" && ch.InviteLink == input.InviteLink {
+ return true
+ }
+ }
+ return false
+}
diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go
new file mode 100644
index 0000000..cc078b0
--- /dev/null
+++ b/tg_bot/screens/creative_details.go
@@ -0,0 +1,384 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+type CreativeDetails struct {
+ CreativeEditorFields // Встраивание общих полей
+
+ CreativeID string
+ ProjectID string
+ ProjectTitle string // Название проекта для сообщений
+
+ BackState bot.State
+
+ // View mode: открыты из режима просмотра
+ ViewMode bool // Открыты из CreativeView
+ ViewBackTo bot.State // Состояние для возврата (CreativeView)
+
+ // Флаг подтверждения удаления
+ confirmingDelete bool
+}
+
+func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
+ // Удаляем сообщение предыдущего экрана
+ if b.LastMessageID != 0 {
+ b.DeleteMessage(b.ChatID, b.LastMessageID)
+ b.LastMessageID = 0
+ }
+
+ creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
+ b.SendNew("❌ Не удалось загрузить креатив", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ // Заполняем поля из загруженного креатива
+ s.Name = &creative.Name
+ if creative.Text != "" {
+ sanitizedText := ui.SanitizeHTML(creative.Text)
+ s.Text = &sanitizedText
+ log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
+ } else {
+ log.Info().Msg("Creative text is empty from backend")
+ }
+ s.MediaItems = nil
+ if len(creative.MediaItems) > 0 {
+ mediaItems := append([]backend.CreativeMediaItem(nil), creative.MediaItems...)
+ sort.Slice(mediaItems, func(i, j int) bool {
+ return mediaItems[i].Position < mediaItems[j].Position
+ })
+ for _, item := range mediaItems {
+ s.MediaItems = append(s.MediaItems, MediaItem{
+ MediaType: item.MediaType,
+ MediaFileID: item.MediaFileID,
+ })
+ }
+ }
+ s.Buttons = nil
+ for _, btn := range creative.Buttons {
+ s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
+ }
+ if creative.Tag != "" {
+ s.Tag = &creative.Tag
+ }
+ s.MediaChanged = false
+ s.ClearInputMode()
+
+ log.Info().
+ Str("creative_id", s.CreativeID).
+ Str("name", creative.Name).
+ Bool("has_text", creative.Text != "").
+ Bool("s_text_is_nil", s.Text == nil).
+ Msg("Sending creative preview and panel")
+
+ // Отправляем превью и панель управления
+ s.SendCreativePreview(b)
+ s.showManagementPanelWithDelete(b)
+}
+
+func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ // Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media)
+ switch {
+ case data == "cancel", data == "back":
+ // Удаляем сообщения текущего экрана перед переходом
+ s.CleanupMessages(b)
+ if s.ViewMode && s.ViewBackTo != nil {
+ b.SetState(s.ViewBackTo, bot.NewMessage)
+ } else if s.BackState != nil {
+ b.SetState(s.BackState, bot.NewMessage)
+ }
+
+ case data == "edit_text":
+ s.InputMode = inputModeText
+ s.ShowTextEditPanel(b)
+
+ case data == "add_button":
+ s.InputMode = inputModeButtonText
+ s.PendingButtonType = "invite"
+ s.ShowAddButtonPanel(b)
+
+ case data == "add_media":
+ s.InputMode = inputModeMedia
+ s.ShowMediaPanel(b)
+
+ case data == "delete_media":
+ s.MediaItems = nil
+ s.MediaChanged = true
+ s.UpdateCreativePreview(b)
+ s.showManagementPanelWithDelete(b)
+
+ case data == "cancel_add_button":
+ // Отменяем добавление кнопки
+ s.CancelPendingButton(b)
+ s.showManagementPanelWithDelete(b)
+
+ case data == "button_type_invite":
+ s.PendingButtonType = "invite"
+ s.InputMode = inputModeButtonText
+ s.ShowAddButtonPanel(b)
+
+ case data == "button_type_custom":
+ s.PendingButtonType = "custom"
+ s.InputMode = inputModeButtonText
+ s.ShowAddButtonPanel(b)
+
+ case data == "cancel_edit", data == "cancel_media":
+ s.ClearInputMode()
+ s.showManagementPanelWithDelete(b)
+
+ case data == "confirm_save":
+ s.updateCreative(b)
+
+ case data == "delete_creative":
+ if !s.confirmingDelete {
+ // Первое нажатие - просим подтверждение
+ s.confirmingDelete = true
+ s.showManagementPanelWithDelete(b)
+ } else {
+ // Второе нажатие - удаляем
+ s.deleteCreative(b)
+ }
+
+ case data == "confirm_delete":
+ // Это только для безопасности, главная логика в delete_creative
+ s.deleteCreative(b)
+
+ case strings.HasPrefix(data, "delete_button:"):
+ parts := strings.Split(data, ":")
+ if len(parts) == 2 {
+ var index int
+ if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil {
+ if index >= 0 && index < len(s.Buttons) {
+ s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
+ s.UpdateCreativePreview(b)
+ s.showManagementPanelWithDelete(b)
+ }
+ }
+ }
+
+ default:
+ // Сбрасываем флаг подтверждения удаления при любом другом действии
+ if s.confirmingDelete {
+ s.confirmingDelete = false
+ s.showManagementPanelWithDelete(b)
+ }
+ }
+}
+
+func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if u.Message == nil {
+ return
+ }
+
+ switch s.InputMode {
+ case inputModeMedia:
+ if !s.SetMediaFromMessage(u.Message) {
+ return
+ }
+ if u.Message.MediaGroupID != "" {
+ groupID := u.Message.MediaGroupID
+ s.scheduleMediaGroupAction(groupID, func() {
+ s.UpdateCreativePreview(b)
+ s.ShowMediaPanel(b)
+ })
+ b.MarkHandled()
+ return
+ }
+ s.UpdateCreativePreview(b)
+ s.ShowMediaPanel(b)
+ return
+ case inputModeButtonText:
+ if u.Message.Text == "" {
+ return
+ }
+ buttonText := u.Message.Text
+ s.DeleteUserMessage(b, u.Message.ID)
+
+ // Создаём кнопку с placeholder URL
+ if s.PendingButtonType == "custom" {
+ s.AddCustomButtonPlaceholder(buttonText)
+ s.UpdateCreativePreview(b)
+ s.InputMode = inputModeButtonURL
+ s.ShowButtonURLPanel(b)
+ } else {
+ s.AddInviteButton(buttonText)
+ s.UpdateCreativePreview(b)
+ s.PendingButtonType = ""
+ s.ClearInputMode()
+ s.showManagementPanelWithDelete(b)
+ }
+ return
+ case inputModeButtonURL:
+ if u.Message.Text == "" {
+ return
+ }
+ url := strings.TrimSpace(u.Message.Text)
+ s.DeleteUserMessage(b, u.Message.ID)
+
+ // Простая валидация URL
+ if !s.IsValidButtonURL(url, true) {
+ s.ShowInvalidButtonURLPanel(b)
+ return
+ }
+
+ // Обновляем URL последней кнопки
+ s.UpdateLastButtonURL(url)
+
+ s.ClearInputMode()
+ s.PendingButtonType = ""
+
+ s.UpdateCreativePreview(b)
+ s.showManagementPanelWithDelete(b)
+ return
+ case inputModeText:
+ if u.Message.Text == "" {
+ return
+ }
+ text := ui.FormatMessageHTML(u.Message)
+ s.Text = &text
+ s.DeleteUserMessage(b, u.Message.ID)
+
+ s.UpdateCreativePreview(b)
+ s.ClearInputMode()
+ s.showManagementPanelWithDelete(b)
+ return
+ }
+
+ // Обрабатываем имя креатива (если нет других состояний)
+ if u.Message.Text != "" {
+ text := ui.FormatMessageHTML(u.Message)
+ s.Text = &text
+ s.DeleteUserMessage(b, u.Message.ID)
+
+ s.UpdateCreativePreview(b)
+ s.showManagementPanelWithDelete(b)
+ }
+}
+
+func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *CreativeDetails) Exit() {}
+
+func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) {
+ var confirmText, confirmCallback string
+
+ if s.confirmingDelete {
+ confirmText = "✖ Подтвердить удаление"
+ confirmCallback = "confirm_delete"
+ } else {
+ confirmText = "✓ Сохранить"
+ confirmCallback = "confirm_save"
+ }
+
+ // Добавляем кнопку удаления креатива
+ deleteButton := Row(Button("⌦ Удалить креатив", "delete_creative"))
+
+ s.ShowManagementPanel(b, confirmText, confirmCallback, "cancel", deleteButton)
+}
+
+func (s *CreativeDetails) updateCreative(b *bot.Bot) {
+ // JWT уже создан в Bot.Update(), просто берем из сессии
+ jwt := b.Session.JWT
+ if jwt == "" {
+ log.Error().Msg("JWT is empty in session")
+ b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", Keyboard())
+ return
+ }
+
+ var err error
+
+ // Подготавливаем данные для обновления
+ input := backend.UpdateCreativeInput{
+ Name: s.Name,
+ Text: s.Text,
+ Tag: s.Tag,
+ }
+ if s.MediaChanged {
+ if len(s.MediaItems) > 0 {
+ mediaItems, err := s.BuildMediaInputs(b)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to download creative media")
+ // Показываем ошибку через панель управления
+ s.ShowControlPanel(b, "❌ Не удалось загрузить медиа\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Назад", "back")),
+ Row(Button("✓ Сохранить без медиа", "confirm_save")),
+ })
+ return
+ }
+ input.MediaItems = &mediaItems
+ } else {
+ emptyItems := []backend.CreativeMediaInput{}
+ input.MediaItems = &emptyItems
+ }
+ }
+
+ buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
+ for _, button := range s.Buttons {
+ buttons = append(buttons, backend.CreativeButton{
+ Text: button.Text,
+ URL: button.URL,
+ })
+ }
+ input.Buttons = &buttons
+
+ // Отправляем запрос на обновление
+ _, err = b.Backend.UpdateCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID, input)
+
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative")
+ // Показываем ошибку через панель управления
+ s.ShowControlPanel(b, "❌ Не удалось сохранить изменения\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Назад", "back")),
+ Row(Button("✓ Попробовать снова", "confirm_save")),
+ })
+ return
+ }
+
+ log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
+
+ // Возвращаемся назад
+ s.CleanupMessages(b)
+ if s.ViewMode && s.ViewBackTo != nil {
+ b.SetState(s.ViewBackTo, bot.NewMessage)
+ } else if s.BackState != nil {
+ b.SetState(s.BackState, bot.NewMessage)
+ }
+}
+
+func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
+ err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
+
+ if err != nil {
+ s.confirmingDelete = false // Сбрасываем флаг подтверждения
+ s.ShowControlPanel(b, "❌ Не удалось удалить креатив\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Назад", "back")),
+ Row(Button("⌦ Попробовать снова", "delete_creative")),
+ })
+ return
+ }
+
+ s.CleanupMessages(b)
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.NewMessage)
+ }
+}
diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go
new file mode 100644
index 0000000..e620bf9
--- /dev/null
+++ b/tg_bot/screens/creative_editor.go
@@ -0,0 +1,777 @@
+package screens
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+// InlineButton представляет кнопку с URL
+type InlineButton struct {
+ Text string
+ URL string
+}
+
+type MediaItem struct {
+ MediaType string // "photo", "video", "animation"
+ MediaFileID string // File ID из Telegram
+}
+
+// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
+type CreativeEditorFields struct {
+ // Данные креатива
+ Name *string
+ Text *string
+ Buttons []InlineButton
+ Tag *string // "testing" or "production"
+
+ // Медиа
+ MediaItems []MediaItem
+ MediaChanged bool
+
+ // Режим ввода
+ InputMode string
+
+ // Состояние для добавления кнопки
+ PendingButtonType string
+
+ // ID сообщения с креативом (которое постоянно обновляется)
+ CreativeMessageID *int
+ // ID сообщения с панелью управления
+ ControlPanelMessageID *int
+ // ID сообщений дополнительного медиа (после первого)
+ ExtraMediaMessageIDs []int
+
+ pendingMediaGroupMu sync.Mutex
+ pendingMediaGroupID string
+ pendingMediaGroupTimer *time.Timer
+ pendingMediaGroupGen int64
+
+ // Предыдущее состояние для определения изменения типа сообщения
+ previousMediaItems []MediaItem
+}
+
+const inviteLinkPlaceholder = "{{invite_link}}"
+const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
+const buttonURLPlaceholder = "https://example.com"
+
+var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`)
+var inviteLinkTagPattern = regexp.MustCompile(`\s*(.*?)\s*`)
+
+const (
+ inputModeText = "text"
+ inputModeButtonText = "button_text"
+ inputModeButtonURL = "button_url"
+ inputModeMedia = "media"
+)
+const msgCreativeDefault = `
+💭 Я твой креатив
+
+У меня пока нет текста, добавь его ниже.
+
+
+`
+const msgCreativeManagement = `
+👆Превью креатива выше — так он будет выглядеть при отправке.
+
+Выберите, что изменить:
+
+`
+const msgCreativePreviewBroken = "⚠️ Не удалось показать превью: креатив содержит некорректную разметку. Создайте его заново или отредактируйте текст."
+
+func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
+ keyboard := echotron.InlineKeyboardMarkup{
+ InlineKeyboard: buttons,
+ }
+ if len(buttons) == 0 {
+ keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
+ }
+ return keyboard
+}
+
+func copyMediaItems(items []MediaItem) []MediaItem {
+ if len(items) == 0 {
+ return nil
+ }
+ clone := make([]MediaItem, len(items))
+ copy(clone, items)
+ return clone
+}
+
+func mediaItemsEqual(a, b []MediaItem) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID {
+ return false
+ }
+ }
+ return true
+}
+
+func (e *CreativeEditorFields) primaryMedia() *MediaItem {
+ if len(e.MediaItems) == 0 {
+ return nil
+ }
+ return &e.MediaItems[0]
+}
+
+func (e *CreativeEditorFields) hasMultipleMedia() bool {
+ return len(e.MediaItems) > 1
+}
+
+func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) {
+ for _, msgID := range e.ExtraMediaMessageIDs {
+ b.DeleteMessage(b.ChatID, msgID)
+ }
+ e.ExtraMediaMessageIDs = nil
+}
+
+func (e *CreativeEditorFields) CleanupMessages(b *bot.Bot) {
+ if e.CreativeMessageID != nil {
+ b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
+ e.CreativeMessageID = nil
+ }
+ e.clearExtraMediaMessages(b)
+ if e.ControlPanelMessageID != nil {
+ b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
+ e.ControlPanelMessageID = nil
+ }
+ b.LastMessageID = 0
+}
+
+const mediaGroupDebounce = 2 * time.Second
+
+func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
+ e.pendingMediaGroupMu.Lock()
+ defer e.pendingMediaGroupMu.Unlock()
+
+ if groupID == "" {
+ action()
+ return
+ }
+
+ if e.pendingMediaGroupTimer != nil {
+ e.pendingMediaGroupTimer.Stop()
+ }
+ e.pendingMediaGroupID = groupID
+ e.pendingMediaGroupGen++
+ gen := e.pendingMediaGroupGen
+ e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() {
+ e.pendingMediaGroupMu.Lock()
+ currentGroup := e.pendingMediaGroupID
+ currentGen := e.pendingMediaGroupGen
+ if currentGen == gen && currentGroup == groupID {
+ e.pendingMediaGroupID = ""
+ e.pendingMediaGroupTimer = nil
+ }
+ e.pendingMediaGroupMu.Unlock()
+ if currentGen == gen && currentGroup == groupID {
+ action()
+ }
+ })
+}
+
+func isTelegramParseError(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "can't parse entities") || strings.Contains(msg, "Unexpected end tag")
+}
+
+// GetCreativeText возвращает текст для превью креатива
+func (e *CreativeEditorFields) GetCreativeText() string {
+ if e.Text == nil {
+ return msgCreativeDefault
+ }
+
+ text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
+ if text == "" {
+ return msgCreativeDefault
+ }
+
+ // Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку.
+ text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string {
+ sub := inviteLinkTagPattern.FindStringSubmatch(match)
+ inner := ""
+ if len(sub) > 1 {
+ inner = strings.TrimSpace(sub[1])
+ }
+ if inner == "" {
+ inner = inviteLinkPreviewURL
+ }
+ return fmt.Sprintf(`%s`, inviteLinkPreviewURL, inner)
+ })
+
+ // Заменяем пригласительные ссылки на preview URL только для отображения.
+ text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL)
+
+ // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках).
+ text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
+
+ return text
+}
+
+// GetCreativeButtons возвращает кнопки для превью креатива
+func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton {
+ var buttons [][]echotron.InlineKeyboardButton
+
+ for _, btn := range e.Buttons {
+ url := btn.URL
+ if url == inviteLinkPlaceholder {
+ url = inviteLinkPreviewURL
+ }
+ buttons = append(buttons, []echotron.InlineKeyboardButton{
+ {
+ Text: btn.Text,
+ URL: url,
+ },
+ })
+ }
+
+ return buttons
+}
+
+// SendCreativePreview отправляет превью креатива
+func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
+ creativeText := e.GetCreativeText()
+ creativeButtons := e.GetCreativeButtons()
+
+ // Создаем клавиатуру с пустым массивом (не nil!)
+ keyboard := buildCreativeKeyboard(creativeButtons)
+
+ var msgID int
+ sendText := func() (int, error) {
+ res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
+ ReplyMarkup: keyboard,
+ ParseMode: echotron.HTML,
+ LinkPreviewOptions: echotron.LinkPreviewOptions{
+ IsDisabled: true,
+ },
+ })
+ if err != nil {
+ return 0, err
+ }
+ if res.Result == nil {
+ return 0, fmt.Errorf("send message: empty result")
+ }
+ return res.Result.ID, nil
+ }
+ sendFallback := func() (int, error) {
+ res, err := b.SendMessage(msgCreativePreviewBroken, b.ChatID, &echotron.MessageOptions{
+ ReplyMarkup: keyboard,
+ LinkPreviewOptions: echotron.LinkPreviewOptions{
+ IsDisabled: true,
+ },
+ })
+ if err != nil {
+ return 0, err
+ }
+ if res.Result == nil {
+ return 0, fmt.Errorf("send message: empty result")
+ }
+ return res.Result.ID, nil
+ }
+
+ // Если есть медиа, отправляем с медиа
+ if len(e.MediaItems) > 1 {
+ e.clearExtraMediaMessages(b)
+ group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems))
+ for i, item := range e.MediaItems {
+ caption := ""
+ parseMode := echotron.ParseMode("")
+ if i == 0 {
+ caption = creativeText
+ parseMode = echotron.HTML
+ }
+ switch item.MediaType {
+ case "photo":
+ group = append(group, echotron.InputMediaPhoto{
+ Type: echotron.MediaTypePhoto,
+ Media: echotron.NewInputFileID(item.MediaFileID),
+ Caption: caption,
+ ParseMode: parseMode,
+ })
+ case "video":
+ group = append(group, echotron.InputMediaVideo{
+ Type: echotron.MediaTypeVideo,
+ Media: echotron.NewInputFileID(item.MediaFileID),
+ Caption: caption,
+ ParseMode: parseMode,
+ })
+ case "animation":
+ group = append(group, echotron.InputMediaVideo{
+ Type: echotron.MediaTypeVideo,
+ Media: echotron.NewInputFileID(item.MediaFileID),
+ Caption: caption,
+ ParseMode: parseMode,
+ })
+ default:
+ log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group")
+ }
+ }
+
+ if len(group) > 0 {
+ res, err := b.SendMediaGroup(b.ChatID, group, nil)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to send media group")
+ if id, err := sendText(); err == nil {
+ msgID = id
+ } else if isTelegramParseError(err) {
+ if id, err := sendFallback(); err == nil {
+ msgID = id
+ }
+ }
+ } else if len(res.Result) > 0 {
+ msgID = res.Result[0].ID
+ for i := 1; i < len(res.Result); i++ {
+ e.ExtraMediaMessageIDs = append(e.ExtraMediaMessageIDs, res.Result[i].ID)
+ }
+ }
+ }
+ } else if media := e.primaryMedia(); media != nil {
+ var res echotron.APIResponseMessage
+ var err error
+
+ switch media.MediaType {
+ case "photo":
+ res, err = b.SendPhoto(
+ echotron.NewInputFileID(media.MediaFileID),
+ b.ChatID,
+ &echotron.PhotoOptions{
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ ReplyMarkup: keyboard,
+ },
+ )
+ case "video":
+ res, err = b.SendVideo(
+ echotron.NewInputFileID(media.MediaFileID),
+ b.ChatID,
+ &echotron.VideoOptions{
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ ReplyMarkup: keyboard,
+ },
+ )
+ case "animation":
+ res, err = b.SendAnimation(
+ echotron.NewInputFileID(media.MediaFileID),
+ b.ChatID,
+ &echotron.AnimationOptions{
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ ReplyMarkup: keyboard,
+ },
+ )
+ }
+
+ if err != nil {
+ log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media")
+ // Fallback to text message - используем прямой SendMessage
+ if id, err := sendText(); err == nil {
+ msgID = id
+ } else if isTelegramParseError(err) {
+ if id, err := sendFallback(); err == nil {
+ msgID = id
+ }
+ }
+ } else if res.Result != nil {
+ msgID = res.Result.ID
+ }
+
+ } else {
+ // Отправляем обычное текстовое сообщение напрямую (не через SendNew)
+ // чтобы не затронуть LastMessageID и не затронуть cleanup
+ id, err := sendText()
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to send creative preview")
+ if isTelegramParseError(err) {
+ id, err = sendFallback()
+ if err == nil {
+ msgID = id
+ } else {
+ log.Error().Err(err).Msg("Failed to send fallback creative preview")
+ return
+ }
+ } else {
+ return
+ }
+ } else {
+ msgID = id
+ }
+ }
+
+ // ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
+ e.CreativeMessageID = &msgID
+ e.previousMediaItems = copyMediaItems(e.MediaItems)
+
+ // Устанавливаем LastMessageID чтобы ShowControlPanel мог корректно работать
+ // ВАЖНО: Это позволяет избежать конфликтов при навигации между экранами
+ b.LastMessageID = msgID
+}
+
+// UpdateCreativePreview обновляет превью креатива
+func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
+ if e.CreativeMessageID == nil {
+ log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
+ // Если превью еще не создано, создаем его
+ e.SendCreativePreview(b)
+ return
+ }
+
+ currentHasMedia := len(e.MediaItems) > 0
+ previousHasMedia := len(e.previousMediaItems) > 0
+ mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems)
+
+ // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
+ if currentHasMedia != previousHasMedia {
+ // Удаляем оба сообщения
+ if e.CreativeMessageID != nil {
+ b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
+ e.CreativeMessageID = nil
+ }
+ e.clearExtraMediaMessages(b)
+ if e.ControlPanelMessageID != nil {
+ b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
+ e.ControlPanelMessageID = nil
+ }
+
+ // Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
+ e.SendCreativePreview(b)
+ return
+ }
+
+ if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) {
+ if e.CreativeMessageID != nil {
+ b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
+ e.CreativeMessageID = nil
+ }
+ e.clearExtraMediaMessages(b)
+ e.SendCreativePreview(b)
+ return
+ }
+
+ // Тип не изменился - просто редактируем на месте
+ creativeText := e.GetCreativeText()
+ creativeButtons := e.GetCreativeButtons()
+
+ keyboard := buildCreativeKeyboard(creativeButtons)
+
+ msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID)
+ logEditError := func(msg string, err error) {
+ textPreview := creativeText
+ runes := []rune(textPreview)
+ if len(runes) > 200 {
+ textPreview = string(runes[:200]) + "..."
+ }
+ mediaType := ""
+ if media := e.primaryMedia(); media != nil {
+ mediaType = media.MediaType
+ }
+ log.Error().
+ Err(err).
+ Int("creative_message_id", *e.CreativeMessageID).
+ Str("media_type", mediaType).
+ Int("text_len", len([]rune(creativeText))).
+ Str("text_preview", textPreview).
+ Msg(msg)
+ }
+
+ // Если есть медиа - редактируем caption
+ updated := false
+ if media := e.primaryMedia(); media != nil {
+ var prevMedia MediaItem
+ if len(e.previousMediaItems) > 0 {
+ prevMedia = e.previousMediaItems[0]
+ }
+ mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType
+ if mediaChanged {
+ var inputMedia echotron.InputMedia
+ switch media.MediaType {
+ case "photo":
+ inputMedia = echotron.InputMediaPhoto{
+ Type: echotron.MediaTypePhoto,
+ Media: echotron.NewInputFileID(media.MediaFileID),
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ }
+ case "video":
+ inputMedia = echotron.InputMediaVideo{
+ Type: echotron.MediaTypeVideo,
+ Media: echotron.NewInputFileID(media.MediaFileID),
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ }
+ case "animation":
+ inputMedia = echotron.InputMediaAnimation{
+ Type: echotron.MediaTypeAnimation,
+ Media: echotron.NewInputFileID(media.MediaFileID),
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ }
+ }
+
+ if inputMedia != nil {
+ _, err := b.EditMessageMedia(
+ msgID,
+ inputMedia,
+ &echotron.MessageMediaOptions{
+ ReplyMarkup: keyboard,
+ },
+ )
+ if err != nil {
+ logEditError("Failed to edit message media", err)
+ } else {
+ updated = true
+ }
+ } else {
+ log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit")
+ }
+ } else {
+ _, err := b.EditMessageCaption(
+ msgID,
+ &echotron.MessageCaptionOptions{
+ Caption: creativeText,
+ ParseMode: echotron.HTML,
+ ReplyMarkup: keyboard,
+ },
+ )
+ if err != nil {
+ logEditError("Failed to edit media caption", err)
+ } else {
+ updated = true
+ }
+ }
+ } else {
+ // Текстовое сообщение - редактируем текст
+ _, err := b.EditMessageText(
+ creativeText,
+ msgID,
+ &echotron.MessageTextOptions{
+ ParseMode: echotron.HTML,
+ ReplyMarkup: keyboard,
+ LinkPreviewOptions: echotron.LinkPreviewOptions{
+ IsDisabled: true,
+ },
+ },
+ )
+ if err != nil {
+ logEditError("Failed to edit message text", err)
+ } else {
+ updated = true
+ }
+ }
+
+ if updated {
+ if currentHasMedia {
+ e.previousMediaItems = copyMediaItems(e.MediaItems)
+ } else {
+ e.previousMediaItems = nil
+ }
+ }
+}
+
+func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) {
+ _, err := b.DeleteMessage(b.ChatID, messageID)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to delete user message")
+ }
+}
+
+func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
+ var item MediaItem
+ switch {
+ case message.Photo != nil && len(message.Photo) > 0:
+ photo := message.Photo[len(message.Photo)-1]
+ item.MediaType = "photo"
+ item.MediaFileID = photo.FileID
+ case message.Video != nil:
+ item.MediaType = "video"
+ item.MediaFileID = message.Video.FileID
+ case message.Animation != nil:
+ item.MediaType = "animation"
+ item.MediaFileID = message.Animation.FileID
+ default:
+ return false
+ }
+
+ e.MediaItems = append(e.MediaItems, item)
+ e.MediaChanged = true
+ return true
+}
+
+func (e *CreativeEditorFields) AddInviteButton(text string) {
+ e.Buttons = append(e.Buttons, InlineButton{
+ Text: text,
+ URL: inviteLinkPlaceholder,
+ })
+}
+
+func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) {
+ e.Buttons = append(e.Buttons, InlineButton{
+ Text: text,
+ URL: buttonURLPlaceholder,
+ })
+}
+
+func (e *CreativeEditorFields) UpdateLastButtonURL(url string) {
+ if len(e.Buttons) > 0 {
+ e.Buttons[len(e.Buttons)-1].URL = url
+ }
+}
+
+func (e *CreativeEditorFields) ClearInputMode() {
+ e.InputMode = ""
+ e.PendingButtonType = ""
+}
+
+func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) {
+ if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 {
+ lastButton := e.Buttons[len(e.Buttons)-1]
+ if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder {
+ e.Buttons = e.Buttons[:len(e.Buttons)-1]
+ e.UpdateCreativePreview(b)
+ }
+ }
+
+ e.ClearInputMode()
+}
+
+func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool {
+ if allowHTTP {
+ return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
+ }
+ return strings.HasPrefix(url, "https://")
+}
+
+// ShowManagementPanel показывает панель управления креативом
+// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить")
+// confirmCallback - callback для кнопки подтверждения
+// cancelCallback - callback для кнопки отмены
+// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
+func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
+ text := msgCreativeManagement
+
+ buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...)
+ e.ShowControlPanel(b, text, buttons)
+}
+
+func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
+ var buttons [][]echotron.InlineKeyboardButton
+
+ // Блок редактирования контента
+ buttons = append(buttons, Row(
+ Button("✎ Текст", "edit_text"),
+ Button("+ Медиа", "add_media"),
+ ))
+ addButtonLabel := "+ Добавить кнопку"
+ addButtonCallback := "add_button"
+ if e.hasMultipleMedia() {
+ addButtonLabel = "Кнопки недоступны для >1 медиа"
+ addButtonCallback = "empty"
+ }
+ buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
+
+ // Кнопка удаления медиа (если медиа добавлено)
+ if len(e.MediaItems) > 0 {
+ label := "⌫ Убрать медиа"
+ if len(e.MediaItems) > 1 {
+ label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems))
+ }
+ buttons = append(buttons, Row(Button(label, "delete_media")))
+ }
+
+ // Кнопки удаления (если есть кнопки)
+ if len(e.Buttons) > 0 {
+ var row []echotron.InlineKeyboardButton
+ for i, btn := range e.Buttons {
+ row = append(row, Button(
+ fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text),
+ fmt.Sprintf("delete_button:%d", i),
+ ))
+
+ if len(row) == 2 {
+ buttons = append(buttons, row)
+ row = nil
+ }
+ }
+
+ if len(row) > 0 {
+ buttons = append(buttons, row)
+ }
+ }
+
+ // Дополнительные кнопки (например "Удалить креатив")
+ for _, extraRow := range extraButtons {
+ buttons = append(buttons, extraRow)
+ }
+
+ // Финальные кнопки
+ cancelLabel := "← " + cancelText
+ if e.Text != nil {
+ buttons = append(buttons, Row(
+ Button(cancelLabel, cancelCallback),
+ Button(confirmText, confirmCallback),
+ ))
+ } else {
+ buttons = append(buttons, Row(
+ Button(cancelLabel, cancelCallback),
+ ))
+ }
+
+ return buttons
+}
+
+// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
+func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
+ if e.ControlPanelMessageID == nil {
+ // Отправляем новую панель
+ keyboard := Keyboard(buttons...)
+ b.SendNew(text, keyboard)
+ // ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
+ controlPanelMsgID := b.LastMessageID
+ e.ControlPanelMessageID = &controlPanelMsgID
+ } else {
+ // Редактируем существующую панель
+ // Временно заменяем LastMessageID на ID панели управления
+ oldLastMessageID := b.LastMessageID
+ b.LastMessageID = *e.ControlPanelMessageID
+ defer func() { b.LastMessageID = oldLastMessageID }()
+
+ keyboard := Keyboard(buttons...)
+ b.Edit(text, keyboard)
+ }
+}
+
+func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) {
+ if len(e.MediaItems) == 0 {
+ return nil, nil
+ }
+ mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems))
+ for _, item := range e.MediaItems {
+ if item.MediaFileID == "" {
+ continue
+ }
+ mediaData, err := b.DownloadFileBytes(item.MediaFileID)
+ if err != nil {
+ return nil, err
+ }
+ mediaItems = append(mediaItems, backend.CreativeMediaInput{
+ MediaType: item.MediaType,
+ MediaFileID: item.MediaFileID,
+ MediaData: mediaData,
+ })
+ }
+ return mediaItems, nil
+}
diff --git a/tg_bot/screens/creative_editor_ui.go b/tg_bot/screens/creative_editor_ui.go
new file mode 100644
index 0000000..15c33a5
--- /dev/null
+++ b/tg_bot/screens/creative_editor_ui.go
@@ -0,0 +1,138 @@
+package screens
+
+import (
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+)
+
+const msgEditCreativeText = `
+✎ Введите текст креатива:
+
+⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала
+
+Формат ссылки:
+https://t.me/+xxx
+
+Пример:
+Присоединяйтесь к нашему каналу!
+https://t.me/+AbCdEfGhIjKlMn
+
+`
+const msgAddButtonPrompt = `
++ Добавить кнопку
+
+Введите текст кнопки:
+
+Например:
+• Перейти на сайт
+• Написать нам
+
+Ссылка на канал подставится автоматически при создании закупа.
+
+`
+const msgAddMediaPrompt = `
++ Добавить медиа
+
+Отправьте фото, видео или GIF
+
+Можно добавить несколько медиа — отправляйте по одному.
+Нажмите «Отмена», когда закончите.
+
+`
+const msgButtonURLPrompt = `
+✧ Введите URL для кнопки:
+
+URL должен начинаться с http:// или https://
+
+Например:
+https://example.com
+https://t.me/yourchannel
+
+`
+const msgInvalidURL = `
+❌ Неверный формат URL
+
+URL должен начинаться с http:// или https://
+
+Попробуйте ещё раз.
+
+`
+
+const msgEditTag = `
+🏷 Выберите тег креатива:
+
+🧪 Тестовый — для A/B тестов и экспериментов
+🚀 Рабочий — проверенный эффективный креатив
+
+`
+
+func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) {
+ testingLabel := "🧪 Тестовый"
+ productionLabel := "🚀 Рабочий"
+
+ if e.Tag != nil {
+ if *e.Tag == "testing" {
+ testingLabel = "✓ 🧪 Тестовый"
+ } else if *e.Tag == "production" {
+ productionLabel = "✓ 🚀 Рабочий"
+ }
+ }
+
+ e.ShowControlPanel(b, msgEditTag, [][]echotron.InlineKeyboardButton{
+ Row(
+ Button(testingLabel, "tag_testing"),
+ Button(productionLabel, "tag_production"),
+ ),
+ Row(Button("« Назад", "cancel_edit")),
+ })
+}
+
+func (e *CreativeEditorFields) GetTagLabel() string {
+ if e.Tag != nil && *e.Tag == "production" {
+ return "◉ Рабочий"
+ }
+ return "◉ Тестовый"
+}
+
+func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
+ inviteLabel := "Ссылка на канал"
+ customLabel := "Своя ссылка"
+ if e.PendingButtonType == "invite" {
+ inviteLabel = "✓ Ссылка на канал"
+ }
+ if e.PendingButtonType == "custom" {
+ customLabel = "✓ Своя ссылка"
+ }
+
+ e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{
+ Row(
+ Button(inviteLabel, "button_type_invite"),
+ Button(customLabel, "button_type_custom"),
+ ),
+ Row(Button("« Отмена", "cancel_add_button")),
+ })
+}
+
+func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) {
+ e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{
+ Row(Button("« Отмена", "cancel_edit")),
+ })
+}
+
+func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) {
+ e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{
+ Row(Button("« Отмена", "cancel_media")),
+ })
+}
+
+func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) {
+ e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{
+ Row(Button("« Отмена", "cancel_add_button")),
+ })
+}
+
+func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) {
+ e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{
+ Row(Button("« Отмена", "cancel_add_button")),
+ })
+}
diff --git a/tg_bot/screens/creative_view.go b/tg_bot/screens/creative_view.go
new file mode 100644
index 0000000..e4c1160
--- /dev/null
+++ b/tg_bot/screens/creative_view.go
@@ -0,0 +1,250 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/rs/zerolog/log"
+)
+
+// CreativeView - экран просмотра креатива (read-only mode)
+// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить
+type CreativeView struct {
+ CreativeEditorFields // Встраивание общих полей для отображения превью
+
+ CreativeID string
+ ProjectID string
+
+ // Данные проекта для передачи в AddPurchase
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+
+ BackState bot.State
+
+ renaming bool // режим ввода нового названия
+}
+
+func msgCreativeView(name string) string {
+ return fmt.Sprintf("📋 %s\n\nВыберите действие:", name)
+}
+
+func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
+ // Удаляем сообщение предыдущего экрана (его клавиатуру не очищает SendCreativePreview)
+ if b.LastMessageID != 0 {
+ b.DeleteMessage(b.ChatID, b.LastMessageID)
+ b.LastMessageID = 0
+ }
+
+ // Сбрасываем старые ID — при re-enter всегда создаём свежие сообщения
+ s.CreativeMessageID = nil
+ s.ControlPanelMessageID = nil
+
+ // Загружаем креатив
+ creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
+ b.SendNew("❌ Не удалось загрузить креатив", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ // Загружаем проект для получения данных
+ project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID)
+ if err != nil {
+ log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project")
+ } else {
+ s.ProjectTitle = project.Title
+ s.ProjectTelegramID = project.TelegramID
+ s.ProjectStatus = project.Status
+ if project.Username != nil {
+ s.ProjectUsername = *project.Username
+ }
+ }
+
+ // Заполняем поля креатива для отображения превью
+ s.Name = &creative.Name
+ if creative.Text != "" {
+ s.Text = &creative.Text
+ }
+ s.MediaItems = nil
+ if len(creative.MediaItems) > 0 {
+ for _, item := range creative.MediaItems {
+ s.MediaItems = append(s.MediaItems, MediaItem{
+ MediaType: item.MediaType,
+ MediaFileID: item.MediaFileID,
+ })
+ }
+ }
+ s.Buttons = nil
+ for _, btn := range creative.Buttons {
+ s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
+ }
+ if creative.Tag != "" {
+ s.Tag = &creative.Tag
+ }
+
+ // Отправляем превью креатива
+ s.SendCreativePreview(b)
+
+ // Отправляем панель управления с кнопками действий
+ s.showActionPanel(b)
+}
+
+func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "back":
+ s.CleanupMessages(b)
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.NewMessage)
+ }
+
+ case data == "rename":
+ s.renaming = true
+ s.ShowControlPanel(b, "✏️ Введите новое название креатива:", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Отмена", "cancel_rename")),
+ })
+
+ case data == "cancel_rename":
+ s.renaming = false
+ s.showActionPanel(b)
+
+ case data == "edit":
+ s.CleanupMessages(b)
+ b.SetState(&CreativeDetails{
+ CreativeID: s.CreativeID,
+ ProjectID: s.ProjectID,
+ ViewMode: true,
+ ViewBackTo: s,
+ ProjectTitle: s.ProjectTitle,
+ BackState: s.BackState,
+ }, bot.NewMessage)
+
+ case data == "toggle_tag":
+ // Переключение тега (testing <-> production)
+ s.toggleTag(b)
+
+ case data == "purchase_creative":
+ s.CleanupMessages(b)
+ creativeTitle := ""
+ if s.Name != nil {
+ creativeTitle = *s.Name
+ }
+
+ b.SetState(&AddPurchase{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ CreativeID: s.CreativeID,
+ CreativeTitle: creativeTitle,
+ BackState: s,
+ }, bot.NewMessage)
+
+ default:
+ log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView")
+ }
+}
+
+func (s *CreativeView) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if !s.renaming || u.Message == nil || u.Message.Text == "" {
+ return
+ }
+
+ newName := strings.TrimSpace(u.Message.Text)
+ s.DeleteUserMessage(b, u.Message.ID)
+
+ input := backend.UpdateCreativeInput{
+ Name: &newName,
+ }
+ _, err := b.Backend.UpdateCreative(
+ context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID, input,
+ )
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to rename creative")
+ s.ShowControlPanel(b, "❌ Не удалось переименовать", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Назад", "cancel_rename")),
+ })
+ return
+ }
+
+ s.Name = &newName
+ s.renaming = false
+ s.showActionPanel(b)
+}
+
+func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *CreativeView) Exit() {}
+
+// showActionPanel показывает панель действий с кнопками
+func (s *CreativeView) showActionPanel(b *bot.Bot) {
+ tagLabel := s.GetTagLabel()
+
+ buttons := [][]echotron.InlineKeyboardButton{
+ Row(
+ Button("✎ Переименовать", "rename"),
+ Button("✎ Редактировать", "edit"),
+ ),
+ Row(Button("+ Закупить", "purchase_creative")),
+ Row(
+ Button("← Назад", "back"),
+ Button(tagLabel, "toggle_tag"),
+ ),
+ }
+
+ name := "Креатив"
+ if s.Name != nil && *s.Name != "" {
+ name = *s.Name
+ }
+ s.ShowControlPanel(b, msgCreativeView(name), buttons)
+}
+
+// toggleTag переключает тег креатива (testing <-> production)
+func (s *CreativeView) toggleTag(b *bot.Bot) {
+ newTag := "testing"
+ if s.Tag != nil && *s.Tag == "testing" {
+ newTag = "production"
+ }
+
+ // Обновляем тег через backend
+ input := backend.UpdateCreativeInput{
+ Tag: &newTag,
+ }
+
+ _, err := b.Backend.UpdateCreative(
+ context.Background(),
+ b.Session.JWT,
+ b.Session.WorkspaceID,
+ s.CreativeID,
+ input,
+ )
+
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag")
+ s.ShowControlPanel(b, "❌ Не удалось изменить тег\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Назад", "back")),
+ Row(Button("↻ Попробовать снова", "toggle_tag")),
+ })
+ return
+ }
+
+ // Обновляем локальное значение
+ s.Tag = &newTag
+
+ // Обновляем панель (показывает новый тег)
+ s.showActionPanel(b)
+}
diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go
new file mode 100644
index 0000000..c49d93d
--- /dev/null
+++ b/tg_bot/screens/creatives.go
@@ -0,0 +1,145 @@
+package screens
+
+import (
+ "context"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+type Creatives struct {
+ CurrentPage int
+ ProjectID string
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+ BackState bot.State
+}
+
+const creativesPerPage = 6
+const creativesPerRow = 2
+
+const msgNoCreatives = `
+Креативы
+
+У вас пока нет креативов
+
+Креатив — это рекламный материал:
+ ‣ Текст объявления
+ ‣ Медиа контент (фото, видео)
+ ‣ Шаблон для автопостинга
+ ‣ Варианты для A/B тестирования
+
+Начните с добавления первого креатива
+`
+
+const msgCreatives = `
+Креативы
+
+Управление рекламными материалами
+
+Выберите креатив
+`
+
+func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
+ page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get creatives")
+ b.SendNew("❌ Не удалось загрузить креативы", refreshKeyboard)
+ return
+ }
+
+ text := msgCreatives
+ if len(page.Items) == 0 {
+ text = msgNoCreatives
+ }
+
+ var kbRows [][]echotron.InlineKeyboardButton
+
+ rowsGrid := ui.BuildGrid(page.Items, creativesPerRow, creativesPerPage, page.Pages,
+ func(creative backend.Creative) (string, string) { return creative.Name, "creative:" + creative.ID },
+ )
+ kbRows = append(kbRows, rowsGrid...)
+
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: page.Pages,
+ MiddleButtons: Row(Button("+ Добавить", "add_creative")),
+ }); navRow != nil {
+ kbRows = append(kbRows, navRow)
+ }
+
+ kbRows = append(kbRows, Row(Button("← Назад", "back"), Button("≡ Архив", "archive")))
+
+ kb := Keyboard(kbRows...)
+ b.Render(text, kb, mode)
+
+ updateProjectHeaderMedia(b, b.LastMessageID, text, kb, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
+}
+
+func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "prev":
+ if s.CurrentPage > 0 {
+ s.CurrentPage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case data == "next":
+ s.CurrentPage++
+ s.Enter(b, bot.EditMessage)
+
+ case data == "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+
+ case data == "archive":
+ b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back"))))
+
+ case data == "add_creative":
+ b.SetState(&AddCreativeStart{Ctx: &AddCreativeCtx{
+ ProjectID: s.ProjectID,
+ BackState: s,
+ }}, bot.EditMessage)
+
+ case strings.HasPrefix(data, "creative:"):
+ creativeID, ok := strings.CutPrefix(data, "creative:")
+ if !ok || creativeID == "" {
+ return
+ }
+
+ // Открываем CreativeView вместо CreativeDetails
+ b.SetState(&CreativeView{
+ CreativeID: creativeID,
+ ProjectID: s.ProjectID,
+ // Данные проекта для передачи в AddPurchase
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ BackState: s,
+ }, bot.NewMessage)
+
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+ return
+}
+
+func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *Creatives) Exit() {}
diff --git a/tg_bot/screens/help.go b/tg_bot/screens/help.go
new file mode 100644
index 0000000..cd38e90
--- /dev/null
+++ b/tg_bot/screens/help.go
@@ -0,0 +1,54 @@
+package screens
+
+import (
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+)
+
+const msgHelp = `
+❓ Помощь
+
+Команды бота:
+/start — Главное меню
+/projects — Мои проекты
+/placements — Размещения
+/platform — Веб-платформа
+/help — Эта справка
+
+Как работать с ботом:
+1. Создайте проект и добавьте креативы
+2. Выберите каналы для размещения
+3. Оформите покупку через безопасную сделку
+4. Получите автоматический отчёт
+
+Поддержка:
+Если возникли вопросы, напишите в поддержку.
+`
+
+type Help struct{}
+
+func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
+ keyboard := Keyboard(
+ Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
+ Row(Button("↩ В главное меню", "main_menu")),
+ )
+
+ b.Render(msgHelp, keyboard, mode)
+}
+
+func (s *Help) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ switch u.CallbackQuery.Data {
+ case "main_menu":
+ b.SetState(&MainMenu{}, bot.EditMessage)
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *Help) HandleMessage(b *bot.Bot, u *echotron.Update) {}
+
+func (s *Help) Handle(b *bot.Bot, u *echotron.Update) {
+ b.SetState(&MainMenu{}, bot.NewMessage)
+}
+
+func (s *Help) Exit() {}
diff --git a/tg_bot/screens/helpers.go b/tg_bot/screens/helpers.go
new file mode 100644
index 0000000..79d8abb
--- /dev/null
+++ b/tg_bot/screens/helpers.go
@@ -0,0 +1,41 @@
+package screens
+
+import (
+ "github.com/NicoNex/echotron/v3"
+)
+
+var emptyKeyboard = Keyboard()
+var refreshKeyboard = Keyboard(Row(Button("↻ Обновить", "refresh")))
+
+func Button(text, data string) echotron.InlineKeyboardButton {
+ return echotron.InlineKeyboardButton{
+ Text: text,
+ CallbackData: data,
+ }
+}
+
+func Stylish(btn echotron.InlineKeyboardButton, style echotron.ButtonStyle) echotron.InlineKeyboardButton {
+ btn.Style = style
+
+ return btn
+}
+
+func URLButton(text, url string) echotron.InlineKeyboardButton {
+ return echotron.InlineKeyboardButton{
+ Text: text,
+ URL: url,
+ }
+}
+
+func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
+
+func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
+ if len(rows) == 0 {
+ return echotron.InlineKeyboardMarkup{
+ InlineKeyboard: [][]echotron.InlineKeyboardButton{},
+ }
+ }
+ return echotron.InlineKeyboardMarkup{
+ InlineKeyboard: rows,
+ }
+}
diff --git a/tg_bot/screens/login.go b/tg_bot/screens/login.go
new file mode 100644
index 0000000..d4e45e8
--- /dev/null
+++ b/tg_bot/screens/login.go
@@ -0,0 +1,59 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/rs/zerolog/log"
+)
+
+type Login struct{}
+
+const msgHelloLogin = `
+Привет, %s!
+Нажми кнопку для входа на сайт. 👇
+`
+
+func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
+ token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID)
+ if err != nil {
+ b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard)
+ return
+ }
+
+ loginURL := b.Backend.LoginURL(token)
+
+ usernameDisplay := "аноним"
+ if b.Session.FirstName != "" {
+ usernameDisplay = b.Session.FirstName
+ }
+
+ kb := Keyboard(Row(URLButton("Войти на сайт", loginURL)))
+
+ res, err := b.SendMessage(fmt.Sprintf(msgHelloLogin, usernameDisplay), b.ChatID, &echotron.MessageOptions{
+ ReplyMarkup: kb,
+ ParseMode: echotron.HTML,
+ LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
+ })
+ if err != nil {
+ log.Err(err).Msg("SendMessage error")
+ return
+ }
+
+ if res.Result != nil {
+ if err := b.Backend.AttachLoginTokenMessage(context.Background(), token, res.Result.ID); err != nil {
+ log.Err(err).Msg("AttachLoginTokenMessage error")
+ }
+ }
+
+ b.SetState(&MainMenu{}, bot.NewMessage)
+}
+func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {}
+
+func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {}
+
+func (s *Login) Handle(b *bot.Bot, u *echotron.Update) {}
+
+func (s *Login) Exit() {}
diff --git a/tg_bot/screens/main_menu.go b/tg_bot/screens/main_menu.go
new file mode 100644
index 0000000..8ecf105
--- /dev/null
+++ b/tg_bot/screens/main_menu.go
@@ -0,0 +1,100 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/rs/zerolog/log"
+)
+
+type MainMenu struct{}
+
+const msgAbout = `
+Главное меню
+
+
+За что отвечает каждая кнопка в этом окне:
+
+• Рабочее пространство — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.
+
+• Мои проекты — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы.
+
+• Размещения — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку).
+
+• Веб-платформа — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов.
+
+• Помощь — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы.
+
+
+Чтобы делать закупы, добавьте канал и креатив:
+/addchannel — новый канал
+`
+
+func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
+ msg := msgAbout
+ if b.Session.WasRestarted {
+ msg = "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\n" + msgAbout
+ b.Session.WasRestarted = false
+ }
+
+ loadWorkspace := func() string {
+ workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get workspaces for main menu")
+ return "ошибка"
+ }
+ if len(workspaces) == 0 {
+ workspace, err := b.Backend.CreateWorkspace(context.Background(), b.Session.JWT, "Личное")
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to create workspace")
+ }
+
+ workspaces = append(workspaces, workspace)
+ }
+
+ selected := workspaces[0] // дефолт — первый
+
+ if b.Session.WorkspaceID != "" {
+ for _, ws := range workspaces {
+ if ws.ID == b.Session.WorkspaceID {
+ selected = ws
+ break
+ }
+ }
+ }
+
+ b.Session.WorkspaceID = selected.ID
+ return selected.Name
+ }
+
+ workspaceName := loadWorkspace()
+
+ keyboard := Keyboard(
+ Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
+ Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")),
+ Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
+ )
+
+ b.Render(msg, keyboard, mode)
+}
+
+func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ switch u.CallbackQuery.Data {
+ case "my_projects":
+ b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
+ case "placements":
+ b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPlacements: true}, bot.EditMessage)
+ case "workspace_menu":
+ b.SetState(&WorkspaceMenu{BackState: &MainMenu{}}, bot.EditMessage)
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {}
+
+func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
+
+func (s *MainMenu) Exit() {}
diff --git a/tg_bot/screens/my_projects.go b/tg_bot/screens/my_projects.go
new file mode 100644
index 0000000..4f7948a
--- /dev/null
+++ b/tg_bot/screens/my_projects.go
@@ -0,0 +1,197 @@
+package screens
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+const projectsPerPage = 6
+const projectsPerRow = 2
+
+type MyProjects struct {
+ CurrentPage int
+ BackState bot.State
+ OpenPlacements bool
+
+ cancel context.CancelFunc
+ lastProjectCount int
+}
+
+const msgNoProjects = `
+Мои проекты
+
+У вас пока нет подключенных каналов.
+
+Что дает подключение канала:
+ ▸ Управление типом приглашений (публичные/с одобрением)
+ ▸ Автоматическая отправка креативов в канал
+ ▸ Интеграция с планом закупов
+ ▸ Статистика и аналитика подписчиков
+ ▸ Настройка уведомлений
+
+Начните с добавления первого проекта
+`
+
+const msgProjects = `
+Мои проекты
+
+Управление вашими Telegram-каналами.
+
+Выберите канал
+`
+
+func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
+ s.renderProjects(b, mode)
+
+ s.startPolling(b)
+}
+
+func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode) {
+ page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
+ if err != nil {
+ b.SendNew("❌ Не удалось загрузить проекты", refreshKeyboard)
+ return
+ }
+
+ s.lastProjectCount = page.Total
+
+ var kbRows [][]echotron.InlineKeyboardButton
+
+ grid := ui.BuildGrid(page.Items, projectsPerRow, projectsPerPage, page.Pages,
+ func(project backend.Project) (string, string) { return project.Title, "project:" + project.ID },
+ )
+ kbRows = append(kbRows, grid...)
+
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: page.Pages,
+ MiddleButtons: Row(Button("+ Добавить", "add_project")),
+ }); navRow != nil {
+ kbRows = append(kbRows, navRow)
+ }
+
+ kbRows = append(kbRows, Row(Button("← Назад", "main_menu"), Button("≡ Архив", "archive")))
+
+ keyboard := Keyboard(kbRows...)
+
+ text := msgProjects
+ if len(page.Items) == 0 {
+ text = msgNoProjects
+ }
+
+ b.Render(text, keyboard, mode)
+}
+
+func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "prev":
+ if s.CurrentPage > 0 {
+ s.CurrentPage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case data == "next":
+ s.CurrentPage++
+ s.Enter(b, bot.EditMessage)
+
+ case data == "main_menu":
+ b.SetState(&MainMenu{}, bot.EditMessage)
+
+ case data == "archive":
+ b.Edit("Эта функция в разработке", Keyboard(Row(Button("← Назад", "in_dev"))))
+
+ case data == "add_project":
+ b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage)
+
+ case strings.HasPrefix(data, "project:"):
+ projectID, ok := strings.CutPrefix(data, "project:")
+ if !ok || projectID == "" {
+ return
+ }
+ project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, projectID)
+ if err != nil {
+ log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
+ return
+ }
+
+ if s.OpenPlacements {
+ b.SetState(&Placements{
+ ProjectID: project.ID,
+ ProjectTitle: project.Title,
+ BackState: &MyProjects{OpenPlacements: true},
+ }, bot.EditMessage)
+ } else {
+ b.SetState(&ProjectDetails{
+ Project: project,
+ BackState: &MyProjects{},
+ }, bot.EditMessage)
+ }
+
+ default:
+ s.Enter(b, bot.EditMessage)
+ }
+ return
+}
+
+func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *MyProjects) Exit() {
+ if s.cancel != nil {
+ log.Info().Msg("MyProjects: cancelling polling goroutine")
+ s.cancel()
+ }
+}
+
+func (s *MyProjects) startPolling(b *bot.Bot) {
+ if s.cancel != nil {
+ s.cancel()
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
+ s.cancel = cancel
+
+ go s.pollProjects(ctx, b)
+}
+
+func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot) {
+ interval := 3 * time.Second
+ timeToSlow := time.Now().Add(30 * time.Second)
+
+ timer := time.NewTimer(interval)
+ defer timer.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+
+ case <-timer.C:
+ if time.Now().After(timeToSlow) {
+ interval = 10 * time.Second
+ }
+
+ page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
+ if err == nil && page.Total != s.lastProjectCount {
+ s.lastProjectCount = page.Total
+ s.renderProjects(b, bot.EditMessage)
+ }
+
+ timer.Reset(interval)
+ }
+ }
+}
diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go
new file mode 100644
index 0000000..a2f57fa
--- /dev/null
+++ b/tg_bot/screens/placement_details.go
@@ -0,0 +1,322 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+)
+
+type PlacementDetails struct {
+ ProjectID string
+ PlacementID string
+ BackState bot.State
+}
+
+func (s *PlacementDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
+ s.renderDetails(b, mode)
+}
+
+func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
+ placement, err := b.Backend.GetPlacement(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, s.PlacementID)
+ if err != nil {
+ b.SendNew("❌ Не удалось загрузить размещение", Keyboard())
+ return
+ }
+
+ placementPost := placement.PlacementPost
+
+ text := "Детали размещения\n\n"
+
+ // Размещение в канале
+ text += "📺 Размещение в канале:\n"
+ text += formatChannelWithName(placement.Channel)
+ text += "\n"
+
+ // Рекламируемый проект
+ if placement.Project != nil {
+ text += "🎯 Рекламируемый проект:\n"
+ text += formatProject(*placement.Project)
+ text += "\n"
+ }
+
+ // Ссылка на пост
+ if placementPost != nil && placementPost.Post.URL != nil && *placementPost.Post.URL != "" {
+ text += fmt.Sprintf("🔗 Ссылка на пост: %s\n", *placementPost.Post.URL)
+ }
+
+ // Пригласительная ссылка
+ if placement.InviteLink != nil && *placement.InviteLink != "" {
+ text += fmt.Sprintf("📩 Пригласительная ссылка: %s\n", *placement.InviteLink)
+ }
+
+ // Тип ссылки
+ text += fmt.Sprintf("🔓 Тип ссылки: %s\n", formatInviteLinkType(placement.InviteLinkType))
+
+ // Short ID
+ if placement.ShortID != "" {
+ text += fmt.Sprintf("🔢 ID: %s\n", placement.ShortID)
+ }
+
+ // Креатив
+ if placement.Details != nil && placement.Details.CreativeName != nil && *placement.Details.CreativeName != "" {
+ text += fmt.Sprintf("✨ Креатив: %s\n", *placement.Details.CreativeName)
+ } else if placement.CreativeName != nil && *placement.CreativeName != "" {
+ text += fmt.Sprintf("✨ Креатив: %s\n", *placement.CreativeName)
+ }
+
+ text += "\n"
+
+ // Финансовая информация
+ if placement.Details != nil {
+ // Стоимость
+ if placement.Details.Cost != nil {
+ costType := formatCostType(placement.Details.Cost.Type)
+ text += fmt.Sprintf("💰 Стоимость: %s %.0f₽\n", costType, placement.Details.Cost.Value)
+ }
+
+ // Стоимость до торга
+ if placement.Details.CostBeforeBargain != nil {
+ costType := formatCostType(placement.Details.CostBeforeBargain.Type)
+ text += fmt.Sprintf("💸 До торга: %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value)
+ }
+
+ // Дата размещения
+ if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
+ text += fmt.Sprintf("📅 Дата размещения: %s\n", formatDateTime(*placement.Details.PlacementAt))
+ }
+
+ // Дата оплаты
+ if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" {
+ text += fmt.Sprintf("💳 Дата оплаты: %s\n", formatDateTime(*placement.Details.PaymentAt))
+ }
+
+ // Формат
+ if placement.Details.Format != nil && *placement.Details.Format != "" {
+ text += fmt.Sprintf("📐 Формат: %s\n", *placement.Details.Format)
+ }
+
+ // Тип закупа
+ if placement.Details.PlacementType != nil {
+ text += fmt.Sprintf("🔄 Тип закупа: %s\n", formatPlacementType(*placement.Details.PlacementType))
+ }
+ }
+
+ text += "\n"
+
+ // Статистика
+ if placementPost != nil {
+ // Кол-во подписчиков
+ if placementPost.SubscriptionsCount > 0 {
+ text += fmt.Sprintf("👥 Подписчики: %d\n", placementPost.SubscriptionsCount)
+ }
+
+ // Просмотры поста
+ if placementPost.ViewsCount != nil {
+ text += fmt.Sprintf("👁️ Просмотры: %d\n", *placementPost.ViewsCount)
+ }
+
+ // CPF (стоимость подписчика)
+ if placementPost.SubscriptionsCount > 0 && placement.Details != nil && placement.Details.Cost != nil {
+ cpf := calculateCPF(placement.Details.Cost.Value, placementPost.SubscriptionsCount)
+ text += fmt.Sprintf("💵 CPF: %.2f₽\n", cpf)
+ }
+
+ // Конверсия
+ if placementPost.ViewsCount != nil && *placementPost.ViewsCount > 0 && placementPost.SubscriptionsCount > 0 {
+ conversion := calculateConversion(placementPost.SubscriptionsCount, *placementPost.ViewsCount)
+ text += fmt.Sprintf("📊 Конверсия: %.1f%%\n", conversion)
+ }
+ }
+
+ // Комментарий
+ if placement.Details != nil && placement.Details.Comment != nil && *placement.Details.Comment != "" {
+ text += fmt.Sprintf("\n📝 Комментарий: %s\n", *placement.Details.Comment)
+ }
+
+ keyboard := Keyboard(Row(Button("← Назад", "back")))
+ b.Render(text, keyboard, mode)
+}
+
+func formatChannelWithName(channel backend.Channel) string {
+ var name string
+ if channel.Title != nil && *channel.Title != "" {
+ name = *channel.Title
+ } else if channel.Username != nil && *channel.Username != "" {
+ name = "@" + *channel.Username
+ } else {
+ name = "Без названия"
+ }
+
+ // Добавляем ссылку если есть username или invite_link
+ if channel.Username != nil && *channel.Username != "" {
+ return fmt.Sprintf(" %s (@%s)\n", name, *channel.Username)
+ } else if channel.InviteLink != nil && *channel.InviteLink != "" {
+ return fmt.Sprintf(" %s\n", name)
+ }
+ return fmt.Sprintf(" %s\n", name)
+}
+
+func formatProject(project backend.ProjectOutput) string {
+ var name string
+ if project.Channel.Title != nil && *project.Channel.Title != "" {
+ name = *project.Channel.Title
+ } else if project.Channel.Username != nil && *project.Channel.Username != "" {
+ name = "@" + *project.Channel.Username
+ } else {
+ name = "Без названия"
+ }
+
+ // Добавляем ссылку если есть username
+ if project.Channel.Username != nil && *project.Channel.Username != "" {
+ return fmt.Sprintf(" %s (@%s)\n", name, *project.Channel.Username)
+ }
+ return fmt.Sprintf(" %s\n", name)
+}
+
+func calculateCPF(cost float64, subscriptions int) float64 {
+ if subscriptions == 0 {
+ return 0
+ }
+ return cost / float64(subscriptions)
+}
+
+func calculateConversion(subscriptions int, views int) float64 {
+ if views == 0 {
+ return 0
+ }
+ return float64(subscriptions) / float64(views) * 100
+}
+
+func formatInviteLinkType(value string) string {
+ if value == "approval" {
+ return "с одобрением"
+ }
+ return "публичная"
+}
+
+func formatPlacementStatus(status string) string {
+ normalized := strings.TrimSpace(strings.ToLower(status))
+ switch normalized {
+ case "planned":
+ return "Планируется"
+ case "approved":
+ return "Согласовано"
+ case "rejected":
+ return "Отклонено"
+ case "in_progress":
+ return "В работе"
+ case "completed":
+ return "Размещено"
+ default:
+ return status
+ }
+}
+
+func formatPlacementType(value string) string {
+ normalized := strings.TrimSpace(strings.ToLower(value))
+ switch normalized {
+ case "self_promo":
+ return "Самопиар"
+ case "standard":
+ return "Стандарт"
+ default:
+ return value
+ }
+}
+
+func formatCostType(value string) string {
+ switch strings.TrimSpace(strings.ToLower(value)) {
+ case "cpm":
+ return "СРМ"
+ default:
+ return "Фикс"
+ }
+}
+
+func formatDateTime(value string) string {
+ if value == "" {
+ return value
+ }
+ var parsed time.Time
+ var err error
+
+ // Try parsing with timezone
+ if parsed, err = time.Parse(time.RFC3339, value); err == nil {
+ return formatCompactDateTime(parsed.In(ui.MskLocation))
+ }
+ if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil {
+ return formatCompactDateTime(parsed.In(ui.MskLocation))
+ }
+ if parsed, err = time.ParseInLocation("2006-01-02T15:04:05", value, ui.MskLocation); err == nil {
+ return formatCompactDateTime(parsed.In(ui.MskLocation))
+ }
+ if parsed, err = time.ParseInLocation("2006-01-02", value, ui.MskLocation); err == nil {
+ return formatCompactDateTime(parsed.In(ui.MskLocation))
+ }
+ return value
+}
+
+func WeekdayName(day time.Weekday) string {
+ switch day {
+ case time.Monday:
+ return "Пн"
+ case time.Tuesday:
+ return "Вт"
+ case time.Wednesday:
+ return "Ср"
+ case time.Thursday:
+ return "Чт"
+ case time.Friday:
+ return "Пт"
+ case time.Saturday:
+ return "Сб"
+ case time.Sunday:
+ return "Вс"
+ default:
+ return ""
+ }
+}
+
+func MonthShort(month time.Month) string {
+ months := []string{
+ "янв", "фев", "мар", "апр", "май", "июн",
+ "июл", "авг", "сен", "окт", "ноя", "дек",
+ }
+ if int(month) < 1 || int(month) > len(months) {
+ return ""
+ }
+ return months[int(month)-1]
+}
+
+func formatCompactDateTime(value time.Time) string {
+ weekday := WeekdayName(value.Weekday())
+ month := MonthShort(value.Month())
+ return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
+}
+
+func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ switch u.CallbackQuery.Data {
+ case "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *PlacementDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PlacementDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PlacementDetails) Exit() {}
diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go
new file mode 100644
index 0000000..db1e9ac
--- /dev/null
+++ b/tg_bot/screens/placements.go
@@ -0,0 +1,246 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+)
+
+type Placements struct {
+ ProjectID string
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+ BackState bot.State
+}
+
+const msgPlacements = `
+Размещения
+
+Управление размещениями для проекта.
+`
+
+func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) {
+ var rows [][]echotron.InlineKeyboardButton
+
+ rows = append(rows, Row(Button("+ Создать размещение", "add_purchase")))
+ rows = append(rows, Row(Button("Список размещений", "placements_list"), URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID))))
+ rows = append(rows, Row(Button("← Назад", "back")))
+
+ keyboard := Keyboard(rows...)
+ b.Render(msgPlacements, keyboard, mode)
+
+ updateProjectHeaderMedia(b, b.LastMessageID, msgPlacements, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
+}
+
+func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch data {
+ case "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+
+ case "add_purchase":
+ b.SetState(&AddPurchase{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ ActivePicker: "",
+ BackState: s,
+ }, bot.EditMessage)
+
+ case "placements_list":
+ b.SetState(&PlacementsList{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ BackState: s,
+ }, bot.EditMessage)
+
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *Placements) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *Placements) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *Placements) Exit() {}
+
+// PlacementsList - экран со списком размещений
+const placementsPerPage = 6
+const placementsPerRow = 1
+
+type PlacementsList struct {
+ ProjectID string
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+ CurrentPage int
+ BackState bot.State
+}
+
+func (s *PlacementsList) Enter(b *bot.Bot, mode bot.RenderMode) {
+ s.render(b, mode)
+}
+
+func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
+ page, err := b.Backend.GetPlacements(
+ context.Background(),
+ b.Session.JWT,
+ b.Session.WorkspaceID,
+ s.ProjectID,
+ s.CurrentPage+1,
+ placementsPerPage,
+ )
+ if err != nil {
+ b.SendNew("❌ Не удалось загрузить размещения", Keyboard())
+ return
+ }
+
+ text := fmt.Sprintf("Список размещений%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages))
+
+ var rows [][]echotron.InlineKeyboardButton
+
+ if len(page.Items) == 0 {
+ text += `Список размещений пуст.
+
+Размещение — это план публикации рекламы в канале:
+ ‣ Выбор креатива
+ ‣ Канал размещения
+ ‣ Стоимость и формат
+ ‣ Статус выполнения
+
+Создайте первое размещение`
+ } else {
+ // Формируем кнопки для каждого размещения
+ buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items))
+ for i, placement := range page.Items {
+ // Нумерация по порядку создания: первое размещение = #1 (на последней странице),
+ // последнее размещение = #N (вверху).
+ globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i
+
+ // Название канала
+ channelName := "Без названия"
+ if placement.Channel.Title != nil && *placement.Channel.Title != "" {
+ channelName = *placement.Channel.Title
+ } else if placement.Channel.Username != nil && *placement.Channel.Username != "" {
+ channelName = "@" + *placement.Channel.Username
+ }
+
+ // Дата размещения
+ dateStr := ""
+ if placement.Details != nil && placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
+ dateStr = formatDate(*placement.Details.PlacementAt)
+ }
+
+ // Текст кнопки: #N • Канал • DD.MM
+ buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
+ if dateStr != "" {
+ buttonText += fmt.Sprintf(" • %s", dateStr)
+ }
+
+ buttons = append(buttons, echotron.InlineKeyboardButton{
+ Text: buttonText,
+ CallbackData: fmt.Sprintf("placement:%s", placement.ID),
+ })
+ }
+
+ // Раскладываем по сетке
+ rows = ui.BuildPageRows(buttons, placementsPerRow, placementsPerPage, page.Pages)
+ }
+
+ // Навигация (без MiddleButtons - без "Создать размещение")
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: page.Pages,
+ }); navRow != nil {
+ rows = append(rows, navRow)
+ }
+
+ // Кнопка назад
+ rows = append(rows, Row(Button("← Назад", "back")))
+
+ keyboard := Keyboard(rows...)
+ b.Render(text, keyboard, mode)
+}
+
+func (s *PlacementsList) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch data {
+ case "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+
+ case "prev":
+ if s.CurrentPage > 0 {
+ s.CurrentPage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case "next":
+ s.CurrentPage++
+ s.Enter(b, bot.EditMessage)
+
+ default:
+ // Проверяем, не нажали ли на конкретное размещение
+ if len(data) > 10 && data[:10] == "placement:" {
+ placementID := data[10:]
+ b.SetState(&PlacementDetails{
+ ProjectID: s.ProjectID,
+ PlacementID: placementID,
+ BackState: s,
+ }, bot.EditMessage)
+ } else {
+ s.Enter(b, bot.NewMessage)
+ }
+ }
+}
+
+func (s *PlacementsList) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PlacementsList) Exit() {}
+
+// formatDate парсит ISO дату и форматирует как "27.01"
+func formatDate(isoDate string) string {
+ // Пробуем парсить RFC3339
+ t, err := time.Parse(time.RFC3339, isoDate)
+ if err != nil {
+ // Пробуем другие форматы
+ t, err = time.ParseInLocation("2006-01-02T15:04:05", isoDate, ui.MskLocation)
+ if err != nil {
+ // Иногда бэкенд может вернуть только дату без времени
+ t, err = time.ParseInLocation("2006-01-02", isoDate, ui.MskLocation)
+ if err != nil {
+ return ""
+ }
+ }
+ }
+
+ return t.In(ui.MskLocation).Format("02.01")
+}
diff --git a/tg_bot/screens/platform_link.go b/tg_bot/screens/platform_link.go
new file mode 100644
index 0000000..8534293
--- /dev/null
+++ b/tg_bot/screens/platform_link.go
@@ -0,0 +1,44 @@
+package screens
+
+import (
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+)
+
+const msgPlatform = `
+🌐 Веб-платформа
+
+Управляйте проектами в браузере — удобно для работы с компьютера.
+
+Полный функционал доступен на сайте.
+`
+
+type PlatformLink struct{}
+
+func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) {
+ keyboard := Keyboard(
+ Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
+ Row(Button("↩ В главное меню", "main_menu")),
+ )
+
+ b.Render(msgPlatform, keyboard, mode)
+}
+
+func (s *PlatformLink) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ switch u.CallbackQuery.Data {
+ case "main_menu":
+ b.SetState(&MainMenu{}, bot.EditMessage)
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *PlatformLink) HandleMessage(b *bot.Bot, u *echotron.Update) {}
+
+func (s *PlatformLink) Handle(b *bot.Bot, u *echotron.Update) {
+ b.SetState(&MainMenu{}, bot.NewMessage)
+}
+
+func (s *PlatformLink) Exit() {}
diff --git a/tg_bot/screens/project_details.go b/tg_bot/screens/project_details.go
new file mode 100644
index 0000000..54f902e
--- /dev/null
+++ b/tg_bot/screens/project_details.go
@@ -0,0 +1,191 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/rs/zerolog/log"
+)
+
+type ProjectDetails struct {
+ Project *backend.Project
+ BackState bot.State
+}
+
+func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
+ if s.Project == nil {
+ b.SendNew("❌ Проект не найден", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ text := s.formatProjectDetails()
+ keyboard := s.buildKeyboard()
+ b.Render(text, keyboard, mode)
+
+ messageID := b.LastMessageID
+ username := usernameFromProject(s.Project)
+ status := statusFromProject(s.Project)
+ updateProjectHeaderMedia(b, messageID, text, keyboard, s.Project.TelegramID, s.Project.Title, username, status)
+}
+
+func (s *ProjectDetails) formatProjectDetails() string {
+ p := s.Project
+
+ text := fmt.Sprintf("%s\n\n", p.Title)
+
+ if p.Username != nil && *p.Username != "" {
+ text += fmt.Sprintf("▸ Tg username: @%s\n", *p.Username)
+ } else {
+ text += "▸ Tg username: не привязан\n"
+ }
+
+ // Статус проекта
+ var statusSymbol, statusText string
+ switch p.Status {
+ case "active":
+ statusSymbol, statusText = "●", "Активный"
+ case "inactive":
+ statusSymbol, statusText = "○", "Неактивен"
+ case "archived":
+ statusSymbol, statusText = "■", "Архивный"
+ case "paused":
+ statusSymbol, statusText = "◐", "Приостановлен"
+ default:
+ statusSymbol, statusText = "○", p.Status
+ }
+ text += fmt.Sprintf("▸ Статус: %s %s\n", statusSymbol, statusText)
+
+ text += "\nУправление проектом"
+
+ return text
+}
+
+func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
+ var buttons [][]echotron.InlineKeyboardButton
+
+ buttons = append(buttons, Row(
+ Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
+ Button("Размещения", fmt.Sprintf("placements:%s", s.Project.ID)),
+ ))
+
+ // Второй ряд: Тип вступления по умолчанию для закупов
+ var currentType string
+ if s.Project.PurchaseInviteTypeDefault == "public" {
+ currentType = "открытая"
+ } else {
+ currentType = "с заявками"
+ }
+ buttons = append(buttons, Row(
+ Button(fmt.Sprintf("Ссылка: %s", currentType), fmt.Sprintf("link_type:%s", s.Project.ID)),
+ ))
+
+ // Нижний ряд: Назад и Архивировать
+ buttons = append(buttons, Row(
+ Button("← Назад", "back"),
+ Button("≡ Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)),
+ ))
+
+ return Keyboard(buttons...)
+}
+
+func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "back":
+ if s.BackState != nil {
+ s.transitionWithNewMessage(b, s.BackState)
+ }
+
+ case strings.HasPrefix(data, "creatives:"):
+ b.SetState(&Creatives{
+ ProjectID: s.Project.ID,
+ ProjectTitle: s.Project.Title,
+ ProjectTelegramID: s.Project.TelegramID,
+ ProjectUsername: usernameFromProject(s.Project),
+ ProjectStatus: statusFromProject(s.Project),
+ BackState: s,
+ }, bot.EditMessage)
+
+ case strings.HasPrefix(data, "placements:"):
+ b.SetState(&Placements{
+ ProjectID: s.Project.ID,
+ ProjectTitle: s.Project.Title,
+ ProjectTelegramID: s.Project.TelegramID,
+ ProjectUsername: usernameFromProject(s.Project),
+ ProjectStatus: statusFromProject(s.Project),
+ BackState: s,
+ }, bot.EditMessage)
+
+ case data == "back_to_project":
+ s.Enter(b, bot.EditMessage)
+
+ case strings.HasPrefix(data, "link_type:"):
+ var newType string
+ if s.Project.PurchaseInviteTypeDefault == "public" {
+ newType = "approval"
+ } else {
+ newType = "public"
+ }
+
+ updatedProject, err := b.Backend.UpdateProjectInviteLinkType(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.Project.ID, newType)
+
+ if err != nil {
+ b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), Keyboard(
+ Row(Button("← Назад", "back_to_project")),
+ ))
+ return
+ }
+
+ s.Project = updatedProject
+ s.Enter(b, bot.EditMessage)
+
+ default:
+ // Для остальных кнопок показываем заглушку
+ b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), Keyboard(
+ Row(Button("← Назад", "back_to_project")),
+ ))
+ }
+ return
+}
+
+func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *ProjectDetails) Exit() {}
+
+func (s *ProjectDetails) transitionWithNewMessage(b *bot.Bot, next bot.State) {
+ if b.LastMessageID != 0 {
+ if _, err := b.DeleteMessage(b.ChatID, b.LastMessageID); err != nil {
+ log.Error().Err(err).Msg("DeleteMessage failed")
+ } else {
+ b.LastMessageID = 0
+ }
+ }
+ b.SetState(next, bot.NewMessage)
+}
+
+func usernameFromProject(p *backend.Project) string {
+ if p == nil || p.Username == nil {
+ return ""
+ }
+ return *p.Username
+}
+
+func statusFromProject(p *backend.Project) string {
+ if p == nil {
+ return ""
+ }
+ return p.Status
+}
diff --git a/tg_bot/screens/project_header.go b/tg_bot/screens/project_header.go
new file mode 100644
index 0000000..99c3f08
--- /dev/null
+++ b/tg_bot/screens/project_header.go
@@ -0,0 +1,442 @@
+package screens
+
+import (
+ "bytes"
+ _ "embed"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/jpeg"
+ _ "image/png"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/rs/zerolog/log"
+ xdraw "golang.org/x/image/draw"
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/opentype"
+ "golang.org/x/image/math/fixed"
+)
+
+//go:embed assets/fonts/JetBrainsMono-Bold.ttf
+var jetBrainsMonoBold []byte
+
+//go:embed assets/fonts/JetBrainsMono-Regular.ttf
+var jetBrainsMonoRegular []byte
+
+const projectHeaderCacheTTL = 30 * time.Minute
+const projectHeaderCacheVersion = "img-v1"
+const projectHeaderCacheMaxBytes = 32 * 1024 * 1024
+
+type projectHeaderCacheEntry struct {
+ bytes []byte
+ sizeBytes int
+ uniqueID string
+ fetchedAt time.Time
+}
+
+var projectHeaderCache = struct {
+ mu sync.Mutex
+ items map[string]projectHeaderCacheEntry
+ total int
+}{
+ items: make(map[string]projectHeaderCacheEntry),
+}
+
+var projectHeaderLastMediaKey = struct {
+ mu sync.Mutex
+ items map[string]string
+}{
+ items: make(map[string]string),
+}
+
+func updateProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, chatID int64, title string, username string, status string) {
+ if messageID == 0 || chatID == 0 || title == "" {
+ return
+ }
+
+ cacheKey := projectHeaderCacheKey(chatID, username, status)
+ if cached, ok := getProjectHeaderFromCache(cacheKey); ok && time.Since(cached.fetchedAt) < projectHeaderCacheTTL {
+ mediaKey := cacheKey + ":" + cached.uniqueID
+ if shouldSkipMediaEdit(chatID, messageID, mediaKey) {
+ return
+ }
+ if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil {
+ setLastMediaKey(chatID, messageID, mediaKey)
+ b.SetLastMessageIsMedia(true)
+ }
+ return
+ }
+
+ chatInfo, err := b.GetChat(chatID)
+ if err != nil {
+ log.Error().Err(err).Int64("chat_id", chatID).Msg("GetChat failed")
+ return
+ }
+ if chatInfo.Result == nil || chatInfo.Result.Photo == nil || chatInfo.Result.Photo.SmallFileID == "" {
+ return
+ }
+
+ uniqueID := chatInfo.Result.Photo.SmallFileUniqueID
+ if cached, ok := getProjectHeaderFromCache(cacheKey); ok && cached.uniqueID == uniqueID && len(cached.bytes) > 0 {
+ setProjectHeaderCache(cacheKey, cached.bytes, uniqueID)
+ mediaKey := cacheKey + ":" + uniqueID
+ if shouldSkipMediaEdit(chatID, messageID, mediaKey) {
+ return
+ }
+ if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil {
+ setLastMediaKey(chatID, messageID, mediaKey)
+ b.SetLastMessageIsMedia(true)
+ }
+ return
+ }
+
+ photoBytes, err := b.DownloadFileBytes(chatInfo.Result.Photo.SmallFileID)
+ if err != nil {
+ log.Error().Err(err).Int64("chat_id", chatID).Msg("Download chat photo failed")
+ return
+ }
+
+ compositeBytes, err := buildProjectHeaderImage(photoBytes, title, username, status)
+ if err != nil {
+ log.Error().Err(err).Int64("chat_id", chatID).Msg("Build project header image failed")
+ return
+ }
+ setProjectHeaderCache(cacheKey, compositeBytes, uniqueID)
+
+ mediaKey := cacheKey + ":" + uniqueID
+ if !shouldSkipMediaEdit(chatID, messageID, mediaKey) {
+ if err := editProjectHeaderMedia(b, messageID, caption, keyboard, compositeBytes, chatID); err == nil {
+ setLastMediaKey(chatID, messageID, mediaKey)
+ b.SetLastMessageIsMedia(true)
+ }
+ }
+}
+
+func editProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, compositeBytes []byte, chatID int64) error {
+ if len(compositeBytes) == 0 {
+ return nil
+ }
+
+ media := echotron.InputMediaPhoto{
+ Type: echotron.MediaTypePhoto,
+ Media: echotron.NewInputFileBytes("project_header.jpg", compositeBytes),
+ Caption: caption,
+ ParseMode: echotron.HTML,
+ }
+
+ _, err := b.EditMessageMedia(
+ echotron.NewMessageID(b.ChatID, messageID),
+ media,
+ &echotron.MessageMediaOptions{
+ ReplyMarkup: keyboard,
+ },
+ )
+ if err != nil {
+ if strings.Contains(err.Error(), "message is not modified") {
+ log.Info().Int64("chat_id", chatID).Msg("EditMessageMedia not modified")
+ return nil
+ }
+ log.Error().Err(err).Int64("chat_id", chatID).Msg("EditMessageMedia failed")
+ return err
+ }
+ return nil
+}
+
+func shouldSkipMediaEdit(chatID int64, messageID int, mediaKey string) bool {
+ key := fmt.Sprintf("%d:%d", chatID, messageID)
+ projectHeaderLastMediaKey.mu.Lock()
+ defer projectHeaderLastMediaKey.mu.Unlock()
+ lastKey, ok := projectHeaderLastMediaKey.items[key]
+ return ok && lastKey == mediaKey
+}
+
+func setLastMediaKey(chatID int64, messageID int, mediaKey string) {
+ key := fmt.Sprintf("%d:%d", chatID, messageID)
+ projectHeaderLastMediaKey.mu.Lock()
+ defer projectHeaderLastMediaKey.mu.Unlock()
+ projectHeaderLastMediaKey.items[key] = mediaKey
+}
+
+func projectHeaderCacheKey(chatID int64, username string, status string) string {
+ return fmt.Sprintf("%s:%d:%s:%s", projectHeaderCacheVersion, chatID, strings.ToUpper(username), strings.ToUpper(status))
+}
+
+func getProjectHeaderFromCache(key string) (projectHeaderCacheEntry, bool) {
+ projectHeaderCache.mu.Lock()
+ defer projectHeaderCache.mu.Unlock()
+ entry, ok := projectHeaderCache.items[key]
+ return entry, ok
+}
+
+func setProjectHeaderCache(key string, value []byte, uniqueID string) {
+ projectHeaderCache.mu.Lock()
+ defer projectHeaderCache.mu.Unlock()
+ if existing, ok := projectHeaderCache.items[key]; ok {
+ projectHeaderCache.total -= existing.sizeBytes
+ }
+ projectHeaderCache.items[key] = projectHeaderCacheEntry{
+ bytes: value,
+ sizeBytes: len(value),
+ uniqueID: uniqueID,
+ fetchedAt: time.Now(),
+ }
+ projectHeaderCache.total += len(value)
+ projectHeaderCacheEvictIfNeeded()
+}
+
+func projectHeaderCacheEvictIfNeeded() {
+ for projectHeaderCache.total > projectHeaderCacheMaxBytes && len(projectHeaderCache.items) > 0 {
+ var oldestKey string
+ var oldestTime time.Time
+ first := true
+ for key, entry := range projectHeaderCache.items {
+ if first || entry.fetchedAt.Before(oldestTime) {
+ oldestKey = key
+ oldestTime = entry.fetchedAt
+ first = false
+ }
+ }
+ if oldestKey == "" {
+ return
+ }
+ projectHeaderCache.total -= projectHeaderCache.items[oldestKey].sizeBytes
+ delete(projectHeaderCache.items, oldestKey)
+ }
+}
+
+func buildProjectHeaderImage(photoBytes []byte, title string, username string, status string) ([]byte, error) {
+ const (
+ canvasW = 720
+ canvasH = 260
+ )
+
+ srcImg, _, err := image.Decode(bytes.NewReader(photoBytes))
+ if err != nil {
+ return nil, err
+ }
+
+ canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
+ drawGradient(canvas, color.RGBA{R: 6, G: 8, B: 16, A: 255}, color.RGBA{R: 18, G: 10, B: 28, A: 255})
+
+ avatarSize := 168
+ avatarX := 24
+ avatarY := (canvasH - avatarSize) / 2
+
+ cropped := cropCenterSquare(srcImg)
+ scaled := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize))
+ xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil)
+
+ mask := circleMask(avatarSize)
+ draw.DrawMask(
+ canvas,
+ image.Rect(avatarX, avatarY, avatarX+avatarSize, avatarY+avatarSize),
+ scaled,
+ image.Point{},
+ mask,
+ image.Point{},
+ draw.Over,
+ )
+
+ textColor := image.NewUniform(color.RGBA{R: 245, G: 247, B: 250, A: 255})
+ usernameColor := image.NewUniform(color.RGBA{R: 84, G: 156, B: 255, A: 255})
+ textX := avatarX + avatarSize + 24
+ textMaxWidth := canvasW - textX - 24
+
+ titleSize := fitFontSize(jetBrainsMonoBold, title, textMaxWidth, float64(canvasH)*0.22, 20)
+ titleFace, err := loadFontFace(jetBrainsMonoBold, titleSize)
+ if err != nil {
+ return nil, err
+ }
+ defer titleFace.Close()
+
+ usernameText := formatUsername(username)
+ statusText, statusColor := statusInfo(status)
+ statusLine := usernameText
+ if statusText != "" {
+ if statusLine != "" {
+ statusLine += " | "
+ }
+ statusLine += statusText
+ }
+
+ statusSize := titleSize * 0.6
+ if statusSize < 14 {
+ statusSize = 14
+ }
+ if statusLine != "" {
+ statusSize = fitFontSize(jetBrainsMonoRegular, statusLine, textMaxWidth, statusSize, 12)
+ }
+ statusFace, err := loadFontFace(jetBrainsMonoRegular, statusSize)
+ if err != nil {
+ return nil, err
+ }
+ defer statusFace.Close()
+
+ titleMetrics := titleFace.Metrics()
+ statusMetrics := statusFace.Metrics()
+ lineGapRatio := 0.03
+ lineGap := int(float64(canvasH) * lineGapRatio)
+ totalHeight := titleMetrics.Height.Ceil()
+ if statusLine != "" {
+ totalHeight += lineGap + statusMetrics.Height.Ceil()
+ }
+ verticalOffsetRatio := 0.02
+ startY := (canvasH-totalHeight)/2 + titleMetrics.Ascent.Ceil() + int(float64(canvasH)*verticalOffsetRatio)
+
+ drawText(canvas, titleFace, textColor, textX, startY, title)
+ if statusLine != "" {
+ statusY := startY + titleMetrics.Descent.Ceil() + lineGap + statusMetrics.Ascent.Ceil()
+ drawStatusLine(canvas, statusFace, textX, statusY, usernameText, statusText, usernameColor, textColor, statusColor)
+ }
+ var out bytes.Buffer
+ if err := jpeg.Encode(&out, canvas, &jpeg.Options{Quality: 85}); err != nil {
+ return nil, err
+ }
+ return out.Bytes(), nil
+}
+
+func cropCenterSquare(img image.Image) image.Image {
+ b := img.Bounds()
+ w, h := b.Dx(), b.Dy()
+ size := w
+ if h < w {
+ size = h
+ }
+ x0 := b.Min.X + (w-size)/2
+ y0 := b.Min.Y + (h-size)/2
+ cropRect := image.Rect(x0, y0, x0+size, y0+size)
+
+ if sub, ok := img.(interface {
+ SubImage(r image.Rectangle) image.Image
+ }); ok {
+ return sub.SubImage(cropRect)
+ }
+
+ dst := image.NewRGBA(image.Rect(0, 0, size, size))
+ draw.Draw(dst, dst.Bounds(), img, cropRect.Min, draw.Src)
+ return dst
+}
+
+func circleMask(diameter int) *image.Alpha {
+ mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
+ r := float64(diameter) / 2
+ cx := r
+ cy := r
+ for y := 0; y < diameter; y++ {
+ for x := 0; x < diameter; x++ {
+ dx := float64(x) + 0.5 - cx
+ dy := float64(y) + 0.5 - cy
+ if dx*dx+dy*dy <= r*r {
+ mask.SetAlpha(x, y, color.Alpha{A: 255})
+ }
+ }
+ }
+ return mask
+}
+func drawGradient(img *image.RGBA, top, bottom color.RGBA) {
+ b := img.Bounds()
+ h := b.Dy()
+ w := b.Dx()
+ for y := 0; y < h; y++ {
+ t := float64(y) / float64(h-1)
+ r := uint8(float64(top.R)*(1-t) + float64(bottom.R)*t)
+ g := uint8(float64(top.G)*(1-t) + float64(bottom.G)*t)
+ bb := uint8(float64(top.B)*(1-t) + float64(bottom.B)*t)
+ for x := 0; x < w; x++ {
+ img.Set(x, y, color.RGBA{R: r, G: g, B: bb, A: 255})
+ }
+ }
+}
+
+func loadFontFace(fontData []byte, size float64) (font.Face, error) {
+ ft, err := opentype.Parse(fontData)
+ if err != nil {
+ return nil, err
+ }
+ return opentype.NewFace(ft, &opentype.FaceOptions{
+ Size: size,
+ DPI: 72,
+ Hinting: font.HintingFull,
+ })
+}
+
+func fitFontSize(fontData []byte, text string, maxWidth int, startSize float64, minSize float64) float64 {
+ size := startSize
+ for size >= minSize {
+ face, err := loadFontFace(fontData, size)
+ if err != nil {
+ return size
+ }
+ width := font.MeasureString(face, text).Ceil()
+ face.Close()
+ if width <= maxWidth {
+ return size
+ }
+ size -= 2
+ }
+ return minSize
+}
+
+func drawText(dst *image.RGBA, face font.Face, src image.Image, x int, y int, text string) {
+ d := &font.Drawer{
+ Dst: dst,
+ Src: src,
+ Face: face,
+ Dot: fixed.P(x, y),
+ }
+ d.DrawString(text)
+}
+
+func formatUsername(username string) string {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return ""
+ }
+ if strings.HasPrefix(username, "@") {
+ return username
+ }
+ return "@" + username
+}
+
+func statusInfo(status string) (string, color.RGBA) {
+ switch status {
+ case "active":
+ return "Активный", color.RGBA{R: 66, G: 211, B: 114, A: 255}
+ case "inactive":
+ return "Неактивен", color.RGBA{R: 160, G: 170, B: 180, A: 255}
+ case "archived":
+ return "Архивный", color.RGBA{R: 180, G: 180, B: 180, A: 255}
+ case "paused":
+ return "Приостановлен", color.RGBA{R: 245, G: 179, B: 66, A: 255}
+ default:
+ if strings.TrimSpace(status) == "" {
+ return "", color.RGBA{}
+ }
+ return status, color.RGBA{R: 160, G: 170, B: 180, A: 255}
+ }
+}
+
+func drawStatusLine(dst *image.RGBA, face font.Face, x int, y int, usernameText string, statusText string, usernameColor image.Image, textColor image.Image, statusColor color.RGBA) {
+ drawX := x
+ if usernameText != "" {
+ drawText(dst, face, usernameColor, drawX, y, usernameText)
+ drawX += font.MeasureString(face, usernameText).Ceil()
+ }
+ if statusText != "" {
+ separator := " | "
+ if usernameText != "" {
+ drawText(dst, face, textColor, drawX, y, separator)
+ drawX += font.MeasureString(face, separator).Ceil()
+ }
+ statusSymbol := "●"
+ statusColorImg := image.NewUniform(statusColor)
+ drawText(dst, face, statusColorImg, drawX, y, statusSymbol)
+ drawX += font.MeasureString(face, statusSymbol).Ceil() + 6
+ drawText(dst, face, textColor, drawX, y, statusText)
+ }
+}
diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go
new file mode 100644
index 0000000..96faf3a
--- /dev/null
+++ b/tg_bot/screens/purchase_optional_details.go
@@ -0,0 +1,2722 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ ui2 "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+type PurchaseOptionalDetails struct {
+ ProjectID string
+ ProjectTitle string
+ ProjectDefaultLinkType string
+ CreativeID string
+ CreativeTitle string
+ Channels []PurchaseChannelInput
+ PlacementDateTime *time.Time
+ PaymentDate *time.Time
+ CostType string
+ CostValue *float64
+ CostBeforeType string
+ CostBeforeBargain *CostEntry
+ PurchaseType string
+ Format string
+ TopTimeMinutes *int
+ FeedTimeMinutes *int // nil = не указан, 0 = без удаления
+ TopTimeByChannel map[string]*int
+ FeedTimeByChannel map[string]*int
+ CustomFormatTopUnit string // "hours" | "minutes"
+ CustomFormatFeedUnit string // "hours" | "days"
+ CustomFormatTopValue *int // промежуточное значение (в минутах)
+ Comment string
+ InviteLinkType string
+ InputMode string
+ CurrentParam string
+ CurrentChannel string
+ ParamPage int
+ ReturnMode string
+ PlacementMode string
+ PaymentDateMode string
+ CostMode string
+ CostBeforeMode string
+ PurchaseTypeMode string
+ CommentMode string
+ FormatMode string
+ InviteLinkTypeMode string
+ PlacementByChannel map[string]*time.Time
+ PaymentDateByChannel map[string]*time.Time
+ CostByChannel map[string]CostEntry
+ CostBeforeByChannel map[string]CostEntry
+ PurchaseTypeByChannel map[string]string
+ CommentByChannel map[string]string
+ FormatByChannel map[string]string
+ InviteLinkTypeByChannel map[string]string
+ PlacementCopy *time.Time
+ PaymentDateCopy *time.Time
+ CostCopy *CostEntry
+ CostBeforeCopy *CostEntry
+ PurchaseTypeCopy *string
+ CommentCopy *string
+ FormatCopy *string
+ InviteLinkTypeCopy *string
+ ChannelEditMode string // "edit" или "copy"
+ BackState bot.State
+}
+
+type CostEntry struct {
+ Type string
+ Value *float64
+}
+
+func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
+ s.ensureDefaults()
+ if s.InputMode != "" {
+ if s.InputMode == "mode_select" {
+ s.renderModeSelect(b, mode)
+ return
+ }
+ if s.renderParamScreens(b, mode) {
+ return
+ }
+ s.renderInputPrompt(b, mode)
+ return
+ }
+
+ text := "Страница создания закупа и необязательные составляющие"
+
+ text += "\n\n"
+ text += s.formatOptionalSummary()
+
+ var rows [][]echotron.InlineKeyboardButton
+
+ // Кнопка переключения режима (только если несколько каналов) - на первой строке
+ if len(s.Channels) > 1 {
+ rows = append(rows, Row(s.globalModeButton()))
+ }
+
+ rows = append(rows, Row(
+ s.placementButton(),
+ s.paymentButton(),
+ ))
+ rows = append(rows, Row(
+ s.typeButton(),
+ s.commentButton(),
+ ))
+ rows = append(rows, Row(
+ s.costButton(),
+ s.costBeforeButton(),
+ ))
+ rows = append(rows, Row(
+ s.formatButton(),
+ s.inviteLinkTypeButton(),
+ ))
+
+ rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next")))
+
+ keyboard := Keyboard(rows...)
+
+ b.Render(text, keyboard, mode)
+}
+
+func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+ s.ensureDefaults()
+
+ switch u.CallbackQuery.Data {
+ case "opt_datetime":
+ // Открываем редактор в текущем режиме
+ if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "placement_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.setParamMode("placement", "common")
+ s.openCommonEditor(b, "placement")
+ }
+
+ case "opt_payment_date":
+ // Открываем редактор в текущем режиме
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "payment_date_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.setParamMode("payment_date", "common")
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date",
+ IncludeTime: false,
+ AllowPast: true,
+ Selected: s.PaymentDate,
+ BackState: s,
+ }), bot.EditMessage)
+ }
+
+ case "opt_cost":
+ // Открываем редактор в текущем режиме
+ if s.CostMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "cost_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.setParamMode("cost", "common")
+ s.openCommonEditor(b, "cost")
+ }
+
+ case "opt_type":
+ // Открываем редактор в текущем режиме
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "purchase_type_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.InputMode = "type"
+ s.Enter(b, bot.EditMessage)
+ }
+
+ case "opt_format":
+ // Открываем редактор в текущем режиме
+ if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "format_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.setParamMode("format", "common")
+ s.openCommonEditor(b, "format")
+ }
+
+ case "opt_invite_link_type":
+ // Открываем редактор в текущем режиме
+ if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "invite_link_type_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.InputMode = "invite_link_type"
+ s.Enter(b, bot.EditMessage)
+ }
+
+ case "opt_comment":
+ // Открываем редактор в текущем режиме
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "comment_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.InputMode = "comment"
+ s.Enter(b, bot.EditMessage)
+ }
+
+ case "delete_comment":
+ s.Comment = ""
+ s.Enter(b, bot.EditMessage)
+
+ case "format_custom":
+ s.InputMode = "format_custom"
+ s.CustomFormatTopUnit = "hours"
+ s.CustomFormatFeedUnit = "hours"
+ s.CustomFormatTopValue = nil
+ s.Enter(b, bot.EditMessage)
+
+ case "cost_type_toggle":
+ s.toggleCostType()
+ s.Enter(b, bot.EditMessage)
+
+ case "cost_before_type_toggle":
+ s.toggleCostBeforeType()
+ s.Enter(b, bot.EditMessage)
+
+ case "cost_value":
+ s.InputMode = "cost_value"
+ s.Enter(b, bot.EditMessage)
+
+ case "cost_before":
+ // Открываем редактор в текущем режиме
+ if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "cost_before_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.setParamMode("cost_before", "common")
+ s.openCommonEditor(b, "cost_before")
+ }
+
+ case "delete_cost_before":
+ s.CostBeforeBargain = nil
+ s.Enter(b, bot.EditMessage)
+
+ case "back_to_optional":
+ s.InputMode = ""
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.CurrentParam = ""
+ s.Enter(b, bot.EditMessage)
+ case "back_to_return":
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+
+ case "back":
+ // Сохраняем текущее состояние перед возвратом назад
+ b.SetState(&SelectChannelsForPurchase{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectDefaultLinkType: s.ProjectDefaultLinkType,
+ CreativeID: s.CreativeID,
+ CreativeTitle: s.CreativeTitle,
+ Channels: s.Channels,
+ Duplicates: []string{},
+ ParsingErrors: []ParseError{},
+ OptionalDetailsState: s, // Сохраняем текущее состояние
+ BackState: s.BackState,
+ }, bot.EditMessage)
+ case "next":
+ jwt := b.Session.JWT
+ if jwt == "" {
+ log.Error().Msg("JWT is empty in session")
+ b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
+ return
+ }
+ s.createPurchase(b, jwt)
+ case "done":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+ default:
+ if u.CallbackQuery.Data == "noop" {
+ // Пустая кнопка - ничего не делаем
+ return
+ }
+ if s.handleParamCallback(b, u.CallbackQuery.Data) {
+ return
+ }
+ if u.CallbackQuery.Data == "toggle_global_mode" {
+ // Открываем меню выбора параметра для переключения режима
+ s.InputMode = "mode_select"
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "channel_mode:") {
+ // Переключаем режим редактирования каналов
+ mode := strings.TrimPrefix(u.CallbackQuery.Data, "channel_mode:")
+ s.ChannelEditMode = mode
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "toggle_param_mode:") {
+ // Переключаем режим конкретного параметра
+ param := strings.TrimPrefix(u.CallbackQuery.Data, "toggle_param_mode:")
+ currentMode := s.paramMode(param)
+ if currentMode == "common" {
+ s.setParamMode(param, "per_channel")
+ // Копируем значение из общего во все каналы
+ s.copyCommonValueToChannels(param)
+ } else {
+ s.setParamMode(param, "common")
+ }
+ // Остаемся в меню выбора режима
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "type:") {
+ value := strings.TrimPrefix(u.CallbackQuery.Data, "type:")
+ // Нормализуем значение для БД
+ var normalized string
+ switch value {
+ case "mutual_pr":
+ normalized = "взаимный пиар"
+ case "standard":
+ normalized = "стандарт"
+ }
+ if s.CurrentChannel != "" {
+ s.PurchaseTypeByChannel[s.CurrentChannel] = normalized
+ } else {
+ s.PurchaseType = normalized
+ }
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "format_preset:") {
+ parts := strings.Split(strings.TrimPrefix(u.CallbackQuery.Data, "format_preset:"), ":")
+ if len(parts) == 2 {
+ topMin, err1 := strconv.Atoi(parts[0])
+ feedMin, err2 := strconv.Atoi(parts[1])
+ if err1 == nil && err2 == nil {
+ s.setFormatPreset(topMin, feedMin)
+ }
+ }
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_top:") {
+ valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_top:")
+ minutes, err := strconv.Atoi(valStr)
+ if err == nil && minutes > 0 {
+ s.CustomFormatTopValue = &minutes
+ s.InputMode = "format_custom_feed"
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_feed:") {
+ valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_feed:")
+ feedMinutes, err := strconv.Atoi(valStr)
+ if err == nil && s.CustomFormatTopValue != nil {
+ s.setFormatPreset(*s.CustomFormatTopValue, feedMinutes)
+ s.CustomFormatTopValue = nil
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if u.CallbackQuery.Data == "format_custom_top_toggle_unit" {
+ if s.CustomFormatTopUnit == "hours" {
+ s.CustomFormatTopUnit = "minutes"
+ } else {
+ s.CustomFormatTopUnit = "hours"
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if u.CallbackQuery.Data == "format_custom_feed_toggle_unit" {
+ if s.CustomFormatFeedUnit == "hours" {
+ s.CustomFormatFeedUnit = "days"
+ } else {
+ s.CustomFormatFeedUnit = "hours"
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if u.CallbackQuery.Data == "format_custom_back_to_select" {
+ s.InputMode = "format_select"
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if u.CallbackQuery.Data == "format_custom_back_to_top" {
+ s.InputMode = "format_custom"
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "format:") {
+ value := strings.TrimPrefix(u.CallbackQuery.Data, "format:")
+ s.setFormatValue(value)
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if strings.HasPrefix(u.CallbackQuery.Data, "invite_link_type:") {
+ value := strings.TrimPrefix(u.CallbackQuery.Data, "invite_link_type:")
+ s.setInviteLinkTypeValue(value)
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ s.Enter(b, bot.EditMessage)
+ }
+}
+
+func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if s.InputMode == "" || u.Message == nil || u.Message.Text == "" {
+ return
+ }
+ s.ensureDefaults()
+
+ text := strings.TrimSpace(u.Message.Text)
+ if text == "" {
+ return
+ }
+
+ switch s.InputMode {
+ case "format_custom":
+ value, err := strconv.Atoi(text)
+ if err != nil || value <= 0 {
+ return
+ }
+ // Конвертируем в минуты по текущей единице
+ if s.CustomFormatTopUnit == "hours" {
+ value = value * 60
+ }
+ s.CustomFormatTopValue = &value
+ s.InputMode = "format_custom_feed"
+ s.Enter(b, bot.NewMessage)
+ return
+ case "format_custom_feed":
+ value, err := strconv.Atoi(text)
+ if err != nil || value <= 0 {
+ return
+ }
+ // Конвертируем в минуты по текущей единице
+ if s.CustomFormatFeedUnit == "days" {
+ value = value * 24 * 60
+ } else {
+ value = value * 60
+ }
+ if s.CustomFormatTopValue != nil {
+ s.setFormatPreset(*s.CustomFormatTopValue, value)
+ s.CustomFormatTopValue = nil
+ }
+ case "comment":
+ if s.CurrentChannel != "" {
+ s.CommentByChannel[s.CurrentChannel] = text
+ } else {
+ s.Comment = text
+ }
+ case "cost_value":
+ value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
+ if err != nil {
+ s.renderInputPrompt(b, bot.EditMessage)
+ return
+ }
+ s.setCostValue(value)
+ case "cost_before_value":
+ value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
+ if err != nil {
+ s.renderInputPrompt(b, bot.EditMessage)
+ return
+ }
+ s.setCostBeforeValue(value)
+ }
+
+ if s.ReturnMode != "" {
+ s.InputMode = s.ReturnMode
+ s.ReturnMode = ""
+ } else {
+ s.InputMode = ""
+ }
+ s.CurrentChannel = ""
+ s.Enter(b, bot.NewMessage)
+}
+
+func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PurchaseOptionalDetails) Exit() {}
+
+func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
+ switch key {
+ case "placement_datetime":
+ s.PlacementDateTime = &value
+ case "payment_date":
+ s.PaymentDate = &value
+ default:
+ if strings.HasPrefix(key, "placement_datetime:") {
+ username := strings.TrimPrefix(key, "placement_datetime:")
+ s.ensureDefaults()
+ s.PlacementByChannel[username] = &value
+ }
+ if strings.HasPrefix(key, "payment_date:") {
+ username := strings.TrimPrefix(key, "payment_date:")
+ s.ensureDefaults()
+ s.PaymentDateByChannel[username] = &value
+ }
+ }
+}
+
+func (s *PurchaseOptionalDetails) formatOptionalSummary() string {
+ var lines []string
+
+ if s.hasPlacementValue() {
+ lines = append(lines, fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails()))
+ }
+ if s.hasPaymentDateValue() {
+ lines = append(lines, fmt.Sprintf("Дата оплаты: %s%s", s.formatPaymentDateSummary(), s.formatPaymentDateDetails()))
+ }
+ if s.hasCostValue() {
+ lines = append(lines, fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails()))
+ }
+ if s.hasCostBeforeValue() {
+ lines = append(lines, fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()))
+ }
+ if s.hasPurchaseTypeValue() {
+ lines = append(lines, fmt.Sprintf("Тип закупа: %s%s", s.formatPurchaseTypeSummary(), s.formatPurchaseTypeDetails()))
+ }
+ if s.hasFormatValue() {
+ lines = append(lines, fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails()))
+ }
+ if s.hasInviteLinkTypeValue() {
+ lines = append(lines, fmt.Sprintf("Тип ссылки: %s%s", s.formatInviteLinkTypeSummary(), s.formatInviteLinkTypeDetails()))
+ }
+ if s.hasCommentValue() {
+ lines = append(lines, fmt.Sprintf("Комментарий: %s%s", s.formatCommentSummary(), s.formatCommentDetails()))
+ }
+
+ if len(lines) == 0 {
+ return "Добавьте параметры ниже"
+ }
+
+ return strings.Join(lines, "\n\n")
+}
+
+func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
+ if value == nil {
+ return "—"
+ }
+ local := value.In(ui2.MskLocation)
+ return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
+}
+
+func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string {
+ if value == nil {
+ return "—"
+ }
+ local := value.In(ui2.MskLocation)
+ return fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()))
+}
+
+func (s *PurchaseOptionalDetails) formatText(value string) string {
+ if value == "" {
+ return "—"
+ }
+ return value
+}
+
+func (s *PurchaseOptionalDetails) formatCostValue() string {
+ if s.CostValue == nil {
+ return "—"
+ }
+ return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
+}
+
+func (s *PurchaseOptionalDetails) formatCostBefore() string {
+ if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil {
+ return "—"
+ }
+ label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain)
+ return fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value)
+}
+
+func (s *PurchaseOptionalDetails) costTypeLabel() string {
+ if s.CostType == "cpm" {
+ return "СРМ"
+ }
+ return "Фикс"
+}
+
+func (s *PurchaseOptionalDetails) costBeforeTypeLabel() string {
+ costType := s.CostBeforeType
+ if costType == "" && s.CostBeforeBargain != nil {
+ costType = s.CostBeforeBargain.Type
+ }
+ if costType == "cpm" {
+ return "СРМ"
+ }
+ return "Фикс"
+}
+
+func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.hasPlacementValue() {
+ icon = "✎"
+ }
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.PlacementMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Дата размещения%s", icon, modeIcon), "opt_datetime")
+}
+
+func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.hasPaymentDateValue() {
+ icon = "✎"
+ }
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.PaymentDateMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Дата оплаты%s", icon, modeIcon), "opt_payment_date")
+}
+
+func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.hasCostValue() {
+ icon = "✎"
+ }
+ return Button(fmt.Sprintf("%s Стоимость", icon), "opt_cost")
+}
+
+func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.hasCostBeforeValue() {
+ icon = "✎"
+ }
+ return Button(fmt.Sprintf("%s До торга", icon), "cost_before")
+}
+
+func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.PurchaseType != "" {
+ icon = "✎"
+ }
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.PurchaseTypeMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Тип%s", icon, modeIcon), "opt_type")
+}
+
+func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.hasFormatValue() {
+ icon = "✎"
+ }
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.FormatMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Формат%s", icon, modeIcon), "opt_format")
+}
+
+func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.Comment != "" {
+ icon = "✎"
+ }
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.CommentMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Комментарий%s", icon, modeIcon), "opt_comment")
+}
+
+func (s *PurchaseOptionalDetails) inviteLinkTypeButton() echotron.InlineKeyboardButton {
+ icon := "+"
+ if s.hasInviteLinkTypeValue() {
+ icon = "✎"
+ }
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.InviteLinkTypeMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ label := "Тип ссылки"
+ if s.InviteLinkType != "" {
+ label += ": " + s.inviteLinkTypeLabel(s.InviteLinkType)
+ }
+ return Button(fmt.Sprintf("%s %s%s", icon, label, modeIcon), "opt_invite_link_type")
+}
+
+func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton {
+ return Button("⚙️ Режимы параметров", "toggle_global_mode")
+}
+
+func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardButton {
+ if s.Comment == "" {
+ return nil
+ }
+ return Row(Button("⌫ Удалить комментарий", "delete_comment"))
+}
+
+func (s *PurchaseOptionalDetails) costBeforeDeleteRow() []echotron.InlineKeyboardButton {
+ if s.CostBeforeBargain == nil || s.CostBeforeMode == "per_channel" {
+ return nil
+ }
+ return Row(Button("⌫ Удалить до торга", "delete_cost_before"))
+}
+
+func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderMode) {
+ text := "Дополнительно\n\n"
+ switch s.InputMode {
+ case "type":
+ text += "Выберите тип закупа"
+ keyboard := Keyboard(
+ Row(Button("Взаимный пиар", "type:mutual_pr"), Button("Стандарт", "type:standard")),
+ Row(Button("← Назад", "back_to_optional")),
+ )
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
+ case "cost_value":
+ text += "Ввод стоимости\n\nНапример: 15000"
+ if s.CurrentChannel != "" {
+ text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel))
+ }
+
+ typeLabel := s.costTypeLabelForCurrent()
+ keyboard := Keyboard(
+ Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_type_toggle")),
+ Row(Button("← Назад", s.backAction())),
+ )
+
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
+ case "cost_before_value":
+ text += "Ввод стоимости до торга\n\nНапример: 20000"
+ if s.CurrentChannel != "" {
+ text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel))
+ }
+
+ typeLabel := s.costBeforeTypeLabelForCurrent()
+ var keyboard echotron.InlineKeyboardMarkup
+
+ // Показываем кнопку удаления только если есть значение и это не режим per-channel
+ if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
+ keyboard = Keyboard(
+ Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
+ Row(Button("⌫ Удалить до торга", "delete_cost_before")),
+ Row(Button("← Назад", s.backAction())),
+ )
+ } else {
+ keyboard = Keyboard(
+ Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
+ Row(Button("← Назад", s.backAction())),
+ )
+ }
+
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
+ case "format_select":
+ text = "Страница ввода формата размещения\n\n"
+ if s.CurrentChannel != "" {
+ text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
+ }
+ text += "Выберите формат\n\n"
+ keyboard := Keyboard(
+ Row(
+ Button("1ч / 24ч", "format_preset:60:1440"),
+ Button("1ч / 36ч", "format_preset:60:2160"),
+ Button("1ч / 48ч", "format_preset:60:2880"),
+ Button("1ч / 72ч", "format_preset:60:4320"),
+ ),
+ Row(
+ Button("1ч / 7д", "format_preset:60:10080"),
+ Button("1ч / 30д", "format_preset:60:43200"),
+ Button("1ч / 60д", "format_preset:60:86400"),
+ Button("1ч / 90д", "format_preset:60:129600"),
+ ),
+ Row(
+ Button("1ч / без удаления", "format_preset:60:0"),
+ ),
+ Row(
+ Button("2ч / 24ч", "format_preset:120:1440"),
+ Button("2ч / 36ч", "format_preset:120:2160"),
+ Button("2ч / 48ч", "format_preset:120:2880"),
+ Button("2ч / 72ч", "format_preset:120:4320"),
+ ),
+ Row(
+ Button("2ч / 7д", "format_preset:120:10080"),
+ Button("2ч / 30д", "format_preset:120:43200"),
+ Button("2ч / 60д", "format_preset:120:86400"),
+ Button("2ч / 90д", "format_preset:120:129600"),
+ ),
+ Row(
+ Button("← Назад", s.backAction()),
+ Button("Свой формат", "format_custom"),
+ ),
+ )
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
+ case "format_custom":
+ s.renderCustomFormatTop(b, mode)
+ return
+ case "format_custom_feed":
+ s.renderCustomFormatFeed(b, mode)
+ return
+ case "invite_link_type":
+ text = "Страница выбора типа ссылки\n\n"
+ if s.CurrentChannel != "" {
+ text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
+ }
+ text += "Выберите тип ссылки\n\n"
+ keyboard := Keyboard(
+ Row(
+ Button("Открытая", "invite_link_type:public"),
+ Button("С заявками", "invite_link_type:approval"),
+ ),
+ Row(Button("← Назад", s.backAction())),
+ )
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
+ case "comment":
+ text += "Введите комментарий"
+ keyboard := Keyboard(Row(Button("← Назад", s.backAction())))
+ if s.Comment != "" {
+ keyboard = Keyboard(
+ Row(Button("⌫ Удалить комментарий", "delete_comment")),
+ Row(Button("← Назад", s.backAction())),
+ )
+ }
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
+ default:
+ s.InputMode = ""
+ s.Enter(b, mode)
+ return
+ }
+
+ keyboard := Keyboard(
+ Row(Button("← Назад", s.backAction())),
+ )
+
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+}
+
+func (s *PurchaseOptionalDetails) renderCustomFormatTop(b *bot.Bot, mode bot.RenderMode) {
+ text := "Свой формат — время в топе\n\n"
+ if s.CurrentChannel != "" {
+ text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
+ }
+
+ var rows [][]echotron.InlineKeyboardButton
+
+ if s.CustomFormatTopUnit == "minutes" {
+ text += "Выберите или введите число (в минутах):"
+ rows = append(rows,
+ Row(
+ Button("10", "format_custom_top:10"),
+ Button("15", "format_custom_top:15"),
+ Button("20", "format_custom_top:20"),
+ Button("30", "format_custom_top:30"),
+ ),
+ Row(
+ Button("45", "format_custom_top:45"),
+ Button("60", "format_custom_top:60"),
+ Button("90", "format_custom_top:90"),
+ Button("120", "format_custom_top:120"),
+ ),
+ Row(Button("⏱ Минуты", "format_custom_top_toggle_unit")),
+ )
+ } else {
+ text += "Выберите или введите число (в часах):"
+ rows = append(rows,
+ Row(
+ Button("1", "format_custom_top:60"),
+ Button("2", "format_custom_top:120"),
+ Button("3", "format_custom_top:180"),
+ Button("4", "format_custom_top:240"),
+ ),
+ Row(
+ Button("5", "format_custom_top:300"),
+ Button("6", "format_custom_top:360"),
+ Button("8", "format_custom_top:480"),
+ Button("12", "format_custom_top:720"),
+ ),
+ Row(Button("⏱ Часы", "format_custom_top_toggle_unit")),
+ )
+ }
+
+ rows = append(rows, Row(Button("← Назад", "format_custom_back_to_select")))
+
+ b.Render(text, Keyboard(rows...), mode)
+}
+
+func (s *PurchaseOptionalDetails) renderCustomFormatFeed(b *bot.Bot, mode bot.RenderMode) {
+ text := "Свой формат — время в ленте\n\n"
+ if s.CurrentChannel != "" {
+ text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
+ }
+
+ if s.CustomFormatTopValue != nil {
+ text += fmt.Sprintf("Время в топе: %s ✓\n\n", formatDuration(*s.CustomFormatTopValue, "top"))
+ }
+
+ var rows [][]echotron.InlineKeyboardButton
+
+ if s.CustomFormatFeedUnit == "days" {
+ text += "Выберите или введите число (в днях):"
+ rows = append(rows,
+ Row(
+ Button("7", "format_custom_feed:10080"),
+ Button("14", "format_custom_feed:20160"),
+ Button("30", "format_custom_feed:43200"),
+ Button("60", "format_custom_feed:86400"),
+ ),
+ Row(
+ Button("90", "format_custom_feed:129600"),
+ Button("120", "format_custom_feed:172800"),
+ Button("180", "format_custom_feed:259200"),
+ Button("365", "format_custom_feed:525600"),
+ ),
+ Row(
+ Button("⏱ Дни", "format_custom_feed_toggle_unit"),
+ Button("Без удаления", "format_custom_feed:0"),
+ ),
+ )
+ } else {
+ text += "Выберите или введите число (в часах):"
+ rows = append(rows,
+ Row(
+ Button("24", "format_custom_feed:1440"),
+ Button("36", "format_custom_feed:2160"),
+ Button("48", "format_custom_feed:2880"),
+ Button("72", "format_custom_feed:4320"),
+ ),
+ Row(
+ Button("96", "format_custom_feed:5760"),
+ Button("120", "format_custom_feed:7200"),
+ Button("144", "format_custom_feed:8640"),
+ Button("168", "format_custom_feed:10080"),
+ ),
+ Row(
+ Button("⏱ Часы", "format_custom_feed_toggle_unit"),
+ Button("Без удаления", "format_custom_feed:0"),
+ ),
+ )
+ }
+
+ rows = append(rows, Row(Button("← Назад", "format_custom_back_to_top")))
+
+ b.Render(text, Keyboard(rows...), mode)
+}
+
+func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) {
+ text := "⚙ Переключить режим параметра\n\n"
+
+ // Показываем текущую информацию о параметрах
+ text += s.formatOptionalSummary()
+ text += "\n\n"
+
+ text += "Выберите параметр для изменения режима:\n\n"
+
+ var rows [][]echotron.InlineKeyboardButton
+
+ // Дата размещения
+ placementLabel := "Дата размещения: "
+ if s.PlacementMode == "per_channel" {
+ placementLabel += "👥"
+ } else {
+ placementLabel += "общий"
+ }
+ rows = append(rows, Row(Button(placementLabel, "toggle_param_mode:placement")))
+
+ // Дата оплаты
+ paymentDateLabel := "Дата оплаты: "
+ if s.PaymentDateMode == "per_channel" {
+ paymentDateLabel += "👥"
+ } else {
+ paymentDateLabel += "общий"
+ }
+ rows = append(rows, Row(Button(paymentDateLabel, "toggle_param_mode:payment_date")))
+
+ // Тип закупа
+ purchaseTypeLabel := "Тип закупа: "
+ if s.PurchaseTypeMode == "per_channel" {
+ purchaseTypeLabel += "👥"
+ } else {
+ purchaseTypeLabel += "общий"
+ }
+ rows = append(rows, Row(Button(purchaseTypeLabel, "toggle_param_mode:purchase_type")))
+
+ // Комментарий
+ commentLabel := "Комментарий: "
+ if s.CommentMode == "per_channel" {
+ commentLabel += "👥"
+ } else {
+ commentLabel += "общий"
+ }
+ rows = append(rows, Row(Button(commentLabel, "toggle_param_mode:comment")))
+
+ // Формат
+ formatLabel := "Формат: "
+ if s.FormatMode == "per_channel" {
+ formatLabel += "👥"
+ } else {
+ formatLabel += "общий"
+ }
+ rows = append(rows, Row(Button(formatLabel, "toggle_param_mode:format")))
+
+ // Тип ссылки
+ inviteLinkTypeLabel := "Тип ссылки: "
+ if s.InviteLinkTypeMode == "per_channel" {
+ inviteLinkTypeLabel += "👥"
+ } else {
+ inviteLinkTypeLabel += "общий"
+ }
+ rows = append(rows, Row(Button(inviteLinkTypeLabel, "toggle_param_mode:invite_link_type")))
+
+ rows = append(rows, Row(Button("← Назад", "back_to_optional")))
+
+ b.Render(text, Keyboard(rows...), mode)
+}
+
+func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.RenderMode) bool {
+ switch s.InputMode {
+ case "placement_channels":
+ s.renderParamChannels(b, mode, "placement")
+ return true
+ case "payment_date_channels":
+ s.renderParamChannels(b, mode, "payment_date")
+ return true
+ case "cost_channels":
+ s.renderParamChannels(b, mode, "cost")
+ return true
+ case "cost_before_channels":
+ s.renderParamChannels(b, mode, "cost_before")
+ return true
+ case "purchase_type_channels":
+ s.renderParamChannels(b, mode, "purchase_type")
+ return true
+ case "comment_channels":
+ s.renderParamChannels(b, mode, "comment")
+ return true
+ case "format_channels":
+ s.renderParamChannels(b, mode, "format")
+ return true
+ case "invite_link_type_channels":
+ s.renderParamChannels(b, mode, "invite_link_type")
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) {
+ // Сбрасываем на редактирование при смене параметра
+ if s.CurrentParam != param || s.ChannelEditMode == "" {
+ s.ChannelEditMode = "edit"
+ }
+ s.CurrentParam = param
+ text := fmt.Sprintf("%s — по каналам\n\n", s.paramTitle(param))
+ text += s.renderParamChannelSummary(param) + "\n\n"
+
+ const perPage = 5
+ total := len(s.Channels)
+ if total == 0 {
+ text += "\nКаналы не выбраны"
+ b.Render(text, Keyboard(Row(Button("← Назад", "back_to_optional"))), mode)
+ return
+ }
+
+ if s.ParamPage*perPage >= total {
+ s.ParamPage = 0
+ }
+ start, end := ui2.GetPageBounds(s.ParamPage, perPage, total)
+
+
+ var rows [][]echotron.InlineKeyboardButton
+
+ // Кнопки переключения режима
+ editLabel := "Редактирование"
+ copyLabel := "Копирование"
+ if s.ChannelEditMode == "edit" {
+ editLabel = "● " + editLabel
+ } else {
+ copyLabel = "● " + copyLabel
+ }
+ rows = append(rows, Row(
+ Button(editLabel, "channel_mode:edit"),
+ Button(copyLabel, "channel_mode:copy"),
+ ))
+
+ // Список каналов с кнопками в зависимости от режима
+ for i := start; i < end; i++ {
+ label := fmt.Sprintf("%d", i+1)
+ channelKey := channelKey(s.Channels[i])
+
+ if s.ChannelEditMode == "copy" {
+ // Режим копирования
+ var channelRow []echotron.InlineKeyboardButton
+ channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
+ channelRow = append(channelRow, Button("⧉", fmt.Sprintf("param_copy:%s:%d", param, i)))
+
+ // Кнопка вставки - только если есть данные в буфере
+ if s.hasCopyBuffer(param) {
+ channelRow = append(channelRow, Button("⇲", fmt.Sprintf("param_paste:%s:%d", param, i)))
+ } else {
+ channelRow = append(channelRow, Button(" ", "noop"))
+ }
+
+ // Кнопка удаления - только если есть данные у канала
+ if s.hasChannelValue(param, channelKey) {
+ channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
+ } else {
+ channelRow = append(channelRow, Button(" ", "noop"))
+ }
+
+ rows = append(rows, channelRow)
+ } else {
+ // Обычный режим редактирования
+ var channelRow []echotron.InlineKeyboardButton
+ channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
+
+ // Кнопка "Добавить" или "Изменить" в зависимости от наличия данных
+ hasValue := s.hasChannelValue(param, channelKey)
+ if hasValue {
+ channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
+ } else {
+ channelRow = append(channelRow, Button("Добавить", fmt.Sprintf("param_edit:%s:%d", param, i)))
+ }
+
+ // Кнопка удаления - только если есть данные у канала
+ if hasValue {
+ channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
+ } else {
+ channelRow = append(channelRow, Button(" ", "noop"))
+ }
+
+ rows = append(rows, channelRow)
+ }
+ }
+
+ pages := ui2.CalculatePages(total, perPage)
+ if navRow := ui2.BuildNavigationRow(ui2.PaginationConfig{
+ CurrentPage: s.ParamPage,
+ TotalPages: pages,
+ }); navRow != nil {
+ rows = append(rows, navRow)
+ }
+
+ rows = append(rows, Row(
+ Button("← Назад", fmt.Sprintf("param_back:%s", param)),
+ ))
+
+ b.Render(text, Keyboard(rows...), mode)
+}
+
+func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
+ // Собираем строки и вычисляем максимальную длину названия канала
+ type channelLine struct {
+ label string
+ value string
+ }
+ var lines []channelLine
+ maxLabelLen := 0
+
+ for _, ch := range s.Channels {
+ label := channelLabel(ch)
+ labelLen := len([]rune(label)) // Считаем руны для Unicode
+ if labelLen > maxLabelLen {
+ maxLabelLen = labelLen
+ }
+ channelKey := channelKey(ch)
+ value := s.formatParamValue(param, channelKey)
+ lines = append(lines, channelLine{label: label, value: value})
+ }
+
+ // Логирование для отладки (INFO уровень)
+ log.Info().
+ Str("param", param).
+ Int("maxLabelLen", maxLabelLen).
+ Int("channelsCount", len(lines)).
+ Msg("🔍 renderParamChannelSummary")
+
+ // Форматируем: маркер + название + паддинг + значение
+ // Паддинг вычисляем так, чтобы все значения начинались с одной позиции
+ const (
+ marker = "· " // Маркер пункта
+ valueIndent = 6 // Отступ после самого длинного названия (увеличен для лучшей читаемости)
+ )
+
+ var result []string
+ for i, line := range lines {
+ labelLen := len([]rune(line.label))
+ // Паддинг = (макс.длина - тек.длина) + отступ после названия
+ paddingLen := maxLabelLen - labelLen + valueIndent
+ padding := strings.Repeat("\u00A0", paddingLen)
+
+ formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value)
+ result = append(result, formattedLine)
+
+ // Логируем каждую строку (INFO уровень)
+ log.Info().
+ Int("index", i).
+ Str("label", line.label).
+ Int("labelLen", labelLen).
+ Int("paddingLen", paddingLen).
+ Str("formattedLine", formattedLine).
+ Msg("📝 Channel line")
+ }
+
+ finalResult := strings.Join(result, "\n")
+
+ // Оборачиваем в для моноширинного шрифта (выравнивание работает только в monospace)
+ finalResult = fmt.Sprintf("%s
", finalResult)
+
+ // Логируем итоговый результат
+ log.Info().
+ Str("param", param).
+ Str("result", finalResult).
+ Msg("✅ Final summary")
+
+ return finalResult
+}
+
+func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool {
+ switch {
+ case strings.HasPrefix(data, "param_edit:"):
+ param, index, ok := s.parseParamIndex(data, "param_edit:")
+ if !ok {
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ if index >= 0 && index < len(s.Channels) {
+ s.CurrentParam = param
+ s.CurrentChannel = channelKey(s.Channels[index])
+ s.openChannelEditor(b, param, s.CurrentChannel)
+ return true
+ }
+ s.Enter(b, bot.EditMessage)
+ return true
+ case strings.HasPrefix(data, "param_copy:"):
+ param, index, ok := s.parseParamIndex(data, "param_copy:")
+ if !ok {
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ if index >= 0 && index < len(s.Channels) {
+ channelKey := channelKey(s.Channels[index])
+ s.copyParamValue(param, channelKey)
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ s.Enter(b, bot.EditMessage)
+ return true
+ case strings.HasPrefix(data, "param_paste:"):
+ param, index, ok := s.parseParamIndex(data, "param_paste:")
+ if !ok {
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ if index >= 0 && index < len(s.Channels) {
+ channelKey := channelKey(s.Channels[index])
+ s.pasteParamValue(param, channelKey)
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ s.Enter(b, bot.EditMessage)
+ return true
+ case strings.HasPrefix(data, "param_clear:"):
+ param, index, ok := s.parseParamIndex(data, "param_clear:")
+ if !ok {
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ if index >= 0 && index < len(s.Channels) {
+ channelKey := channelKey(s.Channels[index])
+ s.clearParamValue(param, channelKey)
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ s.Enter(b, bot.EditMessage)
+ return true
+ case data == "prev" && strings.HasSuffix(s.InputMode, "_channels"):
+ if s.ParamPage > 0 {
+ s.ParamPage--
+ }
+ s.Enter(b, bot.EditMessage)
+ return true
+ case data == "next" && strings.HasSuffix(s.InputMode, "_channels"):
+ s.ParamPage++
+ s.Enter(b, bot.EditMessage)
+ return true
+ case strings.HasPrefix(data, "param_back:"):
+ // Возвращаемся на главный экран
+ s.InputMode = ""
+ s.Enter(b, bot.EditMessage)
+ return true
+ }
+ return false
+}
+
+func (s *PurchaseOptionalDetails) parseParamIndex(data, prefix string) (string, int, bool) {
+ rest := strings.TrimPrefix(data, prefix)
+ parts := strings.Split(rest, ":")
+ if len(parts) != 2 {
+ return "", 0, false
+ }
+ param := parts[0]
+ index, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return "", 0, false
+ }
+ return param, index, true
+}
+
+func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
+ switch param {
+ case "placement":
+ s.InputMode = ""
+ s.ReturnMode = ""
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата и время размещения",
+ Key: "placement_datetime",
+ IncludeTime: true,
+ AllowPast: true,
+ Selected: s.PlacementDateTime,
+ BackState: s,
+ }), bot.EditMessage)
+ case "payment_date":
+ s.InputMode = ""
+ s.ReturnMode = ""
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date",
+ IncludeTime: false,
+ AllowPast: true,
+ Selected: s.PaymentDate,
+ BackState: s,
+ }), bot.EditMessage)
+ case "purchase_type":
+ s.InputMode = "type"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ case "comment":
+ s.InputMode = "comment"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ case "cost":
+ s.InputMode = "cost_value"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ case "cost_before":
+ s.InputMode = "cost_before_value"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ case "format":
+ s.InputMode = "format_select"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ case "invite_link_type":
+ s.InputMode = "invite_link_type"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ default:
+ s.InputMode = ""
+ s.Enter(b, bot.EditMessage)
+ }
+}
+
+func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKey string) {
+ switch param {
+ case "placement":
+ s.InputMode = "placement_channels"
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата и время размещения",
+ Key: "placement_datetime:" + channelKey,
+ IncludeTime: true,
+ AllowPast: true,
+ Selected: s.PlacementByChannel[channelKey],
+ BackState: s,
+ }), bot.EditMessage)
+ case "payment_date":
+ s.InputMode = "payment_date_channels"
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date:" + channelKey,
+ IncludeTime: false,
+ AllowPast: true,
+ Selected: s.PaymentDateByChannel[channelKey],
+ BackState: s,
+ }), bot.EditMessage)
+ case "purchase_type":
+ s.InputMode = "type"
+ s.ReturnMode = "purchase_type_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ case "comment":
+ s.InputMode = "comment"
+ s.ReturnMode = "comment_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ case "cost":
+ s.InputMode = "cost_value"
+ s.ReturnMode = "cost_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ case "cost_before":
+ s.InputMode = "cost_before_value"
+ s.ReturnMode = "cost_before_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ case "format":
+ s.InputMode = "format_select"
+ s.ReturnMode = "format_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ case "invite_link_type":
+ s.InputMode = "invite_link_type"
+ s.ReturnMode = "invite_link_type_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ default:
+ s.InputMode = ""
+ s.Enter(b, bot.EditMessage)
+ }
+}
+
+func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
+ switch param {
+ case "placement":
+ s.PlacementCopy = s.PlacementByChannel[channelKey]
+ case "payment_date":
+ s.PaymentDateCopy = s.PaymentDateByChannel[channelKey]
+ case "cost":
+ entry := s.CostByChannel[channelKey]
+ copied := entry
+ if entry.Value == nil {
+ copied.Value = nil
+ } else {
+ value := *entry.Value
+ copied.Value = &value
+ }
+ s.CostCopy = &copied
+ case "cost_before":
+ entry := s.CostBeforeByChannel[channelKey]
+ copied := entry
+ if entry.Value == nil {
+ copied.Value = nil
+ } else {
+ value := *entry.Value
+ copied.Value = &value
+ }
+ s.CostBeforeCopy = &copied
+ case "purchase_type":
+ if value, ok := s.PurchaseTypeByChannel[channelKey]; ok {
+ copied := value
+ s.PurchaseTypeCopy = &copied
+ } else {
+ s.PurchaseTypeCopy = nil
+ }
+ case "comment":
+ if value, ok := s.CommentByChannel[channelKey]; ok {
+ copied := value
+ s.CommentCopy = &copied
+ } else {
+ s.CommentCopy = nil
+ }
+ case "format":
+ if value, ok := s.FormatByChannel[channelKey]; ok {
+ copied := value
+ s.FormatCopy = &copied
+ } else {
+ s.FormatCopy = nil
+ }
+ case "invite_link_type":
+ if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok {
+ copied := value
+ s.InviteLinkTypeCopy = &copied
+ } else {
+ s.InviteLinkTypeCopy = nil
+ }
+ }
+}
+
+func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) {
+ switch param {
+ case "placement":
+ if s.PlacementDateTime == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ value := s.PlacementDateTime
+ s.PlacementByChannel[key] = value
+ }
+ case "payment_date":
+ if s.PaymentDate == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ value := s.PaymentDate
+ s.PaymentDateByChannel[key] = value
+ }
+ case "cost":
+ if s.CostValue == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ entry := CostEntry{
+ Type: s.CostType,
+ Value: s.CostValue,
+ }
+ s.CostByChannel[key] = entry
+ }
+ case "cost_before":
+ if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ entry := CostEntry{
+ Type: s.CostBeforeBargain.Type,
+ Value: s.CostBeforeBargain.Value,
+ }
+ s.CostBeforeByChannel[key] = entry
+ }
+ case "purchase_type":
+ if s.PurchaseType == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.PurchaseTypeByChannel[key] = s.PurchaseType
+ }
+ case "comment":
+ if s.Comment == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.CommentByChannel[key] = s.Comment
+ }
+ case "format":
+ if s.Format == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.FormatByChannel[key] = s.Format
+ s.TopTimeByChannel[key] = s.TopTimeMinutes
+ s.FeedTimeByChannel[key] = s.FeedTimeMinutes
+ }
+ case "invite_link_type":
+ if s.InviteLinkType == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.InviteLinkTypeByChannel[key] = s.InviteLinkType
+ }
+ }
+}
+
+func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
+ switch param {
+ case "placement":
+ if s.PlacementCopy == nil {
+ s.PlacementByChannel[channelKey] = nil
+ return
+ }
+ value := s.PlacementCopy.In(ui2.MskLocation)
+ s.PlacementByChannel[channelKey] = &value
+ case "payment_date":
+ if s.PaymentDateCopy == nil {
+ s.PaymentDateByChannel[channelKey] = nil
+ return
+ }
+ value := s.PaymentDateCopy.In(ui2.MskLocation)
+ s.PaymentDateByChannel[channelKey] = &value
+ case "cost":
+ if s.CostCopy == nil {
+ delete(s.CostByChannel, channelKey)
+ return
+ }
+ copied := *s.CostCopy
+ if copied.Value != nil {
+ value := *copied.Value
+ copied.Value = &value
+ }
+ s.CostByChannel[channelKey] = copied
+ case "cost_before":
+ if s.CostBeforeCopy == nil {
+ delete(s.CostBeforeByChannel, channelKey)
+ return
+ }
+ copied := *s.CostBeforeCopy
+ if copied.Value != nil {
+ value := *copied.Value
+ copied.Value = &value
+ }
+ s.CostBeforeByChannel[channelKey] = copied
+ case "purchase_type":
+ if s.PurchaseTypeCopy == nil {
+ delete(s.PurchaseTypeByChannel, channelKey)
+ return
+ }
+ s.PurchaseTypeByChannel[channelKey] = *s.PurchaseTypeCopy
+ case "comment":
+ if s.CommentCopy == nil {
+ delete(s.CommentByChannel, channelKey)
+ return
+ }
+ s.CommentByChannel[channelKey] = *s.CommentCopy
+ case "format":
+ if s.FormatCopy == nil {
+ delete(s.FormatByChannel, channelKey)
+ return
+ }
+ s.FormatByChannel[channelKey] = *s.FormatCopy
+ case "invite_link_type":
+ if s.InviteLinkTypeCopy == nil {
+ delete(s.InviteLinkTypeByChannel, channelKey)
+ return
+ }
+ s.InviteLinkTypeByChannel[channelKey] = *s.InviteLinkTypeCopy
+ }
+}
+
+func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
+ switch param {
+ case "placement":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "payment_date":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "cost":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "cost_before":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "purchase_type":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "comment":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "format":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "invite_link_type":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ }
+}
+
+func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
+ switch param {
+ case "placement":
+ s.PlacementByChannel[channelKey] = nil
+ case "payment_date":
+ s.PaymentDateByChannel[channelKey] = nil
+ case "cost":
+ delete(s.CostByChannel, channelKey)
+ case "cost_before":
+ delete(s.CostBeforeByChannel, channelKey)
+ case "purchase_type":
+ delete(s.PurchaseTypeByChannel, channelKey)
+ case "comment":
+ delete(s.CommentByChannel, channelKey)
+ case "format":
+ delete(s.FormatByChannel, channelKey)
+ delete(s.TopTimeByChannel, channelKey)
+ delete(s.FeedTimeByChannel, channelKey)
+ case "invite_link_type":
+ delete(s.InviteLinkTypeByChannel, channelKey)
+ }
+}
+
+func (s *PurchaseOptionalDetails) paramTitle(param string) string {
+ switch param {
+ case "placement":
+ return "Дата и время размещения"
+ case "payment_date":
+ return "Дата оплаты"
+ case "cost":
+ return "Стоимость"
+ case "cost_before":
+ return "Стоимость до торга"
+ case "purchase_type":
+ return "Тип закупа"
+ case "comment":
+ return "Комментарий"
+ case "format":
+ return "Формат"
+ case "invite_link_type":
+ return "Тип ссылки"
+ default:
+ return ""
+ }
+}
+
+func (s *PurchaseOptionalDetails) paramMode(param string) string {
+ switch param {
+ case "placement":
+ if s.PlacementMode == "" {
+ return "common"
+ }
+ return s.PlacementMode
+ case "payment_date":
+ if s.PaymentDateMode == "" {
+ return "common"
+ }
+ return s.PaymentDateMode
+ case "cost":
+ if s.CostMode == "" {
+ return "per_channel"
+ }
+ return s.CostMode
+ case "cost_before":
+ if s.CostBeforeMode == "" {
+ return "per_channel"
+ }
+ return s.CostBeforeMode
+ case "purchase_type":
+ if s.PurchaseTypeMode == "" {
+ return "common"
+ }
+ return s.PurchaseTypeMode
+ case "comment":
+ if s.CommentMode == "" {
+ return "common"
+ }
+ return s.CommentMode
+ case "format":
+ if s.FormatMode == "" {
+ return "common"
+ }
+ return s.FormatMode
+ case "invite_link_type":
+ if s.InviteLinkTypeMode == "" {
+ return "common"
+ }
+ return s.InviteLinkTypeMode
+ default:
+ return "common"
+ }
+}
+
+func (s *PurchaseOptionalDetails) setParamMode(param, mode string) {
+ switch param {
+ case "placement":
+ s.PlacementMode = mode
+ case "payment_date":
+ s.PaymentDateMode = mode
+ case "cost":
+ s.CostMode = mode
+ case "cost_before":
+ s.CostBeforeMode = mode
+ case "purchase_type":
+ s.PurchaseTypeMode = mode
+ case "comment":
+ s.CommentMode = mode
+ case "format":
+ s.FormatMode = mode
+ case "invite_link_type":
+ s.InviteLinkTypeMode = mode
+ }
+}
+
+func (s *PurchaseOptionalDetails) ensureDefaults() {
+ if s.PlacementMode == "" {
+ s.PlacementMode = "common"
+ }
+ if s.PaymentDateMode == "" {
+ s.PaymentDateMode = "common"
+ }
+ if s.CostMode == "" {
+ s.CostMode = "per_channel"
+ }
+ if s.CostBeforeMode == "" {
+ s.CostBeforeMode = "per_channel"
+ }
+ if s.PurchaseTypeMode == "" {
+ s.PurchaseTypeMode = "common"
+ }
+ if s.CommentMode == "" {
+ s.CommentMode = "common"
+ }
+ if s.FormatMode == "" {
+ s.FormatMode = "common"
+ }
+ if s.InviteLinkTypeMode == "" {
+ s.InviteLinkTypeMode = "common"
+ }
+ if s.InviteLinkType == "" {
+ s.InviteLinkType = s.ProjectDefaultLinkType
+ if s.InviteLinkType == "" {
+ s.InviteLinkType = "approval"
+ }
+ }
+ if s.PlacementByChannel == nil {
+ s.PlacementByChannel = make(map[string]*time.Time)
+ }
+ if s.PaymentDateByChannel == nil {
+ s.PaymentDateByChannel = make(map[string]*time.Time)
+ }
+ if s.CostByChannel == nil {
+ s.CostByChannel = make(map[string]CostEntry)
+ }
+ if s.CostBeforeByChannel == nil {
+ s.CostBeforeByChannel = make(map[string]CostEntry)
+ }
+ if s.PurchaseTypeByChannel == nil {
+ s.PurchaseTypeByChannel = make(map[string]string)
+ }
+ if s.CommentByChannel == nil {
+ s.CommentByChannel = make(map[string]string)
+ }
+ if s.FormatByChannel == nil {
+ s.FormatByChannel = make(map[string]string)
+ }
+ if s.TopTimeByChannel == nil {
+ s.TopTimeByChannel = make(map[string]*int)
+ }
+ if s.FeedTimeByChannel == nil {
+ s.FeedTimeByChannel = make(map[string]*int)
+ }
+ if s.CustomFormatTopUnit == "" {
+ s.CustomFormatTopUnit = "hours"
+ }
+ if s.CustomFormatFeedUnit == "" {
+ s.CustomFormatFeedUnit = "hours"
+ }
+ if s.InviteLinkTypeByChannel == nil {
+ s.InviteLinkTypeByChannel = make(map[string]string)
+ }
+ s.syncChannelMaps()
+}
+
+func (s *PurchaseOptionalDetails) syncChannelMaps() {
+ valid := make(map[string]struct{}, len(s.Channels))
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ if key != "" {
+ valid[key] = struct{}{}
+ }
+ }
+ for key := range s.PlacementByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.PlacementByChannel, key)
+ }
+ }
+ for key := range s.PaymentDateByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.PaymentDateByChannel, key)
+ }
+ }
+ for key := range s.CostByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.CostByChannel, key)
+ }
+ }
+ for key := range s.CostBeforeByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.CostBeforeByChannel, key)
+ }
+ }
+ for key := range s.PurchaseTypeByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.PurchaseTypeByChannel, key)
+ }
+ }
+ for key := range s.CommentByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.CommentByChannel, key)
+ }
+ }
+ for key := range s.FormatByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.FormatByChannel, key)
+ }
+ }
+ for key := range s.TopTimeByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.TopTimeByChannel, key)
+ }
+ }
+ for key := range s.FeedTimeByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.FeedTimeByChannel, key)
+ }
+ }
+ for key := range s.InviteLinkTypeByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.InviteLinkTypeByChannel, key)
+ }
+ }
+}
+
+func (s *PurchaseOptionalDetails) formatPlacementSummary() string {
+ if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatDateTime(s.PlacementDateTime)
+}
+
+func (s *PurchaseOptionalDetails) formatPlacementDetails() string {
+ if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("placement")
+}
+
+func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string {
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatDate(s.PaymentDate)
+}
+
+func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string {
+ if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("payment_date")
+}
+
+func (s *PurchaseOptionalDetails) formatCostSummary() string {
+ if s.CostMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatCostValue()
+}
+
+func (s *PurchaseOptionalDetails) formatCostDetails() string {
+ if s.CostMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("cost")
+}
+
+func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string {
+ if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatCostBefore()
+}
+
+func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string {
+ if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("cost_before")
+}
+
+func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string {
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatText(s.PurchaseType)
+}
+
+func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string {
+ if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("purchase_type")
+}
+
+func (s *PurchaseOptionalDetails) formatCommentSummary() string {
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatText(s.Comment)
+}
+
+func (s *PurchaseOptionalDetails) formatCommentDetails() string {
+ if s.CommentMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("comment")
+}
+
+func (s *PurchaseOptionalDetails) formatFormatSummary() string {
+ if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatText(s.Format)
+}
+
+func (s *PurchaseOptionalDetails) formatFormatDetails() string {
+ if s.FormatMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("format")
+}
+
+func (s *PurchaseOptionalDetails) formatInviteLinkTypeSummary() string {
+ if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.inviteLinkTypeLabel(s.InviteLinkType)
+}
+
+func (s *PurchaseOptionalDetails) formatInviteLinkTypeDetails() string {
+ if s.InviteLinkTypeMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return s.renderParamChannelSummary("invite_link_type")
+}
+
+func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string {
+ switch param {
+ case "placement":
+ value := s.PlacementByChannel[channelKey]
+ return s.formatDateTime(value)
+ case "payment_date":
+ value := s.PaymentDateByChannel[channelKey]
+ return s.formatDate(value)
+ case "cost":
+ return s.formatCostForChannel(channelKey)
+ case "cost_before":
+ return s.formatCostBeforeForChannel(channelKey)
+ case "purchase_type":
+ if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
+ return value
+ }
+ return "—"
+ case "comment":
+ if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
+ return value
+ }
+ return "—"
+ case "format":
+ if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
+ return value
+ }
+ return "—"
+ case "invite_link_type":
+ if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" {
+ return s.inviteLinkTypeLabel(value)
+ }
+ return "—"
+ default:
+ return "—"
+ }
+}
+
+func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string {
+ entry, ok := s.CostByChannel[channelKey]
+ if !ok || entry.Value == nil {
+ return "—"
+ }
+ label := s.costTypeLabelForEntry(entry)
+ return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
+}
+
+func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
+ entry, ok := s.CostBeforeByChannel[channelKey]
+ if !ok || entry.Value == nil {
+ return "—"
+ }
+ label := s.costBeforeTypeLabelForEntry(entry)
+ return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
+}
+
+func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
+ switch param {
+ case "placement":
+ return s.PlacementCopy != nil
+ case "payment_date":
+ return s.PaymentDateCopy != nil
+ case "cost":
+ return s.CostCopy != nil
+ case "cost_before":
+ return s.CostBeforeCopy != nil
+ case "purchase_type":
+ return s.PurchaseTypeCopy != nil
+ case "comment":
+ return s.CommentCopy != nil
+ case "format":
+ return s.FormatCopy != nil
+ case "invite_link_type":
+ return s.InviteLinkTypeCopy != nil
+ default:
+ return false
+ }
+}
+
+func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool {
+ switch param {
+ case "placement":
+ value, ok := s.PlacementByChannel[channelKey]
+ return ok && value != nil
+ case "payment_date":
+ value, ok := s.PaymentDateByChannel[channelKey]
+ return ok && value != nil
+ case "cost":
+ entry, ok := s.CostByChannel[channelKey]
+ return ok && entry.Value != nil
+ case "cost_before":
+ entry, ok := s.CostBeforeByChannel[channelKey]
+ return ok && entry.Value != nil
+ case "purchase_type":
+ value, ok := s.PurchaseTypeByChannel[channelKey]
+ return ok && value != ""
+ case "comment":
+ value, ok := s.CommentByChannel[channelKey]
+ return ok && value != ""
+ case "format":
+ value, ok := s.FormatByChannel[channelKey]
+ return ok && value != ""
+ case "invite_link_type":
+ value, ok := s.InviteLinkTypeByChannel[channelKey]
+ return ok && value != ""
+ default:
+ return false
+ }
+}
+
+func (s *PurchaseOptionalDetails) hasPlacementValue() bool {
+ if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.PlacementByChannel {
+ if value != nil {
+ return true
+ }
+ }
+ return false
+ }
+ return s.PlacementDateTime != nil
+}
+
+func (s *PurchaseOptionalDetails) hasPaymentDateValue() bool {
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.PaymentDateByChannel {
+ if value != nil {
+ return true
+ }
+ }
+ return false
+ }
+ return s.PaymentDate != nil
+}
+
+func (s *PurchaseOptionalDetails) hasCostValue() bool {
+ if s.CostMode == "per_channel" && len(s.Channels) > 1 {
+ for _, entry := range s.CostByChannel {
+ if entry.Value != nil {
+ return true
+ }
+ }
+ return false
+ }
+ return s.CostValue != nil
+}
+
+func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool {
+ if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
+ for _, entry := range s.CostBeforeByChannel {
+ if entry.Value != nil {
+ return true
+ }
+ }
+ return false
+ }
+ return s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil
+}
+
+func (s *PurchaseOptionalDetails) hasFormatValue() bool {
+ if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.FormatByChannel {
+ if value != "" {
+ return true
+ }
+ }
+ return false
+ }
+ return s.Format != ""
+}
+
+func (s *PurchaseOptionalDetails) hasPurchaseTypeValue() bool {
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.PurchaseTypeByChannel {
+ if value != "" {
+ return true
+ }
+ }
+ return false
+ }
+ return s.PurchaseType != ""
+}
+
+func (s *PurchaseOptionalDetails) hasCommentValue() bool {
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.CommentByChannel {
+ if value != "" {
+ return true
+ }
+ }
+ return false
+ }
+ return s.Comment != ""
+}
+
+func (s *PurchaseOptionalDetails) hasInviteLinkTypeValue() bool {
+ if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.InviteLinkTypeByChannel {
+ if value != "" {
+ return true
+ }
+ }
+ return false
+ }
+ return s.InviteLinkType != ""
+}
+
+func (s *PurchaseOptionalDetails) inviteLinkTypeLabel(linkType string) string {
+ if linkType == "public" {
+ return "Открытая"
+ }
+ if linkType == "approval" {
+ return "С заявками"
+ }
+ return "—"
+}
+
+func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string {
+ if entry.Type == "cpm" {
+ return "СРМ"
+ }
+ return "Фикс"
+}
+
+func (s *PurchaseOptionalDetails) costBeforeTypeLabelForEntry(entry CostEntry) string {
+ if entry.Type == "cpm" {
+ return "СРМ"
+ }
+ return "Фикс"
+}
+
+func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string {
+ if s.CurrentChannel == "" {
+ return s.costTypeLabel()
+ }
+ entry, ok := s.CostByChannel[s.CurrentChannel]
+ if !ok || entry.Type == "" {
+ return s.costTypeLabel()
+ }
+ return s.costTypeLabelForEntry(entry)
+}
+
+func (s *PurchaseOptionalDetails) costBeforeTypeLabelForCurrent() string {
+ if s.CurrentChannel == "" {
+ return s.costBeforeTypeLabel()
+ }
+ entry, ok := s.CostBeforeByChannel[s.CurrentChannel]
+ if !ok || entry.Type == "" {
+ return s.costBeforeTypeLabel()
+ }
+ return s.costBeforeTypeLabelForEntry(entry)
+}
+
+func (s *PurchaseOptionalDetails) toggleCostType() {
+ if s.CurrentChannel == "" {
+ if s.costTypeLabel() == "СРМ" {
+ s.CostType = "fixed"
+ } else {
+ s.CostType = "cpm"
+ }
+ return
+ }
+ entry := s.CostByChannel[s.CurrentChannel]
+ if entry.Type == "" {
+ entry.Type = s.CostType
+ }
+ if s.costTypeLabelForEntry(entry) == "СРМ" {
+ entry.Type = "fixed"
+ } else {
+ entry.Type = "cpm"
+ }
+ s.CostByChannel[s.CurrentChannel] = entry
+}
+
+func (s *PurchaseOptionalDetails) toggleCostBeforeType() {
+ if s.CurrentChannel == "" {
+ if s.costBeforeTypeLabel() == "СРМ" {
+ s.CostBeforeType = "fixed"
+ } else {
+ s.CostBeforeType = "cpm"
+ }
+ if s.CostBeforeBargain != nil {
+ s.CostBeforeBargain.Type = s.CostBeforeType
+ }
+ return
+ }
+ entry := s.CostBeforeByChannel[s.CurrentChannel]
+ if entry.Type == "" {
+ entry.Type = s.CostBeforeType
+ }
+ if s.costBeforeTypeLabelForEntry(entry) == "СРМ" {
+ entry.Type = "fixed"
+ } else {
+ entry.Type = "cpm"
+ }
+ s.CostBeforeByChannel[s.CurrentChannel] = entry
+}
+
+func (s *PurchaseOptionalDetails) setCostValue(value float64) {
+ if s.CurrentChannel == "" {
+ s.CostValue = &value
+ return
+ }
+ entry := s.CostByChannel[s.CurrentChannel]
+ entry.Value = &value
+ if entry.Type == "" {
+ entry.Type = s.CostType
+ }
+ s.CostByChannel[s.CurrentChannel] = entry
+}
+
+func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) {
+ if s.CurrentChannel == "" {
+ if s.CostBeforeBargain == nil {
+ s.CostBeforeBargain = &CostEntry{}
+ }
+ s.CostBeforeBargain.Value = &value
+ if s.CostBeforeBargain.Type == "" {
+ s.CostBeforeBargain.Type = s.CostBeforeType
+ }
+ return
+ }
+ entry := s.CostBeforeByChannel[s.CurrentChannel]
+ entry.Value = &value
+ if entry.Type == "" {
+ entry.Type = s.CostBeforeType
+ }
+ s.CostBeforeByChannel[s.CurrentChannel] = entry
+}
+
+func formatDuration(minutes int, unit string) string {
+ switch unit {
+ case "top":
+ if minutes > 0 && minutes%60 == 0 {
+ return fmt.Sprintf("%dч", minutes/60)
+ }
+ return fmt.Sprintf("%dмин", minutes)
+ case "feed":
+ if minutes == 0 {
+ return "без удаления"
+ }
+ if minutes >= 7*24*60 && minutes%(24*60) == 0 {
+ return fmt.Sprintf("%dд", minutes/(24*60))
+ }
+ if minutes%60 == 0 {
+ return fmt.Sprintf("%dч", minutes/60)
+ }
+ return fmt.Sprintf("%dмин", minutes)
+ }
+ return fmt.Sprintf("%d", minutes)
+}
+
+func formatLabel(topMinutes, feedMinutes int) string {
+ return formatDuration(topMinutes, "top") + " / " + formatDuration(feedMinutes, "feed")
+}
+
+func (s *PurchaseOptionalDetails) setFormatPreset(topMinutes int, feedMinutes int) {
+ label := formatLabel(topMinutes, feedMinutes)
+ topPtr := &topMinutes
+ var feedPtr *int
+ if feedMinutes == 0 {
+ // 0 = без удаления — сохраняем как 0
+ feedPtr = &feedMinutes
+ } else {
+ feedPtr = &feedMinutes
+ }
+
+ if s.CurrentChannel == "" {
+ s.Format = label
+ s.TopTimeMinutes = topPtr
+ s.FeedTimeMinutes = feedPtr
+ } else {
+ s.FormatByChannel[s.CurrentChannel] = label
+ s.TopTimeByChannel[s.CurrentChannel] = topPtr
+ s.FeedTimeByChannel[s.CurrentChannel] = feedPtr
+ }
+}
+
+func (s *PurchaseOptionalDetails) setFormatValue(value string) {
+ if s.CurrentChannel == "" {
+ s.Format = value
+ s.TopTimeMinutes = nil
+ s.FeedTimeMinutes = nil
+ return
+ }
+ s.FormatByChannel[s.CurrentChannel] = value
+ delete(s.TopTimeByChannel, s.CurrentChannel)
+ delete(s.FeedTimeByChannel, s.CurrentChannel)
+}
+
+func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) {
+ if s.CurrentChannel == "" {
+ s.InviteLinkType = value
+ return
+ }
+ s.InviteLinkTypeByChannel[s.CurrentChannel] = value
+}
+
+func (s *PurchaseOptionalDetails) backAction() string {
+ if s.ReturnMode != "" {
+ return "back_to_return"
+ }
+ return "back_to_optional"
+}
+
+func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
+ if len(s.Channels) == 0 {
+ b.SendNew("❌ Добавьте хотя бы один канал", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ placementType, ok := s.normalizePurchaseType()
+ if !ok {
+ b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
+ for _, ch := range s.Channels {
+ channelDetails := s.buildChannelDetails(channelKey(ch), placementType)
+ if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
+ channelDetails = nil
+ }
+ apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
+ ChannelID: ch.ChannelID,
+ Comment: ch.Comment,
+ Details: channelDetails,
+ })
+ }
+
+ input := backend.CreatePlacementsInput{
+ CreativeID: &s.CreativeID,
+ Channels: apiChannels,
+ }
+
+ placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to create placements")
+ b.SendNew("❌ Не удалось создать размещения", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ for _, placement := range placements.Placements {
+ _, err := b.Backend.BuildPlacementCreative(
+ context.Background(),
+ jwt,
+ b.Session.WorkspaceID,
+ s.ProjectID,
+ placement.ID,
+ )
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to build placement creative")
+ continue
+ }
+ }
+
+ // Очищаем сохранённое состояние после успешного создания
+ // Находим SelectChannelsForPurchase в BackState и очищаем его OptionalDetailsState
+ if selectState, ok := s.BackState.(*SelectChannelsForPurchase); ok {
+ selectState.OptionalDetailsState = nil
+ }
+
+ b.SetState(&Placements{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ BackState: s.BackState,
+ }, bot.NewMessage)
+}
+
+func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placementType *string) *backend.PlacementDetails {
+ details := &backend.PlacementDetails{}
+
+ // Placement date
+ if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
+ if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil {
+ formatted := value.UTC().Format(time.RFC3339)
+ details.PlacementAt = &formatted
+ }
+ } else if s.PlacementDateTime != nil {
+ formatted := s.PlacementDateTime.UTC().Format(time.RFC3339)
+ details.PlacementAt = &formatted
+ }
+
+ // Payment date
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil {
+ formatted := value.UTC().Format(time.RFC3339)
+ details.PaymentAt = &formatted
+ }
+ } else if s.PaymentDate != nil {
+ formatted := s.PaymentDate.UTC().Format(time.RFC3339)
+ details.PaymentAt = &formatted
+ }
+
+ // Cost
+ if s.CostMode == "per_channel" && len(s.Channels) > 1 {
+ entry := s.CostByChannel[channelKey]
+ details.Cost = s.buildCostInfo(entry.Type, entry.Value)
+ } else {
+ details.Cost = s.buildCostInfo(s.CostType, s.CostValue)
+ }
+
+ // Cost before bargain
+ if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
+ if entry, ok := s.CostBeforeByChannel[channelKey]; ok && entry.Value != nil {
+ details.CostBeforeBargain = s.buildCostInfo(entry.Type, entry.Value)
+ }
+ } else if s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
+ details.CostBeforeBargain = s.buildCostInfo(s.CostBeforeBargain.Type, s.CostBeforeBargain.Value)
+ }
+
+ // Placement type
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
+ normalized := normalizePurchaseTypeValue(value)
+ if normalized != nil {
+ details.PlacementType = normalized
+ }
+ }
+ } else if placementType != nil {
+ details.PlacementType = placementType
+ }
+
+ // Comment
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
+ details.Comment = &value
+ }
+ } else if s.Comment != "" {
+ details.Comment = &s.Comment
+ }
+
+ // Format
+ if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
+ if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
+ details.Format = &value
+ }
+ if value, ok := s.TopTimeByChannel[channelKey]; ok && value != nil {
+ details.TopTimeMinutes = value
+ }
+ if value, ok := s.FeedTimeByChannel[channelKey]; ok && value != nil {
+ details.FeedTimeMinutes = value
+ }
+ } else {
+ if s.Format != "" {
+ details.Format = &s.Format
+ }
+ details.TopTimeMinutes = s.TopTimeMinutes
+ details.FeedTimeMinutes = s.FeedTimeMinutes
+ }
+
+ // Invite link type
+ if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 {
+ if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" {
+ details.InviteLinkType = &value
+ }
+ } else if s.InviteLinkType != "" {
+ details.InviteLinkType = &s.InviteLinkType
+ }
+
+ return details
+}
+
+func (s *PurchaseOptionalDetails) buildCostInfo(costType string, value *float64) *backend.CostInfo {
+ if value == nil {
+ return nil
+ }
+ normalized := "fixed"
+ if costType == "cpm" {
+ normalized = "cpm"
+ }
+ return &backend.CostInfo{
+ Type: normalized,
+ Value: *value,
+ }
+}
+
+func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PlacementDetails) bool {
+ return details.PlacementAt == nil &&
+ details.PaymentAt == nil &&
+ details.Cost == nil &&
+ details.CostBeforeBargain == nil &&
+ details.PlacementType == nil &&
+ details.Format == nil &&
+ details.TopTimeMinutes == nil &&
+ details.FeedTimeMinutes == nil &&
+ details.Comment == nil &&
+ details.InviteLinkType == nil
+}
+
+func normalizePurchaseTypeValue(raw string) *string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ normalized := strings.ToLower(raw)
+ normalized = strings.TrimSpace(strings.Trim(normalized, "."))
+ switch normalized {
+ case "взаимный пиар", "взаимнопиар", "self_promo", "mutual_pr", "mutual pr", "вп", "vp":
+ value := "self_promo"
+ return &value
+ case "стандарт", "standard":
+ value := "standard"
+ return &value
+ default:
+ return nil
+ }
+}
+
+func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
+ result := normalizePurchaseTypeValue(s.PurchaseType)
+ if s.PurchaseType == "" {
+ return nil, true
+ }
+ return result, result != nil
+}
diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go
new file mode 100644
index 0000000..ef14542
--- /dev/null
+++ b/tg_bot/screens/select_channels_for_purchase.go
@@ -0,0 +1,641 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+type PurchaseChannelInput struct {
+ ChannelID string
+ Username string
+ Title string
+ InviteLink string
+ PlannedCost *float64
+ Comment *string
+}
+
+type SelectChannelsForPurchase struct {
+ ProjectID string
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+ ProjectDefaultLinkType string
+ CreativeID string
+ CreativeTitle string
+ Channels []PurchaseChannelInput
+ CurrentPage int
+ InvalidUsernames []string
+ Duplicates []string // дубликаты каналов
+ ParsingErrors []ParseError // ошибки парсинга с предложениями
+ OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей
+ BackState bot.State
+}
+
+// ParseError представляет ошибку парсинга с предложением исправления
+type ParseError struct {
+ Input string
+ Suggestion string
+}
+
+func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
+ s.renderChannelSelection(b, mode)
+}
+
+func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
+ text := "Создание закупа\n\n"
+ text += "Шаг 2/2: Добавление каналов\n\n"
+
+ var buttons [][]echotron.InlineKeyboardButton
+
+ if len(s.Channels) == 0 {
+ text += `Добавьте каналы для размещения рекламы
+
+Отправьте username (без @) или invite link приватного канала
+Например: channel_name или https://t.me/+abcdef
+
+После добавления всех каналов нажмите Далее`
+ } else {
+ text += fmt.Sprintf("Добавлено каналов: %d\n\n", len(s.Channels))
+
+ for i, ch := range s.Channels {
+ channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch))
+ if ch.PlannedCost != nil {
+ channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
+ }
+ text += channelText + "\n"
+ }
+ text += "\n"
+
+ const channelsPerPage = 6
+ if s.CurrentPage*channelsPerPage >= len(s.Channels) {
+ s.CurrentPage = 0
+ }
+ start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels))
+
+ var slots []echotron.InlineKeyboardButton
+ for i := start; i < end; i++ {
+ ch := s.Channels[i]
+ buttonText := channelLabel(ch)
+ if ch.PlannedCost != nil {
+ buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *ch.PlannedCost)
+ }
+ slots = append(slots, Button("✖ "+buttonText, fmt.Sprintf("remove_channel:%d", i)))
+ }
+
+ if len(s.Channels) > channelsPerPage {
+ for len(slots) < channelsPerPage {
+ slots = append(slots, Button(" ", "empty"))
+ }
+ }
+
+ for i := 0; i < len(slots); i += 2 {
+ row := []echotron.InlineKeyboardButton{slots[i]}
+ if i+1 < len(slots) {
+ row = append(row, slots[i+1])
+ } else {
+ row = append(row, Button(" ", "empty"))
+ }
+ buttons = append(buttons, row)
+ }
+
+ pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
+ navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalPages: pages,
+ })
+ if navRow != nil {
+ buttons = append(buttons, navRow)
+ }
+
+ text += "\nДобавьте еще каналы или создайте закуп"
+
+ // Кнопка создания закупа (доступна только если есть каналы)
+ }
+
+ if len(s.InvalidUsernames) > 0 {
+ text += fmt.Sprintf("\n\n⚠️ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
+ s.InvalidUsernames = nil
+ }
+
+ // Показываем дубликаты
+ if len(s.Duplicates) > 0 {
+ text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", "))
+ s.Duplicates = nil
+ }
+
+ // Показываем ошибки с предложениями
+ if len(s.ParsingErrors) > 0 {
+ text += "\n\n❌ Ошибки форматирования:\n"
+ for i, err := range s.ParsingErrors {
+ if i < 3 { // Показываем максимум 3 ошибки
+ if err.Suggestion != "" {
+ text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion)
+ } else {
+ text += fmt.Sprintf("• %s\n", err.Input)
+ }
+ }
+ }
+ if len(s.ParsingErrors) > 3 {
+ text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3)
+ }
+ s.ParsingErrors = nil
+ }
+
+ // Кнопки навигации
+ if len(s.Channels) > 0 {
+ buttons = append(buttons, Row(
+ Button("← Назад", "back"),
+ Button("→ Далее", "next_step"),
+ ))
+ } else {
+ buttons = append(buttons, Row(
+ Button("← Назад", "back"),
+ ))
+ }
+
+ keyboard := Keyboard(buttons...)
+ b.Render(text, keyboard, mode)
+}
+
+func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch data {
+ case "back":
+ // Возвращаемся к выбору креатива
+ b.SetState(&AddPurchase{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ CreativeID: s.CreativeID,
+ CreativeTitle: s.CreativeTitle,
+ ActivePicker: "",
+ BackState: s.BackState,
+ }, bot.EditMessage)
+
+ case "next_step":
+ if len(s.Channels) == 0 {
+ b.Edit("❌ Добавьте хотя бы один канал", Keyboard(
+ Row(Button("← Назад", "back_to_select")),
+ ))
+ return
+ }
+
+ // Проверяем, что все каналы резолвлены (имеют ChannelID)
+ var unresolved []string
+ for _, ch := range s.Channels {
+ if ch.ChannelID == "" {
+ label := channelLabel(ch)
+ unresolved = append(unresolved, label)
+ }
+ }
+
+ if len(unresolved) > 0 {
+ text := "❌ Некоторые каналы не были найдены:\n\n"
+ for _, label := range unresolved {
+ text += fmt.Sprintf("• %s\n", label)
+ }
+ text += "\nУдалите их из списка и попробуйте снова"
+
+ b.Edit(text, Keyboard(
+ Row(Button("← Назад", "back_to_select")),
+ ))
+ return
+ }
+
+ // Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
+ // Если есть сохранённое состояние - используем его, иначе создаём новое
+ var optionalDetails *PurchaseOptionalDetails
+ if s.OptionalDetailsState != nil {
+ optionalDetails = s.OptionalDetailsState
+ // Обновляем каналы на случай если они изменились
+ optionalDetails.Channels = s.Channels
+ } else {
+ optionalDetails = &PurchaseOptionalDetails{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectDefaultLinkType: s.ProjectDefaultLinkType,
+ CreativeID: s.CreativeID,
+ CreativeTitle: s.CreativeTitle,
+ Channels: s.Channels,
+ BackState: s,
+ }
+ }
+ b.SetState(optionalDetails, bot.EditMessage)
+
+ case "back_to_select":
+ s.Enter(b, bot.EditMessage)
+
+ case "prev":
+ if s.CurrentPage > 0 {
+ s.CurrentPage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case "next":
+ s.CurrentPage++
+ s.Enter(b, bot.EditMessage)
+
+ default:
+ if len(data) > 15 && data[:15] == "remove_channel:" {
+ var index int
+ if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil {
+ if index >= 0 && index < len(s.Channels) {
+ s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
+ if s.CurrentPage > 0 {
+ channelsPerPage := 6
+ pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
+ if s.CurrentPage >= pages {
+ s.CurrentPage = pages - 1
+ }
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ }
+ }
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
+ // Преобразуем каналы в формат API
+ var apiChannels []backend.CreatePlacementChannelInput
+ for _, ch := range s.Channels {
+ var details *backend.PlacementDetails
+ if ch.PlannedCost != nil {
+ cost := backend.CostInfo{
+ Type: "fixed",
+ Value: *ch.PlannedCost,
+ }
+ details = &backend.PlacementDetails{
+ Cost: &cost,
+ }
+ }
+ apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
+ ChannelID: ch.ChannelID,
+ Comment: ch.Comment,
+ Details: details,
+ })
+ }
+
+ input := backend.CreatePlacementsInput{
+ CreativeID: &s.CreativeID,
+ Channels: apiChannels,
+ }
+
+ placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
+
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to create placements")
+ b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
+ Row(Button("← Назад", "back_to_select")),
+ ))
+ return
+ }
+
+ if len(placements.Placements) == 0 {
+ b.Edit("❌ Размещения не созданы", Keyboard(
+ Row(Button("← Назад", "back_to_select")),
+ ))
+ return
+ }
+
+ b.SetState(&PlacementDetails{
+ ProjectID: s.ProjectID,
+ PlacementID: placements.Placements[0].ID,
+ BackState: s.BackState,
+ }, bot.EditMessage)
+}
+
+func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if u.Message == nil || u.Message.Text == "" {
+ return
+ }
+
+ raw := strings.TrimSpace(u.Message.Text)
+ if raw == "" {
+ return
+ }
+
+ // Проверяем JWT
+ jwt := b.Session.JWT
+ if jwt == "" {
+ log.Error().Msg("JWT is empty in session")
+ b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
+ return
+ }
+
+ // Разбиваем ввод на токены
+ tokens := strings.FieldsFunc(raw, func(r rune) bool {
+ return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
+ })
+
+ added := 0
+ s.Duplicates = nil
+ s.InvalidUsernames = nil
+ s.ParsingErrors = nil
+
+tokensLoop:
+ for _, token := range tokens {
+ entry := strings.TrimSpace(token)
+ entry = strings.Trim(entry, ",;")
+ if entry == "" {
+ continue
+ }
+
+ // Используем новый парсер
+ parsed := ParseChannelInput(entry)
+
+ if !parsed.Valid {
+ // Пробуем предложить исправление
+ if suggestion, ok := SuggestFix(entry); ok {
+ s.ParsingErrors = append(s.ParsingErrors, ParseError{
+ Input: entry,
+ Suggestion: suggestion,
+ })
+ } else {
+ s.InvalidUsernames = append(s.InvalidUsernames, entry)
+ }
+ continue
+ }
+
+ // Проверяем дубликаты по username/invite link
+ if IsDuplicate(parsed, s.Channels) {
+ label := FormatChannelLabel(parsed)
+ s.Duplicates = append(s.Duplicates, label)
+ continue
+ }
+
+ // Сразу резолвим канал через бэкенд
+ channel, err := s.resolveSingleChannel(b, jwt, parsed)
+ if err != nil {
+ // Канал не найден или ошибка
+ s.InvalidUsernames = append(s.InvalidUsernames, entry)
+ log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel")
+ continue
+ }
+
+ // Проверяем дубликаты по ID канала (после резолва)
+ for _, ch := range s.Channels {
+ if ch.ChannelID != "" && ch.ChannelID == channel.ID {
+ var title, username string
+ if channel.Title != nil {
+ title = *channel.Title
+ }
+ if channel.Username != nil {
+ username = *channel.Username
+ }
+ label := channelLabelByID(&channel.ID, &title, &username)
+ s.Duplicates = append(s.Duplicates, label)
+ continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего!
+ }
+
+ // Дополнительная проверка по username (case-insensitive)
+ // На случай, если канал еще не резолвлен или ID отличается
+ if channel.Username != nil && ch.Username != "" {
+ if strings.EqualFold(ch.Username, *channel.Username) {
+ var title string
+ if channel.Title != nil {
+ title = *channel.Title
+ }
+ label := channelLabelByID(&channel.ID, &title, channel.Username)
+ s.Duplicates = append(s.Duplicates, label)
+ continue tokensLoop
+ }
+ }
+ }
+
+ // Добавляем канал с полными данными
+ var title string
+ if channel.Title != nil {
+ title = *channel.Title
+ }
+ s.Channels = append(s.Channels, PurchaseChannelInput{
+ ChannelID: channel.ID,
+ Username: parsed.Username,
+ Title: title,
+ InviteLink: parsed.InviteLink,
+ })
+ added++
+ }
+
+ // Если ничего не добавлено и нет ошибок - не обновляем экран
+ if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
+ return
+ }
+
+ // Обновляем экран
+ s.Enter(b, bot.NewMessage)
+}
+
+// resolveSingleChannel резолвит один канал через бэкенд
+func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) {
+ var input backend.CreateChannelInput
+ if parsed.InviteLink != "" {
+ link := parsed.InviteLink
+ input = backend.CreateChannelInput{InviteLink: &link}
+ } else {
+ username := parsed.Username
+ input = backend.CreateChannelInput{Username: &username}
+ }
+
+ resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
+ Channels: []backend.CreateChannelInput{input},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if len(resp.Results) == 0 {
+ return nil, fmt.Errorf("no response from backend")
+ }
+
+ result := resp.Results[0]
+ if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
+ errMsg := "channel not found"
+ if result.Error != nil {
+ errMsg = *result.Error
+ }
+ return nil, fmt.Errorf(errMsg)
+ }
+
+ // Если пришел username из ответа, обновляем его
+ if result.Channel.Username != nil && *result.Channel.Username != "" {
+ parsed.Username = *result.Channel.Username
+ }
+
+ return result.Channel, nil
+}
+
+// channelLabelByID формирует метку канала по ID
+func channelLabelByID(id, title, username *string) string {
+ if title != nil && *title != "" {
+ return *title
+ }
+ if username != nil && *username != "" {
+ return "@" + *username
+ }
+ if id != nil && *id != "" {
+ return *id
+ }
+ return "канал"
+}
+
+func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
+ inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
+ for _, ch := range s.Channels {
+ if ch.InviteLink != "" {
+ link := ch.InviteLink
+ inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
+ } else {
+ username := ch.Username
+ inputs = append(inputs, backend.CreateChannelInput{Username: &username})
+ }
+ }
+
+ resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
+ Channels: inputs,
+ })
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to create channels")
+ return []string{"ошибка сервера"}
+ }
+
+ var failed []string
+ for _, result := range resp.Results {
+ if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
+ if result.Index >= 0 && result.Index < len(s.Channels) {
+ failed = append(failed, channelLabel(s.Channels[result.Index]))
+ }
+ continue
+ }
+ if result.Index < 0 || result.Index >= len(s.Channels) {
+ continue
+ }
+ ch := &s.Channels[result.Index]
+ ch.ChannelID = result.Channel.ID
+ if result.Channel.Username != nil {
+ ch.Username = *result.Channel.Username
+ }
+ if result.Channel.Title != nil {
+ ch.Title = *result.Channel.Title
+ }
+ }
+
+ return failed
+}
+
+func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
+ // Обрабатываем callback после успешного создания
+ if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
+ // Возвращаемся к списку закупов
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+ }
+}
+
+func (s *SelectChannelsForPurchase) Exit() {}
+
+func channelLabel(ch PurchaseChannelInput) string {
+ if ch.Username != "" {
+ return "@" + ch.Username
+ }
+ if ch.Title != "" {
+ return ch.Title
+ }
+ if ch.InviteLink != "" {
+ return formatInviteLabel(ch.InviteLink)
+ }
+ if ch.ChannelID != "" {
+ return ch.ChannelID
+ }
+ return "канал"
+}
+
+func channelKey(ch PurchaseChannelInput) string {
+ if ch.ChannelID != "" {
+ return ch.ChannelID
+ }
+ if ch.Username != "" {
+ return ch.Username
+ }
+ if ch.InviteLink != "" {
+ return ch.InviteLink
+ }
+ return ""
+}
+
+func channelLabelByKey(channels []PurchaseChannelInput, key string) string {
+ for _, ch := range channels {
+ if channelKey(ch) == key {
+ return channelLabel(ch)
+ }
+ }
+ return key
+}
+
+func isInviteLink(value string) bool {
+ value = strings.TrimSpace(value)
+ if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") {
+ return true
+ }
+ if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") {
+ return true
+ }
+ return false
+}
+
+func formatInviteLabel(inviteLink string) string {
+ link := strings.TrimSpace(inviteLink)
+ if link == "" {
+ return "приватный канал"
+ }
+
+ code := link
+ if strings.HasPrefix(code, "tg://") || strings.HasPrefix(code, "tg:") {
+ if idx := strings.Index(code, "invite="); idx != -1 {
+ code = code[idx+len("invite="):]
+ if end := strings.IndexAny(code, "&?#"); end != -1 {
+ code = code[:end]
+ }
+ }
+ } else {
+ code = strings.TrimPrefix(code, "https://")
+ code = strings.TrimPrefix(code, "http://")
+ code = strings.TrimPrefix(code, "t.me/")
+ code = strings.TrimPrefix(code, "telegram.me/")
+ code = strings.TrimPrefix(code, "joinchat/")
+ code = strings.TrimPrefix(code, "+")
+ if idx := strings.LastIndex(code, "/"); idx != -1 {
+ code = code[idx+1:]
+ }
+ if end := strings.IndexAny(code, "?#"); end != -1 {
+ code = code[:end]
+ }
+ }
+
+ code = strings.Trim(code, "/+ ")
+ if code == "" {
+ return "приватный канал"
+ }
+ if len(code) > 10 {
+ code = code[:6] + "..." + code[len(code)-2:]
+ }
+ return "приватный: " + code
+}
diff --git a/tg_bot/screens/select_workspace.go b/tg_bot/screens/select_workspace.go
new file mode 100644
index 0000000..ce36b54
--- /dev/null
+++ b/tg_bot/screens/select_workspace.go
@@ -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() {}
diff --git a/tg_bot/screens/ui/buttons.go b/tg_bot/screens/ui/buttons.go
new file mode 100644
index 0000000..e2eef78
--- /dev/null
+++ b/tg_bot/screens/ui/buttons.go
@@ -0,0 +1,21 @@
+package ui
+
+import "github.com/NicoNex/echotron/v3"
+
+// BuildGrid builds button rows from items using a callback builder.
+func BuildGrid[T any](items []T, itemsPerRow, itemsPerPage, totalPages int, itemFn func(T) (title string, callback string)) [][]echotron.InlineKeyboardButton {
+ if len(items) == 0 {
+ return nil
+ }
+
+ buttons := make([]echotron.InlineKeyboardButton, 0, len(items))
+ for _, item := range items {
+ title, callback := itemFn(item)
+ buttons = append(buttons, echotron.InlineKeyboardButton{
+ Text: title,
+ CallbackData: callback,
+ })
+ }
+
+ return BuildPageRows(buttons, itemsPerRow, itemsPerPage, totalPages)
+}
diff --git a/tg_bot/screens/ui/date_time_picker.go b/tg_bot/screens/ui/date_time_picker.go
new file mode 100644
index 0000000..2ed728d
--- /dev/null
+++ b/tg_bot/screens/ui/date_time_picker.go
@@ -0,0 +1,720 @@
+package ui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/olebedev/when"
+ "github.com/olebedev/when/rules/common"
+ "github.com/olebedev/when/rules/ru"
+)
+
+const (
+ pickerViewCalendar = "calendar"
+ pickerViewMonths = "months"
+ pickerViewTimeCombined = "time_combined"
+ pickerViewConfirm = "confirm"
+ dtpCallbackPrefix = "dtp:"
+)
+
+var MskLocation = time.FixedZone("MSK", 3*60*60)
+
+type DateTimePickerConfig struct {
+ Title string
+ Key string
+ IncludeTime bool
+ AllowPast bool
+ Selected *time.Time
+ BackState bot.State
+}
+
+type DateTimeSelectionTarget interface {
+ SetDateTimeSelection(key string, value time.Time)
+}
+
+type DateTimePicker struct {
+ Title string
+ Key string
+ IncludeTime bool
+ AllowPast bool
+ BackState bot.State
+
+ view string
+ year int
+ month time.Month
+ selectedDate *time.Time
+ selectedHour *int
+ selectedMinute *int
+}
+
+func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
+ now := time.Now().In(MskLocation)
+ year := now.Year()
+ month := now.Month()
+ var selected *time.Time
+
+ if cfg.Selected != nil {
+ value := cfg.Selected.In(MskLocation)
+ selected = &value
+ year = value.Year()
+ month = value.Month()
+ }
+
+ return &DateTimePicker{
+ Title: cfg.Title,
+ Key: cfg.Key,
+ IncludeTime: cfg.IncludeTime,
+ AllowPast: cfg.AllowPast,
+ BackState: cfg.BackState,
+ view: pickerViewCalendar,
+ year: year,
+ month: month,
+ selectedDate: selected,
+ }
+}
+
+func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
+ text := fmt.Sprintf("%s\n\n", s.Title)
+ text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime())
+ switch s.view {
+ case pickerViewCalendar:
+ text += s.renderCalendarTitle()
+ b.Render(text, s.calendarKeyboard(), mode)
+ case pickerViewMonths:
+ text += "Выберите месяц\n\n"
+ b.Render(text, s.monthsKeyboard(), mode)
+ case pickerViewTimeCombined:
+ b.Render(text, s.timeCombinedKeyboard(), mode)
+ case pickerViewConfirm:
+ text += s.renderSelectedDateTime()
+ b.Render(text, s.confirmKeyboard(), mode)
+ }
+}
+
+func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+ if data == "back" {
+ s.handleBack(b)
+ return
+ }
+ if data == "cancel" {
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+ return
+ }
+
+ if !strings.HasPrefix(data, dtpCallbackPrefix) {
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+
+ payload := strings.TrimPrefix(data, dtpCallbackPrefix)
+ switch {
+ case payload == "month_prev":
+ s.prevMonth()
+ case payload == "month_next":
+ s.nextMonth()
+ case payload == "open_months":
+ s.view = pickerViewMonths
+ case payload == "year_prev":
+ s.year--
+ case payload == "year_next":
+ s.year++
+ case strings.HasPrefix(payload, "month:"):
+ month := parseInt(strings.TrimPrefix(payload, "month:"))
+ if month >= 1 && month <= 12 {
+ s.month = time.Month(month)
+ s.view = pickerViewCalendar
+ }
+ case strings.HasPrefix(payload, "day:"):
+ s.handleDaySelect(payload, b)
+ return // confirmSelection уже делает Enter
+ case strings.HasPrefix(payload, "hour:"):
+ s.handleHourSelect(payload)
+ case strings.HasPrefix(payload, "min:"):
+ s.handleMinuteSelect(payload)
+ case payload == "confirm":
+ s.confirmSelection(b)
+ return
+ }
+
+ // Если не было подтверждения (для дат без времени), перерисовываем
+ s.Enter(b, bot.EditMessage)
+}
+
+func (s *DateTimePicker) handleBack(b *bot.Bot) {
+ switch s.view {
+ case pickerViewMonths:
+ s.view = pickerViewCalendar
+ case pickerViewTimeCombined:
+ s.view = pickerViewCalendar
+ case pickerViewConfirm:
+ if s.IncludeTime {
+ s.view = pickerViewTimeCombined
+ } else {
+ s.view = pickerViewCalendar
+ }
+ default:
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ return
+ }
+ }
+ s.Enter(b, bot.EditMessage)
+}
+
+func (s *DateTimePicker) renderCalendarTitle() string {
+ if s.year == time.Now().In(MskLocation).Year() {
+ return "\n"
+ }
+ return fmt.Sprintf("%d\n\n", s.year)
+}
+
+func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ s.monthPrevButton(),
+ {Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"},
+ {Text: "→", CallbackData: dtpCallbackPrefix + "month_next"},
+ })
+
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ {Text: "Пн", CallbackData: "empty"},
+ {Text: "Вт", CallbackData: "empty"},
+ {Text: "Ср", CallbackData: "empty"},
+ {Text: "Чт", CallbackData: "empty"},
+ {Text: "Пт", CallbackData: "empty"},
+ {Text: "Сб", CallbackData: "empty"},
+ {Text: "Вс", CallbackData: "empty"},
+ })
+
+ firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation)
+ weekday := int(firstOfMonth.Weekday())
+ if weekday == 0 {
+ weekday = 7
+ }
+ daysInMonth := daysInMonth(s.year, s.month)
+ today := time.Now().In(MskLocation)
+ todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
+
+ var row []echotron.InlineKeyboardButton
+ for i := 1; i < weekday; i++ {
+ row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
+ }
+
+ for day := 1; day <= daysInMonth; day++ {
+ date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation)
+ if !s.AllowPast && date.Before(todayDate) {
+ row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
+ } else {
+ label := fmt.Sprintf("%d", day)
+ row = append(row, echotron.InlineKeyboardButton{
+ Text: label,
+ CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day),
+ })
+ }
+ if len(row) == 7 {
+ rows = append(rows, row)
+ row = nil
+ }
+ }
+
+ if len(row) > 0 {
+ for len(row) < 7 {
+ row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
+ }
+ rows = append(rows, row)
+ }
+
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ {Text: "← Назад", CallbackData: "back"},
+ {Text: "Отмена", CallbackData: "cancel"},
+ })
+
+ return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
+}
+
+func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+
+ months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
+ for i := 0; i < 12; i += 3 {
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ {Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)},
+ {Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)},
+ {Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)},
+ })
+ }
+
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ {Text: "← Назад", CallbackData: "back"},
+ })
+
+ return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
+}
+
+func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+ rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}})
+ for i := 0; i < 24; i += 6 {
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ s.hourButton(i),
+ s.hourButton(i + 1),
+ s.hourButton(i + 2),
+ s.hourButton(i + 3),
+ s.hourButton(i + 4),
+ s.hourButton(i + 5),
+ })
+ }
+ rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}})
+ minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
+ for i := 0; i < len(minutes); i += 6 {
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ s.minuteButton(minutes[i]),
+ s.minuteButton(minutes[i+1]),
+ s.minuteButton(minutes[i+2]),
+ s.minuteButton(minutes[i+3]),
+ s.minuteButton(minutes[i+4]),
+ s.minuteButton(minutes[i+5]),
+ })
+ }
+ rows = append(rows, []echotron.InlineKeyboardButton{
+ {Text: "← Назад", CallbackData: "back"},
+ {Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
+ })
+ return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
+}
+
+func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
+ return echotron.InlineKeyboardMarkup{
+ InlineKeyboard: [][]echotron.InlineKeyboardButton{
+ {
+ {Text: "← Назад", CallbackData: "back"},
+ {Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
+ },
+ },
+ }
+}
+
+func (s *DateTimePicker) renderSelectedDateTime() string {
+ if s.selectedDate == nil {
+ return "—"
+ }
+ hour := 0
+ minute := 0
+ if s.selectedHour != nil {
+ hour = *s.selectedHour
+ }
+ if s.selectedMinute != nil {
+ minute = *s.selectedMinute
+ }
+ value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
+ return fmt.Sprintf("%s", formatCompactDateTime(value))
+}
+
+func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
+ hour := "__"
+ minute := "__"
+ if s.selectedHour != nil {
+ hour = fmt.Sprintf("%02d", *s.selectedHour)
+ } else if !showPlaceholders {
+ hour = "00"
+ }
+ if s.selectedMinute != nil {
+ minute = fmt.Sprintf("%02d", *s.selectedMinute)
+ } else if !showPlaceholders {
+ minute = "00"
+ }
+ return fmt.Sprintf("%s:%s", hour, minute)
+}
+
+func (s *DateTimePicker) formatSelectedDateTime() string {
+ displayDate, ok := s.displayDate()
+ if !ok {
+ return "—"
+ }
+ timeLabel := "--:--"
+ if s.selectedHour != nil && s.selectedMinute != nil {
+ timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute)
+ }
+ weekday := WeekdayName(displayDate.Weekday())
+ month := MonthShort(displayDate.Month())
+ return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year())
+}
+
+func (s *DateTimePicker) displayDate() (time.Time, bool) {
+ if s.selectedDate == nil {
+ if s.view == pickerViewCalendar || s.view == pickerViewMonths {
+ return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true
+ }
+ return time.Time{}, false
+ }
+
+ if (s.view == pickerViewCalendar || s.view == pickerViewMonths) &&
+ (s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) {
+ day := s.selectedDate.Day()
+ maxDay := daysInMonth(s.year, s.month)
+ if day > maxDay {
+ day = maxDay
+ }
+ return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true
+ }
+
+ return s.selectedDate.In(MskLocation), true
+}
+
+func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) {
+ datePart := strings.TrimPrefix(payload, "day:")
+ parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation)
+ if err != nil {
+ return
+ }
+ s.selectedDate = &parsed
+ if s.IncludeTime {
+ s.view = pickerViewTimeCombined
+ s.selectedHour = nil
+ s.selectedMinute = nil
+ s.Enter(b, bot.EditMessage)
+ } else {
+ // Сразу подтверждаем выбор для даты без времени
+ s.confirmSelection(b)
+ }
+}
+
+func (s *DateTimePicker) handleHourSelect(payload string) {
+ hour := parseInt(strings.TrimPrefix(payload, "hour:"))
+ if hour < 0 || hour > 23 {
+ return
+ }
+ if s.isHourDisabled(hour) {
+ return
+ }
+ s.selectedHour = &hour
+}
+
+func (s *DateTimePicker) handleMinuteSelect(payload string) {
+ minute := parseInt(strings.TrimPrefix(payload, "min:"))
+ if minute < 0 || minute > 59 {
+ return
+ }
+ if s.isMinuteDisabled(minute) {
+ return
+ }
+ s.selectedMinute = &minute
+}
+
+func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
+ if s.selectedDate == nil {
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
+ s.view = pickerViewTimeCombined
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ hour := 0
+ minute := 0
+ if s.selectedHour != nil {
+ hour = *s.selectedHour
+ }
+ if s.selectedMinute != nil {
+ minute = *s.selectedMinute
+ }
+ value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
+ if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
+ target.SetDateTimeSelection(s.Key, value)
+ }
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+}
+
+func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
+ label := fmt.Sprintf("%02d", hour)
+ if s.isHourDisabled(hour) {
+ return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
+ }
+ if s.selectedHour != nil && *s.selectedHour == hour {
+ label = "● " + label
+ }
+ return echotron.InlineKeyboardButton{
+ Text: label,
+ CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour),
+ }
+}
+
+func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
+ label := fmt.Sprintf("%02d", minute)
+ if s.isMinuteDisabled(minute) {
+ return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
+ }
+ if s.selectedMinute != nil && *s.selectedMinute == minute {
+ label = "● " + label
+ }
+ return echotron.InlineKeyboardButton{
+ Text: label,
+ CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute),
+ }
+}
+
+func (s *DateTimePicker) isHourDisabled(hour int) bool {
+ if s.AllowPast {
+ return false
+ }
+ if s.selectedDate == nil {
+ return false
+ }
+ today := time.Now().In(MskLocation)
+ date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
+ todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
+ if date.After(todayDate) {
+ return false
+ }
+ return hour < today.Hour()
+}
+
+func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
+ if s.AllowPast {
+ return false
+ }
+ if s.selectedDate == nil || s.selectedHour == nil {
+ return false
+ }
+ today := time.Now().In(MskLocation)
+ date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
+ todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
+ if date.After(todayDate) {
+ return false
+ }
+ if *s.selectedHour > today.Hour() {
+ return false
+ }
+ return minute < today.Minute()
+}
+
+func (s *DateTimePicker) prevMonth() {
+ if s.AllowPast {
+ if s.month == time.January {
+ s.month = time.December
+ s.year--
+ } else {
+ s.month--
+ }
+ return
+ }
+
+ now := time.Now().In(MskLocation)
+ if s.year == now.Year() && s.month == now.Month() {
+ return
+ }
+ if s.month == time.January {
+ s.month = time.December
+ s.year--
+ } else {
+ s.month--
+ }
+}
+
+func (s *DateTimePicker) nextMonth() {
+ if s.month == time.December {
+ s.month = time.January
+ s.year++
+ } else {
+ s.month++
+ }
+}
+
+func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
+ if s.AllowPast {
+ return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
+ }
+
+ now := time.Now().In(MskLocation)
+ if s.year == now.Year() && s.month == now.Month() {
+ return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
+ }
+ return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
+}
+
+func daysInMonth(year int, month time.Month) int {
+ return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day()
+}
+
+func monthName(month time.Month) string {
+ names := []string{
+ "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
+ "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
+ }
+ if int(month) < 1 || int(month) > len(names) {
+ return ""
+ }
+ return names[int(month)-1]
+}
+
+func parseInt(value string) int {
+ result := 0
+ for _, r := range value {
+ if r < '0' || r > '9' {
+ return 0
+ }
+ result = result*10 + int(r-'0')
+ }
+ return result
+}
+
+func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
+ if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
+ return
+ }
+
+ input := strings.TrimSpace(u.Message.Text)
+ switch s.view {
+ case pickerViewCalendar:
+ value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
+ if !hasDate {
+ s.Enter(b, bot.NewMessage)
+ return
+ }
+ s.selectedDate = &value
+ if hasTime {
+ hour := value.Hour()
+ minute := value.Minute()
+ s.selectedHour = &hour
+ s.selectedMinute = &minute
+ }
+ if s.IncludeTime {
+ s.view = pickerViewTimeCombined
+ } else {
+ s.view = pickerViewConfirm
+ }
+ s.Enter(b, bot.NewMessage)
+ case pickerViewTimeCombined:
+ value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
+ if !hasTime {
+ s.Enter(b, bot.NewMessage)
+ return
+ }
+ if hasDate {
+ s.selectedDate = &value
+ }
+ hour := value.Hour()
+ minute := value.Minute()
+ s.selectedHour = &hour
+ s.selectedMinute = &minute
+ s.Enter(b, bot.NewMessage)
+ default:
+ value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
+ if !hasDate && !hasTime {
+ s.Enter(b, bot.NewMessage)
+ return
+ }
+ s.selectedDate = &value
+ hour := value.Hour()
+ minute := value.Minute()
+ s.selectedHour = &hour
+ s.selectedMinute = &minute
+ s.view = pickerViewConfirm
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *DateTimePicker) Exit() {}
+
+var whenParser *when.Parser
+
+func init() {
+ whenParser = when.New(nil)
+ whenParser.Add(ru.All...)
+ whenParser.Add(common.All...)
+}
+
+var (
+ timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`)
+ dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`)
+)
+
+func detectTime(matched string) bool {
+ return timeIndicator.MatchString(matched)
+}
+
+func detectDate(matched string) bool {
+ return dateIndicator.MatchString(matched)
+}
+
+func parseDateTimeInput(
+ input string,
+ _ *time.Time,
+ _ *int,
+ _ *int,
+ allowPast bool,
+) (time.Time, bool, bool) {
+ now := time.Now().In(MskLocation)
+
+ r, err := whenParser.Parse(input, now)
+ if err != nil || r == nil {
+ return time.Time{}, false, false
+ }
+
+ result := r.Time.In(MskLocation)
+ matched := strings.ToLower(r.Text)
+
+ hasDate := detectDate(matched)
+ hasTime := detectTime(matched)
+
+ if !allowPast && result.Before(now) {
+ return time.Time{}, false, false
+ }
+
+ return result, hasDate, hasTime
+}
+
+func WeekdayName(day time.Weekday) string {
+ switch day {
+ case time.Monday:
+ return "Пн"
+ case time.Tuesday:
+ return "Вт"
+ case time.Wednesday:
+ return "Ср"
+ case time.Thursday:
+ return "Чт"
+ case time.Friday:
+ return "Пт"
+ case time.Saturday:
+ return "Сб"
+ case time.Sunday:
+ return "Вс"
+ default:
+ return ""
+ }
+}
+
+func formatCompactDateTime(value time.Time) string {
+ weekday := WeekdayName(value.Weekday())
+ month := MonthShort(value.Month())
+ return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
+}
+
+func MonthShort(month time.Month) string {
+ months := []string{
+ "янв", "фев", "мар", "апр", "май", "июн",
+ "июл", "авг", "сен", "окт", "ноя", "дек",
+ }
+ if int(month) < 1 || int(month) > len(months) {
+ return ""
+ }
+ return months[int(month)-1]
+}
diff --git a/tg_bot/screens/ui/message_format.go b/tg_bot/screens/ui/message_format.go
new file mode 100644
index 0000000..a3fbb35
--- /dev/null
+++ b/tg_bot/screens/ui/message_format.go
@@ -0,0 +1,305 @@
+package ui
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+)
+
+type htmlTag struct {
+ open string
+ close string
+}
+
+var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
+ echotron.BoldEntity: {open: "", close: ""},
+ echotron.ItalicEntity: {open: "", close: ""},
+ echotron.UnderlineEntity: {open: "", close: ""},
+ echotron.StrikethroughEntity: {open: "", close: ""},
+ echotron.CodeEntity: {open: "", close: ""},
+ echotron.PreEntity: {open: "", close: "
"},
+}
+
+// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
+func FormatMessageHTML(message *echotron.Message) string {
+ if message == nil {
+ return ""
+ }
+
+ // Telegram sends formatting as entities, so we rebuild the HTML from offsets.
+ text := message.Text
+ entities := message.Entities
+ if text == "" {
+ text = message.Caption
+ entities = message.CaptionEntities
+ }
+
+ if text == "" {
+ return ""
+ }
+
+ if len(entities) == 0 {
+ return EscapeHTML(text)
+ }
+
+ type tagSpan struct {
+ open string
+ close string
+ start int
+ end int
+ len int
+ }
+
+ runes := []rune(text)
+ positions := make([]int, len(runes)+1)
+ utf16Count := 0
+ for i, r := range runes {
+ positions[i] = utf16Count
+ if r > 0xFFFF {
+ utf16Count += 2
+ } else {
+ utf16Count++
+ }
+ }
+ positions[len(runes)] = utf16Count
+
+ utf16ToRuneIndex := func(utf16Index int) (int, bool) {
+ i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
+ if i < len(positions) && positions[i] == utf16Index {
+ return i, true
+ }
+ return 0, false
+ }
+
+ toRuneRange := func(offset, length int) (int, int, bool) {
+ start, ok := utf16ToRuneIndex(offset)
+ if !ok {
+ return 0, 0, false
+ }
+ end, ok := utf16ToRuneIndex(offset + length)
+ if !ok || end > len(runes) || end < start {
+ return 0, 0, false
+ }
+ return start, end, true
+ }
+
+ // URL ranges are used to prevent nested tags inside links.
+ urlRanges := make([][2]int, 0, len(entities))
+
+ for _, entity := range entities {
+ if entity == nil {
+ continue
+ }
+ if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
+ continue
+ }
+ start, end, ok := toRuneRange(entity.Offset, entity.Length)
+ if !ok {
+ continue
+ }
+ urlRanges = append(urlRanges, [2]int{start, end})
+ if entity.Type == echotron.UrlEntity {
+ _ = string(runes[start:end])
+ }
+ }
+
+ isInsideURL := func(offset, length int) bool {
+ for _, urlRange := range urlRanges {
+ if offset > urlRange[0] && offset+length <= urlRange[1] {
+ return true
+ }
+ if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
+ return true
+ }
+ }
+ return false
+ }
+
+ var spans []tagSpan
+ for _, entity := range entities {
+ if entity == nil {
+ continue
+ }
+ start, end, ok := toRuneRange(entity.Offset, entity.Length)
+ if !ok {
+ continue
+ }
+
+ if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
+ continue
+ }
+
+ var openTag, closeTag string
+ if tag, ok := simpleHTMLTags[entity.Type]; ok {
+ openTag, closeTag = tag.open, tag.close
+ } else if entity.Type == echotron.UrlEntity {
+ entityText := string(runes[start:end])
+ openTag = fmt.Sprintf("", entityText)
+ closeTag = ""
+ } else if entity.Type == echotron.TextLinkEntity {
+ if entity.URL != "" {
+ openTag = fmt.Sprintf("", entity.URL)
+ closeTag = ""
+ }
+ }
+
+ 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())
+}
+
+func normalizeHTMLTags(input string) string {
+ if input == "" {
+ return input
+ }
+
+ allowed := map[string]bool{
+ "a": true,
+ "b": true,
+ "i": true,
+ "u": true,
+ "s": true,
+ "code": true,
+ "pre": true,
+ }
+
+ var out strings.Builder
+ out.Grow(len(input))
+ stack := make([]string, 0, 8)
+
+ for i := 0; i < len(input); {
+ if input[i] != '<' {
+ out.WriteByte(input[i])
+ i++
+ continue
+ }
+
+ end := strings.IndexByte(input[i:], '>')
+ if end == -1 {
+ out.WriteByte(input[i])
+ i++
+ continue
+ }
+
+ end += i
+ tag := input[i+1 : end]
+ if tag == "" {
+ out.WriteString(input[i : end+1])
+ i = end + 1
+ continue
+ }
+
+ isClosing := tag[0] == '/'
+ tagName := tag
+ if isClosing {
+ tagName = tag[1:]
+ }
+ if space := strings.IndexByte(tagName, ' '); space != -1 {
+ tagName = tagName[:space]
+ }
+ tagName = strings.TrimSpace(tagName)
+
+ if !allowed[tagName] {
+ out.WriteString(input[i : end+1])
+ i = end + 1
+ continue
+ }
+
+ if isClosing {
+ // Find the tag in stack
+ foundIndex := -1
+ for j := len(stack) - 1; j >= 0; j-- {
+ if stack[j] == tagName {
+ foundIndex = j
+ break
+ }
+ }
+
+ if foundIndex >= 0 {
+ // Close all tags from top of stack down to foundIndex
+ for j := len(stack) - 1; j > foundIndex; j-- {
+ out.WriteString("")
+ out.WriteString(stack[j])
+ out.WriteString(">")
+ }
+ // Close the found tag
+ out.WriteString(input[i : end+1])
+ // Remove closed tags from stack
+ stack = stack[:foundIndex]
+ }
+ } else {
+ stack = append(stack, tagName)
+ out.WriteString(input[i : end+1])
+ }
+
+ i = end + 1
+ }
+
+ for i := len(stack) - 1; i >= 0; i-- {
+ out.WriteString("")
+ out.WriteString(stack[i])
+ out.WriteString(">")
+ }
+
+ return out.String()
+}
+
+// EscapeHTML escapes HTML special characters for safe output.
+func EscapeHTML(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ 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)
+}
diff --git a/tg_bot/screens/ui/pagination.go b/tg_bot/screens/ui/pagination.go
new file mode 100644
index 0000000..4c01c67
--- /dev/null
+++ b/tg_bot/screens/ui/pagination.go
@@ -0,0 +1,219 @@
+package ui
+
+import (
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+)
+
+// PaginationConfig конфигурация для построения навигационного ряда пагинации
+type PaginationConfig struct {
+ CurrentPage int
+ TotalPages int // Для API пагинации (из page.Pages)
+ TotalItems int // Для локальной пагинации (len(array))
+ ItemsPerPage int // Для расчета TotalPages из TotalItems
+ PrevCallback string // По умолчанию "prev"
+ NextCallback string // По умолчанию "next"
+ MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, "+ Добавить")
+}
+
+// BuildNavigationRow создает навигационный ряд с динамической шириной.
+// Показывает ряд только если страниц > 1.
+// Возвращает nil если пагинация не нужна.
+func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
+ // Вычисляем TotalPages из TotalItems если не задан
+ totalPages := config.TotalPages
+ if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
+ totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
+ }
+
+ // Устанавливаем значения по умолчанию для callbacks
+ prevCallback := config.PrevCallback
+ if prevCallback == "" {
+ prevCallback = "prev"
+ }
+ nextCallback := config.NextCallback
+ if nextCallback == "" {
+ nextCallback = "next"
+ }
+
+ var row []echotron.InlineKeyboardButton
+
+ // Левая стрелка (только если есть предыдущая страница)
+ if totalPages > 1 && config.CurrentPage > 0 {
+ row = append(row, echotron.InlineKeyboardButton{
+ Text: "←",
+ CallbackData: prevCallback,
+ })
+ }
+
+ // Средние кнопки (если есть)
+ if len(config.MiddleButtons) > 0 {
+ row = append(row, config.MiddleButtons...)
+ }
+
+ // Правая стрелка (только если есть следующая страница)
+ if totalPages > 1 && config.CurrentPage+1 < totalPages {
+ row = append(row, echotron.InlineKeyboardButton{
+ Text: "→",
+ CallbackData: nextCallback,
+ })
+ }
+
+ if len(row) == 0 {
+ return nil
+ }
+
+ return row
+}
+
+// CalculatePages вычисляет количество страниц из общего количества элементов
+func CalculatePages(totalItems, itemsPerPage int) int {
+ if itemsPerPage <= 0 {
+ return 0
+ }
+ return (totalItems + itemsPerPage - 1) / itemsPerPage
+}
+
+// GetPageBounds возвращает start/end индексы для текущей страницы.
+// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
+func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
+ start = currentPage * itemsPerPage
+ if start >= totalItems {
+ start = 0
+ }
+ end = start + itemsPerPage
+ if end > totalItems {
+ end = totalItems
+ }
+ return start, end
+}
+
+// FormatPageInfo форматирует текст индикатора страницы для заголовка
+func FormatPageInfo(currentPage, totalPages int) string {
+ if totalPages <= 1 {
+ return ""
+ }
+ return fmt.Sprintf(" (стр. %d/%d)", currentPage+1, totalPages)
+}
+
+// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией
+type ElementLayoutConfig struct {
+ CurrentPage int // Текущая страница (0-indexed)
+ ItemsPerPage int // Элементов на странице
+ ItemsPerRow int // Элементов в одном ряду
+ EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ")
+}
+
+// BuildElementRows автоматически раскладывает элементы с пагинацией.
+// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой.
+// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
+// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру.
+//
+// Пример использования:
+//
+// config := ui.ElementLayoutConfig{
+// CurrentPage: 0,
+// ItemsPerPage: 6,
+// ItemsPerRow: 2,
+// }
+// rows := ui.BuildElementRows(config, allButtons)
+// keyboard = append(keyboard, rows...)
+func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
+ if len(allItems) == 0 {
+ return nil
+ }
+
+ // Параметры по умолчанию
+ if config.ItemsPerPage <= 0 {
+ config.ItemsPerPage = 6
+ }
+ if config.ItemsPerRow <= 0 {
+ config.ItemsPerRow = 2
+ }
+
+ // Кнопка-заполнитель по умолчанию
+ emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
+ if config.EmptyButton != nil {
+ emptyButton = *config.EmptyButton
+ }
+
+ // Вычисляем границы страницы
+ totalPages := CalculatePages(len(allItems), config.ItemsPerPage)
+ start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems))
+
+ // Получаем элементы текущей страницы
+ pageItems := allItems[start:end]
+
+ // Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
+ // Это обеспечивает одинаковую высоту клавиатуры на всех страницах
+ if totalPages >= 2 {
+ for len(pageItems) < config.ItemsPerPage {
+ pageItems = append(pageItems, emptyButton)
+ }
+ }
+
+ // Раскладываем элементы по рядам
+ var rows [][]echotron.InlineKeyboardButton
+ for i := 0; i < len(pageItems); i += config.ItemsPerRow {
+ var row []echotron.InlineKeyboardButton
+
+ // Добавляем элементы в ряд
+ for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ {
+ row = append(row, pageItems[i+j])
+ }
+
+ // Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми
+ for len(row) < config.ItemsPerRow {
+ row = append(row, emptyButton)
+ }
+
+ rows = append(rows, row)
+ }
+
+ return rows
+}
+
+// BuildPageRows раскладывает элементы текущей страницы без собственной пагинации.
+// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
+func BuildPageRows(pageItems []echotron.InlineKeyboardButton, itemsPerRow, itemsPerPage, totalPages int) [][]echotron.InlineKeyboardButton {
+ if len(pageItems) == 0 {
+ return nil
+ }
+
+ // Параметры по умолчанию
+ if itemsPerPage <= 0 {
+ itemsPerPage = 6
+ }
+ if itemsPerRow <= 0 {
+ itemsPerRow = 2
+ }
+
+ // Кнопка-заполнитель по умолчанию
+ emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
+
+ // Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
+ if totalPages >= 2 {
+ for len(pageItems) < itemsPerPage {
+ pageItems = append(pageItems, emptyButton)
+ }
+ }
+
+ // Раскладываем элементы по рядам
+ var rows [][]echotron.InlineKeyboardButton
+ for i := 0; i < len(pageItems); i += itemsPerRow {
+ var row []echotron.InlineKeyboardButton
+
+ for j := 0; j < itemsPerRow && i+j < len(pageItems); j++ {
+ row = append(row, pageItems[i+j])
+ }
+
+ for len(row) < itemsPerRow {
+ row = append(row, emptyButton)
+ }
+
+ rows = append(rows, row)
+ }
+
+ return rows
+}
diff --git a/tg_bot/screens/workspace_menu.go b/tg_bot/screens/workspace_menu.go
new file mode 100644
index 0000000..7d795d8
--- /dev/null
+++ b/tg_bot/screens/workspace_menu.go
@@ -0,0 +1,134 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
+ "github.com/rs/zerolog/log"
+)
+
+type WorkspaceMenu struct {
+ CurrentPage int
+ BackState bot.State
+}
+
+const msgWorkspaceEmpty = `
+У вас пока нет рабочих пространств.
+`
+
+const workspacesPerPage = 6
+
+func (s *WorkspaceMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
+ workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get workspaces for workspace menu")
+ b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back"))))
+ return
+ }
+
+ if len(workspaces) == 0 {
+ kb := Keyboard(Row(Button("← Назад", "back")))
+ b.Render(msgWorkspaceEmpty, kb, mode)
+ return
+ }
+
+ var allButtons []echotron.InlineKeyboardButton
+ for _, ws := range workspaces {
+ label := ws.Name
+ if ws.ID == b.Session.WorkspaceID {
+ label = "● " + label
+ }
+ allButtons = append(allButtons, Button(label, fmt.Sprintf("workspace_select:%s", ws.ID)))
+ }
+
+ elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
+ CurrentPage: s.CurrentPage,
+ ItemsPerPage: workspacesPerPage,
+ ItemsPerRow: 2,
+ }, allButtons)
+
+ var buttons [][]echotron.InlineKeyboardButton
+ buttons = append(buttons, elementRows...)
+
+ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
+ CurrentPage: s.CurrentPage,
+ TotalItems: len(allButtons),
+ ItemsPerPage: workspacesPerPage,
+ }); navRow != nil {
+ buttons = append(buttons, navRow)
+ }
+
+ buttons = append(buttons, Row(Button("← Назад", "back")))
+
+ totalPages := ui.CalculatePages(len(allButtons), workspacesPerPage)
+ text := fmt.Sprintf(`Рабочие пространства%s
+
+Выберите текущее рабочее пространство.`, ui.FormatPageInfo(s.CurrentPage, totalPages))
+
+ b.Render(text, Keyboard(buttons...), mode)
+}
+
+func (s *WorkspaceMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "prev":
+ if s.CurrentPage > 0 {
+ s.CurrentPage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case data == "next":
+ s.CurrentPage++
+ s.Enter(b, bot.EditMessage)
+
+ case data == "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ return
+ }
+ b.SetState(&MainMenu{}, bot.EditMessage)
+
+ case strings.HasPrefix(data, "workspace_select:"):
+ parts := strings.Split(data, ":")
+ if len(parts) != 2 {
+ return
+ }
+ workspaceID := parts[1]
+ workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get workspaces for selection")
+ b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back"))))
+ return
+ }
+ for _, ws := range workspaces {
+ if ws.ID == workspaceID {
+ b.Session.WorkspaceID = ws.ID
+ break
+ }
+ }
+
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ return
+ }
+ b.SetState(&MainMenu{}, bot.EditMessage)
+
+ default:
+ s.Enter(b, bot.NewMessage)
+ }
+}
+
+func (s *WorkspaceMenu) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *WorkspaceMenu) Handle(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *WorkspaceMenu) Exit() {}
diff --git a/tg_parser/Dockerfile b/tg_parser/Dockerfile
new file mode 100644
index 0000000..2f5e100
--- /dev/null
+++ b/tg_parser/Dockerfile
@@ -0,0 +1,18 @@
+FROM golang:1.25-alpine AS build
+
+WORKDIR /app/tg_parser
+
+# Modules layer
+COPY tg_parser/go.mod tg_parser/go.sum ./
+COPY pkg /app/pkg
+RUN go mod download
+
+# Build layer
+COPY tg_parser /app/tg_parser
+RUN CGO_ENABLED=0 GOOS=linux go build -o /parser .
+
+FROM alpine:latest AS run
+
+COPY --from=build /parser /parser
+
+CMD ["/parser"]
diff --git a/tg_parser/cmd/auth/main.go b/tg_parser/cmd/auth/main.go
new file mode 100644
index 0000000..755b287
--- /dev/null
+++ b/tg_parser/cmd/auth/main.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/gotd/td/session"
+ "github.com/gotd/td/telegram"
+ "github.com/gotd/td/telegram/auth"
+ "github.com/gotd/td/tg"
+)
+
+func main() {
+ apiID := mustEnvInt("TELEGRAM__API_ID")
+ apiHash := mustEnv("TELEGRAM__API_HASH")
+ sessionFile := mustEnv("TELEGRAM__SESSION_FILE")
+
+ phone := strings.TrimSpace(os.Getenv("TELEGRAM__PHONE"))
+ if phone == "" {
+ phone = prompt("Phone (e.g. +79991234567): ")
+ }
+
+ password := strings.TrimSpace(os.Getenv("TELEGRAM__PASSWORD"))
+ if password == "" {
+ password = prompt("2FA password (empty if not set): ")
+ }
+
+ ctx := context.Background()
+
+ client := telegram.NewClient(apiID, apiHash, telegram.Options{
+ SessionStorage: &session.FileStorage{Path: sessionFile},
+ })
+
+ err := client.Run(ctx, func(ctx context.Context) error {
+ return client.Auth().IfNecessary(ctx, auth.NewFlow(
+ auth.Constant(phone, password, auth.CodeAuthenticatorFunc(
+ func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) {
+ return prompt("Enter code: "), nil
+ },
+ )),
+ auth.SendCodeOptions{},
+ ))
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "auth failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("✓ Authorized")
+}
+
+func mustEnv(key string) string {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ fmt.Fprintf(os.Stderr, "missing required env: %s\n", key)
+ os.Exit(1)
+ }
+ return value
+}
+
+func mustEnvInt(key string) int {
+ raw := mustEnv(key)
+ v, err := strconv.Atoi(raw)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "invalid int in %s: %v\n", key, err)
+ os.Exit(1)
+ }
+ return v
+}
+
+func prompt(label string) string {
+ fmt.Print(label)
+ reader := bufio.NewReader(os.Stdin)
+ value, _ := reader.ReadString('\n')
+ return strings.TrimSpace(value)
+}
diff --git a/tg_parser/config/config.go b/tg_parser/config/config.go
new file mode 100644
index 0000000..bc9c0f0
--- /dev/null
+++ b/tg_parser/config/config.go
@@ -0,0 +1,48 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/TelegramExchange/pkg/postgres"
+ "github.com/TelegramExchange/pkg/telegram"
+ "github.com/joho/godotenv"
+ "github.com/kelseyhightower/envconfig"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/controller/worker"
+)
+
+type HTTP struct {
+ Addr string `envconfig:"HTTP__ADDR" default:":8080"`
+}
+
+type LoggerConfig struct {
+ Level string `default:"info" envconfig:"PARSER__LOGGER__LEVEL"`
+ PrettyConsole bool `default:"true" envconfig:"PARSER__LOGGER__PRETTY_CONSOLE"`
+}
+
+type Config struct {
+ Logger LoggerConfig
+ Postgres postgres.Config
+ Telegram telegram.Config
+ ChannelWorker worker.ChannelConfig
+ ViewsWorker worker.ViewsConfig
+ HTTP HTTP
+}
+
+func New() (Config, error) {
+ var config Config
+
+ err := godotenv.Load(".env")
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return config, fmt.Errorf("godotenv.Load: %w", err)
+ }
+
+ err = envconfig.Process("", &config)
+ if err != nil {
+ return config, fmt.Errorf("envconfig.Process: %w", err)
+ }
+
+ return config, nil
+}
diff --git a/tg_parser/go.mod b/tg_parser/go.mod
new file mode 100644
index 0000000..d56b364
--- /dev/null
+++ b/tg_parser/go.mod
@@ -0,0 +1,54 @@
+module github.com/TelegramExchange/tgex-backend/tg_parser
+
+go 1.25.0
+
+require (
+ github.com/gotd/td v0.136.0
+ github.com/jackc/pgx/v5 v5.7.6
+ github.com/joho/godotenv v1.5.1
+ github.com/kelseyhightower/envconfig v1.4.0
+ github.com/rs/zerolog v1.34.0
+ github.com/TelegramExchange/pkg v0.0.0
+)
+
+require (
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/coder/websocket v1.8.14 // indirect
+ github.com/dlclark/regexp2 v1.11.5 // indirect
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/ghodss/yaml v1.0.0 // indirect
+ github.com/go-faster/errors v0.7.1 // indirect
+ github.com/go-faster/jx v1.2.0 // indirect
+ github.com/go-faster/xor v1.0.0 // indirect
+ github.com/go-faster/yaml v0.4.6 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gotd/ige v0.2.2 // indirect
+ github.com/gotd/neo v0.1.5 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/klauspost/compress v1.18.2 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ogen-go/ogen v1.16.0 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ go.opentelemetry.io/otel v1.38.0 // indirect
+ go.opentelemetry.io/otel/metric v1.38.0 // indirect
+ go.opentelemetry.io/otel/trace v1.38.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.1 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
+ golang.org/x/mod v0.30.0 // indirect
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ golang.org/x/tools v0.39.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ rsc.io/qr v0.2.0 // indirect
+)
+
+replace github.com/TelegramExchange/pkg => ../pkg
diff --git a/tg_parser/go.sum b/tg_parser/go.sum
new file mode 100644
index 0000000..e2a6a74
--- /dev/null
+++ b/tg_parser/go.sum
@@ -0,0 +1,130 @@
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
+github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
+github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
+github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
+github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
+github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
+github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
+github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
+github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
+github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
+github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
+github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
+github.com/gotd/td v0.136.0 h1:f7vx/1rlvP59L5EKR820XpMRO2k267wW8/F0rAWbepc=
+github.com/gotd/td v0.136.0/go.mod h1:mStcqs/9FXhNhWnPTguptSwqkQbRIwXLw3SCSpzPJxM=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
+github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
+github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
+github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
+github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw=
+github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
+go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
+go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
+go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
+go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
+go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
+golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
+nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/tg_parser/internal/adapter/database/create_post.go b/tg_parser/internal/adapter/database/create_post.go
new file mode 100644
index 0000000..0a2fe11
--- /dev/null
+++ b/tg_parser/internal/adapter/database/create_post.go
@@ -0,0 +1,45 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (d *Database) CreatePost(ctx context.Context, post domain.Post) error {
+ query := `INSERT INTO post (id, channel_id, message_id, text, published_at)
+VALUES ($1, $2, $3, $4, $5)
+ON CONFLICT (channel_id, message_id) DO UPDATE
+SET text = EXCLUDED.text,
+ published_at = COALESCE(post.published_at, EXCLUDED.published_at),
+ updated_at = CURRENT_TIMESTAMP;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ dto := createPostDTO{
+ ID: pgtype.UUID{Bytes: post.ID, Valid: true},
+ ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true},
+ MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true},
+ Text: pgtype.Text{String: post.Text, Valid: true},
+ PublishedAt: pgtype.Timestamptz{Time: post.PublishedAt, Valid: !post.PublishedAt.IsZero()},
+ }
+
+ _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text, dto.PublishedAt)
+ if err != nil {
+ return fmt.Errorf("txOrPool.Exec: %w", err)
+ }
+
+ return nil
+}
+
+type createPostDTO struct {
+ ID pgtype.UUID
+ ChannelID pgtype.UUID
+ MessageID pgtype.Int4
+ Text pgtype.Text
+ PublishedAt pgtype.Timestamptz
+}
diff --git a/tg_parser/internal/adapter/database/create_views_snapshot.go b/tg_parser/internal/adapter/database/create_views_snapshot.go
new file mode 100644
index 0000000..ed00999
--- /dev/null
+++ b/tg_parser/internal/adapter/database/create_views_snapshot.go
@@ -0,0 +1,40 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (d *Database) CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error {
+ query := `INSERT INTO post_views_history (id, views_count, fetched_at, post_id)
+VALUES ($1, $2, $3, $4);`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ dto := createViewsSnapshotDTO{
+ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true},
+ ViewsCount: pgtype.Int4{Int32: int32(snapshot.ViewsCount), Valid: true},
+ FetchedAt: pgtype.Timestamptz{Time: snapshot.FetchedAt, Valid: true},
+ PostID: pgtype.UUID{Bytes: snapshot.PostID, Valid: true},
+ }
+
+ _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ViewsCount, dto.FetchedAt, dto.PostID)
+ if err != nil {
+ return fmt.Errorf("txOrPool.Exec: %w", err)
+ }
+
+ return nil
+}
+
+type createViewsSnapshotDTO struct {
+ ID pgtype.UUID
+ ViewsCount pgtype.Int4
+ FetchedAt pgtype.Timestamptz
+ PostID pgtype.UUID
+}
diff --git a/tg_parser/internal/adapter/database/database.go b/tg_parser/internal/adapter/database/database.go
new file mode 100644
index 0000000..65305af
--- /dev/null
+++ b/tg_parser/internal/adapter/database/database.go
@@ -0,0 +1,7 @@
+package database
+
+type Database struct{}
+
+func New() *Database {
+ return &Database{}
+}
diff --git a/tg_parser/internal/adapter/database/delete_post.go b/tg_parser/internal/adapter/database/delete_post.go
new file mode 100644
index 0000000..b5fc1dc
--- /dev/null
+++ b/tg_parser/internal/adapter/database/delete_post.go
@@ -0,0 +1,37 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (d *Database) DeletePost(ctx context.Context, p domain.Post) error {
+ query := `UPDATE post
+SET deleted_from_channel_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE channel_id = $1 AND message_id = $2;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ dto := deletePostDTO{
+ ChannelID: pgtype.UUID{Bytes: p.ChannelID, Valid: true},
+ MessageID: pgtype.Int4{Int32: int32(p.MessageID), Valid: true},
+ }
+
+ _, err := txOrPool.Exec(ctx, query, dto.ChannelID, dto.MessageID)
+ if err != nil {
+ return fmt.Errorf("txOrPool.Exec: %w", err)
+ }
+
+ return nil
+}
+
+type deletePostDTO struct {
+ ChannelID pgtype.UUID
+ MessageID pgtype.Int4
+}
diff --git a/tg_parser/internal/adapter/database/get_channels.go b/tg_parser/internal/adapter/database/get_channels.go
new file mode 100644
index 0000000..9d9004a
--- /dev/null
+++ b/tg_parser/internal/adapter/database/get_channels.go
@@ -0,0 +1,88 @@
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (d *Database) GetChannels(ctx context.Context) []domain.Channel {
+ query := `SELECT
+ id,
+ telegram_id,
+ username,
+ title,
+ access_hash,
+ pts,
+ invite_link,
+ is_accessible
+FROM channel
+WHERE deleted_at IS NULL
+ AND is_accessible = true;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ rows, err := txOrPool.Query(ctx, query)
+ if err != nil {
+ log.Error().Err(err).Msg("txOrPool.Query failed")
+ return []domain.Channel{}
+ }
+ defer rows.Close()
+
+ channels := make([]domain.Channel, 0)
+
+ for rows.Next() {
+ var dto getChannelsDTO
+
+ err = rows.Scan(dto.destination()...)
+ if err != nil {
+ log.Error().Err(err).Msg("rows.Scan failed")
+ return []domain.Channel{}
+ }
+
+ channels = append(channels, dto.toDomain())
+ }
+
+ return channels
+}
+
+type getChannelsDTO struct {
+ ID pgtype.UUID
+ TelegramID pgtype.Int8
+ Username pgtype.Text
+ Title pgtype.Text
+ AccessHash pgtype.Int8
+ Pts pgtype.Int4
+ InviteLink pgtype.Text
+ IsAccessible pgtype.Bool
+}
+
+func (dto *getChannelsDTO) destination() []any {
+ return []any{
+ &dto.ID,
+ &dto.TelegramID,
+ &dto.Username,
+ &dto.Title,
+ &dto.AccessHash,
+ &dto.Pts,
+ &dto.InviteLink,
+ &dto.IsAccessible,
+ }
+}
+
+func (dto *getChannelsDTO) toDomain() domain.Channel {
+ return domain.Channel{
+ ID: dto.ID.Bytes,
+ TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64),
+ Username: dto.Username.String,
+ Title: dto.Title.String,
+ AccessHash: dto.AccessHash.Int64,
+ Pts: int(dto.Pts.Int32),
+ InviteLink: dto.InviteLink.String,
+ IsAccessible: dto.IsAccessible.Bool,
+ }
+}
diff --git a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go
new file mode 100644
index 0000000..c09ceb0
--- /dev/null
+++ b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go
@@ -0,0 +1,89 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error) {
+ query := `SELECT DISTINCT
+ c.id,
+ c.telegram_id,
+ c.username,
+ c.title,
+ c.access_hash,
+ c.pts,
+ c.invite_link,
+ c.is_accessible
+FROM channel c
+INNER JOIN placement p ON p.channel_id = c.id
+INNER JOIN placement_post pp ON pp.placement_id = p.id
+INNER JOIN post po ON po.id = pp.post_id
+WHERE po.deleted_from_channel_at IS NULL
+ AND c.is_accessible = true;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ rows, err := txOrPool.Query(ctx, query)
+ if err != nil {
+ return nil, fmt.Errorf("txOrPool.Query: %w", err)
+ }
+ defer rows.Close()
+
+ channels := make([]domain.Channel, 0)
+
+ for rows.Next() {
+ var dto getChannelsWithTrackedPostsDTO
+
+ err = rows.Scan(dto.destination()...)
+ if err != nil {
+ return nil, fmt.Errorf("rows.Scan: %w", err)
+ }
+
+ channels = append(channels, dto.toDomain())
+ }
+
+ return channels, nil
+}
+
+type getChannelsWithTrackedPostsDTO struct {
+ ID pgtype.UUID
+ TelegramID pgtype.Int8
+ Username pgtype.Text
+ Title pgtype.Text
+ AccessHash pgtype.Int8
+ Pts pgtype.Int4
+ InviteLink pgtype.Text
+ IsAccessible pgtype.Bool
+}
+
+func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
+ return []any{
+ &dto.ID,
+ &dto.TelegramID,
+ &dto.Username,
+ &dto.Title,
+ &dto.AccessHash,
+ &dto.Pts,
+ &dto.InviteLink,
+ &dto.IsAccessible,
+ }
+}
+
+func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel {
+ return domain.Channel{
+ ID: dto.ID.Bytes,
+ TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64),
+ Username: dto.Username.String,
+ Title: dto.Title.String,
+ AccessHash: dto.AccessHash.Int64,
+ Pts: int(dto.Pts.Int32),
+ InviteLink: dto.InviteLink.String,
+ IsAccessible: dto.IsAccessible.Bool,
+ }
+}
diff --git a/tg_parser/internal/adapter/database/get_tracked_posts.go b/tg_parser/internal/adapter/database/get_tracked_posts.go
new file mode 100644
index 0000000..f78fd0d
--- /dev/null
+++ b/tg_parser/internal/adapter/database/get_tracked_posts.go
@@ -0,0 +1,91 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (d *Database) GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error) {
+ query := `SELECT DISTINCT
+ p.id,
+ p.channel_id,
+ p.message_id,
+ p.text,
+ CASE
+ WHEN c.username IS NOT NULL THEN CONCAT('https://t.me/', c.username, '/', p.message_id)
+ WHEN c.telegram_id IS NOT NULL THEN CONCAT('https://t.me/c/', (-c.telegram_id - 1000000000000), '/', p.message_id)
+ ELSE ''
+ END as link,
+ COALESCE(
+ (SELECT pvh.views_count
+ FROM post_views_history pvh
+ WHERE pvh.post_id = p.id
+ ORDER BY pvh.fetched_at DESC
+ LIMIT 1),
+ 0
+ ) as views
+FROM post p
+INNER JOIN channel c ON c.id = p.channel_id
+INNER JOIN placement_post pp ON pp.post_id = p.id
+WHERE p.channel_id = $1
+ AND p.deleted_from_channel_at IS NULL;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ rows, err := txOrPool.Query(ctx, query, pgtype.UUID{Bytes: channel.ID, Valid: true})
+ if err != nil {
+ return nil, fmt.Errorf("txOrPool.Query: %w", err)
+ }
+ defer rows.Close()
+
+ posts := make([]domain.Post, 0)
+
+ for rows.Next() {
+ var dto getTrackedPostsDTO
+
+ err = rows.Scan(dto.destination()...)
+ if err != nil {
+ return nil, fmt.Errorf("rows.Scan: %w", err)
+ }
+
+ posts = append(posts, dto.toDomain())
+ }
+
+ return posts, nil
+}
+
+type getTrackedPostsDTO struct {
+ ID pgtype.UUID
+ ChannelID pgtype.UUID
+ MessageID pgtype.Int4
+ Text pgtype.Text
+ Link pgtype.Text
+ Views pgtype.Int4
+}
+
+func (dto *getTrackedPostsDTO) destination() []any {
+ return []any{
+ &dto.ID,
+ &dto.ChannelID,
+ &dto.MessageID,
+ &dto.Text,
+ &dto.Link,
+ &dto.Views,
+ }
+}
+
+func (dto *getTrackedPostsDTO) toDomain() domain.Post {
+ return domain.Post{
+ ID: dto.ID.Bytes,
+ ChannelID: dto.ChannelID.Bytes,
+ MessageID: int(dto.MessageID.Int32),
+ Text: dto.Text.String,
+ Link: dto.Link.String,
+ Views: int(dto.Views.Int32),
+ }
+}
diff --git a/tg_parser/internal/adapter/database/update_channel.go b/tg_parser/internal/adapter/database/update_channel.go
new file mode 100644
index 0000000..992ef17
--- /dev/null
+++ b/tg_parser/internal/adapter/database/update_channel.go
@@ -0,0 +1,124 @@
+package database
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func (d *Database) UpdateChannelIfNotAccessible(ctx context.Context, channel domain.Channel) error {
+ query := `UPDATE channel
+SET telegram_id = $2,
+ username = $3,
+ title = $4,
+ access_hash = $5,
+ pts = 0,
+ is_accessible = TRUE,
+ invite_link = $6,
+ updated_at = CURRENT_TIMESTAMP
+WHERE telegram_id = $1
+ AND is_accessible = FALSE;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ username := strings.TrimSpace(channel.Username)
+ var usernameDTO pgtype.Text
+ if username != "" {
+ usernameDTO = pgtype.Text{String: username, Valid: true}
+ } else {
+ usernameDTO = pgtype.Text{Valid: false}
+ }
+
+ inviteLink := strings.TrimSpace(channel.InviteLink)
+ var inviteLinkDTO pgtype.Text
+ if inviteLink != "" {
+ inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true}
+ } else {
+ inviteLinkDTO = pgtype.Text{Valid: false}
+ }
+
+ result, err := txOrPool.Exec(ctx, query,
+ channel.TelegramID,
+ pgtype.Int8{Int64: channel.TelegramID, Valid: true},
+ usernameDTO,
+ pgtype.Text{String: channel.Title, Valid: true},
+ pgtype.Int8{Int64: channel.AccessHash, Valid: true},
+ inviteLinkDTO,
+ )
+ if err != nil {
+ return fmt.Errorf("txOrPool.Exec: %w", err)
+ }
+
+ // Log if no rows were updated (channel either doesn't exist or is already accessible)
+ rowsAffected := result.RowsAffected()
+ if rowsAffected == 0 {
+ // Channel doesn't exist or is already accessible - this is fine
+ return nil
+ }
+
+ return nil
+}
+
+func (d *Database) UpdateChannel(ctx context.Context, channel domain.Channel) error {
+ query := `UPDATE channel
+SET telegram_id = $2,
+ username = $3,
+ title = $4,
+ access_hash = $5,
+ pts = $6,
+ is_accessible = $7,
+ invite_link = $8,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;`
+
+ txOrPool := transaction.TryExtractTX(ctx)
+
+ username := strings.TrimSpace(channel.Username)
+ var usernameDTO pgtype.Text
+ if username != "" {
+ usernameDTO = pgtype.Text{String: username, Valid: true}
+ } else {
+ usernameDTO = pgtype.Text{Valid: false}
+ }
+
+ inviteLink := strings.TrimSpace(channel.InviteLink)
+ var inviteLinkDTO pgtype.Text
+ if inviteLink != "" {
+ inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true}
+ } else {
+ inviteLinkDTO = pgtype.Text{Valid: false}
+ }
+
+ dto := updateChannelDTO{
+ ID: pgtype.UUID{Bytes: channel.ID, Valid: true},
+ TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true},
+ Username: usernameDTO,
+ Title: pgtype.Text{String: channel.Title, Valid: true},
+ AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true},
+ Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true},
+ IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, Valid: true},
+ InviteLink: inviteLinkDTO,
+ }
+
+ _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible, dto.InviteLink)
+ if err != nil {
+ return fmt.Errorf("txOrPool.Exec: %w", err)
+ }
+
+ return nil
+}
+
+type updateChannelDTO struct {
+ ID pgtype.UUID
+ TelegramID pgtype.Int8
+ Username pgtype.Text
+ Title pgtype.Text
+ AccessHash pgtype.Int8
+ Pts pgtype.Int4
+ IsAccessible pgtype.Bool
+ InviteLink pgtype.Text
+}
diff --git a/tg_parser/internal/adapter/telegram/get_active_views.go b/tg_parser/internal/adapter/telegram/get_active_views.go
new file mode 100644
index 0000000..80018d6
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/get_active_views.go
@@ -0,0 +1,37 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/gotd/td/tg"
+)
+
+func (t *Telegram) UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error {
+ ids := make([]int, len(posts))
+ for i, p := range posts {
+ ids[i] = p.MessageID
+ }
+
+ req := &tg.MessagesGetMessagesViewsRequest{
+ Peer: &tg.InputPeerChannel{
+ ChannelID: channel.ChannelID(),
+ AccessHash: channel.AccessHash,
+ },
+ ID: ids,
+ Increment: false,
+ }
+
+ resp, err := t.API().MessagesGetMessagesViews(ctx, req)
+ if err != nil {
+ return fmt.Errorf("get messages views: %w", err)
+ }
+
+ // Telegram гарантирует, что resp.Views соответствует порядку ids
+ for i, v := range resp.Views {
+ posts[i].Views = v.Views
+ }
+
+ return nil
+}
diff --git a/tg_parser/internal/adapter/telegram/get_channel_diff.go b/tg_parser/internal/adapter/telegram/get_channel_diff.go
new file mode 100644
index 0000000..28f02b4
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/get_channel_diff.go
@@ -0,0 +1,125 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/gotd/td/tg"
+)
+
+func (t *Telegram) GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error) {
+ req := &tg.UpdatesGetChannelDifferenceRequest{
+ Channel: &tg.InputChannel{
+ ChannelID: channel.ChannelID(),
+ AccessHash: channel.AccessHash,
+ },
+ Filter: &tg.ChannelMessagesFilterEmpty{},
+ Pts: channel.Pts,
+ Limit: limit,
+ }
+
+ rawDiff, err := t.API().UpdatesGetChannelDifference(ctx, req)
+ if err != nil {
+ return domain.ChannelDiff{}, fmt.Errorf("get difference: %w", err)
+ }
+
+ result := domain.ChannelDiff{}
+
+ switch d := rawDiff.(type) {
+ case *tg.UpdatesChannelDifferenceEmpty:
+ result.NewPts = d.Pts
+
+ case *tg.UpdatesChannelDifferenceTooLong:
+ if dialog, ok := d.Dialog.(*tg.Dialog); ok {
+ result.NewPts = dialog.Pts
+ }
+ result.NewPosts = extractPosts(channel, d.Messages)
+ result.UpdatedChannel = extractChannelMeta(channel, d.Chats)
+
+ case *tg.UpdatesChannelDifference:
+ result.NewPts = d.Pts
+ result.NewPosts = extractPosts(channel, d.NewMessages)
+ result.DeletedPosts = extractDeletedPosts(channel, d.OtherUpdates)
+ result.UpdatedChannel = extractChannelMeta(channel, d.Chats)
+
+ default:
+ return domain.ChannelDiff{}, fmt.Errorf("unexpected rawDiff type: %T", rawDiff)
+ }
+
+ return result, nil
+}
+
+func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post {
+ posts := make([]domain.Post, 0, len(msgs))
+
+ for _, raw := range msgs {
+ m, ok := raw.(*tg.Message)
+ if !ok {
+ continue
+ }
+
+ text := messageToHTML(m.Message, m.Entities)
+ publishedAt := time.Unix(int64(m.Date), 0).UTC()
+ p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt)
+ posts = append(posts, p)
+ }
+
+ return posts
+}
+
+func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []domain.Post {
+ var deleted []domain.Post
+
+ for _, upd := range updates {
+ if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok {
+ for _, msgID := range u.Messages {
+ p := domain.NewPost(channel, msgID, "", 0, time.Time{})
+ deleted = append(deleted, p)
+ }
+ }
+ }
+
+ return deleted
+}
+
+func extractChannelMeta(currentChannel domain.Channel, chats []tg.ChatClass) *domain.Channel {
+ for _, chat := range chats {
+ ch, ok := chat.(*tg.Channel)
+ if !ok {
+ continue
+ }
+
+ if ch.ID != currentChannel.ChannelID() {
+ continue
+ }
+
+ // Preserve username if Telegram doesn't provide a new one (private channels may not have username)
+ username := currentChannel.Username
+ if usernameVal, ok := ch.GetUsername(); ok && usernameVal != "" {
+ username = usernameVal
+ }
+
+ // Keep current access hash if new one is not provided or is zero
+ accessHash := currentChannel.AccessHash
+ if accessHashVal, ok := ch.GetAccessHash(); ok && accessHashVal != 0 {
+ accessHash = accessHashVal
+ }
+
+ updated := domain.Channel{
+ ID: currentChannel.ID,
+ TelegramID: domain.ChatIDFromChannelID(ch.ID),
+ Username: username,
+ Title: ch.Title,
+ AccessHash: accessHash,
+ InviteLink: currentChannel.InviteLink, // Preserve invite link (never returned in channel diff)
+ Pts: currentChannel.Pts,
+ IsAccessible: currentChannel.IsAccessible,
+ }
+
+ return &updated
+ }
+
+ return nil
+}
diff --git a/tg_parser/internal/adapter/telegram/history.go b/tg_parser/internal/adapter/telegram/history.go
new file mode 100644
index 0000000..28e5d75
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/history.go
@@ -0,0 +1,49 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/gotd/td/tg"
+ "github.com/rs/zerolog/log"
+)
+
+func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error) {
+ log.Info().Msg(channel.String())
+
+ req := &tg.MessagesGetHistoryRequest{
+ Peer: &tg.InputPeerChannel{
+ ChannelID: channel.ChannelID(),
+ AccessHash: channel.AccessHash,
+ },
+ Limit: limit,
+ }
+
+ resp, err := t.API().MessagesGetHistory(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("resp: %w", err)
+ }
+
+ messages, ok := resp.(*tg.MessagesChannelMessages)
+ if !ok {
+ return nil, nil
+ }
+
+ posts := make([]domain.Post, 0, len(messages.Messages))
+
+ for _, raw := range messages.Messages {
+ m, ok := raw.(*tg.Message)
+ if !ok {
+ continue
+ }
+
+ text := messageToHTML(m.Message, m.Entities)
+ publishedAt := time.Unix(int64(m.Date), 0).UTC()
+ p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt)
+ posts = append(posts, p)
+ }
+
+ return posts, nil
+}
diff --git a/tg_parser/internal/adapter/telegram/message_html.go b/tg_parser/internal/adapter/telegram/message_html.go
new file mode 100644
index 0000000..3ddc4e5
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/message_html.go
@@ -0,0 +1,197 @@
+package telegram
+
+import (
+ "html"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/gotd/td/tg"
+)
+
+type htmlEntity struct {
+ start int
+ end int
+ openTag string
+ closeTag string
+ length int
+}
+
+type htmlEvent struct {
+ pos int
+ tag string
+ isStart bool
+ length int
+}
+
+func messageToHTML(text string, entities []tg.MessageEntityClass) string {
+ if text == "" {
+ return ""
+ }
+
+ if len(entities) == 0 {
+ return html.EscapeString(text)
+ }
+
+ ranges := make([]htmlEntity, 0, len(entities))
+ needed := make([]int, 0, len(entities)*2)
+
+ for _, raw := range entities {
+ offset, length, openTag, closeTag, ok := htmlEntityMeta(raw)
+ if !ok {
+ continue
+ }
+
+ start := offset
+ end := offset + length
+ if length <= 0 {
+ continue
+ }
+
+ ranges = append(ranges, htmlEntity{
+ start: start,
+ end: end,
+ openTag: openTag,
+ closeTag: closeTag,
+ length: length,
+ })
+ needed = append(needed, start, end)
+ }
+
+ if len(ranges) == 0 {
+ return html.EscapeString(text)
+ }
+
+ positions := utf16PositionsToBytes(text, needed)
+ events := make([]htmlEvent, 0, len(ranges)*2)
+
+ for _, r := range ranges {
+ startByte, okStart := positions[r.start]
+ endByte, okEnd := positions[r.end]
+ if !okStart || !okEnd || startByte > endByte {
+ continue
+ }
+
+ events = append(events, htmlEvent{
+ pos: startByte,
+ tag: r.openTag,
+ isStart: true,
+ length: r.length,
+ })
+ events = append(events, htmlEvent{
+ pos: endByte,
+ tag: r.closeTag,
+ isStart: false,
+ length: r.length,
+ })
+ }
+
+ sort.SliceStable(events, func(i, j int) bool {
+ if events[i].pos != events[j].pos {
+ return events[i].pos < events[j].pos
+ }
+ if events[i].isStart != events[j].isStart {
+ return !events[i].isStart
+ }
+ if events[i].isStart {
+ return events[i].length > events[j].length
+ }
+ return events[i].length < events[j].length
+ })
+
+ var b strings.Builder
+ last := 0
+ for _, ev := range events {
+ if ev.pos > last {
+ b.WriteString(html.EscapeString(text[last:ev.pos]))
+ }
+ b.WriteString(ev.tag)
+ last = ev.pos
+ }
+ if last < len(text) {
+ b.WriteString(html.EscapeString(text[last:]))
+ }
+
+ return b.String()
+}
+
+func htmlEntityMeta(entity tg.MessageEntityClass) (offset int, length int, openTag string, closeTag string, ok bool) {
+ switch e := entity.(type) {
+ case *tg.MessageEntityBold:
+ return e.Offset, e.Length, "", "", true
+ case *tg.MessageEntityItalic:
+ return e.Offset, e.Length, "", "", true
+ case *tg.MessageEntityUnderline:
+ return e.Offset, e.Length, "", "", true
+ case *tg.MessageEntityStrike:
+ return e.Offset, e.Length, "", "", true
+ case *tg.MessageEntityCode:
+ return e.Offset, e.Length, "", "", true
+ case *tg.MessageEntityPre:
+ if e.Language != "" {
+ lang := html.EscapeString(e.Language)
+ return e.Offset, e.Length, ``, "
", true
+ }
+ return e.Offset, e.Length, "", "
", true
+ case *tg.MessageEntityTextURL:
+ url := html.EscapeString(e.URL)
+ return e.Offset, e.Length, ``, "", true
+ case *tg.MessageEntityMentionName:
+ userID := html.EscapeString(strconv.FormatInt(e.UserID, 10))
+ return e.Offset, e.Length, ``, "", true
+ case *tg.MessageEntitySpoiler:
+ return e.Offset, e.Length, ``, "", true
+ case *tg.MessageEntityBlockquote:
+ if e.Collapsed {
+ return e.Offset, e.Length, ``, "
", true
+ }
+ return e.Offset, e.Length, "", "
", true
+ case *tg.MessageEntityCustomEmoji:
+ id := html.EscapeString(strconv.FormatInt(e.DocumentID, 10))
+ return e.Offset, e.Length, ``, "", true
+ default:
+ return 0, 0, "", "", false
+ }
+}
+
+func utf16PositionsToBytes(text string, needed []int) map[int]int {
+ result := make(map[int]int, len(needed))
+ needSet := make(map[int]struct{}, len(needed))
+ for _, n := range needed {
+ needSet[n] = struct{}{}
+ }
+
+ utf16Pos := 0
+ if _, ok := needSet[0]; ok {
+ result[0] = 0
+ }
+
+ for i, r := range text {
+ if _, ok := needSet[utf16Pos]; ok {
+ result[utf16Pos] = i
+ }
+
+ step := utf16RuneLen(r)
+ if step == 2 {
+ if _, ok := needSet[utf16Pos+1]; ok {
+ result[utf16Pos+1] = i
+ }
+ }
+ utf16Pos += step
+ }
+
+ if _, ok := needSet[utf16Pos]; ok {
+ result[utf16Pos] = len(text)
+ }
+
+ return result
+}
+
+func utf16RuneLen(r rune) int {
+ const surrSelf = 0x10000
+ if r >= surrSelf && r <= utf8.MaxRune {
+ return 2
+ }
+ return 1
+}
diff --git a/tg_parser/internal/adapter/telegram/pts.go b/tg_parser/internal/adapter/telegram/pts.go
new file mode 100644
index 0000000..cb9d91c
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/pts.go
@@ -0,0 +1,33 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/gotd/td/tg"
+)
+
+func (t *Telegram) GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error) {
+ req := []tg.InputDialogPeerClass{
+ &tg.InputDialogPeer{
+ Peer: &tg.InputPeerChannel{
+ ChannelID: channel.ChannelID(),
+ AccessHash: channel.AccessHash,
+ },
+ },
+ }
+
+ dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req)
+ if err != nil {
+ return 0, fmt.Errorf("peer dialogs: %w", err)
+ }
+
+ if len(dialogs.Dialogs) > 0 {
+ if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
+ return d.Pts, nil
+ }
+ }
+
+ return 0, nil
+}
diff --git a/tg_parser/internal/adapter/telegram/resolve.go b/tg_parser/internal/adapter/telegram/resolve.go
new file mode 100644
index 0000000..a637eec
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/resolve.go
@@ -0,0 +1,58 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/gotd/td/tg"
+)
+
+func (t *Telegram) ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error) {
+ resolved, err := t.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
+ Username: username,
+ })
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("resolve username: %w", err)
+ }
+
+ if len(resolved.Chats) == 0 {
+ return domain.Channel{}, fmt.Errorf("no chats in resolve result")
+ }
+
+ ch, ok := resolved.Chats[0].(*tg.Channel)
+ if !ok {
+ return domain.Channel{}, fmt.Errorf("not a channel")
+ }
+
+ req := []tg.InputDialogPeerClass{
+ &tg.InputDialogPeer{
+ Peer: &tg.InputPeerChannel{
+ ChannelID: ch.ID,
+ AccessHash: ch.AccessHash,
+ },
+ },
+ }
+
+ dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("peer dialogs: %w", err)
+ }
+
+ pts := 0
+ if len(dialogs.Dialogs) > 0 {
+ d, ok := dialogs.Dialogs[0].(*tg.Dialog)
+ if ok {
+ pts = d.Pts
+ }
+ }
+
+ return domain.Channel{
+ TelegramID: domain.ChatIDFromChannelID(ch.ID),
+ Username: username,
+ Title: ch.Title,
+ AccessHash: ch.AccessHash,
+ Pts: pts,
+ IsAccessible: true,
+ }, nil
+}
diff --git a/tg_parser/internal/adapter/telegram/resolve_invite.go b/tg_parser/internal/adapter/telegram/resolve_invite.go
new file mode 100644
index 0000000..406627d
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/resolve_invite.go
@@ -0,0 +1,133 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+ "github.com/gotd/td/telegram/deeplink"
+ "github.com/gotd/td/tg"
+)
+
+func (t *Telegram) ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
+ link, err := deeplink.Parse(inviteLink)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("parse invite link: %w", err)
+ }
+ if link.Type != deeplink.Join {
+ return domain.Channel{}, fmt.Errorf("invite link is not a join link")
+ }
+ hash := link.Args.Get("invite")
+ if hash == "" {
+ return domain.Channel{}, fmt.Errorf("invite link missing hash")
+ }
+
+ info, err := t.API().MessagesCheckChatInvite(ctx, hash)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("check invite: %w", err)
+ }
+
+ var channel *tg.Channel
+
+ switch v := info.(type) {
+ case *tg.ChatInviteAlready:
+ channel = extractChannelFromChat(v.Chat)
+ case *tg.ChatInvite:
+ updates, err := t.API().MessagesImportChatInvite(ctx, hash)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("import invite: %w", err)
+ }
+ channel = extractChannelFromUpdates(updates)
+ case *tg.ChatInvitePeek:
+ // ChatInvitePeek means we can preview the channel without joining (public channels)
+ channel = extractChannelFromChat(v.Chat)
+ default:
+ return domain.Channel{}, fmt.Errorf("unexpected invite response: %T", v)
+ }
+
+ if channel == nil {
+ return domain.Channel{}, fmt.Errorf("no channel in invite response")
+ }
+ if channel.AccessHash == 0 {
+ return domain.Channel{}, fmt.Errorf("channel access hash missing")
+ }
+
+ pts, err := getChannelPTS(ctx, t.API(), channel)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("get peer dialogs: %w", err)
+ }
+
+ username := ""
+ if usernameVal, ok := channel.GetUsername(); ok && usernameVal != "" {
+ username = usernameVal
+ }
+
+ return domain.Channel{
+ TelegramID: domain.ChatIDFromChannelID(channel.ID),
+ Username: username,
+ Title: channel.Title,
+ AccessHash: channel.AccessHash,
+ Pts: pts,
+ InviteLink: inviteLink,
+ IsAccessible: true,
+ }, nil
+}
+
+func extractChannelFromChat(chat tg.ChatClass) *tg.Channel {
+ switch v := chat.(type) {
+ case *tg.Channel:
+ return v
+ case *tg.ChannelForbidden:
+ return &tg.Channel{
+ ID: v.ID,
+ AccessHash: v.AccessHash,
+ Title: v.Title,
+ }
+ default:
+ return nil
+ }
+}
+
+func extractChannelFromUpdates(updates tg.UpdatesClass) *tg.Channel {
+ var chats []tg.ChatClass
+ switch v := updates.(type) {
+ case *tg.Updates:
+ chats = v.Chats
+ case *tg.UpdatesCombined:
+ chats = v.Chats
+ default:
+ return nil
+ }
+
+ for _, chat := range chats {
+ if channel := extractChannelFromChat(chat); channel != nil {
+ return channel
+ }
+ }
+
+ return nil
+}
+
+func getChannelPTS(ctx context.Context, api *tg.Client, channel *tg.Channel) (int, error) {
+ req := []tg.InputDialogPeerClass{
+ &tg.InputDialogPeer{
+ Peer: &tg.InputPeerChannel{
+ ChannelID: channel.ID,
+ AccessHash: channel.AccessHash,
+ },
+ },
+ }
+
+ dialogs, err := api.MessagesGetPeerDialogs(ctx, req)
+ if err != nil {
+ return 0, err
+ }
+
+ if len(dialogs.Dialogs) > 0 {
+ if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
+ return d.Pts, nil
+ }
+ }
+
+ return 0, nil
+}
diff --git a/tg_parser/internal/adapter/telegram/telegram.go b/tg_parser/internal/adapter/telegram/telegram.go
new file mode 100644
index 0000000..409497f
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/telegram.go
@@ -0,0 +1,20 @@
+package telegram
+
+import (
+ "github.com/TelegramExchange/pkg/telegram"
+ "github.com/gotd/td/tg"
+)
+
+type Telegram struct {
+ client *telegram.Client
+}
+
+func New(client *telegram.Client) *Telegram {
+ return &Telegram{
+ client: client,
+ }
+}
+
+func (t *Telegram) API() *tg.Client {
+ return t.client.API()
+}
diff --git a/tg_parser/internal/app/app.go b/tg_parser/internal/app/app.go
new file mode 100644
index 0000000..9e77243
--- /dev/null
+++ b/tg_parser/internal/app/app.go
@@ -0,0 +1,91 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/pkg/postgres"
+ "github.com/TelegramExchange/pkg/telegram"
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/config"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/adapter/database"
+ tgadapter "github.com/TelegramExchange/tgex-backend/tg_parser/internal/adapter/telegram"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/controller/httpserver"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/controller/worker"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase"
+)
+
+func Run(ctx context.Context, c config.Config) error {
+ // Telegram client
+ tgClient, err := telegram.New(c.Telegram)
+ if err != nil {
+ return fmt.Errorf("telegram.New: %w", err)
+ }
+
+ // PostgreSQL
+ pgPool, err := postgres.New(ctx, c.Postgres)
+ if err != nil {
+ return fmt.Errorf("broker.New: %w", err)
+ }
+ defer pgPool.Close()
+
+ transaction.Init(pgPool)
+
+ // Adapters
+ tg := tgadapter.New(tgClient)
+ db := database.New()
+
+ // UseCase
+ uc := usecase.New(tg, db)
+
+ // Controllers
+ channelWorker := worker.NewChannelWorker(uc, c.ChannelWorker)
+ viewsWorker := worker.NewViewsWorker(uc, c.ViewsWorker)
+
+ httpServer := httpserver.New(uc, c.HTTP.Addr)
+ serverErr := make(chan error, 1)
+ go func() {
+ err := httpServer.ListenAndServe()
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ serverErr <- err
+ }
+ }()
+
+ log.Info().Str("addr", c.HTTP.Addr).Msg("HTTP server started")
+ log.Info().Msg("App started")
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+ select {
+ case <-sig:
+ case err := <-serverErr:
+ return fmt.Errorf("http server: %w", err)
+ }
+
+ log.Info().Msg("App got signal to stop")
+
+ // Controllers
+ viewsWorker.Stop()
+ channelWorker.Stop()
+
+ ctxShutdown, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if err := httpServer.Shutdown(ctxShutdown); err != nil {
+ log.Error().Err(err).Msg("HTTP server shutdown failed")
+ }
+
+ // Adapters
+ tgClient.Close()
+
+ log.Info().Msg("App stopped")
+
+ return nil
+}
diff --git a/tg_parser/internal/controller/httpserver/server.go b/tg_parser/internal/controller/httpserver/server.go
new file mode 100644
index 0000000..61e73dc
--- /dev/null
+++ b/tg_parser/internal/controller/httpserver/server.go
@@ -0,0 +1,152 @@
+package httpserver
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase"
+ "github.com/gotd/td/tgerr"
+ "github.com/rs/zerolog/log"
+)
+
+type Server struct {
+ httpServer *http.Server
+}
+
+func New(uc *usecase.UseCase, addr string) *Server {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/fetch-telegram-channel", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ username := strings.TrimSpace(r.URL.Query().Get("username"))
+ username = strings.TrimPrefix(username, "@")
+ if username == "" {
+ http.Error(w, "missing username", http.StatusBadRequest)
+ return
+ }
+
+ channel, err := uc.FetchChannelMeta(r.Context(), username)
+ if err != nil {
+ if isNotFoundError(err) {
+ http.Error(w, "channel not found", http.StatusNotFound)
+ return
+ }
+ log.Error().Err(err).Str("username", username).Msg("fetch channel meta failed")
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ resp := struct {
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Username string `json:"username"`
+ Title string `json:"title"`
+ AccessHash int64 `json:"access_hash"`
+ Pts int `json:"pts"`
+ }{
+ ID: channel.ID.String(),
+ TelegramID: channel.TelegramID,
+ Username: channel.Username,
+ Title: channel.Title,
+ AccessHash: channel.AccessHash,
+ Pts: channel.Pts,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ log.Error().Err(err).Msg("encode channel response")
+ }
+ })
+ mux.HandleFunc("/resolve-channel-by-invite", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var payload struct {
+ InviteLink string `json:"invite_link"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid payload", http.StatusBadRequest)
+ return
+ }
+
+ inviteLink := strings.TrimSpace(payload.InviteLink)
+ if inviteLink == "" {
+ http.Error(w, "missing invite_link", http.StatusBadRequest)
+ return
+ }
+
+ channel, err := uc.FetchChannelMetaByInvite(r.Context(), inviteLink)
+ if err != nil {
+ if isNotFoundError(err) {
+ http.Error(w, "channel not found", http.StatusNotFound)
+ return
+ }
+ log.Error().Err(err).Msg("resolve channel by invite failed")
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ resp := struct {
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Username string `json:"username"`
+ Title string `json:"title"`
+ AccessHash int64 `json:"access_hash"`
+ Pts int `json:"pts"`
+ }{
+ ID: channel.ID.String(),
+ TelegramID: channel.TelegramID,
+ Username: channel.Username,
+ Title: channel.Title,
+ AccessHash: channel.AccessHash,
+ Pts: channel.Pts,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ log.Error().Err(err).Msg("encode channel response")
+ }
+ })
+
+ return &Server{
+ httpServer: &http.Server{
+ Addr: addr,
+ Handler: mux,
+ },
+ }
+}
+
+func (s *Server) ListenAndServe() error {
+ return s.httpServer.ListenAndServe()
+}
+
+func (s *Server) Shutdown(ctx context.Context) error {
+ return s.httpServer.Shutdown(ctx)
+}
+
+func isNotFoundError(err error) bool {
+ if tgerr.Is(
+ err,
+ "USERNAME_NOT_OCCUPIED",
+ "USERNAME_INVALID",
+ "CHANNEL_INVALID",
+ "CHANNEL_PRIVATE",
+ "INVITE_HASH_INVALID",
+ "INVITE_HASH_EXPIRED",
+ "INVITE_HASH_EMPTY",
+ ) {
+ return true
+ }
+
+ msg := err.Error()
+ return strings.Contains(msg, "no chats in resolve result") ||
+ strings.Contains(msg, "not a channel") ||
+ strings.Contains(msg, "invite link")
+}
diff --git a/tg_parser/internal/controller/worker/channel_worker.go b/tg_parser/internal/controller/worker/channel_worker.go
new file mode 100644
index 0000000..663ca5f
--- /dev/null
+++ b/tg_parser/internal/controller/worker/channel_worker.go
@@ -0,0 +1,85 @@
+package worker
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase"
+)
+
+type ChannelConfig struct {
+ MaxWorkers int `envconfig:"WORKER__MAX_WORKERS" default:"1"`
+ ChannelDelay time.Duration `envconfig:"WORKER__CHANNEL_DELAY" default:"500ms"`
+ RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
+ MessagesLimit int `envconfig:"WORKER__MESSAGES_LIMIT" default:"20"`
+ PollInterval time.Duration `envconfig:"WORKER__POLL_INTERVAL" default:"120s"`
+}
+
+type ChannelWorker struct {
+ usecase *usecase.UseCase
+ config ChannelConfig
+ stop chan struct{}
+ done chan struct{}
+}
+
+func NewChannelWorker(uc *usecase.UseCase, cfg ChannelConfig) *ChannelWorker {
+ w := &ChannelWorker{
+ usecase: uc,
+ config: cfg,
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+
+ go w.run()
+
+ return w
+}
+
+func (w *ChannelWorker) run() {
+ log.Info().Msg("channel worker: started")
+
+ var wg sync.WaitGroup
+
+ wg.Add(w.config.MaxWorkers)
+
+ for range w.config.MaxWorkers {
+ go w.spawnWorker(&wg)
+ }
+
+ wg.Wait()
+
+ log.Info().Msg("channel worker: stopped")
+
+ close(w.done)
+}
+
+func (w *ChannelWorker) spawnWorker(wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ limiter := time.NewTicker(w.config.ChannelDelay)
+ defer limiter.Stop()
+
+ poll := time.NewTicker(w.config.PollInterval)
+ defer poll.Stop()
+
+ for {
+ select {
+ case <-w.stop:
+ return
+
+ case <-poll.C:
+ err := w.usecase.FetchChannels(context.Background())
+ if err != nil {
+ log.Error().Err(err).Msg("usecase.FetchChannels error")
+ }
+ }
+ }
+}
+
+func (w *ChannelWorker) Stop() {
+ close(w.stop)
+ <-w.done
+}
diff --git a/tg_parser/internal/controller/worker/views_worker.go b/tg_parser/internal/controller/worker/views_worker.go
new file mode 100644
index 0000000..a8eca60
--- /dev/null
+++ b/tg_parser/internal/controller/worker/views_worker.go
@@ -0,0 +1,65 @@
+package worker
+
+import (
+ "context"
+ "time"
+
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase"
+)
+
+type ViewsConfig struct {
+ Interval time.Duration `envconfig:"WORKER__VIEWS_INTERVAL" default:"1800s"`
+ RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
+}
+
+type ViewsWorker struct {
+ usecase *usecase.UseCase
+ config ViewsConfig
+ stop chan struct{}
+ done chan struct{}
+}
+
+func NewViewsWorker(uc *usecase.UseCase, cfg ViewsConfig) *ViewsWorker {
+ w := &ViewsWorker{
+ usecase: uc,
+ config: cfg,
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+
+ go w.run()
+
+ return w
+}
+
+func (w *ViewsWorker) run() {
+ log.Info().Msg("views worker: started")
+
+ for {
+ select {
+ case <-w.stop:
+ log.Info().Msg("views worker: stopped")
+ close(w.done)
+ return
+ case <-time.After(w.config.Interval):
+ log.Debug().Dur("interval", w.config.Interval).Msg("views worker: refresh tick")
+ ctx, cancel := context.WithTimeout(context.Background(), w.config.RequestTimeout)
+
+ err := w.usecase.FetchViews(ctx)
+ if err != nil {
+ log.Error().Err(err).Msg("views worker: FetchViews error")
+ } else {
+ log.Debug().Msg("views worker: refresh finished")
+ }
+
+ cancel()
+ }
+ }
+}
+
+func (w *ViewsWorker) Stop() {
+ close(w.stop)
+ <-w.done
+}
diff --git a/tg_parser/internal/domain/channel.go b/tg_parser/internal/domain/channel.go
new file mode 100644
index 0000000..bc092ec
--- /dev/null
+++ b/tg_parser/internal/domain/channel.go
@@ -0,0 +1,68 @@
+package domain
+
+import (
+ "fmt"
+
+ "github.com/google/uuid"
+)
+
+type Channel struct {
+ ID uuid.UUID
+ TelegramID int64
+ Username string
+ Title string
+ AccessHash int64
+ Pts int
+ InviteLink string
+ IsAccessible bool
+}
+
+const channelIDOffset int64 = 1000000000000
+
+// ChannelID returns the positive channel identifier expected by Telegram API.
+func (c Channel) ChannelID() int64 {
+ return ChannelIDFromChatID(c.TelegramID)
+}
+
+// ChannelIDFromChatID converts stored chat IDs (Bot API style) to channel IDs used by TDLib.
+func ChannelIDFromChatID(chatID int64) int64 {
+ if chatID >= 0 {
+ return chatID
+ }
+
+ return -chatID - channelIDOffset
+}
+
+// ChatIDFromChannelID converts Telegram channel IDs to Bot API style chat IDs (-100...).
+func ChatIDFromChannelID(channelID int64) int64 {
+ return -channelIDOffset - channelID
+}
+
+// NormalizeChatID ensures that TelegramID is stored in chat-id form (negative).
+func NormalizeChatID(id int64) int64 {
+ if id < 0 {
+ return id
+ }
+
+ return ChatIDFromChannelID(id)
+}
+
+func (c Channel) String() string {
+ return fmt.Sprintf(
+ "Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d invite_link=%t}",
+ c.ID,
+ c.TelegramID,
+ c.Username,
+ c.Title,
+ c.AccessHash,
+ c.Pts,
+ c.InviteLink != "",
+ )
+}
+
+type ChannelDiff struct {
+ NewPts int
+ NewPosts []Post
+ DeletedPosts []Post
+ UpdatedChannel *Channel
+}
diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go
new file mode 100644
index 0000000..7f5d452
--- /dev/null
+++ b/tg_parser/internal/domain/post.go
@@ -0,0 +1,36 @@
+package domain
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Post struct {
+ ID uuid.UUID
+ ChannelID uuid.UUID
+ MessageID int
+ Text string
+ Link string
+ Views int
+ PublishedAt time.Time
+}
+
+func NewPost(channel Channel, messageID int, text string, views int, publishedAt time.Time) Post {
+ link := ""
+ if channel.Username != "" {
+ link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID)
+ } else if channel.TelegramID != 0 {
+ link = fmt.Sprintf("https://t.me/c/%d/%d", ChannelIDFromChatID(channel.TelegramID), messageID)
+ }
+ return Post{
+ ID: uuid.New(),
+ ChannelID: channel.ID,
+ MessageID: messageID,
+ Text: text,
+ Link: link,
+ Views: views,
+ PublishedAt: publishedAt,
+ }
+}
diff --git a/tg_parser/internal/domain/views_snapshot.go b/tg_parser/internal/domain/views_snapshot.go
new file mode 100644
index 0000000..a265e9a
--- /dev/null
+++ b/tg_parser/internal/domain/views_snapshot.go
@@ -0,0 +1,14 @@
+package domain
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type ViewsSnapshot struct {
+ ViewsCount int
+ FetchedAt time.Time
+
+ PostID uuid.UUID
+}
diff --git a/tg_parser/internal/usecase/fetch_channel_meta.go b/tg_parser/internal/usecase/fetch_channel_meta.go
new file mode 100644
index 0000000..cdf07de
--- /dev/null
+++ b/tg_parser/internal/usecase/fetch_channel_meta.go
@@ -0,0 +1,22 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (uc *UseCase) FetchChannelMeta(ctx context.Context, username string) (domain.Channel, error) {
+ channel, err := uc.telegram.ParseChannelMeta(ctx, username)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("uc.telegram.ParseChannelMeta: %s", err)
+ }
+
+ err = uc.database.UpdateChannelIfNotAccessible(ctx, channel)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("uc.database.UpdateChannelIfNotAccessible: %s", err)
+ }
+
+ return channel, nil
+}
diff --git a/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
new file mode 100644
index 0000000..12bdfdd
--- /dev/null
+++ b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
@@ -0,0 +1,22 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (uc *UseCase) FetchChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
+ channel, err := uc.telegram.ParseChannelMetaByInvite(ctx, inviteLink)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("uc.telegram.ParseChannelMetaByInvite: %s", err)
+ }
+
+ err = uc.database.UpdateChannelIfNotAccessible(ctx, channel)
+ if err != nil {
+ return domain.Channel{}, fmt.Errorf("uc.database.UpdateChannelIfNotAccessible: %s", err)
+ }
+
+ return channel, nil
+}
diff --git a/tg_parser/internal/usecase/fetch_channels.go b/tg_parser/internal/usecase/fetch_channels.go
new file mode 100644
index 0000000..a4d9a11
--- /dev/null
+++ b/tg_parser/internal/usecase/fetch_channels.go
@@ -0,0 +1,177 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/TelegramExchange/pkg/transaction"
+ "github.com/gotd/td/tgerr"
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (uc *UseCase) FetchChannels(ctx context.Context) error {
+ channels := uc.database.GetChannels(ctx)
+ log.Debug().Msg("start fetch channels")
+
+ for _, c := range channels {
+ if c.Pts == 0 {
+ go uc.initializeChannel(ctx, c)
+ continue
+ }
+
+ err := uc.processChannel(ctx, c)
+ if err != nil {
+ if isPrivateChannelError(err) {
+ log.Warn().Int64("telegram_id", c.TelegramID).Msg("Private channel, attempting rejoin")
+ go uc.rejoinChannel(ctx, c)
+
+ return nil
+ }
+ return fmt.Errorf("uc.processChannel: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func isPrivateChannelError(err error) bool {
+ return tgerr.Is(err, "CHANNEL_PRIVATE", "CHANNEL_INVALID", "CHANNEL_FORBIDDEN")
+}
+
+func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error {
+ diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20)
+ if err != nil {
+ return fmt.Errorf("telegram.GetChannelDiff: %w", err)
+ }
+
+ for _, p := range diff.NewPosts {
+ log.Info().Msgf("New post: %s - Views: %d, Text: %.10s...", p.Link, p.Views, p.Text)
+
+ err = uc.database.CreatePost(ctx, p)
+ if err != nil {
+ return fmt.Errorf("database.CreatePost: %w", err)
+ }
+ }
+
+ for _, p := range diff.DeletedPosts {
+ log.Info().Msgf("Deleted post: %s - Views: %d", p.Link, p.Views)
+
+ err = uc.database.DeletePost(ctx, p)
+ if err != nil {
+ return fmt.Errorf("database.DeletePost: %w", err)
+ }
+ }
+
+ // Update channel metadata if changed
+ if diff.UpdatedChannel != nil {
+ diff.UpdatedChannel.Pts = diff.NewPts
+ err = uc.database.UpdateChannel(ctx, *diff.UpdatedChannel)
+ if err != nil {
+ return fmt.Errorf("database.UpdateChannel: %w", err)
+ }
+
+ return nil
+ }
+
+ channel.Pts = diff.NewPts
+ err = uc.database.UpdateChannel(ctx, channel)
+ if err != nil {
+ return fmt.Errorf("database.UpdateChannel: %w", err)
+ }
+
+ return nil
+}
+
+func (uc *UseCase) rejoinChannel(ctx context.Context, channel domain.Channel) {
+ defer func() {
+ err := uc.database.UpdateChannel(ctx, channel)
+ if err != nil {
+ log.Err(err).Msg("database.UpdateChannel (rejoinChannel)")
+ }
+ }()
+
+ if channel.InviteLink == "" {
+ log.Warn().Int64("telegram_id", channel.TelegramID).Msg("No invite link stored, marking inaccessible")
+ channel.IsAccessible = false
+
+ return
+ }
+
+ updated, err := uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink)
+ if err != nil {
+ log.Warn().Err(err).Int64("telegram_id", channel.TelegramID).Msg("Invite rejoin failed, marking inaccessible")
+ channel.IsAccessible = false
+
+ return
+ }
+
+ channel.TelegramID = updated.TelegramID
+ channel.Title = updated.Title
+ channel.AccessHash = 0
+ channel.Pts = 0
+ channel.IsAccessible = true
+}
+
+func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) {
+ log.Debug().Stringer("ch", channel).Msg("init channel")
+ var (
+ updated domain.Channel
+ err error
+ )
+
+ switch {
+ case channel.Username != "":
+ updated, err = uc.telegram.ParseChannelMeta(ctx, channel.Username)
+ case channel.InviteLink != "":
+ updated, err = uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink)
+ default:
+ err = errors.New("channel state invalid")
+ }
+ if err != nil {
+ if isPrivateChannelError(err) {
+ channel.IsAccessible = false
+
+ err = uc.database.UpdateChannel(ctx, channel)
+ if err != nil {
+ log.Err(err).Msg("initializeChannel.uc.database.UpdateChannel")
+ }
+ }
+
+ log.Error().Err(err).Msg("initializeChannel.ParseChannel")
+ return
+ }
+
+ channel.Title = updated.Title
+ channel.AccessHash = updated.AccessHash
+ channel.Pts = updated.Pts
+
+ const initPosts = 20
+ posts, err := uc.telegram.GetChannelHistory(ctx, channel, initPosts)
+ if err != nil {
+ log.Error().Err(err).Msg("telegram.GetChannelHistory")
+ return
+ }
+
+ err = transaction.Wrap(ctx, func(ctx context.Context) error {
+ err = uc.database.UpdateChannel(ctx, channel)
+ if err != nil {
+ return fmt.Errorf("database.UpdateChannel: %w", err)
+ }
+
+ for _, p := range posts {
+ log.Info().Msgf("New post (init): %s - Views: %d", p.Link, p.Views)
+
+ err = uc.database.CreatePost(ctx, p)
+ if err != nil {
+ return fmt.Errorf("database.CreatePost: %w", err)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ log.Error().Err(err).Msg("transaction.Wrap")
+ }
+}
diff --git a/tg_parser/internal/usecase/fetch_views.go b/tg_parser/internal/usecase/fetch_views.go
new file mode 100644
index 0000000..338cadd
--- /dev/null
+++ b/tg_parser/internal/usecase/fetch_views.go
@@ -0,0 +1,54 @@
+package usecase
+
+import (
+ "context"
+ "time"
+
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+func (uc *UseCase) FetchViews(ctx context.Context) error {
+ channels, err := uc.database.GetChannelsWithTrackedPosts(ctx)
+ if err != nil {
+ return err
+ }
+
+ for _, channel := range channels {
+ posts, err := uc.database.GetTrackedPosts(ctx, channel)
+ if err != nil {
+ return err
+ }
+
+ err = uc.telegram.UpdatePostsViews(ctx, channel, posts)
+ if err != nil {
+ if isPrivateChannelError(err) {
+ channel.IsAccessible = false
+ err = uc.database.UpdateChannel(ctx, channel)
+ if err != nil {
+ log.Err(err).Msg("initializeChannel.uc.database.UpdateChannel")
+ }
+ return nil
+ }
+ return err
+ }
+
+ for _, p := range posts {
+ v := domain.ViewsSnapshot{
+ ViewsCount: p.Views,
+ FetchedAt: time.Now().UTC(),
+ PostID: p.ID,
+ }
+
+ err = uc.database.CreateViewsSnapshot(ctx, v)
+ if err != nil {
+ return err
+ }
+
+ log.Info().Msgf("New ViewsSnapshot: %s - Post ID: %d, Views: %d", p.Link, p.MessageID, p.Views)
+ }
+ }
+
+ return nil
+}
diff --git a/tg_parser/internal/usecase/usecase.go b/tg_parser/internal/usecase/usecase.go
new file mode 100644
index 0000000..2e50e75
--- /dev/null
+++ b/tg_parser/internal/usecase/usecase.go
@@ -0,0 +1,41 @@
+package usecase
+
+import (
+ "context"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
+)
+
+type Telegram interface {
+ ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error)
+ ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error)
+ GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error)
+ GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error)
+ GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error)
+ UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error
+}
+
+type Database interface {
+ CreatePost(ctx context.Context, post domain.Post) error
+
+ CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error
+
+ UpdateChannelIfNotAccessible(ctx context.Context, channel domain.Channel) error
+ GetChannels(ctx context.Context) []domain.Channel
+ UpdateChannel(ctx context.Context, channel domain.Channel) error
+ GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error)
+ GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error)
+ DeletePost(ctx context.Context, p domain.Post) error
+}
+
+type UseCase struct {
+ telegram Telegram
+ database Database
+}
+
+func New(telegram Telegram, database Database) *UseCase {
+ return &UseCase{
+ telegram: telegram,
+ database: database,
+ }
+}
diff --git a/tg_parser/main.go b/tg_parser/main.go
new file mode 100644
index 0000000..316714b
--- /dev/null
+++ b/tg_parser/main.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+
+ "github.com/TelegramExchange/tgex-backend/tg_parser/config"
+ "github.com/TelegramExchange/tgex-backend/tg_parser/internal/app"
+)
+
+func main() {
+
+ c, err := config.New()
+ if err != nil {
+ log.Fatal().Err(err).Msg("config.New")
+ }
+
+ initLogger(c.Logger)
+ log.Info().Msg("parser starting")
+ log.Info().Msg("parser initialized")
+
+ ctx := context.Background()
+
+ if err := app.Run(ctx, c); err != nil {
+ log.Error().Err(err).Msg("app.Run")
+ }
+}
+
+func initLogger(c config.LoggerConfig) {
+ zerolog.TimeFieldFormat = time.RFC3339
+
+ level := zerolog.InfoLevel
+ if parsedLevel, err := zerolog.ParseLevel(c.Level); err == nil {
+ level = parsedLevel
+ }
+ zerolog.SetGlobalLevel(level)
+
+ log.Logger = zerolog.New(os.Stdout).With().
+ Timestamp().
+ Logger().
+ Level(level)
+
+ if c.PrettyConsole {
+ log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}).
+ With().
+ Timestamp().
+ Logger().
+ Level(level)
+ }
+
+ log.Info().Msg("Logger initialized")
+}
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..e790d0b
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1294 @@
+version = 1
+revision = 2
+requires-python = ">=3.13"
+
+[[package]]
+name = "aerich"
+version = "0.9.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "asyncclick" },
+ { name = "dictdiffer" },
+ { name = "tortoise-orm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/60/5d3885f531fab2cecec67510e7b821efc403940ed9eefd034b2c21350f3c/aerich-0.9.2.tar.gz", hash = "sha256:02d58658714eebe396fe7bd9f9401db3a60a44dc885910ad3990920d0357317d", size = 74231, upload_time = "2025-10-10T05:53:49.632Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/1a/956c6b1e35881bb9835a33c8db1565edcd133f8e45321010489092a0df40/aerich-0.9.2-py3-none-any.whl", hash = "sha256:d0f007acb21f6559f1eccd4e404fb039cf48af2689e0669afa62989389c0582d", size = 46451, upload_time = "2025-10-10T05:53:48.71Z" },
+]
+
+[[package]]
+name = "aioboto3"
+version = "15.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiobotocore", extra = ["boto3"] },
+ { name = "aiofiles" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload_time = "2025-10-30T13:37:16.122Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload_time = "2025-10-30T13:37:14.549Z" },
+]
+
+[[package]]
+name = "aiobotocore"
+version = "2.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "aioitertools" },
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "multidict" },
+ { name = "python-dateutil" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload_time = "2025-10-28T22:33:21.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload_time = "2025-10-28T22:33:19.949Z" },
+]
+
+[package.optional-dependencies]
+boto3 = [
+ { name = "boto3" },
+]
+
+[[package]]
+name = "aiofiles"
+version = "24.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload_time = "2024-06-24T11:02:03.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload_time = "2024-06-24T11:02:01.529Z" },
+]
+
+[[package]]
+name = "aiogram"
+version = "3.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiofiles" },
+ { name = "aiohttp" },
+ { name = "certifi" },
+ { name = "magic-filter" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/92/2c/fe0845a97f6126357d20163ede8f76bc161f73122123c6548ca19d9a12c7/aiogram-3.22.0.tar.gz", hash = "sha256:c483f81e37aeea8e7f592c9bd14f6acc80d9b7a2698e296a45bf47ff60a98510", size = 1520414, upload_time = "2025-08-17T16:20:45.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/e5/9f9fae7b50ed502e33121dd62a7e9b076d00630eaafe1dd7fda64f7e8625/aiogram-3.22.0-py3-none-any.whl", hash = "sha256:1c6eceb078ff62cf0556a5466cf3e7e8119678c26cc56803b7ac5f73633934a8", size = 698216, upload_time = "2025-08-17T16:20:43.354Z" },
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload_time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload_time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.12.15"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload_time = "2025-07-29T05:52:32.215Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload_time = "2025-07-29T05:51:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload_time = "2025-07-29T05:51:21.165Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload_time = "2025-07-29T05:51:22.948Z" },
+ { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload_time = "2025-07-29T05:51:25.211Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload_time = "2025-07-29T05:51:27.145Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload_time = "2025-07-29T05:51:29.366Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload_time = "2025-07-29T05:51:31.285Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload_time = "2025-07-29T05:51:33.219Z" },
+ { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload_time = "2025-07-29T05:51:35.195Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload_time = "2025-07-29T05:51:37.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload_time = "2025-07-29T05:51:39.328Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload_time = "2025-07-29T05:51:41.356Z" },
+ { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload_time = "2025-07-29T05:51:43.452Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload_time = "2025-07-29T05:51:45.643Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload_time = "2025-07-29T05:51:48.203Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload_time = "2025-07-29T05:51:50.718Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload_time = "2025-07-29T05:51:52.549Z" },
+]
+
+[[package]]
+name = "aioitertools"
+version = "0.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload_time = "2025-11-06T22:17:07.609Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload_time = "2025-11-06T22:17:06.502Z" },
+]
+
+[[package]]
+name = "aiolimiter"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload_time = "2024-12-08T15:31:51.496Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload_time = "2024-12-08T15:31:49.874Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload_time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload_time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "aiosqlite"
+version = "0.21.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload_time = "2025-02-03T07:30:16.235Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload_time = "2025-02-03T07:30:13.6Z" },
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload_time = "2025-10-24T14:57:10.718Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload_time = "2025-10-24T14:57:09.462Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload_time = "2025-09-23T09:19:12.58Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload_time = "2025-09-23T09:19:10.601Z" },
+]
+
+[[package]]
+name = "asyncclick"
+version = "8.3.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f9/ca/25e426d16bd0e91c1c9259112cecd17b2c2c239bdd8e5dba430f3bd5e3ef/asyncclick-8.3.0.7.tar.gz", hash = "sha256:8a80d8ac613098ee6a9a8f0248f60c66c273e22402cf3f115ed7f071acfc71d3", size = 277634, upload_time = "2025-10-11T08:35:44.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/d9/782ffcb4c97b889bc12d8276637d2739b99520390ee8fec77c07416c5d12/asyncclick-8.3.0.7-py3-none-any.whl", hash = "sha256:7607046de39a3f315867cad818849f973e29d350c10d92f251db3ff7600c6c7d", size = 109925, upload_time = "2025-10-11T08:35:43.378Z" },
+]
+
+[[package]]
+name = "asyncpg"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload_time = "2024-10-20T00:30:41.127Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload_time = "2024-10-20T00:29:55.165Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload_time = "2024-10-20T00:29:57.14Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload_time = "2024-10-20T00:29:58.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload_time = "2024-10-20T00:30:00.354Z" },
+ { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload_time = "2024-10-20T00:30:02.794Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload_time = "2024-10-20T00:30:04.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload_time = "2024-10-20T00:30:06.537Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload_time = "2024-10-20T00:30:09.024Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload_time = "2025-10-06T13:54:44.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload_time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload_time = "2025-09-29T10:05:42.613Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload_time = "2025-09-29T10:05:43.771Z" },
+]
+
+[[package]]
+name = "boto3"
+version = "1.40.61"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload_time = "2025-10-28T19:26:57.247Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload_time = "2025-10-28T19:26:55.007Z" },
+]
+
+[[package]]
+name = "botocore"
+version = "1.40.61"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload_time = "2025-10-28T19:26:46.108Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload_time = "2025-10-28T19:26:42.15Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.10.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload_time = "2025-10-05T04:12:15.808Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload_time = "2025-10-05T04:12:14.03Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload_time = "2025-09-18T17:32:23.696Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload_time = "2025-09-18T17:32:22.42Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "dictdiffer"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/7b/35cbccb7effc5d7e40f4c55e2b79399e1853041997fcda15c9ff160abba0/dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578", size = 31513, upload_time = "2021-07-22T13:24:29.276Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload_time = "2021-07-22T13:24:26.783Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.121.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload_time = "2025-11-03T10:25:54.818Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload_time = "2025-11-03T10:25:53.27Z" },
+]
+
+[[package]]
+name = "fastapi-pagination"
+version = "0.15.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fastapi" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/be/e5edfb47d0253b5dc019ad0430cc26b34f9b29a21456abfb8ea0cff40782/fastapi_pagination-0.15.3.tar.gz", hash = "sha256:0667c3e31eb0c47f15e2d4d0a971490beed9b65a1079158b5ad0115488a370e2", size = 571922, upload_time = "2025-12-11T21:53:43.297Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/99/11a13d3b2ff6fb716fbe8a1da8f15da332472f13bea84377e1d070cf3a6c/fastapi_pagination-0.15.3-py3-none-any.whl", hash = "sha256:6c0e8b3265270bfa46a580f7a3a24559b0825917625b5c4ed3a1cd60a173552f", size = 56231, upload_time = "2025-12-11T21:53:44.257Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload_time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload_time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload_time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload_time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload_time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload_time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload_time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload_time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload_time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload_time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload_time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload_time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload_time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload_time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload_time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload_time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload_time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload_time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload_time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload_time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload_time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload_time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload_time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload_time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload_time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload_time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload_time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload_time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload_time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload_time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload_time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload_time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload_time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload_time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload_time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload_time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload_time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload_time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload_time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload_time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload_time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload_time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload_time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload_time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload_time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload_time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload_time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload_time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload_time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload_time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload_time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload_time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload_time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload_time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload_time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload_time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload_time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload_time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload_time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload_time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload_time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload_time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload_time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload_time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload_time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload_time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload_time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload_time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "iso8601"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload_time = "2023-10-03T00:25:39.317Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload_time = "2023-10-03T00:25:32.304Z" },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload_time = "2022-06-17T18:00:12.224Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload_time = "2022-06-17T18:00:10.251Z" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload_time = "2025-09-22T04:04:59.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload_time = "2025-09-22T04:01:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload_time = "2025-09-22T04:01:56.282Z" },
+ { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload_time = "2025-09-22T04:01:58.989Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload_time = "2025-09-22T04:02:00.812Z" },
+ { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload_time = "2025-09-22T04:02:02.671Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload_time = "2025-09-22T04:02:04.904Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload_time = "2025-09-22T04:02:06.689Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload_time = "2025-09-22T04:02:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload_time = "2025-09-22T04:02:10.783Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload_time = "2025-09-22T04:02:12.631Z" },
+ { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload_time = "2025-09-22T04:02:14.718Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload_time = "2025-09-22T04:02:16.957Z" },
+ { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload_time = "2025-09-22T04:02:18.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload_time = "2025-09-22T04:02:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload_time = "2025-09-22T04:02:22.489Z" },
+ { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload_time = "2025-09-22T04:02:24.465Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload_time = "2025-09-22T04:02:26.286Z" },
+ { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload_time = "2025-09-22T04:02:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload_time = "2025-09-22T04:02:30.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload_time = "2025-09-22T04:02:32.119Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload_time = "2025-09-22T04:02:34.155Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload_time = "2025-09-22T04:02:36.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload_time = "2025-09-22T04:02:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload_time = "2025-09-22T04:02:40.413Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload_time = "2025-09-22T04:02:42.288Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload_time = "2025-09-22T04:02:44.165Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload_time = "2025-09-22T04:02:46.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload_time = "2025-09-22T04:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload_time = "2025-09-22T04:02:50.746Z" },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload_time = "2025-09-22T04:02:52.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload_time = "2025-09-22T04:02:54.798Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload_time = "2025-09-22T04:02:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload_time = "2025-09-22T04:02:58.966Z" },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload_time = "2025-09-22T04:03:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload_time = "2025-09-22T04:03:39.835Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload_time = "2025-09-22T04:03:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload_time = "2025-09-22T04:03:01.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload_time = "2025-09-22T04:03:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload_time = "2025-09-22T04:03:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload_time = "2025-09-22T04:03:07.452Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload_time = "2025-09-22T04:03:09.297Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload_time = "2025-09-22T04:03:11.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload_time = "2025-09-22T04:03:13.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload_time = "2025-09-22T04:03:15.408Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload_time = "2025-09-22T04:03:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload_time = "2025-09-22T04:03:19.14Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload_time = "2025-09-22T04:03:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload_time = "2025-09-22T04:03:23.27Z" },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload_time = "2025-09-22T04:03:25.767Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload_time = "2025-09-22T04:03:27.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload_time = "2025-09-22T04:03:30.056Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload_time = "2025-09-22T04:03:32.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload_time = "2025-09-22T04:03:34.027Z" },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload_time = "2025-09-22T04:03:36.249Z" },
+]
+
+[[package]]
+name = "magic-filter"
+version = "1.0.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload_time = "2023-10-01T12:33:19.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload_time = "2023-10-01T12:33:17.711Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload_time = "2025-10-06T14:52:30.657Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload_time = "2025-10-06T14:49:54.26Z" },
+ { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload_time = "2025-10-06T14:49:55.82Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload_time = "2025-10-06T14:49:57.048Z" },
+ { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload_time = "2025-10-06T14:49:58.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload_time = "2025-10-06T14:49:59.89Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload_time = "2025-10-06T14:50:01.485Z" },
+ { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload_time = "2025-10-06T14:50:02.955Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload_time = "2025-10-06T14:50:04.446Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload_time = "2025-10-06T14:50:05.98Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload_time = "2025-10-06T14:50:07.511Z" },
+ { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload_time = "2025-10-06T14:50:09.074Z" },
+ { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload_time = "2025-10-06T14:50:10.714Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload_time = "2025-10-06T14:50:12.28Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload_time = "2025-10-06T14:50:14.16Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload_time = "2025-10-06T14:50:15.639Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload_time = "2025-10-06T14:50:17.066Z" },
+ { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload_time = "2025-10-06T14:50:18.264Z" },
+ { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload_time = "2025-10-06T14:50:19.853Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload_time = "2025-10-06T14:50:21.223Z" },
+ { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload_time = "2025-10-06T14:50:22.871Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload_time = "2025-10-06T14:50:24.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload_time = "2025-10-06T14:50:25.716Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload_time = "2025-10-06T14:50:28.192Z" },
+ { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload_time = "2025-10-06T14:50:29.82Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload_time = "2025-10-06T14:50:31.731Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload_time = "2025-10-06T14:50:33.26Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload_time = "2025-10-06T14:50:34.808Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload_time = "2025-10-06T14:50:36.436Z" },
+ { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload_time = "2025-10-06T14:50:37.953Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload_time = "2025-10-06T14:50:39.574Z" },
+ { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload_time = "2025-10-06T14:50:41.612Z" },
+ { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload_time = "2025-10-06T14:50:43.972Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload_time = "2025-10-06T14:50:45.648Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload_time = "2025-10-06T14:50:47.154Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload_time = "2025-10-06T14:50:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload_time = "2025-10-06T14:50:50.16Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload_time = "2025-10-06T14:50:51.92Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload_time = "2025-10-06T14:50:53.275Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload_time = "2025-10-06T14:50:54.911Z" },
+ { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload_time = "2025-10-06T14:50:56.369Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload_time = "2025-10-06T14:50:57.991Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload_time = "2025-10-06T14:50:59.589Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload_time = "2025-10-06T14:51:01.183Z" },
+ { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload_time = "2025-10-06T14:51:02.794Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload_time = "2025-10-06T14:51:04.724Z" },
+ { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload_time = "2025-10-06T14:51:06.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload_time = "2025-10-06T14:51:08.091Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload_time = "2025-10-06T14:51:10.365Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload_time = "2025-10-06T14:51:12.466Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload_time = "2025-10-06T14:51:14.48Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload_time = "2025-10-06T14:51:16.072Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload_time = "2025-10-06T14:51:17.544Z" },
+ { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload_time = "2025-10-06T14:51:18.875Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload_time = "2025-10-06T14:51:20.225Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload_time = "2025-10-06T14:51:21.588Z" },
+ { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload_time = "2025-10-06T14:51:22.93Z" },
+ { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload_time = "2025-10-06T14:51:24.352Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload_time = "2025-10-06T14:51:25.822Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload_time = "2025-10-06T14:51:27.604Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload_time = "2025-10-06T14:51:29.664Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload_time = "2025-10-06T14:51:31.684Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload_time = "2025-10-06T14:51:33.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload_time = "2025-10-06T14:51:36.189Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload_time = "2025-10-06T14:51:41.291Z" },
+ { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload_time = "2025-10-06T14:51:43.55Z" },
+ { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload_time = "2025-10-06T14:51:45.265Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload_time = "2025-10-06T14:51:46.836Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload_time = "2025-10-06T14:51:48.541Z" },
+ { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload_time = "2025-10-06T14:51:50.355Z" },
+ { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload_time = "2025-10-06T14:51:51.883Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload_time = "2025-10-06T14:51:53.672Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload_time = "2025-10-06T14:51:55.415Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload_time = "2025-10-06T14:52:29.272Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.18.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload_time = "2025-09-19T00:11:10.519Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload_time = "2025-09-19T00:10:01.33Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload_time = "2025-09-19T00:10:42.607Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload_time = "2025-09-19T00:11:00.371Z" },
+ { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload_time = "2025-09-19T00:11:03.358Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload_time = "2025-09-19T00:10:26.073Z" },
+ { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload_time = "2025-09-19T00:10:40.035Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload_time = "2025-09-19T00:10:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload_time = "2025-09-19T00:10:51.631Z" },
+ { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload_time = "2025-09-19T00:11:07.955Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload_time = "2025-09-19T00:09:55.572Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload_time = "2025-09-19T00:10:44.827Z" },
+ { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload_time = "2025-09-19T00:10:37.344Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload_time = "2025-09-19T00:10:15.489Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload_time = "2023-12-10T22:30:45Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload_time = "2023-12-10T22:30:43.14Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload_time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload_time = "2025-10-08T19:47:07.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload_time = "2025-10-08T19:47:08.851Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload_time = "2025-10-08T19:47:09.982Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload_time = "2025-10-08T19:47:11.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload_time = "2025-10-08T19:47:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload_time = "2025-10-08T19:47:14.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload_time = "2025-10-08T19:47:16.277Z" },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload_time = "2025-10-08T19:47:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload_time = "2025-10-08T19:47:19.355Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload_time = "2025-10-08T19:47:21.338Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload_time = "2025-10-08T19:47:23.059Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload_time = "2025-10-08T19:47:24.445Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload_time = "2025-10-08T19:47:25.736Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload_time = "2025-10-08T19:47:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload_time = "2025-10-08T19:47:27.961Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload_time = "2025-10-08T19:47:29.445Z" },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload_time = "2025-10-08T19:47:30.579Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload_time = "2025-10-08T19:47:31.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload_time = "2025-10-08T19:47:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload_time = "2025-10-08T19:47:34.906Z" },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload_time = "2025-10-08T19:47:36.338Z" },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload_time = "2025-10-08T19:47:37.692Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload_time = "2025-10-08T19:47:39.659Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload_time = "2025-10-08T19:47:41.084Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload_time = "2025-10-08T19:47:42.51Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload_time = "2025-10-08T19:47:43.927Z" },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload_time = "2025-10-08T19:47:45.448Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload_time = "2025-10-08T19:47:47.202Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload_time = "2025-10-08T19:47:48.336Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload_time = "2025-10-08T19:47:49.876Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload_time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload_time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload_time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload_time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload_time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload_time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload_time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload_time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload_time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload_time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload_time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload_time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload_time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload_time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload_time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload_time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload_time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload_time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload_time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload_time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload_time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload_time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload_time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload_time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload_time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload_time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload_time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload_time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload_time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload_time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload_time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.11.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload_time = "2025-10-04T10:40:41.338Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload_time = "2025-10-04T10:40:39.055Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload_time = "2025-04-23T18:33:52.104Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload_time = "2025-04-23T18:31:53.175Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload_time = "2025-04-23T18:31:54.79Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload_time = "2025-04-23T18:31:57.393Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload_time = "2025-04-23T18:31:59.065Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload_time = "2025-04-23T18:32:00.78Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload_time = "2025-04-23T18:32:02.418Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload_time = "2025-04-23T18:32:04.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload_time = "2025-04-23T18:32:06.129Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload_time = "2025-04-23T18:32:08.178Z" },
+ { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload_time = "2025-04-23T18:32:10.242Z" },
+ { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload_time = "2025-04-23T18:32:12.382Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload_time = "2025-04-23T18:32:14.034Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload_time = "2025-04-23T18:32:15.783Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload_time = "2025-04-23T18:32:18.473Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload_time = "2025-04-23T18:32:20.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload_time = "2025-04-23T18:32:22.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload_time = "2025-04-23T18:32:25.088Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload_time = "2025-09-24T14:19:11.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload_time = "2025-09-24T14:19:10.015Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload_time = "2024-11-28T03:43:29.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload_time = "2024-11-28T03:43:27.893Z" },
+]
+
+[[package]]
+name = "pypika-tortoise"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/86ec1bccb2609d20349def444ef9dfe84aeccc984caa62f4634d50fee164/pypika_tortoise-0.6.3.tar.gz", hash = "sha256:6e17f00e77e78468836cb5c63eb6dc01445f83b1167e4f29f1c678949179c079", size = 80689, upload_time = "2025-11-26T22:07:08.293Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/6a/da5ba6830dd16cea2804163a2cecc1b2a85b8e06c61f0abb0477069d013d/pypika_tortoise-0.6.3-py3-none-any.whl", hash = "sha256:762e508093f4d73d3654cdde5bce8f92f8f41d999993c44d972d4f1703a663df", size = 46918, upload_time = "2025-11-26T22:07:07.052Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload_time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload_time = "2025-09-04T14:34:20.226Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload_time = "2025-09-12T07:33:53.816Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload_time = "2025-09-12T07:33:52.639Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload_time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload_time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload_time = "2025-10-26T15:12:10.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload_time = "2025-10-26T15:12:09.109Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.14.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload_time = "2025-11-06T22:07:45.033Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload_time = "2025-11-06T22:07:01.841Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload_time = "2025-11-06T22:07:05.858Z" },
+ { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload_time = "2025-11-06T22:07:08.023Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload_time = "2025-11-06T22:07:10.015Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload_time = "2025-11-06T22:07:13.085Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload_time = "2025-11-06T22:07:15.386Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload_time = "2025-11-06T22:07:17.631Z" },
+ { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload_time = "2025-11-06T22:07:19.832Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload_time = "2025-11-06T22:07:22.32Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload_time = "2025-11-06T22:07:24.347Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload_time = "2025-11-06T22:07:26.327Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload_time = "2025-11-06T22:07:28.46Z" },
+ { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload_time = "2025-11-06T22:07:31.468Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload_time = "2025-11-06T22:07:33.831Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload_time = "2025-11-06T22:07:35.905Z" },
+ { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload_time = "2025-11-06T22:07:38.43Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload_time = "2025-11-06T22:07:40.496Z" },
+ { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload_time = "2025-11-06T22:07:42.603Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload_time = "2025-09-09T19:23:31.089Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload_time = "2025-09-09T19:23:30.041Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload_time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload_time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload_time = "2025-08-27T15:39:51.78Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload_time = "2025-08-27T15:39:50.179Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "0.49.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload_time = "2025-11-01T15:12:26.13Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload_time = "2025-11-01T15:12:24.387Z" },
+]
+
+[[package]]
+name = "tgex-backend"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "aerich" },
+ { name = "aioboto3" },
+ { name = "aiogram" },
+ { name = "aiolimiter" },
+ { name = "asyncpg" },
+ { name = "beautifulsoup4" },
+ { name = "fastapi" },
+ { name = "fastapi-pagination" },
+ { name = "httpx" },
+ { name = "lxml" },
+ { name = "pydantic-settings" },
+ { name = "pyjwt" },
+ { name = "python-multipart" },
+ { name = "tortoise-orm" },
+ { name = "tortoise-orm-stubs" },
+ { name = "types-aiobotocore-s3" },
+ { name = "uvicorn" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "ruff" },
+ { name = "ty" },
+ { name = "vulture" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aerich", specifier = ">=0.9.2" },
+ { name = "aioboto3", specifier = ">=13.3.0" },
+ { name = "aiogram", specifier = ">=3.16.0" },
+ { name = "aiolimiter", specifier = ">=1.2.1" },
+ { name = "asyncpg", specifier = ">=0.30.0" },
+ { name = "beautifulsoup4", specifier = ">=4.14.2" },
+ { name = "fastapi", specifier = ">=0.121.0" },
+ { name = "fastapi-pagination", specifier = ">=0.15.3" },
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "lxml", specifier = ">=6.0.2" },
+ { name = "pydantic-settings", specifier = ">=2.11.0" },
+ { name = "pyjwt", specifier = ">=2.10.1" },
+ { name = "python-multipart", specifier = ">=0.0.20" },
+ { name = "tortoise-orm", specifier = ">=0.25.1" },
+ { name = "tortoise-orm-stubs", specifier = ">=1.0.2" },
+ { name = "types-aiobotocore-s3", specifier = ">=2.15.2" },
+ { name = "uvicorn", specifier = ">=0.38.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "mypy", specifier = ">=1.18.2" },
+ { name = "pytest", specifier = ">=8.4.2" },
+ { name = "pytest-asyncio", specifier = ">=1.2.0" },
+ { name = "ruff", specifier = ">=0.14.4" },
+ { name = "ty", specifier = ">=0.0.1a25" },
+ { name = "vulture", specifier = ">=2.14" },
+]
+
+[[package]]
+name = "tortoise-orm"
+version = "0.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiosqlite" },
+ { name = "iso8601", marker = "python_full_version < '4'" },
+ { name = "pypika-tortoise", marker = "python_full_version < '4'" },
+ { name = "pytz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d7/9b/de966810021fa773fead258efd8deea2bb73bb12479e27f288bd8ceb8763/tortoise_orm-0.25.1.tar.gz", hash = "sha256:4d5bfd13d5750935ffe636a6b25597c5c8f51c47e5b72d7509d712eda1a239fe", size = 128341, upload_time = "2025-06-05T10:43:31.058Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/55/2bda7f4445f4c07b734385b46d1647a388d05160cf5b8714a713e8709378/tortoise_orm-0.25.1-py3-none-any.whl", hash = "sha256:df0ef7e06eb0650a7e5074399a51ee6e532043308c612db2cac3882486a3fd9f", size = 167723, upload_time = "2025-06-05T10:43:29.309Z" },
+]
+
+[[package]]
+name = "tortoise-orm-stubs"
+version = "1.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tortoise-orm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ba/49/45b06cda907e55226b8ed4ddc71d13ff61505bfe366d72276462eeee9d2b/tortoise_orm_stubs-1.0.2.tar.gz", hash = "sha256:f4d6a810f295bebd83aa71b05ebd2decd883517f3c9530bd2376b9209b0777c6", size = 4559, upload_time = "2023-11-20T14:48:26.806Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/b1/f0b111dcf9381987f8acb143dd95b77934a3e9120a6c63b2cf4255c2934c/tortoise_orm_stubs-1.0.2-py3-none-any.whl", hash = "sha256:5ae3c2b0eb0286669563634b98202bbdf46349966b1c85659f3160de4fb655d6", size = 4681, upload_time = "2023-11-20T14:48:22.536Z" },
+]
+
+[[package]]
+name = "ty"
+version = "0.0.1a25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/6b/e73bc3c1039ea72936158a08313155a49e5aa5e7db5205a149fe516a4660/ty-0.0.1a25.tar.gz", hash = "sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511", size = 4403670, upload_time = "2025-10-29T19:40:23.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/3b/4457231238a2eeb04cba4ba7cc33d735be68ee46ca40a98ae30e187de864/ty-0.0.1a25-py3-none-linux_armv6l.whl", hash = "sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d", size = 8878803, upload_time = "2025-10-29T19:39:42.243Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fa/a328713dd310018fc7a381693d8588185baa2fdae913e01a6839187215df/ty-0.0.1a25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703", size = 8695667, upload_time = "2025-10-29T19:39:45.179Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e8/5707939118992ced2bf5385adc3ede7723c1b717b07ad14c495eea1e47b4/ty-0.0.1a25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf", size = 8159012, upload_time = "2025-10-29T19:39:47.011Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/fb/ff313aa71602225cd78f1bce3017713d6d1b1c1e0fa8101ead4594a60d95/ty-0.0.1a25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7", size = 8433675, upload_time = "2025-10-29T19:39:48.443Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/8d/cc7e7fb57215a15b575a43ed042bdd92971871e0decec1b26d2e7d969465/ty-0.0.1a25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447", size = 8668456, upload_time = "2025-10-29T19:39:50.412Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/6d/d7bf5909ed2dcdcbc1e2ca7eea80929893e2d188d9c36b3fcb2b36532ff6/ty-0.0.1a25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01", size = 9023543, upload_time = "2025-10-29T19:39:52.292Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/b8/72bcefb4be32e5a84f0b21de2552f16cdb4cae3eb271ac891c8199c26b1a/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a", size = 9700013, upload_time = "2025-10-29T19:39:57.283Z" },
+ { url = "https://files.pythonhosted.org/packages/90/0d/cf7e794b840cf6b0bbecb022e593c543f85abad27a582241cf2095048cb1/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b", size = 9372574, upload_time = "2025-10-29T19:40:04.532Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/71/2d35e7d51b48eabd330e2f7b7e0bce541cbd95950c4d2f780e85f3366af1/ty-0.0.1a25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62", size = 9535726, upload_time = "2025-10-29T19:40:06.548Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d3/01ecc23bbd8f3e0dfbcf9172d06d84e88155c5f416f1491137e8066fd859/ty-0.0.1a25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc", size = 9003380, upload_time = "2025-10-29T19:40:08.683Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f9/cde9380d8a1a6ca61baeb9aecb12cbec90d489aa929be55cd78ad5c2ccd9/ty-0.0.1a25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068", size = 8401833, upload_time = "2025-10-29T19:40:10.627Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/39/0acf3625b0c495011795a391016b572f97a812aca1d67f7a76621fdb9ebf/ty-0.0.1a25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9", size = 8706761, upload_time = "2025-10-29T19:40:12.575Z" },
+ { url = "https://files.pythonhosted.org/packages/25/73/7de1648f3563dd9d416d36ab5f1649bfd7b47a179135027f31d44b89a246/ty-0.0.1a25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979", size = 8792426, upload_time = "2025-10-29T19:40:14.553Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8a/b6e761a65eac7acd10b2e452f49b2d8ae0ea163ca36bb6b18b2dadae251b/ty-0.0.1a25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35", size = 9103991, upload_time = "2025-10-29T19:40:16.332Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/25/9324ae947fcc4322470326cf8276a3fc2f08dc82adec1de79d963fdf7af5/ty-0.0.1a25-py3-none-win32.whl", hash = "sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445", size = 8387095, upload_time = "2025-10-29T19:40:18.368Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2b/cb12cbc7db1ba310aa7b1de9b4e018576f653105993736c086ee67d2ec02/ty-0.0.1a25-py3-none-win_amd64.whl", hash = "sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb", size = 9059225, upload_time = "2025-10-29T19:40:20.278Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/c1/f6be8cdd0bf387c1d8ee9d14bb299b7b5d2c0532f550a6693216a32ec0c5/ty-0.0.1a25-py3-none-win_arm64.whl", hash = "sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d", size = 8536832, upload_time = "2025-10-29T19:40:22.014Z" },
+]
+
+[[package]]
+name = "types-aiobotocore-s3"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/f9/76c84023add0e6b8b647eb8d085538b1a180bd560a0b5d115d5fea79cd11/types_aiobotocore_s3-3.1.0.tar.gz", hash = "sha256:2f61d2f785fcbad9af2a01b3162b50436f95bea5440e0b9b848e6f60a23a3602", size = 76650, upload_time = "2026-01-03T02:07:22.875Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/08/9ef8235e3b7fd1bfd843a6047a9518c15852e853df76b14c0bd3df7b38f5/types_aiobotocore_s3-3.1.0-py3-none-any.whl", hash = "sha256:b019d2db117a0f17df0f60c3eec547ae98a17ce4d03e73ba5a3cfe77d7f30291", size = 84332, upload_time = "2026-01-03T02:02:28.847Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload_time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload_time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload_time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload_time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload_time = "2025-12-11T15:56:40.252Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload_time = "2025-12-11T15:56:38.584Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.38.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload_time = "2025-10-18T13:46:44.63Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload_time = "2025-10-18T13:46:42.958Z" },
+]
+
+[[package]]
+name = "vulture"
+version = "2.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload_time = "2024-12-08T17:39:43.319Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload_time = "2024-12-08T17:39:40.573Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "1.17.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload_time = "2025-08-12T05:53:21.714Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload_time = "2025-08-12T05:51:48.627Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload_time = "2025-08-12T05:51:37.156Z" },
+ { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload_time = "2025-08-12T05:51:58.425Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload_time = "2025-08-12T05:52:37.53Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload_time = "2025-08-12T05:52:15.886Z" },
+ { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload_time = "2025-08-12T05:52:17.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload_time = "2025-08-12T05:52:39.243Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload_time = "2025-08-12T05:53:10.074Z" },
+ { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload_time = "2025-08-12T05:53:08.695Z" },
+ { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload_time = "2025-08-12T05:52:55.34Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload_time = "2025-08-12T05:51:49.864Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload_time = "2025-08-12T05:51:38.935Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload_time = "2025-08-12T05:51:59.365Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload_time = "2025-08-12T05:52:40.965Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload_time = "2025-08-12T05:52:20.326Z" },
+ { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload_time = "2025-08-12T05:52:21.581Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload_time = "2025-08-12T05:52:43.043Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload_time = "2025-08-12T05:53:12.605Z" },
+ { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload_time = "2025-08-12T05:53:11.106Z" },
+ { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload_time = "2025-08-12T05:52:56.531Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload_time = "2025-08-12T05:51:51.109Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload_time = "2025-08-12T05:51:39.912Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload_time = "2025-08-12T05:52:00.693Z" },
+ { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload_time = "2025-08-12T05:52:44.521Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload_time = "2025-08-12T05:52:22.618Z" },
+ { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload_time = "2025-08-12T05:52:24.057Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload_time = "2025-08-12T05:52:45.976Z" },
+ { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload_time = "2025-08-12T05:53:15.214Z" },
+ { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload_time = "2025-08-12T05:53:14.178Z" },
+ { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload_time = "2025-08-12T05:52:57.784Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload_time = "2025-08-12T05:53:20.674Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload_time = "2025-10-06T14:12:55.963Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload_time = "2025-10-06T14:10:14.601Z" },
+ { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload_time = "2025-10-06T14:10:16.115Z" },
+ { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload_time = "2025-10-06T14:10:17.993Z" },
+ { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload_time = "2025-10-06T14:10:19.44Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload_time = "2025-10-06T14:10:21.124Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload_time = "2025-10-06T14:10:22.902Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload_time = "2025-10-06T14:10:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload_time = "2025-10-06T14:10:26.406Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload_time = "2025-10-06T14:10:28.461Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload_time = "2025-10-06T14:10:30.541Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload_time = "2025-10-06T14:10:33.352Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload_time = "2025-10-06T14:10:35.034Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload_time = "2025-10-06T14:10:37.76Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload_time = "2025-10-06T14:10:39.649Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload_time = "2025-10-06T14:10:41.313Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload_time = "2025-10-06T14:10:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload_time = "2025-10-06T14:10:44.643Z" },
+ { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload_time = "2025-10-06T14:10:46.554Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload_time = "2025-10-06T14:10:48.007Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload_time = "2025-10-06T14:10:49.997Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload_time = "2025-10-06T14:10:52.004Z" },
+ { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload_time = "2025-10-06T14:10:54.078Z" },
+ { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload_time = "2025-10-06T14:10:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload_time = "2025-10-06T14:10:57.985Z" },
+ { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload_time = "2025-10-06T14:10:59.633Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload_time = "2025-10-06T14:11:01.454Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload_time = "2025-10-06T14:11:03.452Z" },
+ { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload_time = "2025-10-06T14:11:05.115Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload_time = "2025-10-06T14:11:08.137Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload_time = "2025-10-06T14:11:10.284Z" },
+ { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload_time = "2025-10-06T14:11:11.739Z" },
+ { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload_time = "2025-10-06T14:11:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload_time = "2025-10-06T14:11:15.465Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload_time = "2025-10-06T14:11:17.106Z" },
+ { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload_time = "2025-10-06T14:11:19.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload_time = "2025-10-06T14:11:20.996Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload_time = "2025-10-06T14:11:22.847Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload_time = "2025-10-06T14:11:24.889Z" },
+ { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload_time = "2025-10-06T14:11:27.307Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload_time = "2025-10-06T14:11:29.387Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload_time = "2025-10-06T14:11:31.423Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload_time = "2025-10-06T14:11:33.055Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload_time = "2025-10-06T14:11:35.136Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload_time = "2025-10-06T14:11:37.094Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload_time = "2025-10-06T14:11:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload_time = "2025-10-06T14:11:40.624Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload_time = "2025-10-06T14:11:42.578Z" },
+ { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload_time = "2025-10-06T14:11:44.863Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload_time = "2025-10-06T14:11:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload_time = "2025-10-06T14:11:48.845Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload_time = "2025-10-06T14:11:50.897Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload_time = "2025-10-06T14:11:52.549Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload_time = "2025-10-06T14:11:54.225Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload_time = "2025-10-06T14:11:56.069Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload_time = "2025-10-06T14:11:58.783Z" },
+ { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload_time = "2025-10-06T14:12:00.686Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload_time = "2025-10-06T14:12:02.628Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload_time = "2025-10-06T14:12:04.871Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload_time = "2025-10-06T14:12:06.624Z" },
+ { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload_time = "2025-10-06T14:12:08.362Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload_time = "2025-10-06T14:12:10.994Z" },
+ { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload_time = "2025-10-06T14:12:13.317Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload_time = "2025-10-06T14:12:15.398Z" },
+ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload_time = "2025-10-06T14:12:16.935Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload_time = "2025-10-06T14:12:53.872Z" },
+]