mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-05-01 07:42:18 +00:00
Compare commits
5 Commits
bb4d462695
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 44fcd02d9a | |||
| d8d0da4704 | |||
| c8af457af1 | |||
| e5532df7f9 | |||
| 0ab56448c7 |
@@ -4,8 +4,7 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
|
|||||||
|
|
||||||
## Design Considerations
|
## Design Considerations
|
||||||
|
|
||||||
- AI-powered (Anthropic Claude)
|
- AI-powered
|
||||||
- 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.BotCommandScopeChat{ChatID: telegramID},
|
Scope: &models.BotCommandScopeChatMember{ChatID: telegramID, UserID: 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.BotCommandScopeChat{ChatID: u.TelegramID},
|
Scope: &models.BotCommandScopeChatMember{ChatID: u.TelegramID, UserID: 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,36 +470,6 @@ 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)
|
||||||
@@ -675,9 +645,6 @@ 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,9 +23,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -4,14 +4,11 @@
|
|||||||
"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-haiku-4-5",
|
"model": "claude-3-5-haiku-latest",
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"debug_screening": false,
|
"debug_screening": false,
|
||||||
"system_prompts": {
|
"system_prompts": {
|
||||||
|
|||||||
+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, ScopeTTSUse,
|
ScopeModelSet, ScopeUserPromote,
|
||||||
}
|
}
|
||||||
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, ScopeTTSUse,
|
ScopeModelSet, ScopeUserPromote,
|
||||||
}
|
}
|
||||||
assignments := map[string][]string{
|
assignments := map[string][]string{
|
||||||
"user": userScopes,
|
"user": userScopes,
|
||||||
|
|||||||
-115
@@ -1,115 +0,0 @@
|
|||||||
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,12 +15,9 @@ 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,4 +1,3 @@
|
|||||||
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=
|
||||||
@@ -7,23 +6,12 @@ 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=
|
||||||
@@ -32,9 +20,8 @@ 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=
|
||||||
|
|||||||
+2
-87
@@ -12,85 +12,6 @@ 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.
|
||||||
@@ -153,7 +74,8 @@ 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
|
||||||
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
|
err = b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error
|
||||||
|
if err == nil {
|
||||||
isOwner = true
|
isOwner = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,13 +236,6 @@ 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,7 +60,6 @@ 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,9 +11,6 @@ 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,10 +13,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,30 +37,6 @@ 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