## Шаг 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 ") 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 \` - Create new note • \`/append \` - 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 ") 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