feat: initial commit

This commit is contained in:
2026-04-16 19:39:02 +02:00
commit 50d1686b1e
44 changed files with 2089 additions and 0 deletions

View 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
}

View File

@@ -0,0 +1,7 @@
package dto
type WorkflowResultDTO struct {
RequestID string
ReplyText string
Error error
}

View 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
}

View 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)
}

View 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)
}

View 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)
}