Restructured project

- backend moved to backend directory
- added and initialized frontend with vue
- moved infrastructure files to infra directory
This commit is contained in:
2026-04-01 22:27:26 +03:00
parent 48ef7217eb
commit 9d845c8899
96 changed files with 1591 additions and 118 deletions
+77
View File
@@ -0,0 +1,77 @@
package bot
import (
"FamilyHub/src/bot/handlers"
"FamilyHub/src/config"
"FamilyHub/src/integrations/familyHub"
"FamilyHub/src/integrations/ocr"
"context"
"fmt"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Bot struct {
api *tgbotapi.BotAPI
ocr ocr.OCR
router *Router
}
func NewBot(cfg config.Config) (*Bot, error) {
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
if err != nil {
return nil, fmt.Errorf("create telegram bot api: %w", err)
}
api.Debug = cfg.DebugMode
ctx := context.Background()
ocrSvc, err := ocr.NewGoogleOCR(ctx)
if err != nil {
return nil, fmt.Errorf("create google ocr client: %w", err)
}
apiHost := strings.TrimSpace(cfg.APIHost)
if apiHost == "" {
apiHost = "localhost"
}
apiPort := strings.TrimSpace(cfg.APIPort)
if apiPort == "" {
apiPort = "8000"
}
receiptAPI, err := familyHub.NewApiClient(cfg)
if err != nil {
_ = ocrSvc.Close()
return nil, fmt.Errorf("create family hub api client: %w", err)
}
handler := handlers.New(api, ocrSvc, receiptAPI)
return &Bot{api: api, ocr: ocrSvc, router: NewRouter(handler)}, nil
}
func (bot *Bot) Start(ctx context.Context) error {
u := tgbotapi.NewUpdate(0)
u.Timeout = 1
updates := bot.api.GetUpdatesChan(u)
for {
select {
case <-ctx.Done():
log.Println("Telegram bot stopping...")
bot.api.StopReceivingUpdates()
if bot.ocr != nil {
_ = bot.ocr.Close()
}
time.Sleep(500 * time.Millisecond)
return nil
case update, ok := <-updates:
if !ok {
return nil
}
bot.router.Handle(update)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package handlers
import (
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"context"
"errors"
"fmt"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandleCreateFamily(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
if msg.Chat == nil || msg.Chat.Type != "supergroup" {
h.reply(msg.Chat.ID, "Для создания семьи переведи бота в супергруппу и запусти /createFamily там")
return
}
h.setFamilyState(msg.From.ID, familyCreationState{AwaitingName: true, ChatID: msg.Chat.ID})
h.reply(msg.Chat.ID, "Введи имя семьи одним сообщением")
}
func (h *Handler) handleCreateFamilyName(msg *tgbotapi.Message) {
if msg.From == nil || msg.Chat == nil {
return
}
familyName := strings.TrimSpace(msg.Text)
if familyName == "" {
h.reply(msg.Chat.ID, "Имя семьи не может быть пустым. Введи имя еще раз")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
user, err := h.receiptApi.GetUserByTelegramID(ctx, msg.From.ID)
if err != nil {
if errors.Is(err, services.ErrUserNotFound) {
h.reply(msg.Chat.ID, "Сначала зарегистрируйся: /register")
return
}
log.Printf("failed to get user by telegram id: %v", err)
h.reply(msg.Chat.ID, "Не удалось получить пользователя приложения")
return
}
promoteCfg := tgbotapi.PromoteChatMemberConfig{
ChatMemberConfig: tgbotapi.ChatMemberConfig{
ChatID: msg.Chat.ID,
UserID: msg.From.ID,
},
CanManageChat: true,
CanChangeInfo: true,
CanDeleteMessages: true,
CanManageVoiceChats: true,
CanInviteUsers: true,
CanRestrictMembers: true,
CanPinMessages: true,
}
if _, err := h.api.Request(promoteCfg); err != nil {
log.Printf("failed to promote user to admin: %v", err)
h.reply(msg.Chat.ID, "Не удалось назначить тебя администратором. Проверь права бота")
return
}
chatName := msg.Chat.Title
if strings.TrimSpace(chatName) == "" {
chatName = familyName
}
err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{
Name: familyName,
OwnerID: user.ID,
TelegramChatID: msg.Chat.ID,
TelegramChatName: chatName,
})
if err != nil {
log.Printf("failed to create family in api: %v", err)
h.reply(msg.Chat.ID, fmt.Sprintf("Не удалось создать семью в API: %v", err))
return
}
h.clearFamilyState(msg.From.ID)
h.reply(msg.Chat.ID, "Семья создана успешно")
}
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
api "FamilyHub/src/integrations/familyHub"
"FamilyHub/src/integrations/ocr"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type registrationState struct {
AgreementOffered bool
AwaitingApproval bool
}
type familyCreationState struct {
AwaitingName bool
ChatID int64
}
type Handler struct {
api *tgbotapi.BotAPI
ocr ocr.OCR
receiptApi api.ApiClient
registrationMu sync.Mutex
registrationState map[int64]registrationState
familyMu sync.Mutex
familyState map[int64]familyCreationState
}
func New(api *tgbotapi.BotAPI, ocrSvc ocr.OCR, receiptClient api.ApiClient) *Handler {
return &Handler{
api: api,
ocr: ocrSvc,
receiptApi: receiptClient,
registrationState: map[int64]registrationState{},
familyState: map[int64]familyCreationState{},
}
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (h *Handler) HandleHelp(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, "Доступные команды:\n/start\n/register\n/termsOfService\n/getAgreement\n/createFamily\n/help")
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"FamilyHub/src/domain"
"FamilyHub/src/utils"
"context"
"io"
"net/http"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandlePhoto(msg *tgbotapi.Message) {
photo := msg.Photo[len(msg.Photo)-1]
file, err := h.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
if err != nil {
h.reply(msg.Chat.ID, "Не смог получить файл 😢")
return
}
url := file.Link(h.api.Token)
resp, err := http.Get(url)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка загрузки изображения")
return
}
defer resp.Body.Close()
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка чтения изображения")
return
}
text, err := h.ocr.Recognize(context.Background(), imageBytes)
if err != nil {
h.reply(msg.Chat.ID, "Ошибка OCR 😢")
return
}
if text == "" {
h.reply(msg.Chat.ID, "Текст не найден")
return
}
receiptMeta := utils.ExtractReceiptMeta(text)
payload := domain.AddReceiptRequest{Number: receiptMeta.ReceiptID, Date: receiptMeta.Date}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
txt, err := utils.DecodeQR(imageBytes)
println(txt)
err = h.receiptApi.SendReceipt(ctx, payload)
reply := "📄 *Результат распознавания*\n\n"
if receiptMeta.Date != "" {
reply += "📅 Дата: " + receiptMeta.Date + "\n"
} else {
reply += "📅 Дата: не найдена\n"
}
if receiptMeta.ReceiptID != "" {
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
} else {
reply += "🧾 Номер чека: не найден\n"
}
if err != nil {
reply += "Не удалось отправить чек в API " + err.Error()
} else {
reply += "Чек добавлен в базу"
}
h.replyMarkdown(msg.Chat.ID, reply)
}
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"FamilyHub/src/domain"
"context"
"log"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const agreementConfirmationText = "Я принимаю условия"
const termsOfServiceText = "Лицензионное соглашение:\n" +
"1. Вы подтверждаете согласие на обработку данных.\n" +
"2. Вы соглашаетесь с правилами использования FamilyHUB."
func (h *Handler) HandleRegister(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
registered, err := h.receiptApi.IsUserRegistered(ctx, msg.From.ID)
if err != nil {
log.Printf("failed to check registration: %v", err)
h.reply(msg.Chat.ID, "Не удалось проверить регистрацию. Попробуйте позже.")
return
}
if registered {
h.reply(msg.Chat.ID, "Ты уже зарегистрирован. Доступно: /createFamily, /help, /info")
return
}
h.setRegistrationState(msg.From.ID, registrationState{AgreementOffered: true})
h.reply(msg.Chat.ID, termsOfServiceText+"\n\nЕсли согласен, нажми /getAgreement")
}
func (h *Handler) HandleAgreementConfirmation(msg *tgbotapi.Message) {
if msg.From == nil {
return
}
if !strings.EqualFold(strings.TrimSpace(msg.Text), agreementConfirmationText) {
h.reply(msg.Chat.ID, "Фраза не совпадает. Введи точно: \"Я принимаю условия\"")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := h.receiptApi.RegisterUser(ctx, domain.CreateUserRequest{
TelegramID: msg.From.ID,
Username: stringPtrOrNil(msg.From.UserName),
FirstName: stringPtrOrNil(msg.From.FirstName),
LastName: stringPtrOrNil(msg.From.LastName),
LanguageCode: stringPtrOrNil(msg.From.LanguageCode),
})
if err != nil {
log.Printf("failed to register user: %v", err)
h.reply(msg.Chat.ID, "Не удалось завершить регистрацию. Попробуй позже.")
return
}
h.clearRegistrationState(msg.From.ID)
h.reply(msg.Chat.ID, "Регистрация завершена. Доступно: /createFamily, /help, /info")
}
func (h *Handler) HandleGetAgreement(msg *tgbotapi.Message) {
if msg.From == nil {
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
return
}
state, ok := h.getRegistrationState(msg.From.ID)
if !ok || !state.AgreementOffered {
h.reply(msg.Chat.ID, "Сначала запусти /register")
return
}
state.AwaitingApproval = true
h.setRegistrationState(msg.From.ID, state)
h.reply(msg.Chat.ID, "Введи фразу для подтверждения: \"Я принимаю условия\"")
}
func (h *Handler) HandleTermsOfService(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, termsOfServiceText)
}
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"log"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) reply(chat int64, text string) {
m := tgbotapi.NewMessage(chat, text)
_, err := h.api.Send(m)
if err != nil {
log.Fatal(err)
}
}
func (h *Handler) replyMarkdown(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
h.api.Send(msg)
}
+7
View File
@@ -0,0 +1,7 @@
package handlers
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (h *Handler) HandleStart(msg *tgbotapi.Message) {
h.reply(msg.Chat.ID, "Привет! Я FamilyHUB-бот. Доступно: /register, /termsOfService, /help")
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
func (h *Handler) setRegistrationState(userID int64, state registrationState) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
h.registrationState[userID] = state
}
func (h *Handler) getRegistrationState(userID int64) (registrationState, bool) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
state, ok := h.registrationState[userID]
return state, ok
}
func (h *Handler) clearRegistrationState(userID int64) {
h.registrationMu.Lock()
defer h.registrationMu.Unlock()
delete(h.registrationState, userID)
}
func (h *Handler) isAwaitingAgreement(userID int64) bool {
state, ok := h.getRegistrationState(userID)
return ok && state.AwaitingApproval
}
func (h *Handler) setFamilyState(userID int64, state familyCreationState) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
h.familyState[userID] = state
}
func (h *Handler) getFamilyState(userID int64) (familyCreationState, bool) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
state, ok := h.familyState[userID]
return state, ok
}
func (h *Handler) clearFamilyState(userID int64) {
h.familyMu.Lock()
defer h.familyMu.Unlock()
delete(h.familyState, userID)
}
func (h *Handler) isAwaitingFamilyName(userID, chatID int64) bool {
state, ok := h.getFamilyState(userID)
return ok && state.AwaitingName && state.ChatID == chatID
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) HandleUnknown(msg *tgbotapi.Message) {
if msg.From == nil {
return
}
text := strings.TrimSpace(msg.Text)
if text == "" || strings.HasPrefix(text, "/") {
return
}
if h.isAwaitingAgreement(msg.From.ID) {
h.HandleAgreementConfirmation(msg)
return
}
if msg.Chat != nil && h.isAwaitingFamilyName(msg.From.ID, msg.Chat.ID) {
h.handleCreateFamilyName(msg)
}
}
+9
View File
@@ -0,0 +1,9 @@
package handlers
func stringPtrOrNil(value string) *string {
if value == "" {
return nil
}
return &value
}
+40
View File
@@ -0,0 +1,40 @@
package bot
import (
"FamilyHub/src/bot/handlers"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Router struct {
handler *handlers.Handler
}
func NewRouter(handler *handlers.Handler) *Router {
return &Router{handler: handler}
}
func (r *Router) Handle(update tgbotapi.Update) {
if update.Message == nil {
return
}
switch {
case update.Message.Photo != nil:
r.handler.HandlePhoto(update.Message)
case update.Message.Text == "/start":
r.handler.HandleStart(update.Message)
case update.Message.Text == "/register":
r.handler.HandleRegister(update.Message)
case update.Message.Text == "/termsOfService":
r.handler.HandleTermsOfService(update.Message)
case update.Message.Text == "/getAgreement":
r.handler.HandleGetAgreement(update.Message)
case update.Message.Text == "/help":
r.handler.HandleHelp(update.Message)
case update.Message.Text == "/createFamily":
r.handler.HandleCreateFamily(update.Message)
default:
r.handler.HandleUnknown(update.Message)
}
}