diff --git a/anthropic.go b/anthropic.go index d00d6fa..44f9703 100644 --- a/anthropic.go +++ b/anthropic.go @@ -17,7 +17,7 @@ import ( // 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.BetaMessageParam, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) { +func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.BetaMessageParam, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) ([]string, error) { // Use prompts from config var systemMessage string if isNewChat { @@ -143,16 +143,23 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet if err != nil { var apiErr *anthropic.Error if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { - return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model) + return nil, fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model) } - return "", fmt.Errorf("error creating Anthropic message: %w", err) + return nil, fmt.Errorf("error creating Anthropic message: %w", err) } - var sb strings.Builder + // Collect text blocks as separate segments so the Telegram delivery layer + // can render each as its own message (matches the conversational rhythm + // Claude uses around tool calls). Callers that want one joined string + // (voice TTS, stickers) do strings.Join themselves. + var segments []string for _, block := range resp.Content { switch block.Type { case "text": - sb.WriteString(block.Text) + t := strings.TrimSpace(block.Text) + if t != "" { + segments = append(segments, t) + } case "mcp_tool_use": InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s", block.ServerName, block.Name, block.ID, string(block.Input)) @@ -167,9 +174,8 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet InfoLogger.Printf("[mcp] block type=%q (unhandled)", block.Type) } } - out := sb.String() - if out == "" { - return "", fmt.Errorf("unexpected response format from Anthropic") + if len(segments) == 0 { + return nil, fmt.Errorf("unexpected response format from Anthropic") } - return out, nil + return segments, nil } diff --git a/bot.go b/bot.go index 50fdd31..fc3c89a 100644 --- a/bot.go +++ b/bot.go @@ -449,6 +449,44 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin return nil } +// sendMultiResponse delivers a multi-block LLM response as separate Telegram +// messages while keeping a single logical assistant turn in storage. The DB +// row and chat memory hold the joined text (segments separated by blank lines), +// so the model's next-turn context sees one assistant turn — matching today's +// 1-reply-per-prompt invariant — even though the user saw N bubbles. +// +// Partial send failures (a later segment fails after earlier ones succeeded) +// are logged but do not abort the remaining sends. The DB record is canonical: +// the model's next turn will reference what it intended to say. +func (b *Bot) sendMultiResponse(ctx context.Context, chatID int64, segments []string, businessConnectionID string) error { + if len(segments) == 0 { + return nil + } + + fullText := strings.Join(segments, "\n\n") + if _, err := b.screenOutgoingMessage(chatID, fullText); err != nil { + ErrorLogger.Printf("Error storing assistant message: %v", err) + return err + } + + for i, seg := range segments { + params := &bot.SendMessageParams{ + ChatID: chatID, + Text: seg, + } + if businessConnectionID != "" { + params.BusinessConnectionID = businessConnectionID + } + if _, err := b.tgBot.SendMessage(ctx, params); err != nil { + ErrorLogger.Printf("[%s] Error sending segment %d/%d to chat %d with BusinessConnectionID %s: %v", + b.config.ID, i+1, len(segments), chatID, businessConnectionID, err) + // Keep going: earlier segments are already in the user's chat, + // and the DB has the full turn recorded. + } + } + return nil +} + // sendStats sends the bot statistics to the specified chat. func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) { // If targetUserID is 0, show global stats diff --git a/go-telegram-bot.exe b/go-telegram-bot.exe index e1d6f23..365d95d 100644 Binary files a/go-telegram-bot.exe and b/go-telegram-bot.exe differ diff --git a/handlers.go b/handlers.go index 77d93f9..dce7998 100644 --- a/handlers.go +++ b/handlers.go @@ -55,7 +55,7 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u chatMemory := b.getOrCreateChatMemory(chatID) contextMessages := b.prepareContextMessages(chatMemory) - response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime) + segments, 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 { @@ -64,6 +64,10 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u return } + // Voice replies always synthesize as one audio clip — tool-call narration + // across multiple TTS clips would be jarring, so we join here. + response := strings.Join(segments, "\n\n") + audioReader, err := b.generateSpeech(ctx, response) if err != nil { // TTS failed — fall back to text so the user still gets a reply. @@ -92,16 +96,37 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u } // 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. +// 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 { - if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) { + 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." } @@ -341,14 +366,19 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U isEmojiOnly := isOnlyEmojis(text) // Get response from Anthropic - response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime) + segments, 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) + // Errors go out as a single message — no need to fan out a one-line error. + if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil { + ErrorLogger.Printf("Error sending response: %v", err) + } + return } - // Send the response - if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil { + // Successful LLM reply: deliver each text block as its own Telegram bubble, + // matching the conversational rhythm Claude uses around tool calls. + if err := b.sendMultiResponse(ctx, chatID, segments, businessConnectionID); err != nil { ErrorLogger.Printf("Error sending response: %v", err) return } @@ -389,11 +419,13 @@ func (b *Bot) generateStickerResponse(ctx context.Context, message Message, cont // "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) + segments, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime) if err != nil { return "", err } - return response, nil + // Sticker reactions are casual chit-chat; tool use is unusual here, so + // join into one message rather than fanning out as multiple bubbles. + return strings.Join(segments, "\n\n"), nil } return "Hmm, that's interesting!", nil