Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func main() {
newGreetingDetector,
newFloodDetector,
newShortVoiceDetector,
newVoiceReplyWindowStore,

newOwnerWhitelist,
newBusinessConnectionStore,
Expand Down Expand Up @@ -130,6 +131,10 @@ func newShortVoiceDetector(cfg *config.Config) *service.ShortVoiceDetector {
})
}

func newVoiceReplyWindowStore(r *redis.Client) repository.VoiceReplyWindowStore {
return redisstore.NewVoiceReplyWindowStore(r)
}

func newOwnerWhitelist(cfg *config.Config) repository.OwnerWhitelist {
return memory.NewOwnerWhitelist(cfg.AllowedOwners)
}
Expand Down Expand Up @@ -165,8 +170,9 @@ 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,
ShortVoicePrompt: cfg.Bot.ShortVoicePrompt,
SystemPrompt: cfg.Bot.SystemPrompt,
ShortVoicePrompt: cfg.Bot.ShortVoicePrompt,
ShortVoiceResponseWindow: cfg.ShortVoice.ResponseWindow,
}
}

Expand Down
71 changes: 71 additions & 0 deletions internal/domain/repository/mock/voice_reply_window_store.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions internal/domain/repository/voice_reply_window_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package repository

import (
"context"
"time"
)

//go:generate go tool mockgen -source=$GOFILE -destination=mock/$GOFILE -package=mock

type VoiceReplyWindowStore interface {
// TryEnter atomically opens a reply window if it is not already open.
// Returns true if the window was just opened (the caller may reply),
// or false if the window is already open (the caller must skip).
TryEnter(ctx context.Context, connectionID string, guestID int64, ttl time.Duration) (bool, error)

// Release drops the reservation if reply failed (DEL).
Release(ctx context.Context, connectionID string, guestID int64) error
}
4 changes: 3 additions & 1 deletion internal/domain/service/short_voice_detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ func NewShortVoiceDetector(cfg ShortVoiceDetectorConfig) *ShortVoiceDetector {
}
}

func (d *ShortVoiceDetector) Detect(msg model.IncomingMessage) model.TriggerDecision {
func (d *ShortVoiceDetector) Detect(
msg model.IncomingMessage,
) model.TriggerDecision {
if msg.Kind != model.MessageKindVoice {
return model.TriggerDecision{Kind: model.TriggerKindNone}
}
Expand Down
16 changes: 8 additions & 8 deletions internal/domain/service/short_voice_detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import (
func TestShortVoiceDetector_Detect(t *testing.T) {
const maxDuration = 10 * time.Second

detector := service.NewShortVoiceDetector(service.ShortVoiceDetectorConfig{
MaxDuration: maxDuration,
})

tests := []struct {
name string
msg model.IncomingMessage
Expand All @@ -26,40 +30,36 @@ func TestShortVoiceDetector_Detect(t *testing.T) {
wantKind: model.TriggerKindShortVoice,
},
{
name: "voice ровно на пороге — триггер (граничный случай)",
name: "voice ровно на пороге — триггер short_voice",
msg: model.IncomingMessage{
Kind: model.MessageKindVoice,
VoiceDuration: maxDuration,
},
wantKind: model.TriggerKindShortVoice,
},
{
name: "voice длиннее порога — пропускаем",
name: "voice длиннее порога — без триггера",
msg: model.IncomingMessage{
Kind: model.MessageKindVoice,
VoiceDuration: 30 * time.Second,
},
wantKind: model.TriggerKindNone,
},
{
name: "текстовое сообщение — детектор не реагирует",
name: "текстовое сообщение — без триггера",
msg: model.IncomingMessage{
Kind: model.MessageKindText,
Text: "привет",
},
wantKind: model.TriggerKindNone,
},
{
name: "пустой Kind — детектор не реагирует",
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)
Expand Down
54 changes: 54 additions & 0 deletions internal/gateways/redis/voice_reply_window_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package redis

import (
"context"
"fmt"
"noirbot/internal/domain/repository"
"strconv"
"time"

"github.com/redis/go-redis/v9"
)

var _ repository.VoiceReplyWindowStore = (*VoiceReplyWindowStore)(nil)

const voiceReplyKeyPrefix = "vrw:"

type VoiceReplyWindowStore struct {
client *redis.Client
}

func NewVoiceReplyWindowStore(client *redis.Client) *VoiceReplyWindowStore {
return &VoiceReplyWindowStore{
client: client,
}
}

func (s *VoiceReplyWindowStore) TryEnter(
ctx context.Context,
connectionID string,
guestID int64,
ttl time.Duration,
) (bool, error) {
key := voiceReplyKey(connectionID, guestID)

ok, err := s.client.SetNX(ctx, key, "1", ttl).Result()
if err != nil {
return false, fmt.Errorf("voice reply window setnx: %w", err)
}

return ok, nil
}

func voiceReplyKey(connectionID string, guestID int64) string {
return voiceReplyKeyPrefix + connectionID + ":" + strconv.FormatInt(guestID, 10)
}

func (s *VoiceReplyWindowStore) Release(ctx context.Context, connectionID string, guestID int64) error {
key := voiceReplyKey(connectionID, guestID)
if err := s.client.Del(ctx, key).Err(); err != nil {
return fmt.Errorf("voice reply window release: %w", err)
}

return nil
}
126 changes: 126 additions & 0 deletions internal/gateways/redis/voice_reply_window_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package redis_test

import (
"context"
"testing"
"time"

redisstore "noirbot/internal/gateways/redis"

"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)

func newVoiceReplyStore(t *testing.T) (*redisstore.VoiceReplyWindowStore, *miniredis.Miniredis) {
t.Helper()

mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})

t.Cleanup(func() { _ = client.Close() })

return redisstore.NewVoiceReplyWindowStore(client), mr
}

func TestVoiceReplyWindowStore_TryEnter_OpensThenBlocks(t *testing.T) {
store, _ := newVoiceReplyStore(t)
ctx := context.Background()

const (
connID = "conn-1"
guestID = int64(42)
ttl = time.Minute
)

opened, err := store.TryEnter(ctx, connID, guestID, ttl)
require.NoError(t, err)
require.True(t, opened, "first call must open the window")

blocked, err := store.TryEnter(ctx, connID, guestID, ttl)
require.NoError(t, err)
require.False(t, blocked, "second call must see the window already open")
}

func TestVoiceReplyWindowStore_TryEnter_TTLIsSet(t *testing.T) {
store, mr := newVoiceReplyStore(t)
ctx := context.Background()

const ttl = 30 * time.Second

_, err := store.TryEnter(ctx, "conn-1", 42, ttl)
require.NoError(t, err)

keys := mr.Keys()
require.Len(t, keys, 1)
// miniredis rounds TTL to nearest second — check ≈ ttl
require.InDelta(t, ttl.Seconds(), mr.TTL(keys[0]).Seconds(), 1.0)
}

func TestVoiceReplyWindowStore_TryEnter_KeyExpires(t *testing.T) {
store, mr := newVoiceReplyStore(t)
ctx := context.Background()

const ttl = 3 * time.Second

opened, err := store.TryEnter(ctx, "conn-1", 42, ttl)
require.NoError(t, err)
require.True(t, opened)

// fast-forward past TTL
mr.FastForward(ttl + time.Second)

// window must reopen after expiry
openedAgain, err := store.TryEnter(ctx, "conn-1", 42, ttl)
require.NoError(t, err)
require.True(t, openedAgain, "after TTL expires, window must reopen")
}

func TestVoiceReplyWindowStore_TryEnter_ReleaseAllowsReentry(t *testing.T) {
store, _ := newVoiceReplyStore(t)
ctx := context.Background()

const (
connID = "conn-1"
guestID = int64(42)
ttl = time.Minute
)

opened, err := store.TryEnter(ctx, connID, guestID, ttl)
require.NoError(t, err)
require.True(t, opened)

require.NoError(t, store.Release(ctx, connID, guestID))

openedAgain, err := store.TryEnter(ctx, connID, guestID, ttl)
require.NoError(t, err)
require.True(t, openedAgain, "after Release window must reopen")
}

func TestVoiceReplyWindowStore_TryEnter_IsolatesPairs(t *testing.T) {
store, mr := newVoiceReplyStore(t)
ctx := context.Background()
ttl := time.Minute

// conn-1, guest 42 — open
opened, err := store.TryEnter(ctx, "conn-1", 42, ttl)
require.NoError(t, err)
require.True(t, opened)

// conn-1, guest 99 — different guest, must open
opened, err = store.TryEnter(ctx, "conn-1", 99, ttl)
require.NoError(t, err)
require.True(t, opened, "different guest must have own window")

// conn-2, guest 42 — different connection, must open
opened, err = store.TryEnter(ctx, "conn-2", 42, ttl)
require.NoError(t, err)
require.True(t, opened, "different connection must have own window")

// conn-1, guest 42 — still blocked
blocked, err := store.TryEnter(ctx, "conn-1", 42, ttl)
require.NoError(t, err)
require.False(t, blocked)

require.Len(t, mr.Keys(), 3)
}
1 change: 1 addition & 0 deletions internal/usecase/handle_business_message/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var (
ErrResolveOwner = errors.New("resolve owner failed")
ErrWhitelistCheck = errors.New("whitelist check failed")
ErrFloodDetect = errors.New("flood detection failed")
ErrVoiceWindow = errors.New("voice reply window failed")
ErrLLMGenerate = errors.New("llm generate failed")
ErrSend = errors.New("send reply failed")
)
Loading