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

857 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Шаг 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