package main import ( "context" "errors" "fmt" "strconv" "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "golang.org/x/sync/errgroup" ) 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, 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) // Voice path passes nil for onSegment: tool-call narration across multiple // TTS clips would be jarring, so we accumulate everything and synthesize one // audio clip from the joined text. response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil) 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) } } // uploadPhotoFromItem downloads the largest PhotoSize from a Telegram message // item and uploads it to the Anthropic Files API tagged with the bot's filename // convention. Telegram serves photos as JPEG regardless of the user's original // format, so the content-type is fixed. func (b *Bot) uploadPhotoFromItem(ctx context.Context, item *models.Message, chatID int64) (string, error) { photo := largestPhotoSize(item.Photo) data, err := b.downloadTelegramFile(ctx, photo.FileID) if err != nil { return "", fmt.Errorf("download %s: %w", photo.FileID, err) } filename := formatUploadFilename(b.botID, chatID, item.ID, "jpg") return b.uploadImageToAnthropic(ctx, data, filename, "image/jpeg") } // handlePhotoMessage processes a user turn that contains one or more photos — // either a single photo or a Telegram media_group (album) coalesced upstream. // For albums the caller passes the items sorted by message_id. Each item's // largest PhotoSize is downloaded and uploaded to the Anthropic Files API; the // resulting file_ids are persisted on a single Message row representing the // whole user turn. On any upload failure, compensating deletes fire against // already-uploaded file_ids and the DB row is not written — orphans on // Anthropic are preferred over poisoned DB references. func (b *Bot) handlePhotoMessage( ctx context.Context, items []*models.Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, businessConnectionID string, ) { if len(items) == 0 { return } // Phase 1: download + upload each photo in parallel. Album latency collapses // from N*RTT to ~max(t_i) — relevant for the multi-screenshot use case. // Caption capture happens in the sequential loop (one item carries it; order // is preserved upstream via flushAlbum's sort by message_id). uploaded[i] // matches items[i] so file_ids stay in user-intended order; non-photo items // leave their slot empty and are compacted out before commit. uploaded := make([]string, len(items)) caption := "" g, gctx := errgroup.WithContext(ctx) for i, item := range items { if item.Caption != "" { caption = item.Caption } if len(item.Photo) == 0 { continue } i, item := i, item g.Go(func() error { fileID, err := b.uploadPhotoFromItem(gctx, item, chatID) if err != nil { return err } uploaded[i] = fileID return nil }) } if err := g.Wait(); err != nil { ErrorLogger.Printf("[%s] photo upload failed: %v", b.config.ID, err) var successful []string for _, fid := range uploaded { if fid != "" { successful = append(successful, fid) } } b.compensatingDelete(ctx, successful) if sendErr := b.sendResponse(ctx, chatID, "Sorry, I couldn't process one of your images.", businessConnectionID); sendErr != nil { ErrorLogger.Printf("Error sending photo failure message: %v", sendErr) } return } finalUploaded := make([]string, 0, len(uploaded)) for _, fid := range uploaded { if fid != "" { finalUploaded = append(finalUploaded, fid) } } if len(finalUploaded) == 0 { return } // Phase 2: commit Message row. getOrCreateChatMemory MUST run before // storeMessage — on a cold cache, the get-or-create hydrates from DB, and // hydrating after the insert would re-load the just-stored row, causing // addMessageToChatMemory below to produce a duplicate user turn. Mirrors // the ordering used by screenIncomingMessage for the same reason. chatMemory := b.getOrCreateChatMemory(chatID) userMessage := b.createMessage(chatID, userID, username, "user", caption, true) userMessage.ImageFileIDs = finalUploaded if err := b.storeMessage(&userMessage); err != nil { b.compensatingDelete(ctx, finalUploaded) ErrorLogger.Printf("[%s] store photo message failed: %v", b.config.ID, err) if sendErr := b.sendResponse(ctx, chatID, "Sorry, I had trouble saving your message.", businessConnectionID); sendErr != nil { ErrorLogger.Printf("Error sending store failure message: %v", sendErr) } return } b.addMessageToChatMemory(chatMemory, userMessage) // Phase 3: stream Anthropic's reply, same shape as the text path. contextMessages := b.prepareContextMessages(chatMemory) joined, err := b.getAnthropicResponse( ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, func(seg string) error { return b.sendOneSegment(ctx, chatID, seg, businessConnectionID) }, ) if err != nil { ErrorLogger.Printf("Error getting Anthropic response for photo: %v", err) if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil { ErrorLogger.Printf("Error sending anthropic error response: %v", sendErr) } return } if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil { ErrorLogger.Printf("Error recording assistant turn: %v", storeErr) } } // anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse // fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they // can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything // else. Regular users always get the generic fallback to avoid leaking internal details. func (b *Bot) anthropicErrorResponse(err error, userID int64) string { isElevated := b.hasScope(userID, ScopeModelSet) if errors.Is(err, ErrModelNotFound) && isElevated { 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, ) } if isElevated { var apiErr *anthropic.Error if errors.As(err, &apiErr) { body := apiErr.RawJSON() if len(body) > 800 { body = body[:800] + "...(truncated)" } out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body) if apiErr.RequestID != "" { out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID) } return out } // Non-API errors (network, context cancel, etc.) — show the Go error text. return fmt.Sprintf("⚠️ Anthropic call failed: %v", err) } 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 // Determine if the user is the owner — needed up-front so the album buffer // can capture it alongside other per-turn metadata. 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) } } // Photo routing bypasses screenIncomingMessage entirely: handlePhotoMessage // owns its own DB-row creation (one row per coalesced user turn, holding // all uploaded file_ids). Album items go through the 1s buffer first; only // the flush dispatches to handlePhotoMessage. if message.MediaGroupID != "" && len(message.Photo) > 0 { b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID) return } if len(message.Photo) > 0 { if !b.checkRateLimits(userID) { b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID) return } b.handlePhotoMessage(ctx, []*models.Message{message}, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID) return } // Screen incoming message (store to DB + add to chat memory) — text/voice/sticker only. userMsg, err := b.screenIncomingMessage(message) if err != nil { ErrorLogger.Printf("Error storing user message: %v", err) return } // 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, 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) // Stream Anthropic's reply, sending each completed text block to Telegram // as it arrives — gives the conversational rhythm Claude uses around tool // calls (text → pause for tool → text → pause → text), rather than a long // upfront wait followed by all bubbles at once. joined, err := b.getAnthropicResponse( ctx, chatID, contextMessages, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime, func(seg string) error { return b.sendOneSegment(ctx, chatID, seg, businessConnectionID) }, ) if err != nil { ErrorLogger.Printf("Error getting Anthropic response: %v", err) // Errors go out as a single message — no need to fan out a one-line error. if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil { ErrorLogger.Printf("Error sending response: %v", sendErr) } return } // Record the full turn once, at end-of-stream. Same 1-reply-per-prompt // invariant as the non-streaming path: one DB row, one answered_on stamp, // one chat-memory entry containing the joined segments. if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil { ErrorLogger.Printf("Error recording assistant turn: %v", storeErr) } } 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.BetaMessageParam, 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.BetaMessageParam) (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()) // Sticker reactions are casual chit-chat; tool use is unusual here, so // pass nil for onSegment and return the joined text for a single bubble. response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, true, message.Username, "", "", false, "", messageTime, nil) 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 { // Hard delete routes through hardDeleteScope, which orchestrates the // soft-delete → Anthropic Files.Delete → Unscoped().Delete dance. Rows // whose Anthropic-side file cleanup fails stay soft-deleted for the // reconciliation job to retry. if targetUserID == currentUserID { // Own history — delete ALL messages (user + assistant) in the current chat. err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID) 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.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID) InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID) } else { // Bot-wide: user's own messages across every chat plus assistant // responses in their DM chat (where chat_id == user_id by Telegram // convention). The two clauses are collapsed into one OR-WHERE so // the helper's three-step pattern covers both in a single pass. err = b.hardDeleteScope(ctx, "bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))", b.botID, targetUserID, targetUserID, false) 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) } }