Files
second-mind-aep/💡 Идеи/💡 Проекты/Obsidian телеграм бот/План реализации MVP - Telegram Bot для Obsidian.md
Andrey Epifancev e96fec3709 Реорганизация структуры заметок v2.0
- Создана новая организационная структура с эмодзи-папками
- Добавлена система Inbox для быстрого захвата идей
- Созданы шаблоны для всех типов заметок с YAML метаданными
- Перенесен весь контент из старой структуры в новую
- Добавлен главный дашборд с динамическими запросами
- Создано подробное руководство по использованию системы
- Техническая документация реорганизована по типам

Основные улучшения:
 Inbox-first подход для новых заметок
 Тематическая организация по 8 областям знаний
 Шаблоны с метаданными для структурированности
 Система связей между заметками
 Динамические дашборды с аналитикой
 Централизованная техническая документация без дублирования
2025-08-09 22:11:50 +04:00

22 KiB
Raw Blame History

Шаг 1: Настройка проекта и структуры

1.1 Инициализация проекта

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 Установка зависимостей

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 Базовый конфиг

# 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

// 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 Инициализация логирования

// 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 (атомарные операции с файлами)

// 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 (асинхронная синхронизация)

// 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 интеграция)

// 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

// 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
.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