Compare commits

..

7 Commits

Author SHA1 Message Date
HugeFrog24 e1a9261699 Security/quality 2026-03-05 01:51:59 +01:00
HugeFrog24 6e2d2fce2f Precision and dep upgrade 2024-10-23 23:08:22 +02:00
HugeFrog24 37d6242c06 Added readme 2024-10-23 22:06:56 +02:00
HugeFrog24 d8d0da4704 Upgrade dependencies
Added tests, revised logging

Removed dependency on env file

Try reformatting unit file

Comments clarification

Added readme

Added readme
2024-10-23 22:06:56 +02:00
HugeFrog24 c8af457af1 MVP
md formatting doesnt work yet

Started implementing owner feature

Add .gitattributes to enforce LF line endings

Temporary commit before merge

Updated owner management

Updated json and gitignore

Proceed with role management

Again, CI

Fix some lint errors

Implemented screening

Per-bot API keys implemented

Use getRoleByName func

Fix unused imports

Upgrade actions

rm unused function

Upgrade action

Fix unaddressed errors
2024-10-23 22:06:55 +02:00
HugeFrog24 e5532df7f9 Handle business messages 2024-10-20 15:52:41 +02:00
HugeFrog24 0ab56448c7 Multibot finished 2024-10-13 16:41:03 +02:00
17 changed files with 276 additions and 1124 deletions
+1 -3
View File
@@ -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
@@ -124,7 +123,6 @@ journalctl -u telegram-bot -f
| `/clear_hard` | All users | Permanently delete your own chat history | | `/clear_hard` | All users | Permanently delete your own chat history |
| `/clear_hard <user_id>` | Admin/Owner | Permanently delete all messages for a user across every chat | | `/clear_hard <user_id>` | Admin/Owner | Permanently delete all messages for a user across every chat |
| `/clear_hard <user_id> <chat_id>` | Admin/Owner | Permanently delete a user's messages in a specific chat | | `/clear_hard <user_id> <chat_id>` | Admin/Owner | Permanently delete a user's messages in a specific chat |
| `/set_model <model-id>` | Admin/Owner | Switch the AI model live without restarting |
> **Note:** In private DMs each user's `chat_id` equals their `user_id`. The scoped `<chat_id>` form is mainly useful for group chat moderation. > **Note:** In private DMs each user's `chat_id` equals their `user_id`. The scoped `<chat_id>` form is mainly useful for group chat moderation.
+3 -13
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -10,12 +9,7 @@ import (
"github.com/liushuangls/go-anthropic/v2" "github.com/liushuangls/go-anthropic/v2"
) )
// ErrModelNotFound is returned when the configured Anthropic model is no longer available func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isAdminOrOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) {
// (deprecated or removed). Callers can use errors.Is to detect this and surface an
// actionable message to admins/owners while keeping the response vague for regular users.
var ErrModelNotFound = errors.New("model not found or deprecated")
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) {
// Use prompts from config // Use prompts from config
var systemMessage string var systemMessage string
if isNewChat { if isNewChat {
@@ -77,7 +71,7 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Mes
} }
systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext) systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext)
if !isOwner { if !isAdminOrOwner {
systemMessage += " " + b.config.SystemPrompts["avoid_sensitive"] systemMessage += " " + b.config.SystemPrompts["avoid_sensitive"]
} }
@@ -90,7 +84,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)
} }
} }
} }
@@ -125,10 +119,6 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Mes
resp, err := b.anthropicClient.CreateMessages(ctx, request) resp, err := b.anthropicClient.CreateMessages(ctx, request)
if err != nil { if err != nil {
var apiErr *anthropic.APIError
if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() {
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
}
return "", fmt.Errorf("error creating Anthropic message: %w", err) return "", fmt.Errorf("error creating Anthropic message: %w", err)
} }
+35 -140
View File
@@ -221,18 +221,13 @@ func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
if !isNewChat { if !isNewChat {
// Fetch existing messages only if it's not a new chat // Fetch existing messages only if it's not a new chat
err := b.db.Where("chat_id = ? AND bot_id = ?", chatID, b.botID). err := b.db.Where("chat_id = ? AND bot_id = ?", chatID, b.botID).
Order("timestamp desc"). Order("timestamp asc").
Limit(b.memorySize * 2). Limit(b.memorySize * 2).
Find(&messages).Error Find(&messages).Error
if err != nil { if err != nil {
ErrorLogger.Printf("Error fetching messages from database: %v", err) ErrorLogger.Printf("Error fetching messages from database: %v", err)
messages = []Message{} // Initialize an empty slice on error messages = []Message{} // Initialize an empty slice on error
} else {
// Reverse from newest-first to chronological order for conversation context.
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
} }
} else { } else {
messages = []Message{} // Ensure messages is initialized for new chats messages = []Message{} // Ensure messages is initialized for new chats
@@ -308,82 +303,13 @@ func (b *Bot) isNewChat(chatID int64) bool {
return count == 0 // Only consider a chat new if it has 0 messages return count == 0 // Only consider a chat new if it has 0 messages
} }
// roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name. func (b *Bot) isAdminOrOwner(userID int64) bool {
func roleHasScope(role Role, scope string) bool {
for _, s := range role.Scopes {
if s.Name == scope {
return true
}
}
return false
}
// hasScope reports whether the user identified by userID holds the given scope for this bot.
// Owners implicitly hold all scopes regardless of their assigned role.
func (b *Bot) hasScope(userID int64, scope string) bool {
var user User var user User
if err := b.db.Preload("Role.Scopes"). err := b.db.Preload("Role").Where("telegram_id = ? AND bot_id = ?", userID, b.botID).First(&user).Error
Where("telegram_id = ? AND bot_id = ?", userID, b.botID). if err != nil {
First(&user).Error; err != nil {
return false return false
} }
if user.IsOwner { return user.Role.Name == "admin" || user.Role.Name == "owner"
return true
}
return roleHasScope(user.Role, scope)
}
// publicBotCommands are shown to every user in the Telegram command palette.
var publicBotCommands = []models.BotCommand{
{Command: "stats", Description: "Get bot statistics. Usage: /stats or /stats user [user_id]"},
{Command: "whoami", Description: "Get your user information"},
{Command: "clear", Description: "Clear chat history (soft delete). Admins: /clear [user_id]"},
}
// adminBotCommands are shown only in admin/owner chats via BotCommandScopeChatMember.
var adminBotCommands = []models.BotCommand{
{Command: "clear_hard", Description: "Clear chat history (permanently delete). Admins: /clear_hard [user_id]"},
{Command: "set_model", Description: "Switch the AI model (admin/owner only). Usage: /set_model <model-id>"},
}
// registerAdminCommandsForUser scopes the full command palette to a specific user's private chat.
// In Telegram private chats, chat_id == user_id, so both fields carry the same value.
// Errors are logged but treated as non-fatal: the user retains access via permission checks.
func (b *Bot) registerAdminCommandsForUser(ctx context.Context, telegramID int64) {
allCommands := make([]models.BotCommand, 0, len(publicBotCommands)+len(adminBotCommands))
allCommands = append(allCommands, publicBotCommands...)
allCommands = append(allCommands, adminBotCommands...)
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: telegramID},
})
if err != nil {
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
}
}
// setElevatedCommands registers the full command palette (public + admin) for every user
// whose role carries the model:set scope, or who is the bot owner. Called once at startup
// and uses the freshly created tgBot directly (b.tgBot is not yet assigned at that point).
func setElevatedCommands(tgBot TelegramClient, users []User) {
allCommands := make([]models.BotCommand, 0, len(publicBotCommands)+len(adminBotCommands))
allCommands = append(allCommands, publicBotCommands...)
allCommands = append(allCommands, adminBotCommands...)
for _, u := range users {
if u.TelegramID == 0 {
continue // skip placeholder users not yet seen in a chat
}
if !u.IsOwner && !roleHasScope(u.Role, ScopeModelSet) {
continue
}
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: u.TelegramID},
})
if err != nil {
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
}
}
} }
func initTelegramBot(token string, b *Bot) (TelegramClient, error) { func initTelegramBot(token string, b *Bot) (TelegramClient, error) {
@@ -396,25 +322,33 @@ func initTelegramBot(token string, b *Bot) (TelegramClient, error) {
return nil, err return nil, err
} }
// Register public commands for all users. // Define bot commands
_, err = tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{ commands := []models.BotCommand{
Commands: publicBotCommands, {
Scope: &models.BotCommandScopeDefault{}, Command: "stats",
}) Description: "Get bot statistics. Usage: /stats or /stats user [user_id]",
if err != nil { },
ErrorLogger.Printf("Error setting default bot commands: %v", err) {
return nil, err Command: "whoami",
Description: "Get your user information",
},
{
Command: "clear",
Description: "Clear chat history (soft delete). Admins: /clear [user_id]",
},
{
Command: "clear_hard",
Description: "Clear chat history (permanently delete). Admins: /clear_hard [user_id]",
},
} }
// Register full command palette (public + admin) scoped to each known elevated user. // Set bot commands
// BotCommandScopeChatMember targets the user's private DM with the bot (chat_id == user_id). _, err = tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
// Elevation is determined by scope rather than role name, so renaming roles requires no code change. Commands: commands,
// This is best-effort: failures are logged but do not prevent the bot from starting. })
var allUsers []User if err != nil {
if err := b.db.Preload("Role.Scopes").Where("bot_id = ?", b.botID).Find(&allUsers).Error; err != nil { ErrorLogger.Printf("Error setting bot commands: %v", err)
ErrorLogger.Printf("Warning: could not query users for command scoping: %v", err) return nil, err
} else {
setElevatedCommands(tgBot, allUsers)
} }
return tgBot, nil return tgBot, nil
@@ -470,36 +404,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)
@@ -510,7 +414,7 @@ func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetU
// If targetUserID is not 0, show user-specific stats // If targetUserID is not 0, show user-specific stats
// Check permissions if the user is trying to view someone else's stats // Check permissions if the user is trying to view someone else's stats
if targetUserID != userID { if targetUserID != userID {
if !b.hasScope(userID, ScopeStatsViewAny) { if !b.isAdminOrOwner(userID) {
InfoLogger.Printf("User %d attempted to view stats for user %d without permission", userID, targetUserID) InfoLogger.Printf("User %d attempted to view stats for user %d without permission", userID, targetUserID)
if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can view other users' statistics.", businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can view other users' statistics.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err) ErrorLogger.Printf("Error sending response: %v", err)
@@ -584,7 +488,7 @@ func (b *Bot) getUserStats(userID int64) (string, int64, int64, int64, error) {
// Count responses to the user (OUT) // Count responses to the user (OUT)
var messagesOut int64 var messagesOut int64
if err := b.db.Model(&Message{}).Where("chat_id IN (SELECT DISTINCT chat_id FROM messages WHERE user_id = ? AND bot_id = ? AND deleted_at IS NULL) AND bot_id = ? AND is_user = ?", if err := b.db.Model(&Message{}).Where("chat_id IN (SELECT DISTINCT chat_id FROM messages WHERE user_id = ? AND bot_id = ?) AND bot_id = ? AND is_user = ?",
userID, b.botID, b.botID, false).Count(&messagesOut).Error; err != nil { userID, b.botID, b.botID, false).Count(&messagesOut).Error; err != nil {
return "", 0, 0, 0, err return "", 0, 0, 0, err
} }
@@ -675,9 +579,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)
@@ -748,8 +649,8 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
} }
func (b *Bot) promoteUserToAdmin(promoterID, userToPromoteID int64) error { func (b *Bot) promoteUserToAdmin(promoterID, userToPromoteID int64) error {
// Check if the promoter has the user:promote scope // Check if the promoter is an owner or admin
if !b.hasScope(promoterID, ScopeUserPromote) { if !b.isAdminOrOwner(promoterID) {
return errors.New("only admins or owners can promote users to admin") return errors.New("only admins or owners can promote users to admin")
} }
@@ -768,11 +669,5 @@ func (b *Bot) promoteUserToAdmin(promoterID, userToPromoteID int64) error {
// Update the user's role // Update the user's role
userToPromote.RoleID = adminRole.ID userToPromote.RoleID = adminRole.ID
userToPromote.Role = adminRole userToPromote.Role = adminRole
if err := b.db.Save(&userToPromote).Error; err != nil { return b.db.Save(&userToPromote).Error
return err
}
// Surface admin commands in the newly promoted user's private chat.
b.registerAdminCommandsForUser(context.Background(), userToPromoteID)
return nil
} }
-37
View File
@@ -23,11 +23,7 @@ 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
} }
// Custom unmarshalling to handle anthropic.Model // Custom unmarshalling to handle anthropic.Model
@@ -112,7 +108,6 @@ func loadAllConfigs(dir string) ([]BotConfig, error) {
continue continue
} }
config.ConfigFilePath = validPath
configs = append(configs, config) configs = append(configs, config)
} }
} }
@@ -205,35 +200,3 @@ func (c *BotConfig) Reload(configDir, filename string) error {
c.Model = anthropic.Model(c.Model) c.Model = anthropic.Model(c.Model)
return nil return nil
} }
// PersistModel updates the model field in memory and writes it back to the config file on disk.
// Only the "model" key is changed; all other fields are preserved verbatim.
func (c *BotConfig) PersistModel(newModel string) error {
if c.ConfigFilePath == "" {
return fmt.Errorf("config file path not set; cannot persist model")
}
data, err := os.ReadFile(c.ConfigFilePath)
if err != nil {
return fmt.Errorf("failed to read config for update: %w", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("failed to parse config for update: %w", err)
}
raw["model"] = newModel
updated, err := json.MarshalIndent(raw, "", "\t")
if err != nil {
return fmt.Errorf("failed to re-encode config: %w", err)
}
if err := os.WriteFile(c.ConfigFilePath, updated, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
c.Model = anthropic.Model(newModel)
return nil
}
+2 -5
View File
@@ -4,19 +4,16 @@
"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": {
"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}'. 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.", "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.",
"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."
-64
View File
@@ -752,67 +752,3 @@ func TestTemperatureConfig(t *testing.T) {
} }
// Additional tests can be added here to cover more scenarios // Additional tests can be added here to cover more scenarios
// TestBotConfig_PersistModel verifies that PersistModel updates the model both in memory
// and on disk while leaving all other config fields unchanged.
func TestBotConfig_PersistModel(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
tempDir, err := os.MkdirTemp("", "persist_model_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tempDir); err != nil {
t.Errorf("Failed to remove temp directory: %v", err)
}
}()
initialJSON := `{
"id": "bot1",
"telegram_token": "token1",
"model": "claude-v1",
"messages_per_hour": 10,
"messages_per_day": 100
}`
configPath := filepath.Join(tempDir, "config.json")
if err := os.WriteFile(configPath, []byte(initialJSON), 0600); err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
config := BotConfig{
ID: "bot1",
Model: "claude-v1",
ConfigFilePath: configPath,
}
// Successful model update
if err := config.PersistModel("claude-sonnet-4-6"); err != nil {
t.Fatalf("PersistModel() unexpected error: %v", err)
}
// In-memory model must be updated immediately
if string(config.Model) != "claude-sonnet-4-6" {
t.Errorf("in-memory model: got %q, want %q", config.Model, "claude-sonnet-4-6")
}
// On-disk model must be updated; other fields must be preserved
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read updated config file: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("Failed to unmarshal updated config: %v", err)
}
if raw["model"] != "claude-sonnet-4-6" {
t.Errorf("on-disk model: got %v, want %q", raw["model"], "claude-sonnet-4-6")
}
if raw["id"] != "bot1" {
t.Errorf("on-disk id should be preserved: got %v, want %q", raw["id"], "bot1")
}
// Error case: empty ConfigFilePath must return an error
noPath := BotConfig{Model: "claude-v1"}
if err := noPath.PersistModel("claude-sonnet-4-6"); err == nil {
t.Error("PersistModel with empty ConfigFilePath: expected error, got nil")
}
}
+2 -52
View File
@@ -25,7 +25,7 @@ func initDB() (*gorm.DB, error) {
}, },
) )
db, err := gorm.Open(sqlite.Open("data/bot.db?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on"), &gorm.Config{ db, err := gorm.Open(sqlite.Open("data/bot.db?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{
Logger: newLogger, Logger: newLogger,
}) })
if err != nil { if err != nil {
@@ -39,7 +39,7 @@ func initDB() (*gorm.DB, error) {
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
// AutoMigrate the models // AutoMigrate the models
err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{}, &Scope{}) err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to migrate database schema: %w", err) return nil, fmt.Errorf("failed to migrate database schema: %w", err)
} }
@@ -59,59 +59,9 @@ func initDB() (*gorm.DB, error) {
return nil, err return nil, err
} }
if err := createDefaultScopes(db); err != nil {
return nil, fmt.Errorf("createDefaultScopes: %w", err)
}
return db, nil return db, nil
} }
func createDefaultScopes(db *gorm.DB) error {
all := []string{
ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
}
for _, name := range all {
if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil {
return fmt.Errorf("failed to create scope %s: %w", name, err)
}
}
userScopes := []string{
ScopeStatsViewOwn,
ScopeHistoryClearOwn,
ScopeHistoryClearHardOwn,
}
elevatedScopes := []string{
ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
}
assignments := map[string][]string{
"user": userScopes,
"admin": elevatedScopes,
// owner gets the same scopes as admin; owner uniqueness is enforced by the IsOwner flag
"owner": elevatedScopes,
}
for roleName, scopes := range assignments {
var role Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return fmt.Errorf("role %s not found: %w", roleName, err)
}
var scopeModels []Scope
if err := db.Where("name IN ?", scopes).Find(&scopeModels).Error; err != nil {
return fmt.Errorf("failed to find scopes for %s: %w", roleName, err)
}
if err := db.Model(&role).Association("Scopes").Replace(scopeModels); err != nil {
return fmt.Errorf("failed to assign scopes to %s: %w", roleName, err)
}
}
return nil
}
func createDefaultRoles(db *gorm.DB) error { func createDefaultRoles(db *gorm.DB) error {
roles := []string{"user", "admin", "owner"} roles := []string{"user", "admin", "owner"}
for _, roleName := range roles { for _, roleName := range roles {
-115
View File
@@ -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.
-3
View File
@@ -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 -14
View File
@@ -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=
+69 -174
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -12,99 +11,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
// 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.
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
return fmt.Sprintf(
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
b.config.Model,
)
}
return "I'm sorry, I'm having trouble processing your request right now."
}
func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.Update) { func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.Update) {
var message *models.Message var message *models.Message
@@ -141,7 +47,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
messageTime := message.Date messageTime := message.Date
text := message.Text text := message.Text
// Check if it's a new chat (before storing the message so the flag is accurate). // Check if it's a new chat
isNewChatFlag := b.isNewChat(chatID) isNewChatFlag := b.isNewChat(chatID)
// Screen incoming message (store to DB + add to chat memory) // Screen incoming message (store to DB + add to chat memory)
@@ -153,18 +59,38 @@ 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
} }
// Always create/get the user record — on the very first message and on all subsequent ones. // Get the chat memory which now contains the user's message
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
if isNewChatFlag {
// Get response from Anthropic using the context messages
response, err := b.getAnthropicResponse(ctx, contextMessages, true, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
// Use the same error message as in the non-new chat case
response = "I'm sorry, I'm having trouble processing your request right now."
}
// Send the AI-generated response or error message
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
return
}
} else {
user, err := b.getOrCreateUser(userID, username, isOwner) user, err := b.getOrCreateUser(userID, username, isOwner)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting or creating user: %v", err) ErrorLogger.Printf("Error getting or creating user: %v", err)
return return
} }
// Update the username if it has changed // Update the username if it's empty or has changed
if user.Username != username { if user.Username != username {
user.Username = username user.Username = username
if err := b.db.Save(&user).Error; err != nil { if err := b.db.Save(&user).Error; err != nil {
@@ -172,7 +98,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
} }
} }
// Check if the message is a command — applies on every message, including the very first. // Check if the message is a command
if message.Entities != nil { if message.Entities != nil {
for _, entity := range message.Entities { for _, entity := range message.Entities {
if entity.Type == "bot_command" { if entity.Type == "bot_command" {
@@ -244,38 +170,6 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
} }
b.clearChatHistory(ctx, chatID, userID, targetUserID, targetChatID, businessConnectionID, false) b.clearChatHistory(ctx, chatID, userID, targetUserID, targetChatID, businessConnectionID, false)
return return
case "/set_model":
if !b.hasScope(userID, ScopeModelSet) {
if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can change the model.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
parts := strings.Fields(message.Text)
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
if err := b.sendResponse(ctx, chatID, "Usage: /set_model <model-id>", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
newModel := strings.TrimSpace(parts[1])
// No upfront model validation:
// - The go-anthropic library constants are not enumerable at runtime (Go has no const reflection).
// - A live /v1/models probe would add a network round-trip and show in the API audit log.
// - An invalid model ID will produce a 404 on the next real message, which routes through
// anthropicErrorResponse and already delivers an actionable admin-facing hint.
if err := b.config.PersistModel(newModel); err != nil {
ErrorLogger.Printf("Failed to persist model change: %v", err)
if err := b.sendResponse(ctx, chatID, fmt.Sprintf("Model updated in memory to `%s`, but failed to save to config file: %v", newModel, err), businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
}
InfoLogger.Printf("Model changed to %s by user %d", newModel, userID)
if err := b.sendResponse(ctx, chatID, fmt.Sprintf("✅ Model updated to `%s` and saved to config.", newModel), businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
}
return
case "/clear_hard": case "/clear_hard":
parts := strings.Fields(message.Text) parts := strings.Fields(message.Text)
var targetUserID, targetChatID int64 var targetUserID, targetChatID int64
@@ -308,26 +202,15 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
} }
} }
// Rate limit check applies to all message types including stickers. // Rate limit check applies to all message types including stickers
if !b.checkRateLimits(userID) { if !b.checkRateLimits(userID) {
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID) b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
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.
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
// Check if the message contains a sticker // Check if the message contains a sticker
if message.Sticker != nil { if message.Sticker != nil {
b.handleStickerMessage(ctx, chatID, userMsg, message, contextMessages, businessConnectionID) b.handleStickerMessage(ctx, chatID, userMsg, message, businessConnectionID)
return return
} }
@@ -340,11 +223,15 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the text contains only emojis // Determine if the text contains only emojis
isEmojiOnly := isOnlyEmojis(text) isEmojiOnly := isOnlyEmojis(text)
// Prepare context messages for Anthropic
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
// Get response from Anthropic // Get response from Anthropic
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime) response, err := b.getAnthropicResponse(ctx, contextMessages, false, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime) // isNewChat is false here
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err) ErrorLogger.Printf("Error getting Anthropic response: %v", err)
response = b.anthropicErrorResponse(err, userID) response = "I'm sorry, I'm having trouble processing your request right now."
} }
// Send the response // Send the response
@@ -352,6 +239,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
ErrorLogger.Printf("Error sending response: %v", err) ErrorLogger.Printf("Error sending response: %v", err)
return return
} }
}
} }
func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, businessConnectionID string) { func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, businessConnectionID string) {
@@ -360,11 +248,11 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
} }
} }
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) { func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, businessConnectionID string) {
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again. // userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
// Generate AI response about the sticker // Generate AI response about the sticker
response, err := b.generateStickerResponse(ctx, userMessage, contextMessages) response, err := b.generateStickerResponse(ctx, userMessage)
if err != nil { if err != nil {
ErrorLogger.Printf("Error generating sticker response: %v", err) ErrorLogger.Printf("Error generating sticker response: %v", err)
// Provide a fallback dynamic response based on sticker type // Provide a fallback dynamic response based on sticker type
@@ -384,15 +272,35 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
} }
} }
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) { func (b *Bot) generateStickerResponse(ctx context.Context, message Message) (string, error) {
// contextMessages already contains the sticker turn (added by screenIncomingMessage as // Example: Use the sticker type to generate a response
// "Sent a sticker: <emoji>"), so the full conversation history is preserved.
if message.StickerFileID != "" { if message.StickerFileID != "" {
// Create message content with emoji information if available
var messageContent string
if message.StickerEmoji != "" {
messageContent = fmt.Sprintf("User sent a sticker: %s", message.StickerEmoji)
} else {
messageContent = "User sent a sticker."
}
// Prepare context with information about the sticker
contextMessages := []anthropic.Message{
{
Role: anthropic.RoleUser,
Content: []anthropic.MessageContent{
anthropic.NewTextMessageContent(messageContent),
},
},
}
// Treat sticker messages like emoji messages to get emoji responses
// Convert the timestamp to Unix time for the messageTime parameter
messageTime := int(message.Timestamp.Unix()) messageTime := int(message.Timestamp.Unix())
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime) response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime)
if err != nil { if err != nil {
return "", err return "", err
} }
return response, nil return response, nil
} }
@@ -402,11 +310,8 @@ func (b *Bot) generateStickerResponse(ctx context.Context, message Message, cont
func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID int64, targetUserID int64, targetChatID int64, businessConnectionID string, hardDelete bool) { func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID int64, targetUserID int64, targetChatID int64, businessConnectionID string, hardDelete bool) {
// If targetUserID is provided and different from currentUserID, check permissions // If targetUserID is provided and different from currentUserID, check permissions
if targetUserID != 0 && targetUserID != currentUserID { if targetUserID != 0 && targetUserID != currentUserID {
requiredScope := ScopeHistoryClearAny // Check if the current user is an admin or owner
if hardDelete { if !b.isAdminOrOwner(currentUserID) {
requiredScope = ScopeHistoryClearHardAny
}
if !b.hasScope(currentUserID, requiredScope) {
InfoLogger.Printf("User %d attempted to clear history for user %d without permission", currentUserID, targetUserID) InfoLogger.Printf("User %d attempted to clear history for user %d without permission", currentUserID, targetUserID)
if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can clear other users' histories.", businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can clear other users' histories.", businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err) ErrorLogger.Printf("Error sending response: %v", err)
@@ -445,42 +350,32 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
if hardDelete { if hardDelete {
// Permanently delete messages // Permanently delete messages
if targetUserID == currentUserID { if targetUserID == currentUserID {
// Own history — delete ALL messages (user + assistant) in the current chat. // Deleting own messages — scope to the current chat only.
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND user_id = ?", chatID, b.botID, targetUserID).Delete(&Message{}).Error
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID) InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
} else { } else {
// Deleting another user's messages — scope bot-wide by default; chat-scoped if targetChatID given (see above).
if targetChatID != 0 { if targetChatID != 0 {
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat. err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND user_id = ?", targetChatID, b.botID, targetUserID).Delete(&Message{}).Error
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID) InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
} else { } else {
// Bot-wide: delete all of the user's own messages across every chat, then delete
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
if err == nil {
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
}
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID) InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
} }
} }
} else { } else {
// Soft delete messages // Soft delete messages
if targetUserID == currentUserID { if targetUserID == currentUserID {
// Own history — delete ALL messages (user + assistant) in the current chat. // Deleting own messages — scope to the current chat only.
err = b.db.Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error err = b.db.Where("chat_id = ? AND bot_id = ? AND user_id = ?", chatID, b.botID, targetUserID).Delete(&Message{}).Error
InfoLogger.Printf("User %d soft deleted their own chat history in chat %d", currentUserID, chatID) InfoLogger.Printf("User %d soft deleted their own chat history in chat %d", currentUserID, chatID)
} else { } else {
// Deleting another user's messages — scope bot-wide by default; chat-scoped if targetChatID given (see above).
if targetChatID != 0 { if targetChatID != 0 {
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat. err = b.db.Where("chat_id = ? AND bot_id = ? AND user_id = ?", targetChatID, b.botID, targetUserID).Delete(&Message{}).Error
err = b.db.Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
InfoLogger.Printf("Admin/owner %d soft deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID) InfoLogger.Printf("Admin/owner %d soft deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
} else { } else {
// Bot-wide: delete all of the user's own messages across every chat, then delete
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
err = b.db.Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error err = b.db.Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
if err == nil {
err = b.db.Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
}
InfoLogger.Printf("Admin/owner %d soft deleted all chat history for user %d", currentUserID, targetUserID) InfoLogger.Printf("Admin/owner %d soft deleted all chat history for user %d", currentUserID, targetUserID)
} }
} }
+2 -267
View File
@@ -2,10 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -615,277 +611,16 @@ func setupTestDB(t *testing.T) *gorm.DB {
} }
// AutoMigrate the models // AutoMigrate the models
err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{}, &Scope{}) err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{})
if err != nil { if err != nil {
t.Fatalf("Failed to migrate database schema: %v", err) t.Fatalf("Failed to migrate database schema: %v", err)
} }
// Create default roles and scopes // Create default roles
err = createDefaultRoles(db) err = createDefaultRoles(db)
if err != nil { if err != nil {
t.Fatalf("Failed to create default roles: %v", err) t.Fatalf("Failed to create default roles: %v", err)
} }
if err := createDefaultScopes(db); err != nil {
t.Fatalf("Failed to create default scopes: %v", err)
}
return db return db
} }
// setupBotForTest creates a minimal Bot instance backed by an in-memory DB.
// It follows the same pattern as the existing handler tests to avoid duplication.
func setupBotForTest(t *testing.T, ownerID int64) (*Bot, *MockTelegramClient) {
t.Helper()
db := setupTestDB(t)
mockClock := &MockClock{currentTime: time.Now()}
config := BotConfig{
ID: "test_bot",
OwnerTelegramID: ownerID,
TelegramToken: "test_token",
MemorySize: 10,
MessagePerHour: 5,
MessagePerDay: 10,
TempBanDuration: "1h",
Model: "claude-3-5-haiku-latest",
SystemPrompts: make(map[string]string),
Active: true,
}
mockTgClient := &MockTelegramClient{}
botModel := &BotModel{Identifier: config.ID, Name: config.ID}
assert.NoError(t, db.Create(botModel).Error)
assert.NoError(t, db.Create(&ConfigModel{
BotID: botModel.ID,
MemorySize: config.MemorySize,
MessagePerHour: config.MessagePerHour,
MessagePerDay: config.MessagePerDay,
TempBanDuration: config.TempBanDuration,
SystemPrompts: "{}",
TelegramToken: config.TelegramToken,
Active: config.Active,
}).Error)
b, err := NewBot(db, config, mockClock, mockTgClient)
assert.NoError(t, err)
return b, mockTgClient
}
// TestAnthropicErrorResponse verifies that model-deprecation errors surface actionable
// details only to admin/owner, and that regular users and non-model errors always get
// the generic fallback.
func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
b, _ := setupBotForTest(t, 123)
// Create admin user
adminRole, err := b.getRoleByName("admin")
assert.NoError(t, err)
assert.NoError(t, b.db.Create(&User{
BotID: b.botID, TelegramID: 456, Username: "admin",
RoleID: adminRole.ID, Role: adminRole,
}).Error)
// Create regular user
userRole, err := b.getRoleByName("user")
assert.NoError(t, err)
assert.NoError(t, b.db.Create(&User{
BotID: b.botID, TelegramID: 789, Username: "regular",
RoleID: userRole.ID, Role: userRole,
}).Error)
modelErr := fmt.Errorf("%w: claude-3-5-haiku-latest", ErrModelNotFound)
otherErr := errors.New("network error")
tests := []struct {
name string
err error
userID int64
wantSubstr string
wantMissing string
}{
{
name: "owner receives actionable model-not-found message",
err: modelErr,
userID: 123,
wantSubstr: "/set_model",
},
{
name: "admin receives actionable model-not-found message",
err: modelErr,
userID: 456,
wantSubstr: "/set_model",
},
{
name: "regular user receives generic message for model-not-found",
err: modelErr,
userID: 789,
wantSubstr: "I'm sorry",
wantMissing: "/set_model",
},
{
name: "owner receives generic message for non-model error",
err: otherErr,
userID: 123,
wantSubstr: "I'm sorry",
wantMissing: "/set_model",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp := b.anthropicErrorResponse(tc.err, tc.userID)
assert.Contains(t, resp, tc.wantSubstr)
if tc.wantMissing != "" {
assert.NotContains(t, resp, tc.wantMissing)
}
})
}
}
// TestSetModelCommand verifies that /set_model enforces permissions, validates input,
// updates the model in memory, and persists the change to the config file on disk.
func TestSetModelCommand(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
b, mockTgClient := setupBotForTest(t, 123)
// Point the config at a temporary file so PersistModel can write to disk.
tempDir, err := os.MkdirTemp("", "set_model_cmd_test")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tempDir) }()
configPath := filepath.Join(tempDir, "config.json")
initialJSON := `{"id":"test_bot","telegram_token":"test_token","model":"claude-3-5-haiku-latest","messages_per_hour":5,"messages_per_day":10}`
assert.NoError(t, os.WriteFile(configPath, []byte(initialJSON), 0600))
b.config.ConfigFilePath = configPath
// Create admin and regular users
adminRole, err := b.getRoleByName("admin")
assert.NoError(t, err)
assert.NoError(t, b.db.Create(&User{
BotID: b.botID, TelegramID: 456, Username: "admin",
RoleID: adminRole.ID, Role: adminRole,
}).Error)
userRole, err := b.getRoleByName("user")
assert.NoError(t, err)
assert.NoError(t, b.db.Create(&User{
BotID: b.botID, TelegramID: 789, Username: "regular",
RoleID: userRole.ID, Role: userRole,
}).Error)
chatID := int64(1000)
// Seed chat 1000 with a prior message so isNewChatFlag is false for all subtests.
// Commands are only processed in the non-new-chat branch of handleUpdate.
assert.NoError(t, b.db.Create(&Message{
BotID: b.botID, ChatID: chatID, UserID: 789, Username: "regular",
UserRole: "user", Text: "hello", IsUser: true,
}).Error)
makeUpdate := func(userID int64, text string, cmdLen int) *models.Update {
return &models.Update{
Message: &models.Message{
Chat: models.Chat{ID: chatID},
From: &models.User{ID: userID, Username: getUsernameByID(userID)},
Text: text,
Entities: []models.MessageEntity{
{Type: "bot_command", Offset: 0, Length: cmdLen},
},
},
}
}
tests := []struct {
name string
userID int64
text string
wantSubstr string
}{
{
name: "regular user is denied",
userID: 789,
text: "/set_model claude-sonnet-4-6",
wantSubstr: "Permission denied",
},
{
name: "admin missing argument shows usage",
userID: 456,
text: "/set_model",
wantSubstr: "Usage:",
},
{
name: "owner missing argument shows usage",
userID: 123,
text: "/set_model",
wantSubstr: "Usage:",
},
{
name: "admin sets model successfully",
userID: 456,
text: "/set_model claude-sonnet-4-6",
wantSubstr: "✅",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var sentMessage string
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
sentMessage = params.Text
return &models.Message{}, nil
}
b.handleUpdate(context.Background(), nil, makeUpdate(tc.userID, tc.text, 10))
assert.Contains(t, sentMessage, tc.wantSubstr)
})
}
// Verify the successful update took effect in memory and on disk.
t.Run("model change persisted in memory and on disk", func(t *testing.T) {
assert.Equal(t, "claude-sonnet-4-6", string(b.config.Model))
data, err := os.ReadFile(configPath)
assert.NoError(t, err)
assert.Contains(t, string(data), `"claude-sonnet-4-6"`)
})
}
// TestHasScope verifies that scope checks honour role assignments and the owner bypass.
func TestHasScope(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
const ownerID int64 = 100
b, _ := setupBotForTest(t, ownerID)
// Admin user
adminRole, err := b.getRoleByName("admin")
assert.NoError(t, err)
assert.NoError(t, b.db.Create(&User{
BotID: b.botID, TelegramID: 200, Username: "admin_user",
RoleID: adminRole.ID, Role: adminRole,
}).Error)
// Regular user
userRole, err := b.getRoleByName("user")
assert.NoError(t, err)
assert.NoError(t, b.db.Create(&User{
BotID: b.botID, TelegramID: 300, Username: "regular_user",
RoleID: userRole.ID, Role: userRole,
}).Error)
tests := []struct {
name string
userID int64
scope string
want bool
}{
{"owner bypass: model:set", ownerID, ScopeModelSet, true},
{"owner bypass: stats:view:any", ownerID, ScopeStatsViewAny, true},
{"admin: model:set", 200, ScopeModelSet, true},
{"admin: stats:view:any", 200, ScopeStatsViewAny, true},
{"admin: history:clear:any", 200, ScopeHistoryClearAny, true},
{"user: model:set denied", 300, ScopeModelSet, false},
{"user: stats:view:any denied", 300, ScopeStatsViewAny, false},
{"user: history:clear:any denied", 300, ScopeHistoryClearAny, false},
{"user: stats:view:own allowed", 300, ScopeStatsViewOwn, true},
{"user: history:clear:own allowed", 300, ScopeHistoryClearOwn, true},
{"unknown telegram_id", 999, ScopeModelSet, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, b.hasScope(tc.userID, tc.scope))
})
}
}
-19
View File
@@ -50,28 +50,9 @@ type ChatMemory struct {
BusinessConnectionID string // New field to store the business connection ID BusinessConnectionID string // New field to store the business connection ID
} }
// Scope name constants — used in DB seeding, hasScope checks, and tests.
const (
ScopeStatsViewOwn = "stats:view:own"
ScopeStatsViewAny = "stats:view:any"
ScopeHistoryClearOwn = "history:clear:own"
ScopeHistoryClearAny = "history:clear:any"
ScopeHistoryClearHardOwn = "history:clear_hard:own"
ScopeHistoryClearHardAny = "history:clear_hard:any"
ScopeModelSet = "model:set"
ScopeUserPromote = "user:promote"
ScopeTTSUse = "tts:use"
)
type Scope struct {
gorm.Model
Name string `gorm:"uniqueIndex"`
}
type Role struct { type Role struct {
gorm.Model gorm.Model
Name string `gorm:"uniqueIndex"` Name string `gorm:"uniqueIndex"`
Scopes []Scope `gorm:"many2many:role_scopes;"`
} }
type User struct { type User struct {
+1 -4
View File
@@ -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)
GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
FileDownloadLink(f *models.File) string
Start(ctx context.Context) Start(ctx context.Context)
// Add other methods if needed.
} }
-36
View File
@@ -13,10 +13,6 @@ 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)
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)
} }
@@ -32,38 +28,6 @@ func (m *MockTelegramClient) SendMessage(ctx context.Context, params *bot.SendMe
return nil, args.Error(1) return nil, args.Error(1)
} }
// SetMyCommands mocks registering bot commands.
func (m *MockTelegramClient) SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error) {
if m.SetMyCommandsFunc != nil {
return m.SetMyCommandsFunc(ctx, params)
}
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 {
+21 -39
View File
@@ -12,38 +12,26 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
errOpenDB = "Failed to open in-memory database: %v"
errMigrateSchema = "Failed to migrate database schema: %v"
errCreateRoles = "Failed to create default roles: %v"
errCreateScopes = "Failed to create default scopes: %v"
errCreateBot = "Failed to create bot: %v"
memoryDSN = ":memory:"
)
func TestOwnerAssignment(t *testing.T) { func TestOwnerAssignment(t *testing.T) {
// Initialize loggers // Initialize loggers
initLoggers() initLoggers()
// Initialize in-memory database for testing // Initialize in-memory database for testing
db, err := gorm.Open(sqlite.Open(memoryDSN), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf(errOpenDB, err) t.Fatalf("Failed to open in-memory database: %v", err)
} }
// Migrate the schema // Migrate the schema
err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{}, &Scope{}) err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{})
if err != nil { if err != nil {
t.Fatalf(errMigrateSchema, err) t.Fatalf("Failed to migrate database schema: %v", err)
} }
// Create default roles and scopes // Create default roles
err = createDefaultRoles(db) err = createDefaultRoles(db)
if err != nil { if err != nil {
t.Fatalf(errCreateRoles, err) t.Fatalf("Failed to create default roles: %v", err)
}
if err := createDefaultScopes(db); err != nil {
t.Fatalf(errCreateScopes, err)
} }
// Create a bot configuration // Create a bot configuration
@@ -79,7 +67,7 @@ func TestOwnerAssignment(t *testing.T) {
// Create the bot with the mock Telegram client // Create the bot with the mock Telegram client
bot, err := NewBot(db, config, mockClock, mockTGClient) bot, err := NewBot(db, config, mockClock, mockTGClient)
if err != nil { if err != nil {
t.Fatalf(errCreateBot, err) t.Fatalf("Failed to create bot: %v", err)
} }
// Verify that the owner exists // Verify that the owner exists
@@ -131,24 +119,21 @@ func TestPromoteUserToAdmin(t *testing.T) {
initLoggers() initLoggers()
// Initialize in-memory database for testing // Initialize in-memory database for testing
db, err := gorm.Open(sqlite.Open(memoryDSN), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf(errOpenDB, err) t.Fatalf("Failed to open in-memory database: %v", err)
} }
// Migrate the schema // Migrate the schema
err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{}, &Scope{}) err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{})
if err != nil { if err != nil {
t.Fatalf(errMigrateSchema, err) t.Fatalf("Failed to migrate database schema: %v", err)
} }
// Create default roles and scopes // Create default roles
err = createDefaultRoles(db) err = createDefaultRoles(db)
if err != nil { if err != nil {
t.Fatalf(errCreateRoles, err) t.Fatalf("Failed to create default roles: %v", err)
}
if err := createDefaultScopes(db); err != nil {
t.Fatalf(errCreateScopes, err)
} }
config := BotConfig{ config := BotConfig{
@@ -168,7 +153,7 @@ func TestPromoteUserToAdmin(t *testing.T) {
bot, err := NewBot(db, config, mockClock, mockTGClient) bot, err := NewBot(db, config, mockClock, mockTGClient)
if err != nil { if err != nil {
t.Fatalf(errCreateBot, err) t.Fatalf("Failed to create bot: %v", err)
} }
// Create an owner // Create an owner
@@ -207,24 +192,21 @@ func TestGetOrCreateUser(t *testing.T) {
initLoggers() initLoggers()
// Initialize in-memory database for testing // Initialize in-memory database for testing
db, err := gorm.Open(sqlite.Open(memoryDSN), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf(errOpenDB, err) t.Fatalf("Failed to open in-memory database: %v", err)
} }
// Migrate the schema // Migrate the schema
err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{}, &Scope{}) err = db.AutoMigrate(&BotModel{}, &ConfigModel{}, &Message{}, &User{}, &Role{})
if err != nil { if err != nil {
t.Fatalf(errMigrateSchema, err) t.Fatalf("Failed to migrate database schema: %v", err)
} }
// Create default roles and scopes // Create default roles
err = createDefaultRoles(db) err = createDefaultRoles(db)
if err != nil { if err != nil {
t.Fatalf(errCreateRoles, err) t.Fatalf("Failed to create default roles: %v", err)
}
if err := createDefaultScopes(db); err != nil {
t.Fatalf(errCreateScopes, err)
} }
// Create a mock clock starting at a fixed time // Create a mock clock starting at a fixed time
@@ -259,7 +241,7 @@ func TestGetOrCreateUser(t *testing.T) {
// Create the bot with the mock Telegram client // Create the bot with the mock Telegram client
bot, err := NewBot(db, config, mockClock, mockTGClient) bot, err := NewBot(db, config, mockClock, mockTGClient)
if err != nil { if err != nil {
t.Fatalf(errCreateBot, err) t.Fatalf("Failed to create bot: %v", err)
} }
// Verify that the owner exists // Verify that the owner exists