package main import ( "context" "errors" "fmt" "strconv" "strings" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "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 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) { var message *models.Message if update.Message != nil { message = update.Message } else if update.BusinessMessage != nil { message = update.BusinessMessage } else { // No message to process return } // Extract businessConnectionID if available var businessConnectionID string if update.BusinessConnection != nil { businessConnectionID = update.BusinessConnection.ID } else if message.BusinessConnectionID != "" { businessConnectionID = message.BusinessConnectionID } if message.From == nil { // Channel posts and some automated messages have no sender — ignore them. // see: https://core.telegram.org/bots/api#message return } chatID := message.Chat.ID userID := message.From.ID username := message.From.Username firstName := message.From.FirstName lastName := message.From.LastName languageCode := message.From.LanguageCode isPremium := message.From.IsPremium messageTime := message.Date text := message.Text // Check if it's a new chat (before storing the message so the flag is accurate). isNewChatFlag := b.isNewChat(chatID) // Screen incoming message (store to DB + add to chat memory) userMsg, err := b.screenIncomingMessage(message) if err != nil { ErrorLogger.Printf("Error storing user message: %v", err) return } // Determine if the user is the owner var isOwner bool if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil { isOwner = true } // Always create/get the user record — on the very first message and on all subsequent ones. user, err := b.getOrCreateUser(userID, username, isOwner) if err != nil { ErrorLogger.Printf("Error getting or creating user: %v", err) return } // Update the username if it has changed if user.Username != username { user.Username = username if err := b.db.Save(&user).Error; err != nil { ErrorLogger.Printf("Error updating user username: %v", err) } } // Check if the message is a command — applies on every message, including the very first. if message.Entities != nil { for _, entity := range message.Entities { if entity.Type == "bot_command" { command := strings.TrimSpace(message.Text[entity.Offset : entity.Offset+entity.Length]) switch command { case "/stats": // Parse command parameters parts := strings.Fields(message.Text) // Default: show global stats if len(parts) == 1 { b.sendStats(ctx, chatID, userID, 0, businessConnectionID) return } // Check for "user" parameter if len(parts) >= 2 && parts[1] == "user" { targetUserID := userID // Default to current user // If a user ID is provided, parse it if len(parts) >= 3 { var parseErr error targetUserID, parseErr = strconv.ParseInt(parts[2], 10, 64) if parseErr != nil { InfoLogger.Printf("User %d provided invalid user ID format: %s", userID, parts[2]) if err := b.sendResponse(ctx, chatID, "Invalid user ID format. Usage: /stats user [user_id]", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } } b.sendStats(ctx, chatID, userID, targetUserID, businessConnectionID) return } // Invalid parameter if err := b.sendResponse(ctx, chatID, "Invalid command format. Usage: /stats or /stats user [user_id]", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return case "/whoami": b.sendWhoAmI(ctx, chatID, userID, username, businessConnectionID) return case "/clear": parts := strings.Fields(message.Text) var targetUserID, targetChatID int64 if len(parts) > 1 { var parseErr error targetUserID, parseErr = strconv.ParseInt(parts[1], 10, 64) if parseErr != nil { InfoLogger.Printf("User %d provided invalid user ID format: %s", userID, parts[1]) if err := b.sendResponse(ctx, chatID, "Invalid user ID format. Usage: /clear [user_id] [chat_id]", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } } if len(parts) > 2 { var parseErr error targetChatID, parseErr = strconv.ParseInt(parts[2], 10, 64) if parseErr != nil { InfoLogger.Printf("User %d provided invalid chat ID format: %s", userID, parts[2]) if err := b.sendResponse(ctx, chatID, "Invalid chat ID format. Usage: /clear [user_id] [chat_id]", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } } b.clearChatHistory(ctx, chatID, userID, targetUserID, targetChatID, businessConnectionID, false) 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 ", 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": parts := strings.Fields(message.Text) var targetUserID, targetChatID int64 if len(parts) > 1 { var parseErr error targetUserID, parseErr = strconv.ParseInt(parts[1], 10, 64) if parseErr != nil { InfoLogger.Printf("User %d provided invalid user ID format: %s", userID, parts[1]) if err := b.sendResponse(ctx, chatID, "Invalid user ID format. Usage: /clear_hard [user_id] [chat_id]", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } } if len(parts) > 2 { var parseErr error targetChatID, parseErr = strconv.ParseInt(parts[2], 10, 64) if parseErr != nil { InfoLogger.Printf("User %d provided invalid chat ID format: %s", userID, parts[2]) if err := b.sendResponse(ctx, chatID, "Invalid chat ID format. Usage: /clear_hard [user_id] [chat_id]", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } } b.clearChatHistory(ctx, chatID, userID, targetUserID, targetChatID, businessConnectionID, true) return } } } } // Rate limit check applies to all message types including stickers. if !b.checkRateLimits(userID) { b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID) 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 if message.Sticker != nil { b.handleStickerMessage(ctx, chatID, userMsg, message, contextMessages, businessConnectionID) return } // Proceed only if the message contains text if text == "" { InfoLogger.Printf("Received a non-text message from user %d in chat %d", userID, chatID) return } // Determine if the text contains only emojis isEmojiOnly := isOnlyEmojis(text) // Get response from Anthropic response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime) if err != nil { ErrorLogger.Printf("Error getting Anthropic response: %v", err) response = b.anthropicErrorResponse(err, userID) } // Send the response if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) return } } func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, businessConnectionID string) { if err := b.sendResponse(ctx, chatID, "Rate limit exceeded. Please try again later.", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending rate limit exceeded message: %v", err) } } func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) { // userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again. // Generate AI response about the sticker response, err := b.generateStickerResponse(ctx, userMessage, contextMessages) if err != nil { ErrorLogger.Printf("Error generating sticker response: %v", err) // Provide a fallback dynamic response based on sticker type if message.Sticker.IsAnimated { response = "Wow, that's a cool animated sticker!" } else if message.Sticker.IsVideo { response = "Interesting video sticker!" } else { response = "That's a cool sticker!" } } // Send the response if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) return } } func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) { // contextMessages already contains the sticker turn (added by screenIncomingMessage as // "Sent a sticker: "), so the full conversation history is preserved. if message.StickerFileID != "" { messageTime := int(message.Timestamp.Unix()) response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime) if err != nil { return "", err } return response, nil } return "Hmm, that's interesting!", nil } 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 != 0 && targetUserID != currentUserID { requiredScope := ScopeHistoryClearAny if hardDelete { requiredScope = ScopeHistoryClearHardAny } if !b.hasScope(currentUserID, requiredScope) { 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 { ErrorLogger.Printf("Error sending response: %v", err) } return } // Check if the target user exists var targetUser User err := b.db.Where("telegram_id = ? AND bot_id = ?", targetUserID, b.botID).First(&targetUser).Error if err != nil { ErrorLogger.Printf("Error finding target user %d: %v", targetUserID, err) if err := b.sendResponse(ctx, chatID, fmt.Sprintf("User with ID %d not found.", targetUserID), businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } } else { // If no targetUserID is provided, set it to currentUserID targetUserID = currentUserID } // Delete messages from the database // // Assumption: this bot is primarily used in private DMs, where each user's messages // are stored with chat_id == their own user_id — not the caller's chat_id. Scoping // a cross-user delete by the caller's chatID would therefore match 0 rows. // // When clearing another user's history the default (targetChatID == 0) deletes all // of that user's messages across every chat for this bot — the natural meaning of // "/clear " (wipe their entire history with the bot). // // When targetChatID != 0 the deletion is scoped to that specific chat, which is // useful for group moderation ("/clear "). var err error if hardDelete { // Permanently delete messages if targetUserID == currentUserID { // Own history — delete ALL messages (user + assistant) in the current chat. err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID) } else { if targetChatID != 0 { // Chat-scoped: delete ALL messages (user + assistant) in the specified chat. 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) } 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 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) } } } else { // Soft delete messages if targetUserID == currentUserID { // Own history — delete ALL messages (user + assistant) in the current chat. err = b.db.Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error InfoLogger.Printf("User %d soft deleted their own chat history in chat %d", currentUserID, chatID) } else { if targetChatID != 0 { // Chat-scoped: delete ALL messages (user + assistant) in the specified chat. 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) } 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 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) } } } if err != nil { ErrorLogger.Printf("Error clearing chat history: %v", err) if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't clear the chat history.", businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } return } // Evict the relevant in-memory cache entry so the next access rebuilds from // the now-clean DB. Applies to all cases: own history, cross-user // scoped to a specific chat, and bot-wide cross-user clear. b.chatMemoriesMu.Lock() if targetUserID == currentUserID { // Own history is always scoped to the current chat. delete(b.chatMemories, chatID) } else if targetChatID != 0 { // Admin cleared a specific chat — evict that chat's cache. delete(b.chatMemories, targetChatID) } else { // Bot-wide clear: primary use-case is DMs where chatID == userID. delete(b.chatMemories, targetUserID) } b.chatMemoriesMu.Unlock() // Send a confirmation message var confirmationMessage string if targetUserID == currentUserID { confirmationMessage = "Your chat history has been cleared." } else { // Get the username of the target user if available var targetUser User err := b.db.Where("telegram_id = ? AND bot_id = ?", targetUserID, b.botID).First(&targetUser).Error if err == nil && targetUser.Username != "" { confirmationMessage = fmt.Sprintf("Chat history for user @%s (ID: %d) has been cleared.", targetUser.Username, targetUserID) } else { confirmationMessage = fmt.Sprintf("Chat history for user with ID %d has been cleared.", targetUserID) } } if err := b.sendResponse(ctx, chatID, confirmationMessage, businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) } }