mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-05-01 07:42:18 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a9261699 | |||
| 6e2d2fce2f | |||
| 37d6242c06 | |||
| 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
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
+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": {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
+69
-174
@@ -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
@@ -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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user