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
17 changes: 17 additions & 0 deletions .codegraph/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ The bot reacts to a single greeting or to flooding within a defined interval win
- Go 1.26.3
- [Golang Telegram Bot](https://github.com/go-telegram/bot)
- [DeepSeek API](https://platform.deepseek.com/)

Redis or some tiny DB like SQLite is on the TODO list, in case the bot needs to grow.
- [Redis](https://redis.io/)

## How to deploy on your own VDS

Expand Down
3 changes: 1 addition & 2 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ Telegram открыл возможность установить бота-се
- Go 1.26.3
- [Golang Telegram Bot](https://github.com/go-telegram/bot)
- [DeepSeek API](https://platform.deepseek.com/)

В TODO занесен Redis или какая-то простенькая базка типа SQLite, если потребуется расширение бота.
- [Redis](https://redis.io/)

## Как поднять у себя на VDS

Expand Down
59 changes: 52 additions & 7 deletions cmd/service/main.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
package main

import (
"context"
"fmt"
"log/slog"
"noirbot/internal/domain/repository"
"noirbot/internal/domain/service"
"noirbot/internal/gateways/deepseek"
httpgw "noirbot/internal/gateways/http"
"noirbot/internal/gateways/memory"
redisstore "noirbot/internal/gateways/redis"
"noirbot/internal/gateways/telegram/inbound"
"noirbot/internal/gateways/telegram/outbound"
"noirbot/internal/usecase/handle_business_connection"
"noirbot/internal/usecase/handle_business_message"
"noirbot/pkg/config"
"os"

httpgw "noirbot/internal/gateways/http"

"github.com/go-telegram/bot"
"github.com/redis/go-redis/v9"
"go.uber.org/fx"
)

Expand All @@ -29,6 +31,7 @@ func main() {

newGreetingDetector,
newFloodDetector,
newShortVoiceDetector,

newOwnerWhitelist,
newBusinessConnectionStore,
Expand All @@ -40,6 +43,8 @@ func main() {
newDeepseekConfig,
newLLMClient,

newRedisClient,

newHandleBusinessMessageConfig,
handle_business_connection.New,
handle_business_message.New,
Expand All @@ -55,6 +60,7 @@ func main() {
fx.Invoke(
wireLazyHandler,
httpgw.RegisterRoutes,
bindRedisLifecycle,
bindHTTPServerLifecycle,
),
)
Expand All @@ -73,6 +79,24 @@ func bindHTTPServerLifecycle(lc fx.Lifecycle, s *httpgw.Server) {
})
}

func bindRedisLifecycle(cfg *config.Config, lc fx.Lifecycle, r *redis.Client) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, cfg.Redis.DialTimeout)
defer cancel()

if err := r.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("ping redis: %w", err)
}

return nil
},
OnStop: func(_ context.Context) error {
return r.Close()
},
})
}

func newLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
Expand Down Expand Up @@ -100,16 +124,22 @@ 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)
}

func newBusinessConnectionStore() repository.BusinessConnectionStore {
return memory.NewBusinessConnectionStore()
func newBusinessConnectionStore(r *redis.Client, cfg *config.Config) repository.BusinessConnectionStore {
return redisstore.NewBusinessConnectionStore(r, cfg.Redis.BusinessConnectionTTL)
}

func newMessageWindowStore() repository.MessageWindowStore {
return memory.NewMessageWindowStore()
func newMessageWindowStore(r *redis.Client, cfg *config.Config) repository.MessageWindowStore {
return redisstore.NewMessageWindowStore(r, cfg.Flood.WindowDuration, cfg.Flood.RedisTTL)
}

func newBusinessSender(b *bot.Bot) repository.BusinessSender {
Expand All @@ -135,6 +165,21 @@ 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,
}
}

func newRedisClient(cfg *config.Config) *redis.Client {
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
PoolSize: cfg.Redis.PoolSize,
})

return rdb
}
34 changes: 33 additions & 1 deletion docker/docker-compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ services:
bot:
image: ${BOT_IMAGE:?BOT_IMAGE is required}
container_name: noirBot
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
env_file:
- .env

networks:
- traefik-network
- bot-internal

labels:
- "traefik.enable=true"
- "traefik.docker.network=telegram-n8n-bot_traefik-network"
Expand All @@ -33,7 +38,34 @@ services:
- "traefik.http.routers.noirbot-health.priority=90"
- "traefik.http.routers.noirbot-health.service=noirbot"

redis:
image: redis:7-alpine
container_name: noirBot-redis
restart: unless-stopped
command: redis-server --appendonly yes
--maxmemory 256mb
--maxmemory-policy noeviction
--requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
volumes:
- redis-data:/data

networks:
- bot-internal

healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s

networks:
traefik-network:
external: true
name: telegram-n8n-bot_traefik-network
name: telegram-n8n-bot_traefik-network

bot-internal:
driver: bridge

volumes:
redis-data:
10 changes: 10 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ BOT_TOKEN=wowthisisbottokenamaizing!
BOT_DOMAIN=bot.example.com
WEBHOOK_SECRET=andthisiswebhooksecret

# Production: container name from docker-compose.prod.yaml
REDIS_ADDR=bot-redis:6379
# Local dev (when polling/local-compose is added):
# REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_BUSINESS_TTL=604800s
FLOOD_REDIS_TTL=120s

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
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ module noirbot
go 1.26.3

require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/gin-gonic/gin v1.12.0
github.com/go-telegram/bot v1.21.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/redis/go-redis/v9 v9.20.0
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
go.uber.org/mock v0.6.0
Expand All @@ -16,6 +18,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
Expand All @@ -37,7 +40,9 @@ require (
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
Expand Down
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -59,6 +67,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand All @@ -76,8 +86,14 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
Expand Down
9 changes: 9 additions & 0 deletions internal/domain/model/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 4 additions & 3 deletions internal/domain/model/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions internal/domain/service/short_voice_detector.go
Original file line number Diff line number Diff line change
@@ -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(),
}
}
Loading