feat: initial commit
This commit is contained in:
17
internal/application/dto/incoming_message.go
Normal file
17
internal/application/dto/incoming_message.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type IncomingMessageDTO struct {
|
||||
MessageID int64
|
||||
ChatID int64
|
||||
UserID int64
|
||||
Username string
|
||||
FirstName string
|
||||
LastName string
|
||||
Text string
|
||||
VoiceFileID string
|
||||
IsVoice bool
|
||||
Timestamp time.Time
|
||||
Language string
|
||||
}
|
||||
7
internal/application/dto/workflow_result.go
Normal file
7
internal/application/dto/workflow_result.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package dto
|
||||
|
||||
type WorkflowResultDTO struct {
|
||||
RequestID string
|
||||
ReplyText string
|
||||
Error error
|
||||
}
|
||||
115
internal/application/usecase/handle_text_message.go
Normal file
115
internal/application/usecase/handle_text_message.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/paramah/gw_telegram/internal/application/dto"
|
||||
"github.com/paramah/gw_telegram/internal/domain/apperror"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
"github.com/paramah/gw_telegram/internal/domain/port"
|
||||
)
|
||||
|
||||
type HandleTextMessage struct {
|
||||
router port.IntentRouter
|
||||
dispatcher port.WorkflowDispatcher
|
||||
sessions port.SessionStore
|
||||
gateway port.MessageGateway
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandleTextMessage(
|
||||
router port.IntentRouter,
|
||||
dispatcher port.WorkflowDispatcher,
|
||||
sessions port.SessionStore,
|
||||
gateway port.MessageGateway,
|
||||
logger *slog.Logger,
|
||||
) *HandleTextMessage {
|
||||
return &HandleTextMessage{
|
||||
router: router,
|
||||
dispatcher: dispatcher,
|
||||
sessions: sessions,
|
||||
gateway: gateway,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HandleTextMessage) Execute(ctx context.Context, in dto.IncomingMessageDTO) error {
|
||||
if in.Text == "" {
|
||||
return apperror.ErrMessageEmpty
|
||||
}
|
||||
|
||||
msg := entity.Message{
|
||||
MessageID: in.MessageID,
|
||||
ChatID: in.ChatID,
|
||||
UserID: in.UserID,
|
||||
Username: in.Username,
|
||||
Type: entity.MessageTypeText,
|
||||
Text: in.Text,
|
||||
Timestamp: in.Timestamp,
|
||||
Metadata: map[string]any{"language": in.Language},
|
||||
}
|
||||
|
||||
session, err := h.sessions.Get(ctx, in.UserID)
|
||||
if err != nil {
|
||||
h.logger.WarnContext(ctx, "session not found, using empty session", "user_id", in.UserID)
|
||||
session = entity.Session{
|
||||
UserID: in.UserID,
|
||||
ChatID: in.ChatID,
|
||||
Data: make(map[string]any),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
route, err := h.router.Route(ctx, msg)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "routing failed", "error", err, "user_id", in.UserID)
|
||||
_ = h.gateway.SendText(ctx, in.ChatID, "Sorry, I couldn't process your message. Please try again.")
|
||||
return fmt.Errorf("handle text message: route: %w", err)
|
||||
}
|
||||
|
||||
session.History = append(session.History, msg)
|
||||
if len(session.History) > 20 {
|
||||
session.History = session.History[len(session.History)-20:]
|
||||
}
|
||||
|
||||
req := entity.WorkflowRequest{
|
||||
RequestID: uuid.New().String(),
|
||||
ChatID: in.ChatID,
|
||||
UserID: in.UserID,
|
||||
Username: in.Username,
|
||||
MessageText: in.Text,
|
||||
Intent: entity.Intent{
|
||||
Name: route.IntentName,
|
||||
},
|
||||
Session: session,
|
||||
Timestamp: in.Timestamp,
|
||||
Metadata: map[string]any{"route_target_type": string(route.Target.Type)},
|
||||
}
|
||||
|
||||
_ = h.gateway.SendTyping(ctx, in.ChatID)
|
||||
|
||||
resp, err := h.dispatcher.Dispatch(ctx, req)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "dispatch failed", "error", err, "request_id", req.RequestID)
|
||||
_ = h.gateway.SendText(ctx, in.ChatID, "Sorry, the service is temporarily unavailable. Please try again later.")
|
||||
return fmt.Errorf("handle text message: dispatch: %w", err)
|
||||
}
|
||||
|
||||
if resp.ReplyText != "" {
|
||||
if err := h.gateway.SendText(ctx, in.ChatID, resp.ReplyText); err != nil {
|
||||
return fmt.Errorf("handle text message: send reply: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
session.CurrentWorkflow = route.Target.WorkflowID
|
||||
session.UpdatedAt = time.Now()
|
||||
if err := h.sessions.Set(ctx, session); err != nil {
|
||||
h.logger.WarnContext(ctx, "failed to persist session", "error", err, "user_id", in.UserID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
131
internal/application/usecase/handle_text_message_test.go
Normal file
131
internal/application/usecase/handle_text_message_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/paramah/gw_telegram/internal/application/dto"
|
||||
"github.com/paramah/gw_telegram/internal/application/usecase"
|
||||
"github.com/paramah/gw_telegram/internal/domain/apperror"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
"github.com/paramah/gw_telegram/test/testutil"
|
||||
)
|
||||
|
||||
func newTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func validTextDTO() dto.IncomingMessageDTO {
|
||||
return dto.IncomingMessageDTO{
|
||||
MessageID: 1,
|
||||
ChatID: 100,
|
||||
UserID: 200,
|
||||
Username: "testuser",
|
||||
Text: "Hello, I need help",
|
||||
Timestamp: time.Now(),
|
||||
Language: "en",
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_Execute_HappyPath(t *testing.T) {
|
||||
router := &testutil.FakeIntentRouter{
|
||||
RouteResult: entity.Route{
|
||||
IntentName: "general_query",
|
||||
Target: entity.RouteTarget{
|
||||
Type: entity.RouteTargetN8n,
|
||||
WorkflowID: "general-webhook",
|
||||
},
|
||||
},
|
||||
}
|
||||
dispatcher := &testutil.FakeWorkflowDispatcher{
|
||||
Response: entity.WorkflowResponse{
|
||||
ReplyText: "Here is my response",
|
||||
},
|
||||
}
|
||||
sessions := &testutil.FakeSessionStore{}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
|
||||
uc := usecase.NewHandleTextMessage(router, dispatcher, sessions, gateway, newTestLogger())
|
||||
err := uc.Execute(context.Background(), validTextDTO())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, dispatcher.CallCount)
|
||||
assert.Equal(t, "Here is my response", gateway.LastSentText)
|
||||
assert.Equal(t, int64(100), gateway.LastChatID)
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_Execute_EmptyText(t *testing.T) {
|
||||
uc := usecase.NewHandleTextMessage(
|
||||
&testutil.FakeIntentRouter{},
|
||||
&testutil.FakeWorkflowDispatcher{},
|
||||
&testutil.FakeSessionStore{},
|
||||
&testutil.FakeMessageGateway{},
|
||||
newTestLogger(),
|
||||
)
|
||||
|
||||
in := validTextDTO()
|
||||
in.Text = ""
|
||||
err := uc.Execute(context.Background(), in)
|
||||
|
||||
assert.ErrorIs(t, err, apperror.ErrMessageEmpty)
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_Execute_RouteNotFound(t *testing.T) {
|
||||
router := &testutil.FakeIntentRouter{Error: apperror.ErrRouteNotFound}
|
||||
dispatcher := &testutil.FakeWorkflowDispatcher{}
|
||||
sessions := &testutil.FakeSessionStore{}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
|
||||
uc := usecase.NewHandleTextMessage(router, dispatcher, sessions, gateway, newTestLogger())
|
||||
err := uc.Execute(context.Background(), validTextDTO())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, apperror.ErrRouteNotFound))
|
||||
assert.Equal(t, 0, dispatcher.CallCount)
|
||||
assert.NotEmpty(t, gateway.LastSentText) // error message sent to user
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_Execute_DispatchError(t *testing.T) {
|
||||
router := &testutil.FakeIntentRouter{
|
||||
RouteResult: entity.Route{IntentName: "general_query"},
|
||||
}
|
||||
dispatcher := &testutil.FakeWorkflowDispatcher{
|
||||
Error: errors.New("n8n unavailable"),
|
||||
}
|
||||
sessions := &testutil.FakeSessionStore{}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
|
||||
uc := usecase.NewHandleTextMessage(router, dispatcher, sessions, gateway, newTestLogger())
|
||||
err := uc.Execute(context.Background(), validTextDTO())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, dispatcher.CallCount)
|
||||
assert.NotEmpty(t, gateway.LastSentText) // fallback error message sent to user
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_Execute_SessionPersisted(t *testing.T) {
|
||||
router := &testutil.FakeIntentRouter{
|
||||
RouteResult: entity.Route{
|
||||
IntentName: "general_query",
|
||||
Target: entity.RouteTarget{WorkflowID: "wf-1"},
|
||||
},
|
||||
}
|
||||
dispatcher := &testutil.FakeWorkflowDispatcher{
|
||||
Response: entity.WorkflowResponse{ReplyText: "OK"},
|
||||
}
|
||||
sessions := &testutil.FakeSessionStore{}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
|
||||
uc := usecase.NewHandleTextMessage(router, dispatcher, sessions, gateway, newTestLogger())
|
||||
err := uc.Execute(context.Background(), validTextDTO())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sessions.SetCalled)
|
||||
assert.Equal(t, "wf-1", sessions.LastSetSession.CurrentWorkflow)
|
||||
}
|
||||
80
internal/application/usecase/handle_voice_message.go
Normal file
80
internal/application/usecase/handle_voice_message.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/paramah/gw_telegram/internal/application/dto"
|
||||
"github.com/paramah/gw_telegram/internal/domain/port"
|
||||
)
|
||||
|
||||
type HandleVoiceMessage struct {
|
||||
downloader port.FileDownloader
|
||||
converter AudioConverter
|
||||
transcriber port.SpeechTranscriber
|
||||
textHandler *HandleTextMessage
|
||||
gateway port.MessageGateway
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// AudioConverter converts audio between formats (OGG -> WAV etc.)
|
||||
type AudioConverter interface {
|
||||
Convert(ctx context.Context, input []byte, fromMime, toMime string) ([]byte, error)
|
||||
}
|
||||
|
||||
func NewHandleVoiceMessage(
|
||||
downloader port.FileDownloader,
|
||||
converter AudioConverter,
|
||||
transcriber port.SpeechTranscriber,
|
||||
textHandler *HandleTextMessage,
|
||||
gateway port.MessageGateway,
|
||||
logger *slog.Logger,
|
||||
) *HandleVoiceMessage {
|
||||
return &HandleVoiceMessage{
|
||||
downloader: downloader,
|
||||
converter: converter,
|
||||
transcriber: transcriber,
|
||||
textHandler: textHandler,
|
||||
gateway: gateway,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HandleVoiceMessage) Execute(ctx context.Context, in dto.IncomingMessageDTO) error {
|
||||
_ = h.gateway.SendTyping(ctx, in.ChatID)
|
||||
|
||||
audioBytes, mimeType, err := h.downloader.Download(ctx, in.VoiceFileID)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "voice download failed", "error", err, "file_id", in.VoiceFileID)
|
||||
_ = h.gateway.SendText(ctx, in.ChatID, "Sorry, I couldn't download the audio message. Please try again.")
|
||||
return fmt.Errorf("handle voice: download: %w", err)
|
||||
}
|
||||
|
||||
// Convert OGG Opus (Telegram default) to WAV for Whisper
|
||||
if mimeType == "audio/ogg" || mimeType == "" {
|
||||
wavBytes, err := h.converter.Convert(ctx, audioBytes, "audio/ogg", "audio/wav")
|
||||
if err != nil {
|
||||
h.logger.WarnContext(ctx, "audio conversion failed, trying raw", "error", err)
|
||||
// fall through with original bytes
|
||||
} else {
|
||||
audioBytes = wavBytes
|
||||
mimeType = "audio/wav"
|
||||
}
|
||||
}
|
||||
|
||||
transcript, err := h.transcriber.Transcribe(ctx, audioBytes, mimeType)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "transcription failed", "error", err)
|
||||
_ = h.gateway.SendText(ctx, in.ChatID, "Sorry, I couldn't understand the voice message. Please try sending text instead.")
|
||||
return fmt.Errorf("handle voice: transcribe: %w", err)
|
||||
}
|
||||
|
||||
h.logger.InfoContext(ctx, "voice transcribed", "user_id", in.UserID, "length", len(transcript))
|
||||
|
||||
textIn := in
|
||||
textIn.Text = transcript
|
||||
textIn.IsVoice = false
|
||||
|
||||
return h.textHandler.Execute(ctx, textIn)
|
||||
}
|
||||
99
internal/application/usecase/handle_voice_message_test.go
Normal file
99
internal/application/usecase/handle_voice_message_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/paramah/gw_telegram/internal/application/dto"
|
||||
"github.com/paramah/gw_telegram/internal/application/usecase"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
"github.com/paramah/gw_telegram/test/testutil"
|
||||
)
|
||||
|
||||
func validVoiceDTO() dto.IncomingMessageDTO {
|
||||
return dto.IncomingMessageDTO{
|
||||
MessageID: 2,
|
||||
ChatID: 100,
|
||||
UserID: 200,
|
||||
Username: "testuser",
|
||||
VoiceFileID: "file_abc123",
|
||||
IsVoice: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVoiceMessage_Execute_HappyPath(t *testing.T) {
|
||||
downloader := &testutil.FakeFileDownloader{
|
||||
Data: []byte("fake-ogg-audio"),
|
||||
MimeType: "audio/ogg",
|
||||
}
|
||||
converter := &testutil.FakeAudioConverter{Output: []byte("fake-wav-audio")}
|
||||
transcriber := &testutil.FakeSpeechTranscriber{Transcript: "I need help with my order"}
|
||||
router := &testutil.FakeIntentRouter{
|
||||
RouteResult: entity.Route{IntentName: "order_inquiry"},
|
||||
}
|
||||
dispatcher := &testutil.FakeWorkflowDispatcher{
|
||||
Response: entity.WorkflowResponse{ReplyText: "Order status: shipped"},
|
||||
}
|
||||
sessions := &testutil.FakeSessionStore{}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
|
||||
textUC := usecase.NewHandleTextMessage(router, dispatcher, sessions, gateway, newTestLogger())
|
||||
uc := usecase.NewHandleVoiceMessage(downloader, converter, transcriber, textUC, gateway, newTestLogger())
|
||||
|
||||
err := uc.Execute(context.Background(), validVoiceDTO())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, transcriber.CallCount)
|
||||
assert.Equal(t, 1, dispatcher.CallCount)
|
||||
assert.Equal(t, "I need help with my order", dispatcher.LastRequest.MessageText)
|
||||
assert.Equal(t, "Order status: shipped", gateway.LastSentText)
|
||||
}
|
||||
|
||||
func TestHandleVoiceMessage_Execute_DownloadFails(t *testing.T) {
|
||||
downloader := &testutil.FakeFileDownloader{Error: errors.New("download error")}
|
||||
transcriber := &testutil.FakeSpeechTranscriber{}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
|
||||
textUC := usecase.NewHandleTextMessage(
|
||||
&testutil.FakeIntentRouter{},
|
||||
&testutil.FakeWorkflowDispatcher{},
|
||||
&testutil.FakeSessionStore{},
|
||||
gateway,
|
||||
newTestLogger(),
|
||||
)
|
||||
uc := usecase.NewHandleVoiceMessage(downloader, &testutil.FakeAudioConverter{}, transcriber, textUC, gateway, newTestLogger())
|
||||
|
||||
err := uc.Execute(context.Background(), validVoiceDTO())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, transcriber.CallCount)
|
||||
assert.NotEmpty(t, gateway.LastSentText) // error message sent
|
||||
}
|
||||
|
||||
func TestHandleVoiceMessage_Execute_TranscriptionFails(t *testing.T) {
|
||||
downloader := &testutil.FakeFileDownloader{
|
||||
Data: []byte("audio"),
|
||||
MimeType: "audio/ogg",
|
||||
}
|
||||
transcriber := &testutil.FakeSpeechTranscriber{Error: errors.New("whisper error")}
|
||||
gateway := &testutil.FakeMessageGateway{}
|
||||
dispatcher := &testutil.FakeWorkflowDispatcher{}
|
||||
|
||||
textUC := usecase.NewHandleTextMessage(
|
||||
&testutil.FakeIntentRouter{},
|
||||
dispatcher,
|
||||
&testutil.FakeSessionStore{},
|
||||
gateway,
|
||||
newTestLogger(),
|
||||
)
|
||||
uc := usecase.NewHandleVoiceMessage(downloader, &testutil.FakeAudioConverter{}, transcriber, textUC, gateway, newTestLogger())
|
||||
|
||||
err := uc.Execute(context.Background(), validVoiceDTO())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, dispatcher.CallCount)
|
||||
assert.NotEmpty(t, gateway.LastSentText)
|
||||
}
|
||||
69
internal/config/config.go
Normal file
69
internal/config/config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Bot BotConfig
|
||||
N8n N8nConfig
|
||||
Speech SpeechConfig
|
||||
Redis RedisConfig
|
||||
Server ServerConfig
|
||||
Log LogConfig
|
||||
}
|
||||
|
||||
type BotConfig struct {
|
||||
Token string `envconfig:"TELEGRAM_BOT_TOKEN" required:"true"`
|
||||
Mode string `envconfig:"BOT_MODE" default:"polling"`
|
||||
WebhookURL string `envconfig:"TELEGRAM_WEBHOOK_URL"`
|
||||
Debug bool `envconfig:"TELEGRAM_DEBUG" default:"false"`
|
||||
}
|
||||
|
||||
type N8nConfig struct {
|
||||
BaseURL string `envconfig:"N8N_BASE_URL" required:"true"`
|
||||
AuthToken string `envconfig:"N8N_AUTH_TOKEN"`
|
||||
TimeoutSecs int `envconfig:"N8N_TIMEOUT" default:"30"`
|
||||
RetryCount int `envconfig:"N8N_RETRY_COUNT" default:"3"`
|
||||
}
|
||||
|
||||
type SpeechConfig struct {
|
||||
Provider string `envconfig:"STT_PROVIDER" default:"openai"`
|
||||
OpenAIKey string `envconfig:"OPENAI_API_KEY"`
|
||||
WhisperModel string `envconfig:"WHISPER_MODEL" default:"whisper-1"`
|
||||
Language string `envconfig:"WHISPER_LANGUAGE" default:""`
|
||||
FFmpegPath string `envconfig:"FFMPEG_PATH" default:"ffmpeg"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
URL string `envconfig:"REDIS_URL" default:"redis://localhost:6379"`
|
||||
TTLHours int `envconfig:"SESSION_TTL" default:"24"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `envconfig:"SERVER_PORT" default:"8080"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `envconfig:"LOG_LEVEL" default:"info"`
|
||||
Format string `envconfig:"LOG_FORMAT" default:"json"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
if err := envconfig.Process("", &cfg); err != nil {
|
||||
return nil, fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func MustLoad() *Config {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
25
internal/domain/apperror/errors.go
Normal file
25
internal/domain/apperror/errors.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package apperror
|
||||
|
||||
import "errors"
|
||||
|
||||
type AppError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
var (
|
||||
ErrMessageEmpty = &AppError{Code: "MSG_EMPTY", Message: "message text is empty"}
|
||||
ErrRouteNotFound = &AppError{Code: "ROUTE_NOT_FOUND", Message: "no route found for message"}
|
||||
ErrWorkflowTimeout = &AppError{Code: "WORKFLOW_TIMEOUT", Message: "workflow call timed out"}
|
||||
ErrTranscriptionFailed = &AppError{Code: "TRANSCRIPTION_FAILED", Message: "voice transcription failed"}
|
||||
ErrSessionNotFound = &AppError{Code: "SESSION_NOT_FOUND", Message: "session not found"}
|
||||
ErrDownloadFailed = &AppError{Code: "DOWNLOAD_FAILED", Message: "file download failed"}
|
||||
)
|
||||
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
28
internal/domain/entity/intent.go
Normal file
28
internal/domain/entity/intent.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package entity
|
||||
|
||||
type Intent struct {
|
||||
Name string
|
||||
Confidence float64
|
||||
Entities map[string]any
|
||||
}
|
||||
|
||||
type RouteTargetType string
|
||||
|
||||
const (
|
||||
RouteTargetN8n RouteTargetType = "n8n"
|
||||
RouteTargetAIAgent RouteTargetType = "ai_agent"
|
||||
RouteTargetBuiltin RouteTargetType = "builtin"
|
||||
)
|
||||
|
||||
type RouteTarget struct {
|
||||
Type RouteTargetType
|
||||
WorkflowID string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Pattern string
|
||||
IntentName string
|
||||
Target RouteTarget
|
||||
Priority int
|
||||
}
|
||||
29
internal/domain/entity/message.go
Normal file
29
internal/domain/entity/message.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeText MessageType = "text"
|
||||
MessageTypeVoice MessageType = "voice"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
MessageID int64
|
||||
ChatID int64
|
||||
UserID int64
|
||||
Username string
|
||||
Type MessageType
|
||||
Text string
|
||||
VoiceFileID string
|
||||
Timestamp time.Time
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type VoiceMessage struct {
|
||||
FileID string
|
||||
Duration int
|
||||
MimeType string
|
||||
FileSize int
|
||||
}
|
||||
22
internal/domain/entity/user.go
Normal file
22
internal/domain/entity/user.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
TelegramID int64
|
||||
Username string
|
||||
FirstName string
|
||||
LastName string
|
||||
Language string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
UserID int64
|
||||
ChatID int64
|
||||
CurrentWorkflow string
|
||||
History []Message
|
||||
Data map[string]any
|
||||
UpdatedAt time.Time
|
||||
TTL int // hours
|
||||
}
|
||||
29
internal/domain/entity/workflow.go
Normal file
29
internal/domain/entity/workflow.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type WorkflowRequest struct {
|
||||
RequestID string
|
||||
ChatID int64
|
||||
UserID int64
|
||||
Username string
|
||||
MessageText string
|
||||
Intent Intent
|
||||
Session Session
|
||||
Timestamp time.Time
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type string
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
type WorkflowResponse struct {
|
||||
RequestID string
|
||||
ReplyText string
|
||||
Actions []Action
|
||||
NextWorkflow string
|
||||
Error error
|
||||
}
|
||||
7
internal/domain/port/file_downloader.go
Normal file
7
internal/domain/port/file_downloader.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
type FileDownloader interface {
|
||||
Download(ctx context.Context, fileID string) ([]byte, string, error) // bytes, mimeType, error
|
||||
}
|
||||
10
internal/domain/port/intent_router.go
Normal file
10
internal/domain/port/intent_router.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
)
|
||||
|
||||
type IntentRouter interface {
|
||||
Route(ctx context.Context, msg entity.Message) (entity.Route, error)
|
||||
}
|
||||
8
internal/domain/port/message_gateway.go
Normal file
8
internal/domain/port/message_gateway.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
type MessageGateway interface {
|
||||
SendText(ctx context.Context, chatID int64, text string) error
|
||||
SendTyping(ctx context.Context, chatID int64) error
|
||||
}
|
||||
12
internal/domain/port/session_store.go
Normal file
12
internal/domain/port/session_store.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
)
|
||||
|
||||
type SessionStore interface {
|
||||
Get(ctx context.Context, userID int64) (entity.Session, error)
|
||||
Set(ctx context.Context, session entity.Session) error
|
||||
Delete(ctx context.Context, userID int64) error
|
||||
}
|
||||
7
internal/domain/port/speech_transcriber.go
Normal file
7
internal/domain/port/speech_transcriber.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
type SpeechTranscriber interface {
|
||||
Transcribe(ctx context.Context, audioData []byte, mimeType string) (string, error)
|
||||
}
|
||||
10
internal/domain/port/workflow_dispatcher.go
Normal file
10
internal/domain/port/workflow_dispatcher.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
)
|
||||
|
||||
type WorkflowDispatcher interface {
|
||||
Dispatch(ctx context.Context, req entity.WorkflowRequest) (entity.WorkflowResponse, error)
|
||||
}
|
||||
8
internal/infrastructure/n8n/payload.go
Normal file
8
internal/infrastructure/n8n/payload.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package n8n
|
||||
|
||||
// payload.go contains shared payload types for n8n webhook communication.
|
||||
// The primary types (n8nPayload, n8nResponse) are defined in webhook_dispatcher.go
|
||||
// to keep them co-located with their usage.
|
||||
//
|
||||
// This file is reserved for any additional payload structures needed
|
||||
// for extended n8n integration (e.g., batch requests, webhook registration).
|
||||
124
internal/infrastructure/n8n/webhook_dispatcher.go
Normal file
124
internal/infrastructure/n8n/webhook_dispatcher.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package n8n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
)
|
||||
|
||||
type WorkflowConfig struct {
|
||||
ID string
|
||||
WebhookURL string
|
||||
AuthToken string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type WebhookDispatcher struct {
|
||||
workflows map[string]WorkflowConfig
|
||||
client *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewWebhookDispatcher(workflows map[string]WorkflowConfig, logger *slog.Logger) *WebhookDispatcher {
|
||||
return &WebhookDispatcher{
|
||||
workflows: workflows,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type n8nPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
MessageText string `json:"message_text"`
|
||||
IntentName string `json:"intent_name"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type n8nResponse struct {
|
||||
Reply string `json:"reply"`
|
||||
Actions []entity.Action `json:"actions,omitempty"`
|
||||
NextWorkflow string `json:"next_workflow,omitempty"`
|
||||
}
|
||||
|
||||
func (d *WebhookDispatcher) Dispatch(ctx context.Context, req entity.WorkflowRequest) (entity.WorkflowResponse, error) {
|
||||
wf, ok := d.workflows[req.Intent.Name]
|
||||
if !ok {
|
||||
// Try default workflow
|
||||
wf, ok = d.workflows["default"]
|
||||
if !ok {
|
||||
return entity.WorkflowResponse{}, fmt.Errorf("no workflow configured for intent %q", req.Intent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
payload := n8nPayload{
|
||||
RequestID: req.RequestID,
|
||||
ChatID: req.ChatID,
|
||||
UserID: req.UserID,
|
||||
Username: req.Username,
|
||||
MessageText: req.MessageText,
|
||||
IntentName: req.Intent.Name,
|
||||
Timestamp: req.Timestamp,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return entity.WorkflowResponse{}, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, wf.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return entity.WorkflowResponse{}, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if wf.AuthToken != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+wf.AuthToken)
|
||||
}
|
||||
|
||||
client := d.client
|
||||
if wf.Timeout > 0 {
|
||||
client = &http.Client{Timeout: wf.Timeout}
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return entity.WorkflowResponse{}, fmt.Errorf("n8n webhook call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return entity.WorkflowResponse{}, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return entity.WorkflowResponse{}, fmt.Errorf("n8n returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var n8nResp n8nResponse
|
||||
if err := json.Unmarshal(respBody, &n8nResp); err != nil {
|
||||
// If not valid JSON, treat the raw body as reply text
|
||||
return entity.WorkflowResponse{
|
||||
RequestID: req.RequestID,
|
||||
ReplyText: string(respBody),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return entity.WorkflowResponse{
|
||||
RequestID: req.RequestID,
|
||||
ReplyText: n8nResp.Reply,
|
||||
Actions: n8nResp.Actions,
|
||||
NextWorkflow: n8nResp.NextWorkflow,
|
||||
}, nil
|
||||
}
|
||||
44
internal/infrastructure/router/rule_based_router.go
Normal file
44
internal/infrastructure/router/rule_based_router.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"github.com/paramah/gw_telegram/internal/domain/apperror"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Pattern *regexp.Regexp
|
||||
IntentName string
|
||||
Target entity.RouteTarget
|
||||
Priority int
|
||||
}
|
||||
|
||||
type RuleBasedRouter struct {
|
||||
rules []Rule
|
||||
}
|
||||
|
||||
func NewRuleBasedRouter(rules []Rule) *RuleBasedRouter {
|
||||
sorted := make([]Rule, len(rules))
|
||||
copy(sorted, rules)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Priority > sorted[j].Priority
|
||||
})
|
||||
return &RuleBasedRouter{rules: sorted}
|
||||
}
|
||||
|
||||
func (r *RuleBasedRouter) Route(_ context.Context, msg entity.Message) (entity.Route, error) {
|
||||
for _, rule := range r.rules {
|
||||
if rule.Pattern.MatchString(msg.Text) {
|
||||
return entity.Route{
|
||||
Pattern: rule.Pattern.String(),
|
||||
IntentName: rule.IntentName,
|
||||
Target: rule.Target,
|
||||
Priority: rule.Priority,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return entity.Route{}, apperror.ErrRouteNotFound
|
||||
}
|
||||
63
internal/infrastructure/speech/audio_converter.go
Normal file
63
internal/infrastructure/speech/audio_converter.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package speech
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FFmpegConverter struct {
|
||||
ffmpegPath string
|
||||
tempDir string
|
||||
}
|
||||
|
||||
func NewFFmpegConverter(ffmpegPath, tempDir string) *FFmpegConverter {
|
||||
if ffmpegPath == "" {
|
||||
ffmpegPath = "ffmpeg"
|
||||
}
|
||||
if tempDir == "" {
|
||||
tempDir = os.TempDir()
|
||||
}
|
||||
return &FFmpegConverter{ffmpegPath: ffmpegPath, tempDir: tempDir}
|
||||
}
|
||||
|
||||
func (c *FFmpegConverter) Convert(ctx context.Context, input []byte, fromMime, toMime string) ([]byte, error) {
|
||||
id := uuid.New().String()
|
||||
inFile := filepath.Join(c.tempDir, id+".input")
|
||||
outFile := filepath.Join(c.tempDir, id+".wav")
|
||||
|
||||
defer os.Remove(inFile)
|
||||
defer os.Remove(outFile)
|
||||
|
||||
if err := os.WriteFile(inFile, input, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write temp input: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, c.ffmpegPath,
|
||||
"-i", inFile,
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-f", "wav",
|
||||
"-y",
|
||||
outFile,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg conversion: %w: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read converted file: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
89
internal/infrastructure/speech/openai_whisper.go
Normal file
89
internal/infrastructure/speech/openai_whisper.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package speech
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const whisperAPIURL = "https://api.openai.com/v1/audio/transcriptions"
|
||||
|
||||
type OpenAIWhisper struct {
|
||||
apiKey string
|
||||
model string
|
||||
language string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewOpenAIWhisper(apiKey, model, language string) *OpenAIWhisper {
|
||||
return &OpenAIWhisper{
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
language: language,
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type whisperResponse struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (w *OpenAIWhisper) Transcribe(ctx context.Context, audioData []byte, mimeType string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
fw, err := mw.CreateFormFile("file", "audio.wav")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create form file: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(audioData); err != nil {
|
||||
return "", fmt.Errorf("write audio data: %w", err)
|
||||
}
|
||||
|
||||
if err := mw.WriteField("model", w.model); err != nil {
|
||||
return "", fmt.Errorf("write model field: %w", err)
|
||||
}
|
||||
if err := mw.WriteField("response_format", "json"); err != nil {
|
||||
return "", fmt.Errorf("write response_format: %w", err)
|
||||
}
|
||||
if w.language != "" {
|
||||
if err := mw.WriteField("language", w.language); err != nil {
|
||||
return "", fmt.Errorf("write language field: %w", err)
|
||||
}
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, whisperAPIURL, &buf)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create whisper request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+w.apiKey)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("whisper API call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read whisper response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("whisper API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result whisperResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse whisper response: %w", err)
|
||||
}
|
||||
|
||||
return result.Text, nil
|
||||
}
|
||||
62
internal/infrastructure/storage/redis_session_store.go
Normal file
62
internal/infrastructure/storage/redis_session_store.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/paramah/gw_telegram/internal/domain/apperror"
|
||||
"github.com/paramah/gw_telegram/internal/domain/entity"
|
||||
)
|
||||
|
||||
type RedisSessionStore struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewRedisSessionStore(client *redis.Client, ttlHours int) *RedisSessionStore {
|
||||
return &RedisSessionStore{
|
||||
client: client,
|
||||
ttl: time.Duration(ttlHours) * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func sessionKey(userID int64) string {
|
||||
return fmt.Sprintf("session:%d", userID)
|
||||
}
|
||||
|
||||
func (s *RedisSessionStore) Get(ctx context.Context, userID int64) (entity.Session, error) {
|
||||
data, err := s.client.Get(ctx, sessionKey(userID)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return entity.Session{}, apperror.ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return entity.Session{}, fmt.Errorf("redis get session: %w", err)
|
||||
}
|
||||
|
||||
var session entity.Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return entity.Session{}, fmt.Errorf("unmarshal session: %w", err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *RedisSessionStore) Set(ctx context.Context, session entity.Session) error {
|
||||
data, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal session: %w", err)
|
||||
}
|
||||
if err := s.client.Set(ctx, sessionKey(session.UserID), data, s.ttl).Err(); err != nil {
|
||||
return fmt.Errorf("redis set session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedisSessionStore) Delete(ctx context.Context, userID int64) error {
|
||||
if err := s.client.Del(ctx, sessionKey(userID)).Err(); err != nil {
|
||||
return fmt.Errorf("redis del session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
37
internal/infrastructure/telegram/bot_gateway.go
Normal file
37
internal/infrastructure/telegram/bot_gateway.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type BotGateway struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewBotGateway(bot *tgbotapi.BotAPI, logger *slog.Logger) *BotGateway {
|
||||
return &BotGateway{bot: bot, logger: logger}
|
||||
}
|
||||
|
||||
func (g *BotGateway) SendText(ctx context.Context, chatID int64, text string) error {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||
_, err := g.bot.Send(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("telegram send text: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BotGateway) SendTyping(ctx context.Context, chatID int64) error {
|
||||
action := tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)
|
||||
_, err := g.bot.Request(action)
|
||||
if err != nil {
|
||||
g.logger.WarnContext(ctx, "failed to send typing action", "error", err, "chat_id", chatID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
internal/infrastructure/telegram/file_downloader.go
Normal file
48
internal/infrastructure/telegram/file_downloader.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type TelegramFileDownloader struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewTelegramFileDownloader(bot *tgbotapi.BotAPI) *TelegramFileDownloader {
|
||||
return &TelegramFileDownloader{
|
||||
bot: bot,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *TelegramFileDownloader) Download(ctx context.Context, fileID string) ([]byte, string, error) {
|
||||
file, err := d.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get file info: %w", err)
|
||||
}
|
||||
|
||||
url := file.Link(d.bot.Token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read file body: %w", err)
|
||||
}
|
||||
|
||||
return data, "audio/ogg", nil
|
||||
}
|
||||
50
internal/infrastructure/telegram/update_poller.go
Normal file
50
internal/infrastructure/telegram/update_poller.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type UpdateHandler interface {
|
||||
Handle(ctx context.Context, update tgbotapi.Update)
|
||||
}
|
||||
|
||||
type UpdatePoller struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
handler UpdateHandler
|
||||
logger *slog.Logger
|
||||
timeout int
|
||||
}
|
||||
|
||||
func NewUpdatePoller(bot *tgbotapi.BotAPI, handler UpdateHandler, logger *slog.Logger) *UpdatePoller {
|
||||
return &UpdatePoller{
|
||||
bot: bot,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
timeout: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UpdatePoller) Start(ctx context.Context) error {
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = p.timeout
|
||||
|
||||
updates := p.bot.GetUpdatesChan(u)
|
||||
p.logger.InfoContext(ctx, "update poller started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.bot.StopReceivingUpdates()
|
||||
p.logger.InfoContext(ctx, "update poller stopped")
|
||||
return ctx.Err()
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
go p.handler.Handle(ctx, update)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
internal/interfaces/health_handler.go
Normal file
27
internal/interfaces/health_handler.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HealthHandler struct{}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(healthResponse{
|
||||
Status: "ok",
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
74
internal/interfaces/telegram_handler.go
Normal file
74
internal/interfaces/telegram_handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/paramah/gw_telegram/internal/application/dto"
|
||||
"github.com/paramah/gw_telegram/internal/application/usecase"
|
||||
)
|
||||
|
||||
type TelegramHandler struct {
|
||||
textUC *usecase.HandleTextMessage
|
||||
voiceUC *usecase.HandleVoiceMessage
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewTelegramHandler(
|
||||
textUC *usecase.HandleTextMessage,
|
||||
voiceUC *usecase.HandleVoiceMessage,
|
||||
logger *slog.Logger,
|
||||
) *TelegramHandler {
|
||||
return &TelegramHandler{textUC: textUC, voiceUC: voiceUC, logger: logger}
|
||||
}
|
||||
|
||||
func (h *TelegramHandler) Handle(ctx context.Context, update tgbotapi.Update) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
h.logger.ErrorContext(ctx, "panic in update handler", "panic", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := update.Message
|
||||
|
||||
switch {
|
||||
case msg.Voice != nil:
|
||||
in := dto.IncomingMessageDTO{
|
||||
MessageID: int64(msg.MessageID),
|
||||
ChatID: msg.Chat.ID,
|
||||
UserID: msg.From.ID,
|
||||
Username: msg.From.UserName,
|
||||
FirstName: msg.From.FirstName,
|
||||
LastName: msg.From.LastName,
|
||||
VoiceFileID: msg.Voice.FileID,
|
||||
IsVoice: true,
|
||||
Timestamp: time.Unix(int64(msg.Date), 0),
|
||||
Language: msg.From.LanguageCode,
|
||||
}
|
||||
if err := h.voiceUC.Execute(ctx, in); err != nil {
|
||||
h.logger.ErrorContext(ctx, "voice handler error", "error", err, "user_id", msg.From.ID)
|
||||
}
|
||||
|
||||
case msg.Text != "":
|
||||
in := dto.IncomingMessageDTO{
|
||||
MessageID: int64(msg.MessageID),
|
||||
ChatID: msg.Chat.ID,
|
||||
UserID: msg.From.ID,
|
||||
Username: msg.From.UserName,
|
||||
FirstName: msg.From.FirstName,
|
||||
LastName: msg.From.LastName,
|
||||
Text: msg.Text,
|
||||
Timestamp: time.Unix(int64(msg.Date), 0),
|
||||
Language: msg.From.LanguageCode,
|
||||
}
|
||||
if err := h.textUC.Execute(ctx, in); err != nil {
|
||||
h.logger.ErrorContext(ctx, "text handler error", "error", err, "user_id", msg.From.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user