- Создана новая организационная структура с эмодзи-папками - Добавлена система Inbox для быстрого захвата идей - Созданы шаблоны для всех типов заметок с YAML метаданными - Перенесен весь контент из старой структуры в новую - Добавлен главный дашборд с динамическими запросами - Создано подробное руководство по использованию системы - Техническая документация реорганизована по типам Основные улучшения: ✅ Inbox-first подход для новых заметок ✅ Тематическая организация по 8 областям знаний ✅ Шаблоны с метаданными для структурированности ✅ Система связей между заметками ✅ Динамические дашборды с аналитикой ✅ Централизованная техническая документация без дублирования
857 lines
22 KiB
Markdown
857 lines
22 KiB
Markdown
|
||
## Шаг 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 |