From df506b488b56e2fd3469db2ec3327dc4f739d31d Mon Sep 17 00:00:00 2001 From: Vinokurov Maxim Date: Sun, 31 May 2026 02:05:32 +0300 Subject: [PATCH 1/2] short voice detector impl --- cmd/service/main.go | 10 ++- internal/domain/model/message.go | 9 ++ internal/domain/model/trigger.go | 7 +- .../domain/service/short_voice_detector.go | 35 ++++++++ .../service/short_voice_detector_test.go | 69 +++++++++++++++ internal/gateways/telegram/inbound/mapper.go | 24 +++-- .../gateways/telegram/inbound/mapper_test.go | 88 +++++++++++++++++++ .../handle_business_message/usecase.go | 72 ++++++++++----- .../handle_business_message/usecase_test.go | 71 ++++++++++++++- pkg/config/config.go | 12 ++- 10 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 internal/domain/service/short_voice_detector.go create mode 100644 internal/domain/service/short_voice_detector_test.go create mode 100644 internal/gateways/telegram/inbound/mapper_test.go diff --git a/cmd/service/main.go b/cmd/service/main.go index 78db123..18d7de6 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -29,6 +29,7 @@ func main() { newGreetingDetector, newFloodDetector, + newShortVoiceDetector, newOwnerWhitelist, newBusinessConnectionStore, @@ -100,6 +101,12 @@ func newFloodDetector(cfg *config.Config, store repository.MessageWindowStore) * }, store) } +func newShortVoiceDetector(cfg *config.Config) *service.ShortVoiceDetector { + return service.NewShortVoiceDetector(service.ShortVoiceDetectorConfig{ + MaxDuration: cfg.ShortVoice.MaxDuration, + }) +} + func newOwnerWhitelist(cfg *config.Config) repository.OwnerWhitelist { return memory.NewOwnerWhitelist(cfg.AllowedOwners) } @@ -135,6 +142,7 @@ func newLLMClient(c deepseek.Config) repository.LLMClient { func newHandleBusinessMessageConfig(cfg *config.Config) handle_business_message.Config { return handle_business_message.Config{ - SystemPrompt: cfg.Bot.SystemPrompt, + SystemPrompt: cfg.Bot.SystemPrompt, + ShortVoicePrompt: cfg.Bot.ShortVoicePrompt, } } diff --git a/internal/domain/model/message.go b/internal/domain/model/message.go index 21bc9bc..ee6f460 100644 --- a/internal/domain/model/message.go +++ b/internal/domain/model/message.go @@ -2,11 +2,20 @@ package model import "time" +type MessageKind string + +const ( + MessageKindText MessageKind = "text" + MessageKindVoice MessageKind = "voice" +) + type IncomingMessage struct { BusinessConnectionID string OwnerID int64 GuestID int64 + Kind MessageKind Text string + VoiceDuration time.Duration ReceivedAt time.Time } diff --git a/internal/domain/model/trigger.go b/internal/domain/model/trigger.go index e5faaf0..4746aab 100644 --- a/internal/domain/model/trigger.go +++ b/internal/domain/model/trigger.go @@ -3,9 +3,10 @@ package model type TriggerKind string const ( - TriggerKindNone TriggerKind = "none" - TriggerKindGreeting TriggerKind = "greeting" - TriggerKindFlood TriggerKind = "flood" + TriggerKindNone TriggerKind = "none" + TriggerKindGreeting TriggerKind = "greeting" + TriggerKindFlood TriggerKind = "flood" + TriggerKindShortVoice TriggerKind = "short_voice" ) type TriggerDecision struct { diff --git a/internal/domain/service/short_voice_detector.go b/internal/domain/service/short_voice_detector.go new file mode 100644 index 0000000..b1cad27 --- /dev/null +++ b/internal/domain/service/short_voice_detector.go @@ -0,0 +1,35 @@ +package service + +import ( + "noirbot/internal/domain/model" + "time" +) + +type ShortVoiceDetectorConfig struct { + MaxDuration time.Duration +} + +type ShortVoiceDetector struct { + cfg ShortVoiceDetectorConfig +} + +func NewShortVoiceDetector(cfg ShortVoiceDetectorConfig) *ShortVoiceDetector { + return &ShortVoiceDetector{ + cfg: cfg, + } +} + +func (d *ShortVoiceDetector) Detect(msg model.IncomingMessage) model.TriggerDecision { + if msg.Kind != model.MessageKindVoice { + return model.TriggerDecision{Kind: model.TriggerKindNone} + } + + if msg.VoiceDuration > d.cfg.MaxDuration { + return model.TriggerDecision{Kind: model.TriggerKindNone} + } + + return model.TriggerDecision{ + Kind: model.TriggerKindShortVoice, + Reason: "voice " + msg.VoiceDuration.String() + " <= " + d.cfg.MaxDuration.String(), + } +} diff --git a/internal/domain/service/short_voice_detector_test.go b/internal/domain/service/short_voice_detector_test.go new file mode 100644 index 0000000..561fa96 --- /dev/null +++ b/internal/domain/service/short_voice_detector_test.go @@ -0,0 +1,69 @@ +package service_test + +import ( + "noirbot/internal/domain/model" + "noirbot/internal/domain/service" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestShortVoiceDetector_Detect(t *testing.T) { + const maxDuration = 10 * time.Second + + tests := []struct { + name string + msg model.IncomingMessage + wantKind model.TriggerKind + }{ + { + name: "voice короче порога — триггер short_voice", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: 5 * time.Second, + }, + wantKind: model.TriggerKindShortVoice, + }, + { + name: "voice ровно на пороге — триггер (граничный случай)", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: maxDuration, + }, + wantKind: model.TriggerKindShortVoice, + }, + { + name: "voice длиннее порога — пропускаем", + msg: model.IncomingMessage{ + Kind: model.MessageKindVoice, + VoiceDuration: 30 * time.Second, + }, + wantKind: model.TriggerKindNone, + }, + { + name: "текстовое сообщение — детектор не реагирует", + msg: model.IncomingMessage{ + Kind: model.MessageKindText, + Text: "привет", + }, + wantKind: model.TriggerKindNone, + }, + { + name: "пустой Kind — детектор не реагирует", + msg: model.IncomingMessage{}, + wantKind: model.TriggerKindNone, + }, + } + + detector := service.NewShortVoiceDetector(service.ShortVoiceDetectorConfig{ + MaxDuration: maxDuration, + }) + + 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/telegram/inbound/mapper.go b/internal/gateways/telegram/inbound/mapper.go index 5445d3f..20c6ec0 100644 --- a/internal/gateways/telegram/inbound/mapper.go +++ b/internal/gateways/telegram/inbound/mapper.go @@ -14,16 +14,30 @@ func NewUpdateMapper() *UpdateMapper { } func (m *UpdateMapper) ToIncomingMessage(src *tgmodels.Message) (model.IncomingMessage, bool) { - if src == nil || src.From == nil || src.Text == "" { + if src == nil || src.From == nil { return model.IncomingMessage{}, false } - return model.IncomingMessage{ + base := model.IncomingMessage{ BusinessConnectionID: src.BusinessConnectionID, GuestID: src.From.ID, - Text: src.Text, - ReceivedAt: time.Unix(int64(src.Date), 0), - }, true + ReceivedAt: time.Now().UTC(), + } + + switch { + case src.Text != "": + base.Kind = model.MessageKindText + base.Text = src.Text + + return base, true + case src.Voice != nil: + base.Kind = model.MessageKindVoice + base.VoiceDuration = time.Duration(src.Voice.Duration) * time.Second + + return base, true + default: + return model.IncomingMessage{}, false + } } func (m *UpdateMapper) ToBusinessConnection(src *tgmodels.BusinessConnection) model.BusinessConnection { diff --git a/internal/gateways/telegram/inbound/mapper_test.go b/internal/gateways/telegram/inbound/mapper_test.go new file mode 100644 index 0000000..c6476c1 --- /dev/null +++ b/internal/gateways/telegram/inbound/mapper_test.go @@ -0,0 +1,88 @@ +package inbound_test + +import ( + "noirbot/internal/domain/model" + "noirbot/internal/gateways/telegram/inbound" + "testing" + "time" + + tgmodels "github.com/go-telegram/bot/models" + "github.com/stretchr/testify/require" +) + +func TestUpdateMapper_ToIncomingMessage(t *testing.T) { + mapper := inbound.NewUpdateMapper() + from := &tgmodels.User{ID: 999} + + tests := []struct { + name string + src *tgmodels.Message + wantOK bool + wantKind model.MessageKind + wantText string + wantDur time.Duration + }{ + { + name: "text сообщение → MessageKindText", + src: &tgmodels.Message{ + BusinessConnectionID: "conn-1", + From: from, + Text: "привет", + }, + wantOK: true, + wantKind: model.MessageKindText, + wantText: "привет", + }, + { + name: "voice сообщение → MessageKindVoice + длительность", + src: &tgmodels.Message{ + BusinessConnectionID: "conn-1", + From: from, + Voice: &tgmodels.Voice{Duration: 7}, + }, + wantOK: true, + wantKind: model.MessageKindVoice, + wantDur: 7 * time.Second, + }, + { + name: "audio file (не voice) → пропускаем как неподдерживаемый тип", + src: &tgmodels.Message{ + BusinessConnectionID: "conn-1", + From: from, + Audio: &tgmodels.Audio{Duration: 5}, + }, + wantOK: false, + }, + { + name: "ни text, ни voice → пропускаем", + src: &tgmodels.Message{From: from}, + wantOK: false, + }, + { + name: "From == nil → пропускаем", + src: &tgmodels.Message{Text: "x"}, + wantOK: false, + }, + { + name: "src == nil → пропускаем", + src: nil, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := mapper.ToIncomingMessage(tt.src) + + require.Equal(t, tt.wantOK, ok) + + if !tt.wantOK { + return + } + + require.Equal(t, tt.wantKind, got.Kind) + require.Equal(t, tt.wantText, got.Text) + require.Equal(t, tt.wantDur, got.VoiceDuration) + }) + } +} diff --git a/internal/usecase/handle_business_message/usecase.go b/internal/usecase/handle_business_message/usecase.go index a0f55da..b269dce 100644 --- a/internal/usecase/handle_business_message/usecase.go +++ b/internal/usecase/handle_business_message/usecase.go @@ -10,19 +10,26 @@ import ( ) type Config struct { - SystemPrompt string + SystemPrompt string + ShortVoicePrompt string } type Usecase struct { - cfg Config - whitelist repository.OwnerWhitelist - connStore repository.BusinessConnectionStore - accountReader repository.BusinessAccountReader - greetingDetector *service.GreetingDetector - floodDetector *service.FloodDetector - llm repository.LLMClient - sender repository.BusinessSender - log *slog.Logger + cfg Config + whitelist repository.OwnerWhitelist + connStore repository.BusinessConnectionStore + accountReader repository.BusinessAccountReader + greetingDetector *service.GreetingDetector + floodDetector *service.FloodDetector + shortVoiceDetector *service.ShortVoiceDetector + llm repository.LLMClient + sender repository.BusinessSender + log *slog.Logger +} + +type llmInput struct { + SystemPrompt string + UserText string } func New( @@ -32,20 +39,22 @@ func New( accountReader repository.BusinessAccountReader, greetingDetector *service.GreetingDetector, floodDetector *service.FloodDetector, + shortVoiceDetector *service.ShortVoiceDetector, llm repository.LLMClient, sender repository.BusinessSender, log *slog.Logger, ) *Usecase { return &Usecase{ - cfg: cfg, - whitelist: whitelist, - connStore: connStore, - accountReader: accountReader, - greetingDetector: greetingDetector, - floodDetector: floodDetector, - llm: llm, - sender: sender, - log: log.With("usecase", "handle_business_message"), + cfg: cfg, + whitelist: whitelist, + connStore: connStore, + accountReader: accountReader, + greetingDetector: greetingDetector, + floodDetector: floodDetector, + shortVoiceDetector: shortVoiceDetector, + llm: llm, + sender: sender, + log: log.With("usecase", "handle_business_message"), } } @@ -95,7 +104,9 @@ func (uc *Usecase) Execute(ctx context.Context, msg model.IncomingMessage) error ) } - reply, err := uc.llm.Generate(ctx, uc.cfg.SystemPrompt, msg.Text) + in := uc.llmInputs(msg) + + reply, err := uc.llm.Generate(ctx, in.SystemPrompt, in.UserText) if err != nil { return fmt.Errorf("%w: %w", ErrLLMGenerate, err) } @@ -135,9 +146,24 @@ func (uc *Usecase) resolveOwner(ctx context.Context, connectionID string) (model } func (uc *Usecase) classify(ctx context.Context, msg model.IncomingMessage) (model.TriggerDecision, error) { - if decision := uc.greetingDetector.Detect(msg); decision.ShouldReply() { - return decision, nil + switch msg.Kind { + case model.MessageKindVoice: + return uc.shortVoiceDetector.Detect(msg), nil + case model.MessageKindText: + if decision := uc.greetingDetector.Detect(msg); decision.ShouldReply() { + return decision, nil + } + + return uc.floodDetector.Detect(ctx, msg) + default: + return model.TriggerDecision{Kind: model.TriggerKindNone}, nil + } +} + +func (uc *Usecase) llmInputs(msg model.IncomingMessage) llmInput { + if msg.Kind == model.MessageKindVoice { + return llmInput{SystemPrompt: uc.cfg.ShortVoicePrompt, UserText: ""} } - return uc.floodDetector.Detect(ctx, msg) + return llmInput{SystemPrompt: uc.cfg.SystemPrompt, UserText: msg.Text} } diff --git a/internal/usecase/handle_business_message/usecase_test.go b/internal/usecase/handle_business_message/usecase_test.go index 803e865..38bf1e6 100644 --- a/internal/usecase/handle_business_message/usecase_test.go +++ b/internal/usecase/handle_business_message/usecase_test.go @@ -30,11 +30,20 @@ var ( testMsg = model.IncomingMessage{ BusinessConnectionID: "conn-1", GuestID: 999, + Kind: model.MessageKindText, Text: "привет", ReceivedAt: time.Now(), } - testReply = "Ну какой привет, пиши сразу, что тебе надо!" - systemPrompt = "Отвечай как нуарный детектив, повидавший некоторое дерьмо" + testVoiceMsg = model.IncomingMessage{ + BusinessConnectionID: "conn-1", + GuestID: 999, + Kind: model.MessageKindVoice, + VoiceDuration: 5 * time.Second, + ReceivedAt: time.Now(), + } + testReply = "Ну какой привет, пиши сразу, что тебе надо!" + systemPrompt = "Отвечай как нуарный детектив, повидавший некоторое дерьмо" + shortVoicePrompt = "Тебе пришло голосовое — отреагируй нуарно" ) func expectShowThinking(ctx context.Context, sender *mock.MockBusinessSender, msg model.IncomingMessage) { @@ -69,13 +78,21 @@ func newUsecase( Threshold: 5, }, windowStore) + shortVoice := service.NewShortVoiceDetector(service.ShortVoiceDetectorConfig{ + MaxDuration: 10 * time.Second, + }) + return New( - Config{SystemPrompt: systemPrompt}, + Config{ + SystemPrompt: systemPrompt, + ShortVoicePrompt: shortVoicePrompt, + }, whitelist, connStore, accountReader, greeting, flood, + shortVoice, llm, sender, slog.Default(), @@ -149,6 +166,7 @@ func TestUsecase_Execute(t *testing.T) { msg: model.IncomingMessage{ BusinessConnectionID: "conn-1", GuestID: 999, + Kind: model.MessageKindText, Text: "это очень длинное сообщение которое точно больше двадцати символов", ReceivedAt: time.Now(), }, @@ -259,6 +277,53 @@ func TestUsecase_Execute(t *testing.T) { msg: testMsg, wantErr: nil, }, + { + name: "short voice ≤ порога — LLM вызван с short voice prompt и пустым userText", + setup: func(ctrl *gomock.Controller) *Usecase { + whitelist := mock.NewMockOwnerWhitelist(ctrl) + connStore := mock.NewMockBusinessConnectionStore(ctrl) + accountReader := mock.NewMockBusinessAccountReader(ctrl) + llm := mock.NewMockLLMClient(ctrl) + sender := mock.NewMockBusinessSender(ctrl) + + connStore.EXPECT().Get(ctx, testConn.ID).Return(testConn, true, nil) + whitelist.EXPECT().IsAllowed(ctx, testConn.Owner.UserID).Return(true, nil) + expectShowThinking(ctx, sender, testVoiceMsg) + llm.EXPECT().Generate(ctx, shortVoicePrompt, "").Return(testReply, nil) + sender.EXPECT().Send(ctx, model.ReplyDraft{ + BusinessConnectionID: testVoiceMsg.BusinessConnectionID, + GuestID: testVoiceMsg.GuestID, + Text: testReply, + }).Return(nil) + + return newUsecase(t, whitelist, connStore, accountReader, llm, sender) + }, + msg: testVoiceMsg, + wantErr: nil, + }, + { + name: "long voice > порога — бот молчит, LLM не вызывается", + setup: func(ctrl *gomock.Controller) *Usecase { + whitelist := mock.NewMockOwnerWhitelist(ctrl) + connStore := mock.NewMockBusinessConnectionStore(ctrl) + accountReader := mock.NewMockBusinessAccountReader(ctrl) + llm := mock.NewMockLLMClient(ctrl) + sender := mock.NewMockBusinessSender(ctrl) + + connStore.EXPECT().Get(ctx, testConn.ID).Return(testConn, true, nil) + whitelist.EXPECT().IsAllowed(ctx, testConn.Owner.UserID).Return(true, nil) + + return newUsecase(t, whitelist, connStore, accountReader, llm, sender) + }, + msg: model.IncomingMessage{ + BusinessConnectionID: "conn-1", + GuestID: 999, + Kind: model.MessageKindVoice, + VoiceDuration: 30 * time.Second, + ReceivedAt: time.Now(), + }, + wantErr: nil, + }, } for _, tt := range tests { diff --git a/pkg/config/config.go b/pkg/config/config.go index feaef54..fbb2fc5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,8 +13,9 @@ type Config struct { DeepSeek DeepSeekConfig Bot BotConfig Flood FloodConfig - Greetings []string `default:"привет,прив,здоров,хай,ку" envconfig:"GREETINGS"` - AllowedOwners []int64 `envconfig:"ALLOWED_OWNERS"` + Greetings []string `default:"привет,прив,здоров,хай,ку" envconfig:"GREETINGS"` + ShortVoice ShortVoiceConfig + AllowedOwners []int64 `envconfig:"ALLOWED_OWNERS"` } type TelegramConfig struct { @@ -37,7 +38,8 @@ type DeepSeekConfig struct { } type BotConfig struct { - SystemPrompt string `envconfig:"BOT_SYSTEM_PROMPT" required:"true"` + SystemPrompt string `envconfig:"BOT_SYSTEM_PROMPT" required:"true"` + ShortVoicePrompt string `envconfig:"BOT_SHORT_VOICE_PROMPT" required:"true"` } type FloodConfig struct { @@ -46,6 +48,10 @@ type FloodConfig struct { Threshold int `default:"5" envconfig:"FLOOD_THRESHOLD"` } +type ShortVoiceConfig struct { + MaxDuration time.Duration `default:"10s" envconfig:"SHORT_VOICE_MAX_DURATION"` +} + func Load() (*Config, error) { cfg := &Config{} if err := envconfig.Process("", cfg); err != nil { From 02551c597688e46ec1d880b9f9e00ec8557961d0 Mon Sep 17 00:00:00 2001 From: Vinokurov Maxim Date: Sun, 7 Jun 2026 14:58:47 +0300 Subject: [PATCH 2/2] BOT_SHORT_VOICE_PROMPT add example --- env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/env.example b/env.example index 94a827f..1626d97 100644 --- a/env.example +++ b/env.example @@ -6,6 +6,7 @@ DEEPSEEK_API_KEY=guesswhatisit! DEEPSEEK_MODEL=deepseek-v4-pro BOT_SYSTEM_PROMPT="You are breathtaking!" +BOT_SHORT_VOICE_PROMPT="Short voice messages is corruption of mankind!" ALLOWED_OWNERS=2281489 GREETINGS=hi,hello,sup,yo