diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore deleted file mode 100644 index 1a3da33..0000000 --- a/.codegraph/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log -*.pid - -# Hook markers -.dirty diff --git a/.gitignore b/.gitignore index d685cdb..f44aeef 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ docker/traefik/acme.json docker/traefik/.env # my personal compose -docker/docker-compose-local.yaml \ No newline at end of file +docker/docker-compose-local.yaml + +# codegraph +.codegraph diff --git a/cmd/service/main.go b/cmd/service/main.go index e759be5..b3d2a5b 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -7,13 +7,16 @@ import ( "noirbot/internal/domain/repository" "noirbot/internal/domain/service" "noirbot/internal/gateways/deepseek" + "noirbot/internal/gateways/groq" httpgw "noirbot/internal/gateways/http" "noirbot/internal/gateways/memory" redisstore "noirbot/internal/gateways/redis" + "noirbot/internal/gateways/telegram/files" "noirbot/internal/gateways/telegram/inbound" "noirbot/internal/gateways/telegram/outbound" - "noirbot/internal/usecase/handle_business_connection" - "noirbot/internal/usecase/handle_business_message" + "noirbot/internal/usecase/businessconn" + "noirbot/internal/usecase/businessmsg" + "noirbot/internal/usecase/longvoice" "noirbot/pkg/config" "os" @@ -32,6 +35,7 @@ func main() { newGreetingDetector, newFloodDetector, newShortVoiceDetector, + newLongVoiceDetector, newVoiceReplyWindowStore, newOwnerWhitelist, @@ -44,11 +48,17 @@ func main() { newDeepseekConfig, newLLMClient, + newGroqConfig, + newTranscriber, + newVoiceDownloader, + newHandleLongVoiceConfig, + longvoice.New, + newRedisClient, newHandleBusinessMessageConfig, - handle_business_connection.New, - handle_business_message.New, + businessconn.New, + businessmsg.New, inbound.NewLazyHandler, inbound.NewUpdateMapper, @@ -168,8 +178,8 @@ func newLLMClient(c deepseek.Config) repository.LLMClient { return deepseek.NewClient(c) } -func newHandleBusinessMessageConfig(cfg *config.Config) handle_business_message.Config { - return handle_business_message.Config{ +func newHandleBusinessMessageConfig(cfg *config.Config) businessmsg.Config { + return businessmsg.Config{ SystemPrompt: cfg.Bot.SystemPrompt, ShortVoicePrompt: cfg.Bot.ShortVoicePrompt, ShortVoiceResponseWindow: cfg.ShortVoice.ResponseWindow, @@ -189,3 +199,34 @@ func newRedisClient(cfg *config.Config) *redis.Client { return rdb } + +func newLongVoiceDetector(cfg *config.Config) *service.LongVoiceDetector { + return service.NewLongVoiceDetector( + service.LongVoiceDetectorConfig{ + MaxDuration: cfg.LongVoice.MaxDuration, + }, + ) +} + +func newGroqConfig(cfg *config.Config) groq.Config { + return groq.Config{ + BaseURL: cfg.Groq.BaseURL, + APIKey: cfg.Groq.APIKey, + Model: cfg.Groq.Model, + Timeout: cfg.Groq.Timeout, + } +} + +func newTranscriber(cfg groq.Config) repository.Transcriber { + return groq.NewClient(cfg) +} + +func newVoiceDownloader(b *bot.Bot, cfg *config.Config) repository.VoiceDownloader { + return files.NewDownloader(b, cfg.Telegram.FileDownloadTimeout) +} + +func newHandleLongVoiceConfig(cfg *config.Config) longvoice.Config { + return longvoice.Config{ + LongVoicePrompt: cfg.Bot.LongVoicePrompt, + } +} diff --git a/internal/domain/model/message.go b/internal/domain/model/message.go index ee6f460..0665f39 100644 --- a/internal/domain/model/message.go +++ b/internal/domain/model/message.go @@ -17,6 +17,7 @@ type IncomingMessage struct { Text string VoiceDuration time.Duration ReceivedAt time.Time + VoiceFileID string } type ReplyDraft struct { diff --git a/internal/domain/model/trigger.go b/internal/domain/model/trigger.go index 4746aab..c0193fa 100644 --- a/internal/domain/model/trigger.go +++ b/internal/domain/model/trigger.go @@ -7,6 +7,7 @@ const ( TriggerKindGreeting TriggerKind = "greeting" TriggerKindFlood TriggerKind = "flood" TriggerKindShortVoice TriggerKind = "short_voice" + TriggerKindLongVoice TriggerKind = "long_voice" ) type TriggerDecision struct { diff --git a/internal/domain/repository/mock/transcriber.go b/internal/domain/repository/mock/transcriber.go new file mode 100644 index 0000000..003319c --- /dev/null +++ b/internal/domain/repository/mock/transcriber.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: transcriber.go +// +// Generated by this command: +// +// mockgen -source=transcriber.go -destination=mock/transcriber.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockTranscriber is a mock of Transcriber interface. +type MockTranscriber struct { + ctrl *gomock.Controller + recorder *MockTranscriberMockRecorder + isgomock struct{} +} + +// MockTranscriberMockRecorder is the mock recorder for MockTranscriber. +type MockTranscriberMockRecorder struct { + mock *MockTranscriber +} + +// NewMockTranscriber creates a new mock instance. +func NewMockTranscriber(ctrl *gomock.Controller) *MockTranscriber { + mock := &MockTranscriber{ctrl: ctrl} + mock.recorder = &MockTranscriberMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTranscriber) EXPECT() *MockTranscriberMockRecorder { + return m.recorder +} + +// Transcribe mocks base method. +func (m *MockTranscriber) Transcribe(ctx context.Context, reader io.ReadCloser) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Transcribe", ctx, reader) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Transcribe indicates an expected call of Transcribe. +func (mr *MockTranscriberMockRecorder) Transcribe(ctx, reader any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transcribe", reflect.TypeOf((*MockTranscriber)(nil).Transcribe), ctx, reader) +} diff --git a/internal/domain/repository/mock/voice_downloader.go b/internal/domain/repository/mock/voice_downloader.go new file mode 100644 index 0000000..6c72fae --- /dev/null +++ b/internal/domain/repository/mock/voice_downloader.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: voice_downloader.go +// +// Generated by this command: +// +// mockgen -source=voice_downloader.go -destination=mock/voice_downloader.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockVoiceDownloader is a mock of VoiceDownloader interface. +type MockVoiceDownloader struct { + ctrl *gomock.Controller + recorder *MockVoiceDownloaderMockRecorder + isgomock struct{} +} + +// MockVoiceDownloaderMockRecorder is the mock recorder for MockVoiceDownloader. +type MockVoiceDownloaderMockRecorder struct { + mock *MockVoiceDownloader +} + +// NewMockVoiceDownloader creates a new mock instance. +func NewMockVoiceDownloader(ctrl *gomock.Controller) *MockVoiceDownloader { + mock := &MockVoiceDownloader{ctrl: ctrl} + mock.recorder = &MockVoiceDownloaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVoiceDownloader) EXPECT() *MockVoiceDownloaderMockRecorder { + return m.recorder +} + +// Download mocks base method. +func (m *MockVoiceDownloader) Download(ctx context.Context, fileID string) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Download", ctx, fileID) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Download indicates an expected call of Download. +func (mr *MockVoiceDownloaderMockRecorder) Download(ctx, fileID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Download", reflect.TypeOf((*MockVoiceDownloader)(nil).Download), ctx, fileID) +} diff --git a/internal/domain/repository/transcriber.go b/internal/domain/repository/transcriber.go new file mode 100644 index 0000000..95dc36d --- /dev/null +++ b/internal/domain/repository/transcriber.go @@ -0,0 +1,12 @@ +package repository + +import ( + "context" + "io" +) + +//go:generate go tool mockgen -source=$GOFILE -destination=mock/$GOFILE -package=mock + +type Transcriber interface { + Transcribe(ctx context.Context, reader io.ReadCloser) (string, error) +} diff --git a/internal/domain/repository/voice_downloader.go b/internal/domain/repository/voice_downloader.go new file mode 100644 index 0000000..934220f --- /dev/null +++ b/internal/domain/repository/voice_downloader.go @@ -0,0 +1,12 @@ +package repository + +import ( + "context" + "io" +) + +//go:generate go tool mockgen -source=$GOFILE -destination=mock/$GOFILE -package=mock + +type VoiceDownloader interface { + Download(ctx context.Context, fileID string) (io.ReadCloser, error) +} diff --git a/internal/domain/service/long_voice_detector.go b/internal/domain/service/long_voice_detector.go new file mode 100644 index 0000000..2f4fbb1 --- /dev/null +++ b/internal/domain/service/long_voice_detector.go @@ -0,0 +1,38 @@ +package service + +import ( + "fmt" + "noirbot/internal/domain/model" + "time" +) + +type LongVoiceDetectorConfig struct { + MinDuration time.Duration + MaxDuration time.Duration +} + +type LongVoiceDetector struct { + cfg LongVoiceDetectorConfig +} + +func NewLongVoiceDetector(cfg LongVoiceDetectorConfig) *LongVoiceDetector { + return &LongVoiceDetector{ + cfg: cfg, + } +} + +func (d *LongVoiceDetector) Detect(msg model.IncomingMessage) model.TriggerDecision { + switch { + case msg.Kind != model.MessageKindVoice: + return model.TriggerDecision{Kind: model.TriggerKindNone} + case msg.VoiceDuration <= d.cfg.MinDuration: + return model.TriggerDecision{Kind: model.TriggerKindNone} + case msg.VoiceDuration > d.cfg.MaxDuration: + return model.TriggerDecision{Kind: model.TriggerKindNone} + default: + return model.TriggerDecision{ + Kind: model.TriggerKindLongVoice, + Reason: fmt.Sprintf("voice %s > %s <= %s", msg.VoiceDuration.String(), d.cfg.MinDuration.String(), d.cfg.MaxDuration.String()), + } + } +} diff --git a/internal/domain/service/long_voice_detector_test.go b/internal/domain/service/long_voice_detector_test.go new file mode 100644 index 0000000..c5983fa --- /dev/null +++ b/internal/domain/service/long_voice_detector_test.go @@ -0,0 +1,89 @@ +package service_test + +import ( + "noirbot/internal/domain/model" + "noirbot/internal/domain/service" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestLongVoiceDetector_Detect(t *testing.T) { + const ( + minDuration = 10 * time.Second + maxDuration = 600 * time.Second + ) + + detector := service.NewLongVoiceDetector(service.LongVoiceDetectorConfig{ + MinDuration: minDuration, + MaxDuration: maxDuration, + }) + + tests := []struct { + name string + msg model.IncomingMessage + wantKind model.TriggerKind + }{ + { + name: "voice в диапазоне — триггер long_voice", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: 30 * time.Second, + }, + wantKind: model.TriggerKindLongVoice, + }, + { + name: "voice ровно на верхней границе — триггер long_voice", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: maxDuration, + }, + wantKind: model.TriggerKindLongVoice, + }, + { + name: "voice на нижней границе short — без триггера", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: minDuration, + }, + wantKind: model.TriggerKindNone, + }, + { + name: "voice короче min — без триггера", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: 5 * time.Second, + }, + wantKind: model.TriggerKindNone, + }, + { + name: "voice длиннее max — без триггера", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: maxDuration + time.Second, + }, + wantKind: model.TriggerKindNone, + }, + { + name: "текстовое сообщение — без триггера", + msg: model.IncomingMessage{ + Kind: model.MessageKindText, + Text: "привет", + }, + wantKind: model.TriggerKindNone, + }, + { + name: "пустой Kind — без триггера", + msg: model.IncomingMessage{}, + wantKind: model.TriggerKindNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detector.Detect(tt.msg) + require.Equal(t, tt.wantKind, got.Kind) + }) + } +} diff --git a/internal/gateways/groq/client.go b/internal/gateways/groq/client.go new file mode 100644 index 0000000..e656031 --- /dev/null +++ b/internal/gateways/groq/client.go @@ -0,0 +1,97 @@ +package groq + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" +) + +type Config struct { + BaseURL string + APIKey string + Model string + Timeout time.Duration +} + +type Client struct { + cfg Config + http *http.Client +} + +func NewClient(cfg Config) *Client { + return &Client{ + cfg: cfg, + http: &http.Client{ + Timeout: cfg.Timeout, + }, + } +} + +func (c *Client) Transcribe(ctx context.Context, reader io.ReadCloser) (string, error) { + defer func() { _ = reader.Close() }() + + var buf bytes.Buffer + + w := multipart.NewWriter(&buf) + + part, err := w.CreateFormFile("file", "audio.ogg") + if err != nil { + return "", fmt.Errorf("groq transcribe: create form file err: %w", err) + } + + if _, copyErr := io.Copy(part, reader); copyErr != nil { + return "", fmt.Errorf("groq transcribe: copy file err: %w", copyErr) + } + + if wfErr := w.WriteField("model", c.cfg.Model); wfErr != nil { + return "", fmt.Errorf("groq transcribe: write field err: %w", wfErr) + } + + if clsErr := w.Close(); clsErr != nil { + return "", fmt.Errorf("groq transcribe: close multipart writer: %w", clsErr) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.cfg.BaseURL, + &buf, + ) + if err != nil { + return "", fmt.Errorf("groq transcribe: create request err: %w", err) + } + + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.cfg.APIKey)) + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("groq transcribe: http request err: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + + return "", fmt.Errorf( + "groq transcribe: %w, status: %d, body: %s", + ErrUnexpectedStatus, + resp.StatusCode, + bodyBytes, + ) + } + + var result transcribeResponse + + if ndErr := json.NewDecoder(resp.Body).Decode(&result); ndErr != nil { + return "", fmt.Errorf("groq transcribe: decode response err: %w", ndErr) + } + + return result.Text, nil +} diff --git a/internal/gateways/groq/client_test.go b/internal/gateways/groq/client_test.go new file mode 100644 index 0000000..e5b83ba --- /dev/null +++ b/internal/gateways/groq/client_test.go @@ -0,0 +1,148 @@ +package groq_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "noirbot/internal/gateways/groq" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testModel = "whisper-large-v3" + testAPIKey = "secret-key" + testAudio = "fake-audio-bytes" + wantTranscr = "детектив, мне нужна помощь" // лол, тесты писала нейронка, кста +) + +// closeSpyReader tracks whether Close was called. +type closeSpyReader struct { + io.Reader + closed bool + mu sync.Mutex +} + +func (r *closeSpyReader) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + r.closed = true + + return nil +} + +func (r *closeSpyReader) wasClosed() bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.closed +} + +func newClient(url string) *groq.Client { + return groq.NewClient(groq.Config{ + BaseURL: url, + APIKey: testAPIKey, + Model: testModel, + Timeout: 5 * time.Second, + }) +} + +func TestClient_Transcribe_HappyPath(t *testing.T) { + var ( + gotAuth string + gotModel string + gotFileName string + gotFileBytes []byte + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + + if !assert.NoError(t, r.ParseMultipartForm(1<<20)) { + return + } + + gotModel = r.FormValue("model") + + file, hdr, err := r.FormFile("file") + if !assert.NoError(t, err) { + return + } + + defer func() { _ = file.Close() }() + + gotFileName = hdr.Filename + gotFileBytes, err = io.ReadAll(file) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"text":"` + wantTranscr + `"}`)) + })) + defer srv.Close() + + reader := &closeSpyReader{Reader: strings.NewReader(testAudio)} + + text, err := newClient(srv.URL).Transcribe(context.Background(), reader) + + require.NoError(t, err) + require.Equal(t, wantTranscr, text) + require.Equal(t, "Bearer "+testAPIKey, gotAuth) + require.Equal(t, testModel, gotModel) + require.Equal(t, "audio.ogg", gotFileName) + require.Equal(t, testAudio, string(gotFileBytes)) + require.True(t, reader.wasClosed(), "reader must be closed") +} + +func TestClient_Transcribe_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`invalid api key`)) + })) + defer srv.Close() + + reader := &closeSpyReader{Reader: strings.NewReader(testAudio)} + + text, err := newClient(srv.URL).Transcribe(context.Background(), reader) + + require.ErrorIs(t, err, groq.ErrUnexpectedStatus) + require.Empty(t, text) + require.Contains(t, err.Error(), "invalid api key") + require.Contains(t, err.Error(), "401") + require.True(t, reader.wasClosed(), "reader must be closed even on error") +} + +func TestClient_Transcribe_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{not-json`)) + })) + defer srv.Close() + + reader := &closeSpyReader{Reader: strings.NewReader(testAudio)} + + text, err := newClient(srv.URL).Transcribe(context.Background(), reader) + + require.Error(t, err) + require.Empty(t, text) + require.Contains(t, err.Error(), "decode") +} + +func TestClient_Transcribe_RequestError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() // server down → connection refused + + reader := &closeSpyReader{Reader: strings.NewReader(testAudio)} + + text, err := newClient(srv.URL).Transcribe(context.Background(), reader) + + require.Error(t, err) + require.Empty(t, text) + require.True(t, reader.wasClosed(), "reader must be closed even on transport error") +} diff --git a/internal/gateways/groq/dto.go b/internal/gateways/groq/dto.go new file mode 100644 index 0000000..986e0ee --- /dev/null +++ b/internal/gateways/groq/dto.go @@ -0,0 +1,5 @@ +package groq + +type transcribeResponse struct { + Text string `json:"text"` +} diff --git a/internal/gateways/groq/errors.go b/internal/gateways/groq/errors.go new file mode 100644 index 0000000..6da57ad --- /dev/null +++ b/internal/gateways/groq/errors.go @@ -0,0 +1,5 @@ +package groq + +import "errors" + +var ErrUnexpectedStatus = errors.New("groq: unexpected status") diff --git a/internal/gateways/telegram/files/downloader.go b/internal/gateways/telegram/files/downloader.go new file mode 100644 index 0000000..f6bba3f --- /dev/null +++ b/internal/gateways/telegram/files/downloader.go @@ -0,0 +1,61 @@ +package files + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type fileGetter interface { + GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error) + FileDownloadLink(f *models.File) string +} +type Downloader struct { + client fileGetter + httpClient *http.Client +} + +func NewDownloader(b *bot.Bot, timeout time.Duration) *Downloader { + return &Downloader{ + client: b, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +func (d *Downloader) Download(ctx context.Context, fileID string) (io.ReadCloser, error) { + f, err := d.client.GetFile(ctx, &bot.GetFileParams{FileID: fileID}) + if err != nil { + return nil, fmt.Errorf("tg downloader: get file %s: %w", fileID, err) + } + + l := d.client.FileDownloadLink(f) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, l, http.NoBody) + if err != nil { + return nil, fmt.Errorf("tg downloader: new request: %w", err) + } + + resp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("tg downloader: do request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + + return nil, fmt.Errorf( + "tg downloader: %w, status: %s", + ErrUnexpectedStatus, + resp.Status, + ) + } + + return resp.Body, nil +} diff --git a/internal/gateways/telegram/files/downloader_test.go b/internal/gateways/telegram/files/downloader_test.go new file mode 100644 index 0000000..6b33eb5 --- /dev/null +++ b/internal/gateways/telegram/files/downloader_test.go @@ -0,0 +1,100 @@ +package files + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + "github.com/stretchr/testify/require" +) + +var errGetFileStub = errors.New("get file boom") + +// mockFileGetter подменяет *bot.Bot: GetFile + FileDownloadLink. +type mockFileGetter struct { + file *models.File + getErr error + link string +} + +func (m mockFileGetter) GetFile(_ context.Context, _ *bot.GetFileParams) (*models.File, error) { + return m.file, m.getErr +} + +func (m mockFileGetter) FileDownloadLink(_ *models.File) string { + return m.link +} + +func newDownloader(getter fileGetter, timeout time.Duration) *Downloader { + return &Downloader{ + client: getter, + httpClient: &http.Client{Timeout: timeout}, + } +} + +func TestDownloader_Download_HappyPath(t *testing.T) { + const body = "audio-bytes" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + getter := mockFileGetter{file: &models.File{FileID: "f1"}, link: srv.URL} + + rc, err := newDownloader(getter, 5*time.Second).Download(context.Background(), "f1") + + require.NoError(t, err) + require.NotNil(t, rc) + + defer func() { _ = rc.Close() }() + + got, err := io.ReadAll(rc) + require.NoError(t, err) + require.Equal(t, body, string(got)) +} + +func TestDownloader_Download_GetFileError(t *testing.T) { + getter := mockFileGetter{getErr: errGetFileStub} + + rc, err := newDownloader(getter, 5*time.Second).Download(context.Background(), "f1") + + require.ErrorIs(t, err, errGetFileStub) + require.Nil(t, rc) +} + +func TestDownloader_Download_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + getter := mockFileGetter{file: &models.File{FileID: "f1"}, link: srv.URL} + + rc, err := newDownloader(getter, 5*time.Second).Download(context.Background(), "f1") + + require.ErrorIs(t, err, ErrUnexpectedStatus) + require.Nil(t, rc) +} + +func TestDownloader_Download_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(200 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + getter := mockFileGetter{file: &models.File{FileID: "f1"}, link: srv.URL} + + rc, err := newDownloader(getter, 50*time.Millisecond).Download(context.Background(), "f1") + + require.Error(t, err) + require.Nil(t, rc) +} diff --git a/internal/gateways/telegram/files/errors.go b/internal/gateways/telegram/files/errors.go new file mode 100644 index 0000000..d34c5be --- /dev/null +++ b/internal/gateways/telegram/files/errors.go @@ -0,0 +1,5 @@ +package files + +import "errors" + +var ErrUnexpectedStatus = errors.New("tg downloader: unexpected status") diff --git a/internal/gateways/telegram/inbound/mapper.go b/internal/gateways/telegram/inbound/mapper.go index 20c6ec0..9030753 100644 --- a/internal/gateways/telegram/inbound/mapper.go +++ b/internal/gateways/telegram/inbound/mapper.go @@ -33,6 +33,7 @@ func (m *UpdateMapper) ToIncomingMessage(src *tgmodels.Message) (model.IncomingM case src.Voice != nil: base.Kind = model.MessageKindVoice base.VoiceDuration = time.Duration(src.Voice.Duration) * time.Second + base.VoiceFileID = src.Voice.FileID return base, true default: diff --git a/internal/gateways/telegram/inbound/mapper_test.go b/internal/gateways/telegram/inbound/mapper_test.go index c6476c1..4a53c1c 100644 --- a/internal/gateways/telegram/inbound/mapper_test.go +++ b/internal/gateways/telegram/inbound/mapper_test.go @@ -15,12 +15,13 @@ func TestUpdateMapper_ToIncomingMessage(t *testing.T) { from := &tgmodels.User{ID: 999} tests := []struct { - name string - src *tgmodels.Message - wantOK bool - wantKind model.MessageKind - wantText string - wantDur time.Duration + name string + src *tgmodels.Message + wantOK bool + wantKind model.MessageKind + wantText string + wantDur time.Duration + wantFileID string }{ { name: "text сообщение → MessageKindText", @@ -34,15 +35,16 @@ func TestUpdateMapper_ToIncomingMessage(t *testing.T) { wantText: "привет", }, { - name: "voice сообщение → MessageKindVoice + длительность", + name: "voice сообщение → MessageKindVoice + длительность + file id", src: &tgmodels.Message{ BusinessConnectionID: "conn-1", From: from, - Voice: &tgmodels.Voice{Duration: 7}, + Voice: &tgmodels.Voice{Duration: 7, FileID: "voice-abc"}, }, - wantOK: true, - wantKind: model.MessageKindVoice, - wantDur: 7 * time.Second, + wantOK: true, + wantKind: model.MessageKindVoice, + wantDur: 7 * time.Second, + wantFileID: "voice-abc", }, { name: "audio file (не voice) → пропускаем как неподдерживаемый тип", @@ -83,6 +85,7 @@ func TestUpdateMapper_ToIncomingMessage(t *testing.T) { require.Equal(t, tt.wantKind, got.Kind) require.Equal(t, tt.wantText, got.Text) require.Equal(t, tt.wantDur, got.VoiceDuration) + require.Equal(t, tt.wantFileID, got.VoiceFileID) }) } } diff --git a/internal/gateways/telegram/inbound/update_router.go b/internal/gateways/telegram/inbound/update_router.go index 59f991a..97f5786 100644 --- a/internal/gateways/telegram/inbound/update_router.go +++ b/internal/gateways/telegram/inbound/update_router.go @@ -3,23 +3,23 @@ package inbound import ( "context" "log/slog" - "noirbot/internal/usecase/handle_business_connection" - "noirbot/internal/usecase/handle_business_message" + "noirbot/internal/usecase/businessconn" + "noirbot/internal/usecase/businessmsg" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" ) type UpdateRouter struct { - connUC *handle_business_connection.Usecase - msgUC *handle_business_message.Usecase + connUC *businessconn.Usecase + msgUC *businessmsg.Usecase mapper *UpdateMapper log *slog.Logger } func NewUpdateRouter( - connUC *handle_business_connection.Usecase, - msgUC *handle_business_message.Usecase, + connUC *businessconn.Usecase, + msgUC *businessmsg.Usecase, m *UpdateMapper, log *slog.Logger, ) *UpdateRouter { @@ -36,7 +36,7 @@ func (r *UpdateRouter) Handle(ctx context.Context, _ *bot.Bot, update *models.Up case update.BusinessConnection != nil: conn := r.mapper.ToBusinessConnection(update.BusinessConnection) if err := r.connUC.Execute(ctx, conn); err != nil { - r.log.WarnContext(ctx, "handle_business_connection failed", "err", err) + r.log.WarnContext(ctx, "businessconn failed", "err", err) } case update.BusinessMessage != nil: msg, ok := r.mapper.ToIncomingMessage(update.BusinessMessage) @@ -47,7 +47,7 @@ func (r *UpdateRouter) Handle(ctx context.Context, _ *bot.Bot, update *models.Up } if err := r.msgUC.Execute(ctx, msg); err != nil { - r.log.ErrorContext(ctx, "handle_business_message failed", + r.log.ErrorContext(ctx, "businessmsg failed", "err", err, "guest_id", msg.GuestID, "conn_id", msg.BusinessConnectionID, diff --git a/internal/usecase/handle_business_connection/usecase.go b/internal/usecase/businessconn/usecase.go similarity index 91% rename from internal/usecase/handle_business_connection/usecase.go rename to internal/usecase/businessconn/usecase.go index 700d582..981f6ea 100644 --- a/internal/usecase/handle_business_connection/usecase.go +++ b/internal/usecase/businessconn/usecase.go @@ -1,4 +1,4 @@ -package handle_business_connection +package businessconn import ( "context" @@ -16,7 +16,7 @@ type Usecase struct { func New(store repository.BusinessConnectionStore, log *slog.Logger) *Usecase { return &Usecase{ store: store, - log: log.With("usecase", "handle_business_connection"), + log: log.With("usecase", "businessconn"), } } diff --git a/internal/usecase/handle_business_message/errors.go b/internal/usecase/businessmsg/errors.go similarity index 91% rename from internal/usecase/handle_business_message/errors.go rename to internal/usecase/businessmsg/errors.go index 5e99be6..99c7ff4 100644 --- a/internal/usecase/handle_business_message/errors.go +++ b/internal/usecase/businessmsg/errors.go @@ -1,4 +1,4 @@ -package handle_business_message +package businessmsg import "errors" diff --git a/internal/usecase/handle_business_message/usecase.go b/internal/usecase/businessmsg/usecase.go similarity index 81% rename from internal/usecase/handle_business_message/usecase.go rename to internal/usecase/businessmsg/usecase.go index 6238616..6c5075e 100644 --- a/internal/usecase/handle_business_message/usecase.go +++ b/internal/usecase/businessmsg/usecase.go @@ -1,4 +1,4 @@ -package handle_business_message +package businessmsg import ( "context" @@ -10,6 +10,10 @@ import ( "time" ) +type LongVoiceHandler interface { + Execute(ctx context.Context, msg model.IncomingMessage) error +} + type Config struct { SystemPrompt string ShortVoicePrompt string @@ -24,9 +28,11 @@ type Usecase struct { greetingDetector *service.GreetingDetector floodDetector *service.FloodDetector shortVoiceDetector *service.ShortVoiceDetector + longVoiceDetector *service.LongVoiceDetector voiceWindow repository.VoiceReplyWindowStore llm repository.LLMClient sender repository.BusinessSender + longVoiceUC LongVoiceHandler log *slog.Logger } @@ -43,9 +49,11 @@ func New( greetingDetector *service.GreetingDetector, floodDetector *service.FloodDetector, shortVoiceDetector *service.ShortVoiceDetector, + longVoiceDetector *service.LongVoiceDetector, voiceWindow repository.VoiceReplyWindowStore, llm repository.LLMClient, sender repository.BusinessSender, + longVoiceUC LongVoiceHandler, log *slog.Logger, ) *Usecase { return &Usecase{ @@ -56,10 +64,12 @@ func New( greetingDetector: greetingDetector, floodDetector: floodDetector, shortVoiceDetector: shortVoiceDetector, + longVoiceDetector: longVoiceDetector, voiceWindow: voiceWindow, llm: llm, sender: sender, - log: log.With("usecase", "handle_business_message"), + longVoiceUC: longVoiceUC, + log: log.With("usecase", "businessmsg"), } } @@ -93,8 +103,10 @@ func (uc *Usecase) Execute(ctx context.Context, msg model.IncomingMessage) error return nil } - shortVoice := decision.Kind == model.TriggerKindShortVoice - if shortVoice { + switch decision.Kind { + case model.TriggerKindLongVoice: + return uc.longVoiceUC.Execute(ctx, msg) + case model.TriggerKindShortVoice: acquired, tryErr := uc.voiceWindow.TryEnter( ctx, msg.BusinessConnectionID, @@ -108,6 +120,10 @@ func (uc *Usecase) Execute(ctx context.Context, msg model.IncomingMessage) error if !acquired { return nil } + case model.TriggerKindGreeting, model.TriggerKindFlood: + // shared text reply path below + case model.TriggerKindNone: + return nil } uc.log.InfoContext(ctx, "trigger fired", @@ -126,11 +142,11 @@ func (uc *Usecase) Execute(ctx context.Context, msg model.IncomingMessage) error ) } - in := uc.llmInputs(msg) + in := uc.llmInputs(decision, msg) reply, err := uc.llm.Generate(ctx, in.SystemPrompt, in.UserText) if err != nil { - if shortVoice { + if decision.Kind == model.TriggerKindShortVoice { uc.releaseVoiceWindow(ctx, msg) } @@ -140,7 +156,7 @@ func (uc *Usecase) Execute(ctx context.Context, msg model.IncomingMessage) error replyTarget.Text = reply if sndErr := uc.sender.Send(ctx, replyTarget); sndErr != nil { - if shortVoice { + if decision.Kind == model.TriggerKindShortVoice { uc.releaseVoiceWindow(ctx, msg) } @@ -184,7 +200,12 @@ func (uc *Usecase) resolveOwner(ctx context.Context, connectionID string) (model func (uc *Usecase) classify(ctx context.Context, msg model.IncomingMessage) (model.TriggerDecision, error) { switch msg.Kind { case model.MessageKindVoice: - return uc.shortVoiceDetector.Detect(msg), nil + res := uc.shortVoiceDetector.Detect(msg) + if res.Kind == model.TriggerKindNone { + return uc.longVoiceDetector.Detect(msg), nil + } + + return res, nil case model.MessageKindText: if decision := uc.greetingDetector.Detect(msg); decision.ShouldReply() { return decision, nil @@ -196,8 +217,8 @@ func (uc *Usecase) classify(ctx context.Context, msg model.IncomingMessage) (mod } } -func (uc *Usecase) llmInputs(msg model.IncomingMessage) llmInput { - if msg.Kind == model.MessageKindVoice { +func (uc *Usecase) llmInputs(decision model.TriggerDecision, msg model.IncomingMessage) llmInput { + if decision.Kind == model.TriggerKindShortVoice { return llmInput{SystemPrompt: uc.cfg.ShortVoicePrompt, UserText: ""} } diff --git a/internal/usecase/handle_business_message/usecase_test.go b/internal/usecase/businessmsg/usecase_test.go similarity index 82% rename from internal/usecase/handle_business_message/usecase_test.go rename to internal/usecase/businessmsg/usecase_test.go index 1ee05ae..3a741e3 100644 --- a/internal/usecase/handle_business_message/usecase_test.go +++ b/internal/usecase/businessmsg/usecase_test.go @@ -1,4 +1,4 @@ -package handle_business_message +package businessmsg import ( "context" @@ -50,6 +50,16 @@ var ( responseWindow = 60 * time.Second ) +type stubLongVoiceHandler struct { + called bool +} + +func (s *stubLongVoiceHandler) Execute(_ context.Context, _ model.IncomingMessage) error { + s.called = true + + return nil +} + func expectShowThinking(ctx context.Context, sender *mock.MockBusinessSender, msg model.IncomingMessage) { sender.EXPECT().ShowThinking(ctx, model.ReplyDraft{ BusinessConnectionID: msg.BusinessConnectionID, @@ -91,10 +101,14 @@ func (m usecaseMocks) expectTextReply(ctx context.Context, sendErr error) { m.sender.EXPECT().Send(ctx, gomock.Any()).Return(sendErr) } -func (m usecaseMocks) usecase(t *testing.T, voiceCooldown repository.VoiceReplyWindowStore) *Usecase { +func (m usecaseMocks) usecase( + t *testing.T, + voiceCooldown repository.VoiceReplyWindowStore, + longVoiceUC LongVoiceHandler, +) *Usecase { t.Helper() - return newUsecase(t, m.whitelist, m.connStore, m.accountReader, m.llm, m.sender, voiceCooldown) + return newUsecase(t, m.whitelist, m.connStore, m.accountReader, m.llm, m.sender, voiceCooldown, longVoiceUC) } // mockVoiceCooldown returns a mock VoiceReplyWindowStore that expects @@ -141,6 +155,7 @@ func newUsecase( llm *mock.MockLLMClient, sender *mock.MockBusinessSender, voiceCooldown repository.VoiceReplyWindowStore, + longVoiceUC LongVoiceHandler, ) *Usecase { t.Helper() @@ -163,6 +178,15 @@ func newUsecase( MaxDuration: 10 * time.Second, }) + longVoice := service.NewLongVoiceDetector(service.LongVoiceDetectorConfig{ + MinDuration: 10 * time.Second, + MaxDuration: 600 * time.Second, + }) + + if longVoiceUC == nil { + longVoiceUC = &stubLongVoiceHandler{} + } + return New( Config{ SystemPrompt: systemPrompt, @@ -175,9 +199,11 @@ func newUsecase( greeting, flood, shortVoice, + longVoice, voiceCooldown, llm, sender, + longVoiceUC, slog.Default(), ) } @@ -226,7 +252,7 @@ func TestUsecase_TextMessages(t *testing.T) { m.connStore.EXPECT().Get(ctx, testConn.ID).Return(testConn, true, nil) m.whitelist.EXPECT().IsAllowed(ctx, testConn.Owner.UserID).Return(false, nil) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: nil, @@ -244,7 +270,7 @@ func TestUsecase_TextMessages(t *testing.T) { Text: testReply, }).Return(nil) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: nil, @@ -255,7 +281,7 @@ func TestUsecase_TextMessages(t *testing.T) { m := newMocks(ctrl) m.expectAllowedOwner(ctx) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: model.IncomingMessage{ BusinessConnectionID: "conn-1", @@ -287,7 +313,7 @@ func TestUsecase_ErrorPropagation(t *testing.T) { m.llm.EXPECT().Generate(ctx, systemPrompt, testMsg.Text). Return("", errDeepseekTimeoutStub) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: ErrLLMGenerate, @@ -298,7 +324,7 @@ func TestUsecase_ErrorPropagation(t *testing.T) { m := newMocks(ctrl) m.expectTextReply(ctx, errTelegramRateLimitStub) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: ErrSend, @@ -315,7 +341,7 @@ func TestUsecase_ErrorPropagation(t *testing.T) { m.llm.EXPECT().Generate(ctx, systemPrompt, testMsg.Text).Return(testReply, nil) m.sender.EXPECT().Send(ctx, gomock.Any()).Return(nil) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: nil, @@ -326,7 +352,7 @@ func TestUsecase_ErrorPropagation(t *testing.T) { m := newMocks(ctrl) m.expectAllowedOwner(ctx) - return m.usecase(t, mockVoiceCooldownError(ctrl)) + return m.usecase(t, mockVoiceCooldownError(ctrl), nil) }, msg: testVoiceMsg, wantErr: ErrVoiceWindow, @@ -355,7 +381,7 @@ func TestUsecase_EdgeCases(t *testing.T) { m.llm.EXPECT().Generate(ctx, systemPrompt, testMsg.Text).Return(testReply, nil) m.sender.EXPECT().Send(ctx, gomock.Any()).Return(nil) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: nil, @@ -366,7 +392,7 @@ func TestUsecase_EdgeCases(t *testing.T) { m := newMocks(ctrl) m.expectTextReply(ctx, nil) - return m.usecase(t, mockVoiceCooldown(ctrl)) + return m.usecase(t, mockVoiceCooldown(ctrl), nil) }, msg: testMsg, wantErr: nil, @@ -396,7 +422,7 @@ func TestUsecase_VoiceMessages(t *testing.T) { Text: testReply, }).Return(nil) - return m.usecase(t, mockVoiceCooldownAcquired(ctrl)) + return m.usecase(t, mockVoiceCooldownAcquired(ctrl), nil) }, msg: testVoiceMsg, wantErr: nil, @@ -418,7 +444,7 @@ func TestUsecase_VoiceMessages(t *testing.T) { Release(gomock.Any(), "conn-1", int64(999)). Return(nil) - return m.usecase(t, voiceWindow) + return m.usecase(t, voiceWindow, nil) }, msg: testVoiceMsg, wantErr: ErrLLMGenerate, @@ -429,27 +455,58 @@ func TestUsecase_VoiceMessages(t *testing.T) { m := newMocks(ctrl) m.expectAllowedOwner(ctx) - return m.usecase(t, mockVoiceCooldownBlocked(ctrl)) + return m.usecase(t, mockVoiceCooldownBlocked(ctrl), nil) }, msg: testVoiceMsg, wantErr: nil, }, - { - name: "long voice > порога — бот молчит, store и LLM не вызываются", - setup: func(ctrl *gomock.Controller) *Usecase { - m := newMocks(ctrl) - m.expectAllowedOwner(ctx) - // TryEnter is never called — gomock enforces this - return m.usecase(t, mockVoiceCooldown(ctrl)) - }, - msg: model.IncomingMessage{ - BusinessConnectionID: "conn-1", - GuestID: 999, - Kind: model.MessageKindVoice, - VoiceDuration: 30 * time.Second, - ReceivedAt: time.Now(), - }, - wantErr: nil, - }, + }) +} + +func TestUsecase_LongVoice(t *testing.T) { + ctx := context.Background() + + t.Run("в диапазоне — делегирует в longVoiceUC, LLM не вызывается", func(t *testing.T) { + ctrl := gomock.NewController(t) + m := newMocks(ctrl) + m.expectAllowedOwner(ctx) + + longVoiceUC := &stubLongVoiceHandler{} + uc := m.usecase(t, mockVoiceCooldown(ctrl), longVoiceUC) + + msg := model.IncomingMessage{ + BusinessConnectionID: "conn-1", + GuestID: 999, + Kind: model.MessageKindVoice, + VoiceDuration: 30 * time.Second, + ReceivedAt: time.Now(), + } + + err := uc.Execute(ctx, msg) + + require.NoError(t, err) + require.True(t, longVoiceUC.called) + }) + + t.Run("длиннее max — молчит, longVoiceUC не вызывается", func(t *testing.T) { + ctrl := gomock.NewController(t) + m := newMocks(ctrl) + m.expectAllowedOwner(ctx) + + longVoiceUC := &stubLongVoiceHandler{} + uc := m.usecase(t, mockVoiceCooldown(ctrl), longVoiceUC) + + msg := model.IncomingMessage{ + BusinessConnectionID: "conn-1", + GuestID: 999, + Kind: model.MessageKindVoice, + VoiceDuration: 700 * time.Second, + ReceivedAt: time.Now(), + } + + err := uc.Execute(ctx, msg) + + require.NoError(t, err) + require.False(t, longVoiceUC.called) }) } diff --git a/internal/usecase/longvoice/errors.go b/internal/usecase/longvoice/errors.go new file mode 100644 index 0000000..8f59e47 --- /dev/null +++ b/internal/usecase/longvoice/errors.go @@ -0,0 +1,10 @@ +package longvoice + +import "errors" + +var ( + ErrDownload = errors.New("handle long voice: download failed") + ErrTranscribe = errors.New("handle long voice: transcribe failed") + ErrLLMGenerate = errors.New("handle long voice: llm generate failed") + ErrSend = errors.New("handle long voice: send failed") +) diff --git a/internal/usecase/longvoice/usecase.go b/internal/usecase/longvoice/usecase.go new file mode 100644 index 0000000..adb1bce --- /dev/null +++ b/internal/usecase/longvoice/usecase.go @@ -0,0 +1,85 @@ +package longvoice + +import ( + "context" + "fmt" + "log/slog" + "noirbot/internal/domain/model" + "noirbot/internal/domain/repository" + "strings" +) + +type Config struct { + LongVoicePrompt string +} + +type Usecase struct { + cfg Config + downloader repository.VoiceDownloader + transcriber repository.Transcriber + llmClient repository.LLMClient + sender repository.BusinessSender + log *slog.Logger +} + +func New( + cfg Config, + downloader repository.VoiceDownloader, + transcriber repository.Transcriber, + llmClient repository.LLMClient, + sender repository.BusinessSender, + log *slog.Logger, +) *Usecase { + return &Usecase{ + cfg: cfg, + downloader: downloader, + transcriber: transcriber, + llmClient: llmClient, + sender: sender, + log: log.With("usecase", "longvoice"), + } +} + +func (uc *Usecase) Execute(ctx context.Context, msg model.IncomingMessage) error { + reader, err := uc.downloader.Download(ctx, msg.VoiceFileID) + if err != nil { + return fmt.Errorf("%w: %w", ErrDownload, err) + } + + text, err := uc.transcriber.Transcribe(ctx, reader) + if err != nil { + return fmt.Errorf("%w: %w", ErrTranscribe, err) + } + + if strings.TrimSpace(text) == "" { + uc.log.InfoContext(ctx, "empty transcript, skip", + slog.String("voice_file_id", msg.VoiceFileID), + ) + + return nil + } + + rd := model.ReplyDraft{ + BusinessConnectionID: msg.BusinessConnectionID, + GuestID: msg.GuestID, + } + + if shThrErr := uc.sender.ShowThinking(ctx, rd); shThrErr != nil { + uc.log.WarnContext(ctx, "show thinking failed", + slog.String("error", shThrErr.Error()), + ) + } + + reply, err := uc.llmClient.Generate(ctx, uc.cfg.LongVoicePrompt, text) + if err != nil { + return fmt.Errorf("%w: %w", ErrLLMGenerate, err) + } + + rd.Text = reply + + if sndErr := uc.sender.Send(ctx, rd); sndErr != nil { + return fmt.Errorf("%w: %w", ErrSend, sndErr) + } + + return nil +} diff --git a/internal/usecase/longvoice/usecase_test.go b/internal/usecase/longvoice/usecase_test.go new file mode 100644 index 0000000..4327dba --- /dev/null +++ b/internal/usecase/longvoice/usecase_test.go @@ -0,0 +1,185 @@ +package longvoice + +import ( + "context" + "errors" + "io" + "log/slog" + "noirbot/internal/domain/model" + "noirbot/internal/domain/repository/mock" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +var ( + errDownloadStub = errors.New("download failed") + errTranscribeStub = errors.New("transcribe failed") + errLLMStub = errors.New("llm failed") + errSendStub = errors.New("send failed") + errThinkingStub = errors.New("thinking failed") +) + +const ( + longVoicePrompt = "Ответь на расшифровку голосового нуарно" + voiceFileID = "voice-file-123" + transcript = "детектив, мне нужна помощь" + testReply = "Говори, что случилось." +) + +var testMsg = model.IncomingMessage{ + BusinessConnectionID: "conn-1", + GuestID: 999, + Kind: model.MessageKindVoice, + VoiceFileID: voiceFileID, +} + +type usecaseMocks struct { + downloader *mock.MockVoiceDownloader + transcriber *mock.MockTranscriber + llm *mock.MockLLMClient + sender *mock.MockBusinessSender +} + +func newMocks(ctrl *gomock.Controller) usecaseMocks { + return usecaseMocks{ + downloader: mock.NewMockVoiceDownloader(ctrl), + transcriber: mock.NewMockTranscriber(ctrl), + llm: mock.NewMockLLMClient(ctrl), + sender: mock.NewMockBusinessSender(ctrl), + } +} + +func (m usecaseMocks) usecase(t *testing.T) *Usecase { + t.Helper() + + return New( + Config{LongVoicePrompt: longVoicePrompt}, + m.downloader, + m.transcriber, + m.llm, + m.sender, + slog.Default(), + ) +} + +func expectHappyPath( + ctx context.Context, + m usecaseMocks, + reader io.ReadCloser, + text string, +) { + m.downloader.EXPECT().Download(ctx, voiceFileID).Return(reader, nil) + m.transcriber.EXPECT().Transcribe(ctx, gomock.Any()).Return(text, nil) + m.sender.EXPECT().ShowThinking(ctx, model.ReplyDraft{ + BusinessConnectionID: testMsg.BusinessConnectionID, + GuestID: testMsg.GuestID, + }).Return(nil) + m.llm.EXPECT().Generate(ctx, longVoicePrompt, text).Return(testReply, nil) + m.sender.EXPECT().Send(ctx, model.ReplyDraft{ + BusinessConnectionID: testMsg.BusinessConnectionID, + GuestID: testMsg.GuestID, + Text: testReply, + }).Return(nil) +} + +func TestUsecase_Execute(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + setup func(m usecaseMocks) + wantErr error + }{ + { + name: "happy path — download, transcribe, llm, send", + setup: func(m usecaseMocks) { + expectHappyPath( + ctx, + m, + io.NopCloser(strings.NewReader("audio")), + transcript, + ) + }, + }, + { + name: "пустая транскрипция — skip без llm и send", + setup: func(m usecaseMocks) { + m.downloader.EXPECT().Download(ctx, voiceFileID). + Return(io.NopCloser(strings.NewReader("audio")), nil) + m.transcriber.EXPECT().Transcribe(ctx, gomock.Any()).Return(" ", nil) + }, + }, + { + name: "download error", + setup: func(m usecaseMocks) { + m.downloader.EXPECT().Download(ctx, voiceFileID). + Return(nil, errDownloadStub) + }, + wantErr: ErrDownload, + }, + { + name: "transcribe error", + setup: func(m usecaseMocks) { + m.downloader.EXPECT().Download(ctx, voiceFileID). + Return(io.NopCloser(strings.NewReader("audio")), nil) + m.transcriber.EXPECT().Transcribe(ctx, gomock.Any()). + Return("", errTranscribeStub) + }, + wantErr: ErrTranscribe, + }, + { + name: "llm error", + setup: func(m usecaseMocks) { + m.downloader.EXPECT().Download(ctx, voiceFileID). + Return(io.NopCloser(strings.NewReader("audio")), nil) + m.transcriber.EXPECT().Transcribe(ctx, gomock.Any()).Return(transcript, nil) + m.sender.EXPECT().ShowThinking(ctx, gomock.Any()).Return(nil) + m.llm.EXPECT().Generate(ctx, longVoicePrompt, transcript). + Return("", errLLMStub) + }, + wantErr: ErrLLMGenerate, + }, + { + name: "send error", + setup: func(m usecaseMocks) { + m.downloader.EXPECT().Download(ctx, voiceFileID). + Return(io.NopCloser(strings.NewReader("audio")), nil) + m.transcriber.EXPECT().Transcribe(ctx, gomock.Any()).Return(transcript, nil) + m.sender.EXPECT().ShowThinking(ctx, gomock.Any()).Return(nil) + m.llm.EXPECT().Generate(ctx, longVoicePrompt, transcript).Return(testReply, nil) + m.sender.EXPECT().Send(ctx, gomock.Any()).Return(errSendStub) + }, + wantErr: ErrSend, + }, + { + name: "show thinking error — пайплайн продолжается", + setup: func(m usecaseMocks) { + m.downloader.EXPECT().Download(ctx, voiceFileID). + Return(io.NopCloser(strings.NewReader("audio")), nil) + m.transcriber.EXPECT().Transcribe(ctx, gomock.Any()).Return(transcript, nil) + m.sender.EXPECT().ShowThinking(ctx, gomock.Any()).Return(errThinkingStub) + m.llm.EXPECT().Generate(ctx, longVoicePrompt, transcript).Return(testReply, nil) + m.sender.EXPECT().Send(ctx, gomock.Any()).Return(nil) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + m := newMocks(ctrl) + tt.setup(m) + + err := m.usecase(t).Execute(ctx, testMsg) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5f097d6..3547154 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,16 +12,19 @@ type Config struct { HTTP HTTPConfig Redis RedisConfig DeepSeek DeepSeekConfig + Groq GroqConfig Bot BotConfig Flood FloodConfig Greetings []string `default:"привет,прив,здоров,хай,ку" envconfig:"GREETINGS"` ShortVoice ShortVoiceConfig + LongVoice LongVoiceConfig AllowedOwners []int64 `envconfig:"ALLOWED_OWNERS"` } type TelegramConfig struct { - BotToken string `envconfig:"BOT_TOKEN" required:"true"` - WebhookSecret string `envconfig:"WEBHOOK_SECRET"` + BotToken string `envconfig:"BOT_TOKEN" required:"true"` + WebhookSecret string `envconfig:"WEBHOOK_SECRET"` + FileDownloadTimeout time.Duration `default:"120s" envconfig:"TELEGRAM_FILE_TIMEOUT"` } type HTTPConfig struct { @@ -49,9 +52,17 @@ type DeepSeekConfig struct { Timeout time.Duration `default:"30s" envconfig:"DEEPSEEK_TIMEOUT"` } +type GroqConfig struct { + BaseURL string `envconfig:"GROQ_BASE_URL" required:"true"` + APIKey string `envconfig:"GROQ_API_KEY" required:"true"` + Model string `default:"whisper-large-v3" envconfig:"GROQ_MODEL"` + Timeout time.Duration `default:"30s" envconfig:"GROQ_TIMEOUT"` +} + type BotConfig struct { SystemPrompt string `envconfig:"BOT_SYSTEM_PROMPT" required:"true"` ShortVoicePrompt string `envconfig:"BOT_SHORT_VOICE_PROMPT" required:"true"` + LongVoicePrompt string `envconfig:"BOT_LONG_VOICE_PROMPT" required:"true"` } type FloodConfig struct { @@ -66,6 +77,10 @@ type ShortVoiceConfig struct { ResponseWindow time.Duration `default:"60s" envconfig:"SHORT_VOICE_RESPONSE_WINDOW"` } +type LongVoiceConfig struct { + MaxDuration time.Duration `default:"600s" envconfig:"LONG_VOICE_MAX_DURATION"` +} + func Load() (*Config, error) { cfg := &Config{} if err := envconfig.Process("", cfg); err != nil { @@ -80,6 +95,10 @@ func Load() (*Config, error) { return nil, fmt.Errorf("validate config: %w", err) } + if err := cfg.validateLongVoice(); err != nil { + return nil, fmt.Errorf("validate config: %w", err) + } + return cfg, nil } @@ -97,7 +116,7 @@ func (c *Config) validateFlood() error { func (c *Config) validateShortVoice() error { if c.ShortVoice.ResponseWindow <= 0 || c.ShortVoice.MaxDuration <= 0 { - return fmt.Errorf("%w: response_window=%d, max_duration=%d", + return fmt.Errorf("%w: response_window=%s, max_duration=%s", ErrInvalidShortVoiceCfg, c.ShortVoice.ResponseWindow, c.ShortVoice.MaxDuration, @@ -106,3 +125,20 @@ func (c *Config) validateShortVoice() error { return nil } + +func (c *Config) validateLongVoice() error { + switch { + case c.LongVoice.MaxDuration <= 0: + return fmt.Errorf("%w: max_duration=%s", + ErrInvalidLongVoiceCfg, + c.LongVoice.MaxDuration) + case c.ShortVoice.MaxDuration >= c.LongVoice.MaxDuration: + return fmt.Errorf("%w: short_max=%s, long_max=%s", + ErrVoiceDurationOverlap, + c.ShortVoice.MaxDuration, + c.LongVoice.MaxDuration, + ) + default: + return nil + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..d2e1a72 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestConfig_validateLongVoice(t *testing.T) { + tests := []struct { + name string + shortMax time.Duration + longMax time.Duration + wantErr error + }{ + { + name: "short < long — валидно", + shortMax: 10 * time.Second, + longMax: 600 * time.Second, + wantErr: nil, + }, + { + name: "long max = 0 — ErrInvalidLongVoiceCfg", + shortMax: 10 * time.Second, + longMax: 0, + wantErr: ErrInvalidLongVoiceCfg, + }, + { + name: "long max < 0 — ErrInvalidLongVoiceCfg", + shortMax: 10 * time.Second, + longMax: -1 * time.Second, + wantErr: ErrInvalidLongVoiceCfg, + }, + { + name: "short == long — ErrVoiceDurationOverlap", + shortMax: 600 * time.Second, + longMax: 600 * time.Second, + wantErr: ErrVoiceDurationOverlap, + }, + { + name: "short > long — ErrVoiceDurationOverlap", + shortMax: 700 * time.Second, + longMax: 600 * time.Second, + wantErr: ErrVoiceDurationOverlap, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{} + c.ShortVoice.MaxDuration = tt.shortMax + c.LongVoice.MaxDuration = tt.longMax + + err := c.validateLongVoice() + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestConfig_validateShortVoice(t *testing.T) { + tests := []struct { + name string + window time.Duration + maxDur time.Duration + wantErr bool + }{ + { + name: "оба > 0 — валидно", + window: 60 * time.Second, + maxDur: 10 * time.Second, + wantErr: false, + }, + { + name: "window = 0 — ошибка", + window: 0, + maxDur: 10 * time.Second, + wantErr: true, + }, + { + name: "max = 0 — ошибка", + window: 60 * time.Second, + maxDur: 0, + wantErr: true, + }, + { + name: "window < 0 — ошибка", + window: -1 * time.Second, + maxDur: 10 * time.Second, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{} + c.ShortVoice.ResponseWindow = tt.window + c.ShortVoice.MaxDuration = tt.maxDur + + err := c.validateShortVoice() + + if tt.wantErr { + require.ErrorIs(t, err, ErrInvalidShortVoiceCfg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestConfig_validateFlood(t *testing.T) { + tests := []struct { + name string + ttl time.Duration + window time.Duration + wantErr bool + }{ + { + name: "ttl > window — валидно", + ttl: 120 * time.Second, + window: 60 * time.Second, + wantErr: false, + }, + { + name: "ttl == window — валидно", + ttl: 60 * time.Second, + window: 60 * time.Second, + wantErr: false, + }, + { + name: "ttl < window — ошибка", + ttl: 30 * time.Second, + window: 60 * time.Second, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{} + c.Flood.RedisTTL = tt.ttl + c.Flood.WindowDuration = tt.window + + err := c.validateFlood() + + if tt.wantErr { + require.ErrorIs(t, err, ErrInvalidFloodTTL) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/config/errors.go b/pkg/config/errors.go index 6fd7b56..6c4a40f 100644 --- a/pkg/config/errors.go +++ b/pkg/config/errors.go @@ -5,4 +5,6 @@ import "errors" var ( ErrInvalidFloodTTL = errors.New("config: FLOOD_REDIS_TTL must be >= FLOOD_WINDOW") ErrInvalidShortVoiceCfg = errors.New("config: SHORT_VOICE_MAX_DURATION and SHORT_VOICE_RESPONSE_WINDOW must be > 0") + ErrInvalidLongVoiceCfg = errors.New("config: LONG_VOICE_MAX_DURATION must be > 0 and > SHORT_VOICE_MAX_DURATION") + ErrVoiceDurationOverlap = errors.New("config: SHORT_VOICE_MAX_DURATION must be < LONG_VOICE_MAX_DURATION") )