diff --git a/Идеи/Obsidian телеграм бот/MVP Telegram бота для Obsidian.md b/Идеи/Obsidian телеграм бот/MVP Telegram бота для Obsidian.md index 24939cf..e58110b 100644 --- a/Идеи/Obsidian телеграм бот/MVP Telegram бота для Obsidian.md +++ b/Идеи/Obsidian телеграм бот/MVP Telegram бота для Obsidian.md @@ -1,94 +1,220 @@ --- tags: - - mvp - - teleram-bot - - LLM --- + +--- + +tags: + +- mvp +- telegram-bot +- obsidian +- go + +--- + ## 1. Цели MVP -- Принимать текстовые сообщения в Telegram. -- Создавать заметки в Obsidian Vault в формате Markdown. -- Автоматически коммитить и пушить изменения в Git-репозиторий. -- Поддерживать базовое форматирование текста через YandexGPT. -- Поддержка одного пользователя. +- Принимать текстовые сообщения в Telegram +- Создавать заметки в Obsidian Vault в формате Markdown +- Асинхронно синхронизировать изменения с Git-репозиторием +- Поддерживать базовое форматирование текста (без LLM) +- Поддержка whitelist пользователей > **Не включаем на первом этапе:** -> Голосовые сообщения, векторный поиск, Quartz-публикацию. +> Голосовые сообщения, векторный поиск, Quartz-публикация, LLM интеграция --- ## 2. Компоненты MVP ### 2.1 Bot Handler (Go + Telegram API) -- Используем `telegram-bot-api/v5`. + +- Используем `telegram-bot-api/v5` - Поддерживаем команды: - - `/new <текст>` — создать новую заметку. - - `/append <имя>` — дописать к существующей заметке (по exact match). - - `/list` — список последних 5 заметок. + - `/new <текст>` — создать новую заметку + - `/append <частичное_имя>` — дописать к существующей заметке (fuzzy search) + - `/list` — список последних 5 заметок +- Whitelist авторизация по Telegram User ID +- Graceful shutdown с завершением pending операций ### 2.2 Note Service -- Создание Markdown-файлов в директории `vault/notes`. -- Простая генерация имени файла по дате + заголовку. -- Добавление timestamps в YAML frontmatter. -- Вызов LLM Service для легкого форматирования (через YandexGPT API). -### 2.3 LLM Service (YandexGPT) -- YandexGPT форматирует Markdown (добавляет заголовки, убирает лишние пробелы). -- Транскрипции и поиск по эмбеддингам — **не реализуем** на MVP. +- Создание Markdown-файлов в директории `vault/notes` +- Умная генерация имени файла: `YYYY-MM-DD-HHMM-slug.md` +- Добавление timestamps в YAML frontmatter +- Базовое форматирование (первая строка = заголовок) +- Fuzzy search для поиска существующих заметок -### 2.4 Git Service -- Используем `go-git/v5`. -- После создания/обновления заметки: - 1. `git add ` - 2. `git commit -m "add note "` - 3. `git push` +### 2.3 Git Service (Асинхронный) -### 2.5 File Service -- Создает файлы в `vault/notes`. -- Проверяет уникальность имени. -- Обеспечивает atomic save. +- Используем `go-git/v5` +- **Eventual consistency pattern:** + 1. Файл сохраняется мгновенно + 2. Пользователь получает подтверждение + 3. Git sync идет в фоне через канал + 4. Батчевые коммиты каждые 30 секунд +- Retry logic при ошибках сети +- Graceful handling git failures (не влияют на UX) + +### 2.4 File Service + +- Создает файлы в `vault/notes` +- Atomic file operations +- Проверка уникальности имени файла +- Slug generation для читаемых имен --- -## 3. Поток обработки сообщений MVP +## 3. Поток обработки сообщений ```mermaid graph TB A[User] --> B[Telegram Bot] - B --> C[Bot Handler] + B --> C[Auth Check] C --> D["/new text"] D --> E[Note Service] - E --> F[LLM Service format] - F --> G[File Service] - G --> H[Git Service] - H --> I["Заметка создана: filename"] - I --> A + E --> F[File Service: Save] + F --> G["✅ Instant Response"] + G --> A - subgraph "MVP Flow" + F --> H[Git Queue] + H --> I[Background Worker] + I --> J[Batch Commit + Push] + + subgraph "Synchronous (Fast)" + C D E F G + end + + subgraph "Asynchronous (Eventual)" H + I + J end ``` --- -## 4. Технологический стек MVP +## 4. Технологический стек **Основные зависимости:** -- `telegram-bot-api/v5` - Telegram Bot API -- `yandexcloud-sdk-go` - YandexGPT API интеграция -- `go-git/v5` - Git операции -- `viper` - конфигурация -- `logrus` - структурированное логирование -**Внешние сервисы:** -- YandexGPT API (YandexGPT Lite/Pro) -- Telegram Bot API +- `telegram-bot-api/v5` - Telegram Bot API +- `go-git/v5` - Git операции +- `viper` - конфигурация +- `logrus` - структурированное логирование **Конфигурация:** -- `YANDEX_API_KEY` - API ключ для YandexGPT -- `TELEGRAM_BOT_TOKEN` - токен Telegram бота -- `VAULT_PATH` - путь к Obsidian vault + +```yaml +telegram_token: "your_bot_token" +vault_path: "./vault/notes" +allowed_user_ids: [123456789] + +git: + sync_interval: "30s" + max_queue_size: 100 + +logging: + level: "info" +``` + +**Внешние сервисы:** + +- Telegram Bot API +- Git remote repository + +--- + +## 5. Структура проекта + +``` +obsidian-telegram-bot/ +├── cmd/ +│ └── bot/ +│ └── main.go +├── internal/ +│ ├── bot/ # Telegram handlers + auth +│ ├── note/ # Note operations + fuzzy search +│ ├── git/ # Async git sync +│ └── config/ # Configuration management +├── configs/ +│ └── config.yaml +├── vault/ +│ └── notes/ # Generated markdown files +├── go.mod +└── README.md +``` + +--- + +## 6. Архитектурные решения + +### 6.1 Filename Generation + +```go +// Пример: "2024-01-15-1430-team-meeting.md" +func generateFilename(text string) string { + timestamp := time.Now().Format("2006-01-02-1504") + slug := slugify(extractTitle(text)) // Первые 3 слова + return fmt.Sprintf("%s-%s.md", timestamp, slug) +} +``` + +### 6.2 Error Handling Strategy + +- **File operations:** Fail fast, немедленный ответ пользователю +- **Git operations:** Fail gracefully, логирование, retry в фоне +- **Network issues:** Не блокируют создание заметок + +### 6.3 Fuzzy Search для /append + +```go +// /append meeting -> найдет "2024-01-15-1430-team-meeting.md" +func FindNoteByPartialName(query string) ([]string, error) +``` + +### 6.4 Security + +- Whitelist Telegram User IDs в конфиге +- Валидация всех входящих команд +- Rate limiting через git sync intervals + +--- + +## 7. MVP Success Criteria + +**Функциональные:** + +- ✅ Создание заметки за < 2 секунды +- ✅ Git sync работает в фоне без блокировок +- ✅ Fuzzy search находит заметки по частичному имени +- ✅ Только авторизованные пользователи имеют доступ + +**Технические:** + +- ✅ Graceful shutdown без потери данных +- ✅ Логирование всех операций +- ✅ Retry logic для git операций +- ✅ Читаемые имена файлов + +**Ограничения MVP:** + +- Один пользователь (легко расширить до нескольких) +- Только текстовые сообщения +- Базовое форматирование без LLM +- Локальный git (без конфликт-резолюции) + +--- + +## 8. Roadmap после MVP + +**v0.2:** YandexGPT интеграция для улучшенного форматирования +**v0.3:** Поддержка голосовых сообщений + транскрипция +**v0.4:** Векторный поиск по заметкам +**v0.5:** Quartz автопубликация +**v1.0:** Multi-user support с персональными vaults \ No newline at end of file diff --git a/Идеи/Obsidian телеграм бот/План реализации MVP - Telegram Bot для Obsidian.md b/Идеи/Obsidian телеграм бот/План реализации MVP - Telegram Bot для Obsidian.md new file mode 100644 index 0000000..3cde04b --- /dev/null +++ b/Идеи/Obsidian телеграм бот/План реализации MVP - Telegram Bot для Obsidian.md @@ -0,0 +1,857 @@ + +## Шаг 1: Настройка проекта и структуры + +### 1.1 Инициализация проекта + +```bash +mkdir obsidian-telegram-bot +cd obsidian-telegram-bot +go mod init github.com/yourusername/obsidian-telegram-bot + +# Создание структуры +mkdir -p cmd/bot +mkdir -p internal/{bot,note,git,config} +mkdir -p configs +mkdir -p vault/notes +``` + +### 1.2 Установка зависимостей + +```bash +go get github.com/go-telegram-bot-api/telegram-bot-api/v5 +go get github.com/go-git/go-git/v5 +go get github.com/spf13/viper +go get github.com/sirupsen/logrus +``` + +### 1.3 Базовый конфиг + +```yaml +# configs/config.yaml +telegram_token: "" +vault_path: "./vault/notes" +allowed_user_ids: [] + +git: + sync_interval: "30s" + max_queue_size: 100 + +logging: + level: "info" +``` + +--- + +## Шаг 2: Конфигурация и логирование + +### 2.1 Config service + +```go +// internal/config/config.go +package config + +import ( + "github.com/spf13/viper" + "time" +) + +type Config struct { + TelegramToken string `mapstructure:"telegram_token"` + VaultPath string `mapstructure:"vault_path"` + AllowedUserIDs []int64 `mapstructure:"allowed_user_ids"` + Git GitConfig `mapstructure:"git"` + Logging LoggingConfig `mapstructure:"logging"` +} + +type GitConfig struct { + SyncInterval string `mapstructure:"sync_interval"` + MaxQueueSize int `mapstructure:"max_queue_size"` +} + +type LoggingConfig struct { + Level string `mapstructure:"level"` +} + +func Load(configPath string) (*Config, error) { + viper.SetConfigFile(configPath) + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + return nil, err + } + + var config Config + if err := viper.Unmarshal(&config); err != nil { + return nil, err + } + + return &config, nil +} + +func (c *Config) GetSyncInterval() time.Duration { + duration, err := time.ParseDuration(c.Git.SyncInterval) + if err != nil { + return 30 * time.Second // default + } + return duration +} +``` + +### 2.2 Инициализация логирования + +```go +// internal/config/logger.go +package config + +import ( + "github.com/sirupsen/logrus" +) + +func SetupLogger(level string) { + logLevel, err := logrus.ParseLevel(level) + if err != nil { + logLevel = logrus.InfoLevel + } + + logrus.SetLevel(logLevel) + logrus.SetFormatter(&logrus.JSONFormatter{}) +} +``` + +--- + +## Шаг 3: File Service (атомарные операции с файлами) + +```go +// internal/note/file_service.go +package note + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +type FileService struct { + vaultPath string +} + +func NewFileService(vaultPath string) *FileService { + return &FileService{vaultPath: vaultPath} +} + +func (f *FileService) CreateNote(text string) (string, error) { + // Создаем директорию если не существует + if err := os.MkdirAll(f.vaultPath, 0755); err != nil { + return "", fmt.Errorf("failed to create vault directory: %w", err) + } + + // Генерируем имя файла + filename := f.generateFilename(text) + filePath := filepath.Join(f.vaultPath, filename) + + // Проверяем уникальность + if _, err := os.Stat(filePath); err == nil { + filename = f.generateUniqueFilename(text) + filePath = filepath.Join(f.vaultPath, filename) + } + + // Форматируем содержимое + content := f.formatMarkdown(text) + + // Атомарная запись + if err := f.atomicWrite(filePath, content); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return filename, nil +} + +func (f *FileService) AppendToNote(filename, text string) error { + filePath := filepath.Join(f.vaultPath, filename) + + // Читаем существующий контент + existingContent, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read existing note: %w", err) + } + + // Добавляем новый контент + newContent := string(existingContent) + "\n\n" + text + + // Атомарная перезапись + return f.atomicWrite(filePath, newContent) +} + +func (f *FileService) generateFilename(text string) string { + title := f.extractTitle(text) + slug := f.slugify(title) + timestamp := time.Now().Format("2006-01-02-1504") + + return fmt.Sprintf("%s-%s.md", timestamp, slug) +} + +func (f *FileService) generateUniqueFilename(text string) string { + title := f.extractTitle(text) + slug := f.slugify(title) + timestamp := time.Now().Format("2006-01-02-150405") // Добавляем секунды + + return fmt.Sprintf("%s-%s.md", timestamp, slug) +} + +func (f *FileService) extractTitle(text string) string { + lines := strings.Split(text, "\n") + if len(lines) == 0 { + return "note" + } + + // Берем первую строку, ограничиваем длину + title := strings.TrimSpace(lines[0]) + if len(title) > 50 { + title = title[:50] + } + + if title == "" { + return "note" + } + + return title +} + +func (f *FileService) slugify(text string) string { + // Убираем спецсимволы + reg := regexp.MustCompile(`[^a-zA-Z0-9\s]+`) + clean := reg.ReplaceAllString(text, "") + + // Разбиваем на слова + words := strings.Fields(strings.ToLower(clean)) + + // Берем первые 3 слова + if len(words) > 3 { + words = words[:3] + } + + if len(words) == 0 { + return "note" + } + + return strings.Join(words, "-") +} + +func (f *FileService) formatMarkdown(text string) string { + lines := strings.Split(text, "\n") + + // Добавляем YAML frontmatter + frontmatter := fmt.Sprintf(`--- +created: %s +tags: [telegram-note] +--- + +`, time.Now().Format("2006-01-02T15:04:05Z07:00")) + + // Если первая строка не заголовок - делаем её заголовком + if len(lines) > 0 && !strings.HasPrefix(strings.TrimSpace(lines[0]), "#") { + lines[0] = "# " + strings.TrimSpace(lines[0]) + } + + return frontmatter + strings.Join(lines, "\n") +} + +func (f *FileService) atomicWrite(filePath, content string) error { + // Записываем во временный файл + tempFile := filePath + ".tmp" + + file, err := os.Create(tempFile) + if err != nil { + return err + } + + _, err = file.WriteString(content) + closeErr := file.Close() + + if err != nil { + os.Remove(tempFile) + return err + } + + if closeErr != nil { + os.Remove(tempFile) + return closeErr + } + + // Атомарное переименование + return os.Rename(tempFile, filePath) +} + +func (f *FileService) FindNotesByPartialName(query string) ([]string, error) { + files, err := os.ReadDir(f.vaultPath) + if err != nil { + return nil, err + } + + var matches []string + query = strings.ToLower(query) + + for _, file := range files { + if file.IsDir() { + continue + } + + filename := strings.ToLower(file.Name()) + if strings.Contains(filename, query) { + matches = append(matches, file.Name()) + } + } + + // Ограничиваем до 5 результатов + if len(matches) > 5 { + matches = matches[:5] + } + + return matches, nil +} + +func (f *FileService) ListRecentNotes(limit int) ([]string, error) { + files, err := os.ReadDir(f.vaultPath) + if err != nil { + return nil, err + } + + // Сортируем по времени модификации (новые первые) + var fileInfos []os.FileInfo + for _, file := range files { + if file.IsDir() { + continue + } + + info, err := file.Info() + if err != nil { + continue + } + fileInfos = append(fileInfos, info) + } + + // Простая сортировка по времени модификации + for i := 0; i < len(fileInfos)-1; i++ { + for j := i + 1; j < len(fileInfos); j++ { + if fileInfos[i].ModTime().Before(fileInfos[j].ModTime()) { + fileInfos[i], fileInfos[j] = fileInfos[j], fileInfos[i] + } + } + } + + var result []string + for i, info := range fileInfos { + if i >= limit { + break + } + result = append(result, info.Name()) + } + + return result, nil +} +``` + +--- + +## Шаг 4: Git Service (асинхронная синхронизация) + +```go +// internal/git/service.go +package git + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/sirupsen/logrus" +) + +type Service struct { + repo *git.Repository + worktree *git.Worktree + fileQueue chan string + stopChan chan struct{} + vaultPath string + syncInterval time.Duration +} + +func NewService(vaultPath string, syncInterval time.Duration) (*Service, error) { + // Инициализируем или открываем git репозиторий + repo, err := git.PlainOpen(vaultPath) + if err != nil { + // Если репозитория нет, создаем новый + repo, err = git.PlainInit(vaultPath, false) + if err != nil { + return nil, fmt.Errorf("failed to init git repo: %w", err) + } + } + + worktree, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("failed to get worktree: %w", err) + } + + return &Service{ + repo: repo, + worktree: worktree, + fileQueue: make(chan string, 100), // Буфер на 100 файлов + stopChan: make(chan struct{}), + vaultPath: vaultPath, + syncInterval: syncInterval, + }, nil +} + +func (s *Service) Start() { + go s.syncWorker() +} + +func (s *Service) Stop() { + close(s.stopChan) +} + +func (s *Service) QueueFile(filename string) { + select { + case s.fileQueue <- filename: + logrus.Debugf("File queued for git sync: %s", filename) + default: + logrus.Warnf("Git queue full, file will be synced later: %s", filename) + } +} + +func (s *Service) syncWorker() { + ticker := time.NewTicker(s.syncInterval) + defer ticker.Stop() + + var pendingFiles []string + + for { + select { + case filename := <-s.fileQueue: + pendingFiles = append(pendingFiles, filename) + + case <-ticker.C: + if len(pendingFiles) > 0 { + s.commitBatch(pendingFiles) + pendingFiles = nil + } + + case <-s.stopChan: + // Последний sync перед выходом + if len(pendingFiles) > 0 { + s.commitBatch(pendingFiles) + } + return + } + } +} + +func (s *Service) commitBatch(files []string) { + logrus.Infof("Starting git sync for %d files", len(files)) + + // Добавляем файлы в индекс + for _, filename := range files { + filePath := filepath.Join("notes", filename) + _, err := s.worktree.Add(filePath) + if err != nil { + logrus.Errorf("Failed to add file to git: %s, error: %v", filename, err) + continue + } + } + + // Создаем коммит + message := s.generateCommitMessage(files) + commit, err := s.worktree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Telegram Bot", + Email: "bot@telegram", + When: time.Now(), + }, + }) + + if err != nil { + logrus.Errorf("Failed to commit: %v", err) + return + } + + logrus.Infof("Created commit: %s", commit.String()) + + // Пушим если есть remote + s.tryPush() +} + +func (s *Service) generateCommitMessage(files []string) string { + if len(files) == 1 { + return fmt.Sprintf("Add note: %s", files[0]) + } + return fmt.Sprintf("Add %d notes via Telegram bot", len(files)) +} + +func (s *Service) tryPush() { + // Проверяем есть ли remote + remotes, err := s.repo.Remotes() + if err != nil || len(remotes) == 0 { + logrus.Debug("No remotes configured, skipping push") + return + } + + // Пытаемся запушить + err = s.repo.Push(&git.PushOptions{}) + if err != nil { + if err == git.NoErrAlreadyUpToDate { + logrus.Debug("Git: already up to date") + } else { + logrus.Errorf("Failed to push: %v", err) + } + return + } + + logrus.Info("Successfully pushed to remote") +} +``` + +--- + +## Шаг 5: Bot Handler (Telegram интеграция) + +```go +// internal/bot/handler.go +package bot + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/sirupsen/logrus" + + "your-module/internal/config" + "your-module/internal/git" + "your-module/internal/note" +) + +type Handler struct { + bot *tgbotapi.BotAPI + fileService *note.FileService + gitService *git.Service + config *config.Config +} + +func NewHandler(bot *tgbotapi.BotAPI, fileService *note.FileService, gitService *git.Service, cfg *config.Config) *Handler { + return &Handler{ + bot: bot, + fileService: fileService, + gitService: gitService, + config: cfg, + } +} + +func (h *Handler) HandleUpdates() { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := h.bot.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { + continue + } + + go h.handleUpdate(update) + } +} + +func (h *Handler) handleUpdate(update tgbotapi.Update) { + // Проверяем авторизацию + if !h.isUserAllowed(update.Message.From.ID) { + h.sendMessage(update.Message.Chat.ID, "❌ Access denied") + logrus.Warnf("Unauthorized access attempt from user %d", update.Message.From.ID) + return + } + + // Обрабатываем команды + if update.Message.IsCommand() { + h.handleCommand(update) + } else { + // Обычное сообщение - создаем заметку + h.handleNewNote(update, update.Message.Text) + } +} + +func (h *Handler) handleCommand(update tgbotapi.Update) { + command := update.Message.Command() + args := update.Message.CommandArguments() + + switch command { + case "start": + h.handleStart(update) + case "new": + if args == "" { + h.sendMessage(update.Message.Chat.ID, "❌ Usage: /new <text>") + return + } + h.handleNewNote(update, args) + case "append": + h.handleAppend(update, args) + case "list": + h.handleList(update) + default: + h.sendMessage(update.Message.Chat.ID, "❌ Unknown command. Available: /new, /append, /list") + } +} + +func (h *Handler) handleStart(update tgbotapi.Update) { + message := `🤖 *Obsidian Telegram Bot* + +Available commands: +• \`/new <text>\` - Create new note +• \`/append <name>\` - Append to existing note +• \`/list\` - Show recent notes +• Just send text to create a note + +Your notes are automatically synced to Git!` + + h.sendMessage(update.Message.Chat.ID, message) +} + +func (h *Handler) handleNewNote(update tgbotapi.Update, text string) { + chatID := update.Message.Chat.ID + + if strings.TrimSpace(text) == "" { + h.sendMessage(chatID, "❌ Cannot create empty note") + return + } + + // Создаем файл + filename, err := h.fileService.CreateNote(text) + if err != nil { + logrus.Errorf("Failed to create note for user %d: %v", update.Message.From.ID, err) + h.sendMessage(chatID, "❌ Failed to create note. Please try again.") + return + } + + // Мгновенный ответ пользователю + message := fmt.Sprintf("✅ Note created: `%s`", filename) + h.sendMessage(chatID, message) + + // Асинхронная синхронизация с git + h.gitService.QueueFile(filename) + + logrus.Infof("Note created by user %d: %s", update.Message.From.ID, filename) +} + +func (h *Handler) handleAppend(update tgbotapi.Update, args string) { + chatID := update.Message.Chat.ID + + if args == "" { + h.sendMessage(chatID, "❌ Usage: /append <partial_filename>") + return + } + + // Ищем файлы по частичному имени + matches, err := h.fileService.FindNotesByPartialName(args) + if err != nil { + h.sendMessage(chatID, "❌ Error searching notes") + return + } + + if len(matches) == 0 { + h.sendMessage(chatID, fmt.Sprintf("❌ No notes found matching: %s", args)) + return + } + + if len(matches) == 1 { + // Найден один файл - просим текст для добавления + h.sendMessage(chatID, fmt.Sprintf("📝 Found: `%s`\nSend text to append:", matches[0])) + // TODO: Реализовать состояние для следующего сообщения + return + } + + // Несколько совпадений - показываем список + message := fmt.Sprintf("Found %d matches:\n", len(matches)) + for i, match := range matches { + message += fmt.Sprintf("%d. `%s`\n", i+1, match) + } + message += "\nBe more specific or use exact filename with /append" + + h.sendMessage(chatID, message) +} + +func (h *Handler) handleList(update tgbotapi.Update) { + chatID := update.Message.Chat.ID + + notes, err := h.fileService.ListRecentNotes(5) + if err != nil { + h.sendMessage(chatID, "❌ Error listing notes") + return + } + + if len(notes) == 0 { + h.sendMessage(chatID, "📝 No notes found") + return + } + + message := "📚 *Recent notes:*\n" + for i, note := range notes { + message += fmt.Sprintf("%d. `%s`\n", i+1, note) + } + + h.sendMessage(chatID, message) +} + +func (h *Handler) isUserAllowed(userID int64) bool { + for _, allowedID := range h.config.AllowedUserIDs { + if userID == allowedID { + return true + } + } + return false +} + +func (h *Handler) sendMessage(chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + + if _, err := h.bot.Send(msg); err != nil { + logrus.Errorf("Failed to send message: %v", err) + } +} +``` + +--- + +## Шаг 6: Main application + +```go +// cmd/bot/main.go +package main + +import ( + "flag" + "os" + "os/signal" + "syscall" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/sirupsen/logrus" + + "your-module/internal/bot" + "your-module/internal/config" + "your-module/internal/git" + "your-module/internal/note" +) + +func main() { + configPath := flag.String("config", "configs/config.yaml", "Path to config file") + flag.Parse() + + // Загружаем конфигурацию + cfg, err := config.Load(*configPath) + if err != nil { + logrus.Fatalf("Failed to load config: %v", err) + } + + // Настраиваем логирование + config.SetupLogger(cfg.Logging.Level) + + // Инициализируем Telegram бота + telegramBot, err := tgbotapi.NewBotAPI(cfg.TelegramToken) + if err != nil { + logrus.Fatalf("Failed to create Telegram bot: %v", err) + } + + logrus.Infof("Authorized on account %s", telegramBot.Self.UserName) + + // Инициализируем сервисы + fileService := note.NewFileService(cfg.VaultPath) + + gitService, err := git.NewService(cfg.VaultPath, cfg.GetSyncInterval()) + if err != nil { + logrus.Fatalf("Failed to create git service: %v", err) + } + + // Запускаем git синхронизацию + gitService.Start() + + // Создаем обработчик бота + handler := bot.NewHandler(telegramBot, fileService, gitService, cfg) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + logrus.Info("Shutting down...") + + // Останавливаем git service + gitService.Stop() + + os.Exit(0) + }() + + logrus.Info("Bot started") + + // Запускаем обработку сообщений + handler.HandleUpdates() +} +``` + +--- + +## Шаг 7: Makefile и запуск + +```makefile +# Makefile +.PHONY: build run clean test + +APP_NAME=obsidian-bot +BUILD_DIR=bin + +build: + go build -o $(BUILD_DIR)/$(APP_NAME) cmd/bot/main.go + +run: build + ./$(BUILD_DIR)/$(APP_NAME) -config configs/config.yaml + +dev: + go run cmd/bot/main.go -config configs/config.yaml + +clean: + rm -rf $(BUILD_DIR) + +test: + go test ./... + +docker-build: + docker build -t $(APP_NAME) . + +docker-run: docker-build + docker run --rm -v $(PWD)/configs:/app/configs -v $(PWD)/vault:/app/vault $(APP_NAME) +``` + +--- + +## Последовательность реализации: + +1. **Шаг 1-2**: Настройка проекта и конфигурации (30 минут) +2. **Шаг 3**: File Service - основная логика работы с файлами (1-2 часа) +3. **Шаг 4**: Git Service - асинхронная синхронизация (1 час) +4. **Шаг 5**: Bot Handler - интеграция с Telegram (1 час) +5. **Шаг 6**: Main app и тестирование (30 минут) + +**Общее время: 4-5 часов** для полнофункционального MVP. + +После реализации нужно будет: + +- Создать Telegram бота через @BotFather +- Настроить config.yaml с токенами +- Инициализировать git репозиторий в vault/ +- Добавить свой Telegram User ID в whitelist \ No newline at end of file