mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-05-01 07:42:18 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dbe2bd20f | |||
| bb4d462695 | |||
| 265f6676d8 |
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type BotConfig struct {
|
|||||||
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"`
|
||||||
|
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
|
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
@@ -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
@@ -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
@@ -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.
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import (
|
|||||||
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)
|
||||||
|
SendAudioFunc func(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
|
||||||
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
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)
|
StartFunc func(ctx context.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user