3 Commits

Author SHA1 Message Date
HugeFrog24 1dbe2bd20f Prompt tuned (language) 2026-03-08 18:52:20 +01:00
HugeFrog24 bb4d462695 OK 2026-03-05 08:46:15 +01:00
HugeFrog24 265f6676d8 Design 2026-03-05 08:41:48 +01:00
14 changed files with 309 additions and 22 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
## Design Considerations ## Design Considerations
- AI-powered - AI-powered (Anthropic Claude)
- Voice message support (ElevenLabs STT + TTS) — optional, enabled per bot via config
- Supports multiple bot profiles - Supports multiple bot profiles
- Uses SQLite for persistence - Uses SQLite for persistence
- Implements rate limiting and user management - Implements rate limiting and user management
+1 -1
View File
@@ -90,7 +90,7 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Mes
for i, msg := range messages { for i, msg := range messages {
for _, content := range msg.Content { for _, content := range msg.Content {
if content.Type == anthropic.MessagesContentTypeText { if content.Type == anthropic.MessagesContentTypeText {
InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, content.Text) InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, *content.Text)
} }
} }
} }
+41 -8
View File
@@ -15,10 +15,10 @@ import (
) )
type Bot struct { type Bot struct {
tgBot TelegramClient tgBot TelegramClient
db *gorm.DB db *gorm.DB
anthropicClient *anthropic.Client anthropicClient *anthropic.Client
chatMemories map[int64]*ChatMemory chatMemories map[int64]*ChatMemory
memorySize int memorySize int
chatMemoriesMu sync.RWMutex chatMemoriesMu sync.RWMutex
config BotConfig config BotConfig
@@ -84,8 +84,8 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
anthropicClient := anthropic.NewClient(config.AnthropicAPIKey) anthropicClient := anthropic.NewClient(config.AnthropicAPIKey)
b := &Bot{ b := &Bot{
db: db, db: db,
anthropicClient: anthropicClient, anthropicClient: anthropicClient,
chatMemories: make(map[int64]*ChatMemory), chatMemories: make(map[int64]*ChatMemory),
memorySize: config.MemorySize, memorySize: config.MemorySize,
config: config, config: config,
@@ -355,7 +355,7 @@ func (b *Bot) registerAdminCommandsForUser(ctx context.Context, telegramID int64
allCommands = append(allCommands, adminBotCommands...) allCommands = append(allCommands, adminBotCommands...)
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{ _, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
Commands: allCommands, Commands: allCommands,
Scope: &models.BotCommandScopeChatMember{ChatID: telegramID, UserID: telegramID}, Scope: &models.BotCommandScopeChat{ChatID: telegramID},
}) })
if err != nil { if err != nil {
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err) ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
@@ -378,7 +378,7 @@ func setElevatedCommands(tgBot TelegramClient, users []User) {
} }
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{ _, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
Commands: allCommands, Commands: allCommands,
Scope: &models.BotCommandScopeChatMember{ChatID: u.TelegramID, UserID: u.TelegramID}, Scope: &models.BotCommandScopeChat{ChatID: u.TelegramID},
}) })
if err != nil { if err != nil {
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err) ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
@@ -470,6 +470,36 @@ func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetU
totalMessages, totalMessages,
) )
if b.hasScope(userID, ScopeStatsViewAny) {
type topEntry struct {
UserID int64
MsgCount int64
}
var top []topEntry
if err := b.db.Model(&Message{}).
Select("user_id, COUNT(*) as msg_count").
Where("bot_id = ? AND is_user = ? AND deleted_at IS NULL", b.botID, true).
Group("user_id").
Order("msg_count DESC").
Limit(3).
Scan(&top).Error; err != nil {
ErrorLogger.Printf("Error fetching top users: %v", err)
} else if len(top) > 0 {
statsMessage += "\n\n🏆 Most Active Users:"
for i, entry := range top {
var u User
if err := b.db.Select("username").Where("telegram_id = ? AND bot_id = ?", entry.UserID, b.botID).First(&u).Error; err != nil {
u.Username = fmt.Sprintf("ID:%d", entry.UserID)
}
name := u.Username
if name == "" {
name = fmt.Sprintf("ID:%d", entry.UserID)
}
statsMessage += fmt.Sprintf("\n%d. @%s — %d messages", i+1, name, entry.MsgCount)
}
}
}
// Send the response through the centralized screen // Send the response through the centralized screen
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending stats message: %v", err) ErrorLogger.Printf("Error sending stats message: %v", err)
@@ -645,6 +675,9 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
messageText = "Sent a sticker." messageText = "Sent a sticker."
} }
} }
if message.Voice != nil {
messageText = "[Voice message]"
}
userMessage := b.createMessage(message.Chat.ID, message.From.ID, message.From.Username, userRole, messageText, true) userMessage := b.createMessage(message.Chat.ID, message.From.ID, message.From.Username, userRole, messageText, true)
+5 -2
View File
@@ -22,8 +22,11 @@ type BotConfig struct {
SystemPrompts map[string]string `json:"system_prompts"` SystemPrompts map[string]string `json:"system_prompts"`
Active bool `json:"active"` Active bool `json:"active"`
OwnerTelegramID int64 `json:"owner_telegram_id"` OwnerTelegramID int64 `json:"owner_telegram_id"`
AnthropicAPIKey string `json:"anthropic_api_key"` AnthropicAPIKey string `json:"anthropic_api_key"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs ElevenLabsAPIKey string `json:"elevenlabs_api_key"`
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
ElevenLabsModel string `json:"elevenlabs_model"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
ConfigFilePath string `json:"-"` // Set at load time; not serialized ConfigFilePath string `json:"-"` // Set at load time; not serialized
} }
+5 -2
View File
@@ -4,16 +4,19 @@
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN", "telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
"owner_telegram_id": 111111111, "owner_telegram_id": 111111111,
"anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY", "anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY",
"elevenlabs_api_key": "",
"elevenlabs_voice_id": "",
"elevenlabs_model": "",
"memory_size": 10, "memory_size": 10,
"messages_per_hour": 20, "messages_per_hour": 20,
"messages_per_day": 100, "messages_per_day": 100,
"temp_ban_duration": "24h", "temp_ban_duration": "24h",
"model": "claude-3-5-haiku-latest", "model": "claude-haiku-4-5",
"temperature": 0.7, "temperature": 0.7,
"debug_screening": false, "debug_screening": false,
"system_prompts": { "system_prompts": {
"default": "You are a helpful assistant.", "default": "You are a helpful assistant.",
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.", "custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'. Prefer replying in this language when talking to '{username}'.\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.", "continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.", "avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only." "respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
+2 -2
View File
@@ -71,7 +71,7 @@ func createDefaultScopes(db *gorm.DB) error {
ScopeStatsViewOwn, ScopeStatsViewAny, ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny, ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny, ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
} }
for _, name := range all { for _, name := range all {
if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil { if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil {
@@ -88,7 +88,7 @@ func createDefaultScopes(db *gorm.DB) error {
ScopeStatsViewOwn, ScopeStatsViewAny, ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny, ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny, ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
} }
assignments := map[string][]string{ assignments := map[string][]string{
"user": userScopes, "user": userScopes,
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
tgbot "github.com/go-telegram/bot"
)
const (
elevenLabsTTSURL = "https://api.elevenlabs.io/v1/text-to-speech/"
elevenLabsSTTURL = "https://api.elevenlabs.io/v1/speech-to-text"
elevenLabsDefaultModel = "eleven_multilingual_v2"
)
// generateSpeech converts text to an mp3 audio stream via ElevenLabs TTS.
func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error) {
model := b.config.ElevenLabsModel
if model == "" {
model = elevenLabsDefaultModel
}
body, err := json.Marshal(map[string]string{
"text": text,
"model_id": model,
})
if err != nil {
return nil, fmt.Errorf("elevenlabs TTS marshal error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
elevenLabsTTSURL+b.config.ElevenLabsVoiceID, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("elevenlabs TTS request error: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("xi-api-key", b.config.ElevenLabsAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
errBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
}
return resp.Body, nil
}
// transcribeVoice downloads a Telegram voice file and transcribes it via ElevenLabs STT.
// Uses a direct multipart HTTP call instead of the SDK wrapper to avoid a bug in the
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
// string with Content-Type: application/json, which ElevenLabs rejects with 400.
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
// 1. Resolve and download the voice file from Telegram.
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
if err != nil {
return "", fmt.Errorf("telegram GetFile error: %w", err)
}
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
audioResp, err := http.Get(downloadURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("voice download error: %w", err)
}
defer audioResp.Body.Close()
// 2. Build multipart body with binary audio — bypasses SDK encoding issues.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := mw.WriteField("model_id", "scribe_v1"); err != nil {
return "", fmt.Errorf("multipart write error: %w", err)
}
part, err := mw.CreateFormFile("file", "audio.ogg")
if err != nil {
return "", fmt.Errorf("multipart create file error: %w", err)
}
if _, err := io.Copy(part, audioResp.Body); err != nil {
return "", fmt.Errorf("multipart copy error: %w", err)
}
if err := mw.Close(); err != nil {
return "", fmt.Errorf("multipart close error: %w", err)
}
// 3. POST to ElevenLabs STT.
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
elevenLabsSTTURL, &buf)
if err != nil {
return "", fmt.Errorf("create STT request error: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("xi-api-key", b.config.ElevenLabsAPIKey)
sttResp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("elevenlabs STT request error: %w", err)
}
defer sttResp.Body.Close()
if sttResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(sttResp.Body)
return "", fmt.Errorf("elevenlabs STT error: status %d: %s", sttResp.StatusCode, body)
}
var result struct {
Text string `json:"text"`
}
if err := json.NewDecoder(sttResp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("elevenlabs STT decode error: %w", err)
}
return result.Text, nil
}
Binary file not shown.
+3
View File
@@ -15,9 +15,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+14 -1
View File
@@ -1,3 +1,4 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg= github.com/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg=
@@ -6,12 +7,23 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/liushuangls/go-anthropic/v2 v2.17.1 h1:ca3oFzgQHs9/mJr+xx2XFQIYcQLM2rDCqieUx0g+8p4= github.com/liushuangls/go-anthropic/v2 v2.17.1 h1:ca3oFzgQHs9/mJr+xx2XFQIYcQLM2rDCqieUx0g+8p4=
github.com/liushuangls/go-anthropic/v2 v2.17.1/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU= github.com/liushuangls/go-anthropic/v2 v2.17.1/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -20,8 +32,9 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+87 -2
View File
@@ -12,6 +12,85 @@ import (
"github.com/liushuangls/go-anthropic/v2" "github.com/liushuangls/go-anthropic/v2"
) )
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) {
// If ElevenLabs is not configured, respond with text — consistent with all other error paths.
if b.config.ElevenLabsAPIKey == "" {
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending voice-unsupported message: %v", err)
}
return
}
if !b.hasScope(userID, ScopeTTSUse) {
if err := b.sendResponse(ctx, chatID, "You don't have permission to use voice features.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending permission denied message: %v", err)
}
return
}
transcript, err := b.transcribeVoice(ctx, message.Voice.FileID)
if err != nil {
ErrorLogger.Printf("Error transcribing voice message from user %d: %v", userID, err)
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't understand your voice message.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending transcription error message: %v", err)
}
return
}
// Replace the stored "[Voice message]" placeholder with the actual transcript,
// keeping the audit record intact while giving the LLM meaningful context.
if err := b.db.Model(&userMsg).Update("text", transcript).Error; err != nil {
ErrorLogger.Printf("Error updating voice transcript in DB: %v", err)
}
b.chatMemoriesMu.Lock()
if mem, exists := b.chatMemories[chatID]; exists {
for i := len(mem.Messages) - 1; i >= 0; i-- {
if mem.Messages[i].ID == userMsg.ID {
mem.Messages[i].Text = transcript
break
}
}
}
b.chatMemoriesMu.Unlock()
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending anthropic error response: %v", err)
}
return
}
audioReader, err := b.generateSpeech(ctx, response)
if err != nil {
// TTS failed — fall back to text so the user still gets a reply.
ErrorLogger.Printf("Error generating speech, falling back to text: %v", err)
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending text fallback: %v", err)
}
return
}
// Store the assistant response before sending.
if _, err := b.screenOutgoingMessage(chatID, response); err != nil {
ErrorLogger.Printf("Error storing assistant voice response: %v", err)
}
params := &bot.SendAudioParams{
ChatID: chatID,
Audio: &models.InputFileUpload{Filename: "response.mp3", Data: audioReader},
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendAudio(ctx, params); err != nil {
ErrorLogger.Printf("Error sending audio to chat %d: %v", chatID, err)
}
}
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse // anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users // fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
// always get the generic fallback to avoid leaking internal details. // always get the generic fallback to avoid leaking internal details.
@@ -74,8 +153,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the user is the owner // Determine if the user is the owner
var isOwner bool var isOwner bool
err = b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
if err == nil {
isOwner = true isOwner = true
} }
@@ -236,6 +314,13 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
return return
} }
// Check if the message contains a voice note (context is built inside the handler
// after the transcript replaces the placeholder, so it must not be built here).
if message.Voice != nil {
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
return
}
// Build context once — shared by the sticker and text response paths. // Build context once — shared by the sticker and text response paths.
chatMemory := b.getOrCreateChatMemory(chatID) chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory) contextMessages := b.prepareContextMessages(chatMemory)
+1
View File
@@ -60,6 +60,7 @@ const (
ScopeHistoryClearHardAny = "history:clear_hard:any" ScopeHistoryClearHardAny = "history:clear_hard:any"
ScopeModelSet = "model:set" ScopeModelSet = "model:set"
ScopeUserPromote = "user:promote" ScopeUserPromote = "user:promote"
ScopeTTSUse = "tts:use"
) )
type Scope struct { type Scope struct {
+3
View File
@@ -11,6 +11,9 @@ import (
// TelegramClient defines the methods required from the Telegram bot. // TelegramClient defines the methods required from the Telegram bot.
type TelegramClient interface { type TelegramClient interface {
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error) SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
FileDownloadLink(f *models.File) string
Start(ctx context.Context) Start(ctx context.Context)
} }
+30 -3
View File
@@ -12,9 +12,12 @@ import (
// MockTelegramClient is a mock implementation of TelegramClient for testing. // MockTelegramClient is a mock implementation of TelegramClient for testing.
type MockTelegramClient struct { type MockTelegramClient struct {
mock.Mock mock.Mock
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error) SendAudioFunc func(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
StartFunc func(ctx context.Context) SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
GetFileFunc func(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
FileDownloadLinkFunc func(f *models.File) string
StartFunc func(ctx context.Context)
} }
// SendMessage mocks sending a message. // SendMessage mocks sending a message.
@@ -37,6 +40,30 @@ func (m *MockTelegramClient) SetMyCommands(ctx context.Context, params *bot.SetM
return true, nil return true, nil
} }
// SendAudio mocks sending an audio message.
func (m *MockTelegramClient) SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error) {
if m.SendAudioFunc != nil {
return m.SendAudioFunc(ctx, params)
}
return nil, nil
}
// GetFile mocks retrieving file info from Telegram.
func (m *MockTelegramClient) GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error) {
if m.GetFileFunc != nil {
return m.GetFileFunc(ctx, params)
}
return &models.File{}, nil
}
// FileDownloadLink mocks building the file download URL.
func (m *MockTelegramClient) FileDownloadLink(f *models.File) string {
if m.FileDownloadLinkFunc != nil {
return m.FileDownloadLinkFunc(f)
}
return ""
}
// Start mocks starting the Telegram client. // Start mocks starting the Telegram client.
func (m *MockTelegramClient) Start(ctx context.Context) { func (m *MockTelegramClient) Start(ctx context.Context) {
if m.StartFunc != nil { if m.StartFunc != nil {