package main import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/packages/param" ) // ErrModelNotFound is returned when the configured Anthropic model is no longer available // (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") // getAnthropicResponse streams the model's response. Each completed text block // is delivered to onSegment as soon as the model finishes writing it — so the // caller can send segments to Telegram with natural rhythm around tool calls, // rather than batched at the very end of the turn. onSegment may be nil for // callers that only want the joined text (voice TTS, sticker reactions, etc.). // The returned string is every text segment joined by blank lines. 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, onSegment func(string) error) (string, error) { // Use prompts from config var systemMessage string if isNewChat { systemMessage = b.config.SystemPrompts["new_chat"] } else { systemMessage = b.config.SystemPrompts["continue_conversation"] } // Combine default prompt with custom instructions systemMessage = b.config.SystemPrompts["default"] + " " + b.config.SystemPrompts["custom_instructions"] + " " + systemMessage // Handle username placeholder usernameValue := username if username == "" { usernameValue = "unknown" // Use "unknown" when username is not available } systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue) // Handle firstname placeholder firstnameValue := firstName if firstName == "" { firstnameValue = "unknown" // Use "unknown" when first name is not available } systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue) // Handle lastname placeholder lastnameValue := lastName if lastName == "" { lastnameValue = "" // Empty string when last name is not available } systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue) // Handle language code placeholder langValue := languageCode if languageCode == "" { langValue = "en" // Default to English when language code is not available } systemMessage = strings.ReplaceAll(systemMessage, "{language}", langValue) // Handle premium status premiumStatus := "regular user" if isPremium { premiumStatus = "premium user" } systemMessage = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus) // Handle time awareness timeObj := time.Unix(int64(messageTime), 0) hour := timeObj.Hour() var timeContext string if hour >= 5 && hour < 12 { timeContext = "morning" } else if hour >= 12 && hour < 18 { timeContext = "afternoon" } else if hour >= 18 && hour < 22 { timeContext = "evening" } else { timeContext = "night" } systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext) if !isOwner { systemMessage += " " + b.config.SystemPrompts["avoid_sensitive"] } if isEmojiOnly { systemMessage += " " + b.config.SystemPrompts["respond_with_emojis"] } // Debug logging InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) params := anthropic.BetaMessageNewParams{ Model: b.config.Model, MaxTokens: 1000, Messages: messages, System: []anthropic.BetaTextBlockParam{{Text: systemMessage}}, } // Apply temperature if set in config if b.config.Temperature != nil { params.Temperature = param.NewOpt(float64(*b.config.Temperature)) } // MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta // requires per-tool filtering on the toolset (Configs + DefaultConfig), // NOT the deprecated per-server tool_configuration block. if len(b.config.MCPServers) > 0 { mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers)) tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers)) for _, s := range b.config.MCPServers { srv := anthropic.BetaRequestMCPServerURLDefinitionParam{ Name: s.Name, URL: s.URL, } if s.AuthorizationToken != "" { srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken) } mcpServers = append(mcpServers, srv) toolset := &anthropic.BetaMCPToolsetParam{ MCPServerName: s.Name, } if len(s.AllowedTools) > 0 { toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{ Enabled: param.NewOpt(false), } toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools)) for _, tool := range s.AllowedTools { toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{ Enabled: param.NewOpt(true), } } } tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset}) } params.MCPServers = mcpServers params.Tools = tools params.Betas = []anthropic.AnthropicBeta{anthropic.AnthropicBetaMCPClient2025_11_20} } stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params) defer func() { if err := stream.Close(); err != nil { ErrorLogger.Printf("[stream] close failed: %v", err) } }() // Per-block accumulators. Reset on content_block_start, consumed on // content_block_stop. Only one block is active at a time per the SSE // contract; SDK guarantees deltas arrive between matching start/stop. var ( allSegments []string currentKind string currentText strings.Builder currentInputJSON strings.Builder currentTUseName, currentTUseServer, currentTUseID string currentTResultUseID, currentTResultServer string currentTResultIsError bool currentTResultContent string ) for stream.Next() { e := stream.Current() switch e.Type { case "content_block_start": cbs := e.AsContentBlockStart() currentKind = cbs.ContentBlock.Type currentText.Reset() currentInputJSON.Reset() switch currentKind { case "mcp_tool_use": currentTUseName = cbs.ContentBlock.Name currentTUseServer = cbs.ContentBlock.ServerName currentTUseID = cbs.ContentBlock.ID case "mcp_tool_result": currentTResultUseID = cbs.ContentBlock.ToolUseID currentTResultServer = cbs.ContentBlock.ServerName currentTResultIsError = cbs.ContentBlock.IsError // Tool-result content arrives populated on start (server-side // pre-assembled), not via subsequent deltas like text/JSON. currentTResultContent = cbs.ContentBlock.JSON.Content.Raw() } case "content_block_delta": cbd := e.AsContentBlockDelta() switch cbd.Delta.Type { case "text_delta": if currentKind == "text" { currentText.WriteString(cbd.Delta.Text) } case "input_json_delta": if currentKind == "mcp_tool_use" { currentInputJSON.WriteString(cbd.Delta.PartialJSON) } } case "content_block_stop": switch currentKind { case "text": seg := strings.TrimSpace(currentText.String()) if seg != "" { allSegments = append(allSegments, seg) if onSegment != nil { if cbErr := onSegment(seg); cbErr != nil { // Log but keep streaming — the model's response // is still inbound; we want it recorded even if // one Telegram send failed. ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr) } } } case "mcp_tool_use": InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s", currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String()) case "mcp_tool_result": preview := currentTResultContent if len(preview) > 500 { preview = preview[:500] + "...(truncated)" } InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s", currentTResultUseID, currentTResultServer, currentTResultIsError, preview) default: if currentKind != "" { InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind) } } currentKind = "" } } if err := stream.Err(); 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 "", fmt.Errorf("error creating Anthropic message: %w", err) } if len(allSegments) == 0 { return "", fmt.Errorf("unexpected response format from Anthropic") } return strings.Join(allSegments, "\n\n"), nil }