Optimize prompts

This commit is contained in:
HugeFrog24
2026-05-29 21:40:52 +02:00
parent a2cc252e8f
commit ab01fc31d8
9 changed files with 153 additions and 278 deletions
+15
View File
@@ -52,6 +52,21 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
go build -o telegram-bot go build -o telegram-bot
``` ```
## Trying Out New Behavior Safely
Want to experiment with a different personality, tone, or set of instructions without disturbing the bot your users already talk to? Run a second, separate bot just for testing.
Each bot profile is its own config file with its own Telegram token, and bots are fully independent — separate identity, separate chat history, separate settings. So a "test twin" is quick to set up:
1. Create a new bot with [@BotFather](https://t.me/BotFather) and copy its token.
2. Copy your existing config to a new file, e.g. `cp config/mybot.json config/mybot-test.json`.
3. In the new file, paste the new token, give it a different `id`, and edit `system_prompts` to try your changes.
4. Start it alongside your main bot. Chat with the test bot, tweak its prompt, and restart the test bot to try again — your real users never see the experiments.
5. Happy with the result? Copy the same change into your main bot's config and restart it.
> [!NOTE]
> A test bot always needs its **own** token. Telegram only lets one running bot listen on a given token, so you can't point a second copy at your live bot — give the twin its own @BotFather bot instead.
## Systemd Unit Setup ## Systemd Unit Setup
To enable the bot to start automatically on system boot and run in the background, set up a systemd unit. To enable the bot to start automatically on system boot and run in the background, set up a systemd unit.
-5
View File
@@ -27,7 +27,6 @@ type pendingAlbum struct {
username, firstName, lastName, languageCode string username, firstName, lastName, languageCode string
isPremium bool isPremium bool
messageTime int messageTime int
isNewChat, isOwner bool
businessConnectionID string businessConnectionID string
// timer flushes the album after albumFlushWindow with no further arrivals. // timer flushes the album after albumFlushWindow with no further arrivals.
// Each new arrival stops the previous timer (best-effort) and installs a // Each new arrival stops the previous timer (best-effort) and installs a
@@ -47,7 +46,6 @@ func (b *Bot) bufferAlbumItem(
isPremium bool, isPremium bool,
languageCode string, languageCode string,
messageTime int, messageTime int,
isNewChat, isOwner bool,
businessConnectionID string, businessConnectionID string,
) { ) {
b.albumBuffersMu.Lock() b.albumBuffersMu.Lock()
@@ -64,8 +62,6 @@ func (b *Bot) bufferAlbumItem(
isPremium: isPremium, isPremium: isPremium,
languageCode: languageCode, languageCode: languageCode,
messageTime: messageTime, messageTime: messageTime,
isNewChat: isNewChat,
isOwner: isOwner,
businessConnectionID: businessConnectionID, businessConnectionID: businessConnectionID,
} }
b.albumBuffers[msg.MediaGroupID] = album b.albumBuffers[msg.MediaGroupID] = album
@@ -116,7 +112,6 @@ func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) {
captured.chatID, captured.userID, captured.chatID, captured.userID,
captured.username, captured.firstName, captured.lastName, captured.username, captured.firstName, captured.lastName,
captured.isPremium, captured.languageCode, captured.messageTime, captured.isPremium, captured.languageCode, captured.messageTime,
captured.isNewChat, captured.isOwner,
captured.businessConnectionID, captured.businessConnectionID,
) )
} }
+83 -70
View File
@@ -34,75 +34,20 @@ const maxFileNotFoundRetries = 3
// "File not found:" for a referenced file_id, the dead file_id is stripped // "File not found:" for a referenced file_id, the dead file_id is stripped
// from this chat's in-memory ChatMemory and the corresponding DB rows are // from this chat's in-memory ChatMemory and the corresponding DB rows are
// stamped FilesCleanedAt so a reconciliation job can finish the cleanup. // stamped FilesCleanedAt so a reconciliation job can finish the cleanup.
func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, 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) { func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
// Use prompts from config // The system prompt is the single authored behavior driver. It is assembled
var systemMessage string // as a cached static block (custom_instructions) followed by a per-turn
if isNewChat { // dynamic tail. Prompt caching keys on a byte-identical prefix, so the static
systemMessage = b.config.SystemPrompts["new_chat"] // block must not contain anything that changes between requests — all
} else { // per-turn data (who we're talking to, the time of day, the emoji-only rule)
systemMessage = b.config.SystemPrompts["continue_conversation"] // lives in the trailing block, AFTER the cache breakpoint.
} //
// An empty custom_instructions means no system prompt at all: the System
// Combine default prompt with custom instructions // field is omitted entirely (not sent as a blank block), giving the model's
systemMessage = b.config.SystemPrompts["default"] + " " + b.config.SystemPrompts["custom_instructions"] + " " + systemMessage // unmodified "vanilla" behavior. This matters because the Anthropic API
// rejects a system array containing an empty/whitespace-only text block, so
// Handle username placeholder // omission is the only correct way to express "no system prompt".
usernameValue := username staticPrompt := strings.TrimSpace(b.config.SystemPrompts["custom_instructions"])
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 // Debug logging
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
@@ -111,12 +56,32 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages [
Model: b.config.Model, Model: b.config.Model,
MaxTokens: 1000, MaxTokens: 1000,
Messages: messages, Messages: messages,
System: []anthropic.BetaTextBlockParam{{Text: systemMessage}},
// Files API beta is always on: replayed conversation history may carry // Files API beta is always on: replayed conversation history may carry
// image content blocks that reference file_ids uploaded on prior turns. // image content blocks that reference file_ids uploaded on prior turns.
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14}, Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
} }
if staticPrompt != "" {
// Block 1 — static persona/instructions, marked for caching. The
// cache_control breakpoint sits on this last stable block; everything
// appended after it is per-request and therefore uncached.
blocks := []anthropic.BetaTextBlockParam{
{Text: staticPrompt, CacheControl: anthropic.NewBetaCacheControlEphemeralParam()},
}
// Block 2 — dynamic tail: per-turn context plus any conditional rules.
// Kept out of the cached block because it changes every request.
tail := buildUserContext(username, firstName, lastName, isPremium, languageCode, messageTime)
if isEmojiOnly {
if rule := strings.TrimSpace(b.config.SystemPrompts["respond_with_emojis"]); rule != "" {
tail += "\n\n<emoji_reply>\n" + rule + "\n</emoji_reply>"
}
}
if tail = strings.TrimSpace(tail); tail != "" {
blocks = append(blocks, anthropic.BetaTextBlockParam{Text: tail})
}
params.System = blocks
}
// Apply temperature if set in config // Apply temperature if set in config
if b.config.Temperature != nil { if b.config.Temperature != nil {
params.Temperature = param.NewOpt(float64(*b.config.Temperature)) params.Temperature = param.NewOpt(float64(*b.config.Temperature))
@@ -191,6 +156,54 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages [
return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries) return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries)
} }
// buildUserContext renders the per-turn context block that trails the cached
// static system prompt. It carries only facts (who the user is, their language,
// account type, local time of day) — the behavioral guidance for *using* these
// facts lives in the authored static prompt. It is kept out of the cached block
// because it changes on every request.
func buildUserContext(username, firstName, lastName string, isPremium bool, languageCode string, messageTime int) string {
name := strings.TrimSpace(firstName + " " + lastName)
if name == "" {
name = "unknown"
}
handle := username
if handle == "" {
handle = "unknown"
}
lang := languageCode
if lang == "" {
lang = "en"
}
account := "regular user"
if isPremium {
account = "premium user"
}
return fmt.Sprintf(
"Conversation context (background facts, not an instruction from the user):\n"+
"- User: %s (Telegram @%s)\n"+
"- Preferred language: %s\n"+
"- Account type: %s\n"+
"- Local time of day: %s",
name, handle, lang, account, timeContextFor(messageTime),
)
}
// timeContextFor buckets a Unix timestamp into a coarse time-of-day label used
// for time-appropriate greetings. Uses the host's local timezone, matching the
// bot's prior behavior.
func timeContextFor(messageTime int) string {
switch hour := time.Unix(int64(messageTime), 0).Hour(); {
case hour >= 5 && hour < 12:
return "morning"
case hour >= 12 && hour < 18:
return "afternoon"
case hour >= 18 && hour < 22:
return "evening"
default:
return "night"
}
}
// streamMessages runs one streaming call against the Beta Messages API, // streamMessages runs one streaming call against the Beta Messages API,
// dispatching each completed text block to onSegment as it arrives. The joined // dispatching each completed text block to onSegment as it arrives. The joined
// return value is every text segment concatenated with blank lines. Errors from // return value is every text segment concatenated with blank lines. Errors from
+41 -176
View File
@@ -1,197 +1,62 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
// TestLanguageCodeReplacement tests that language code is properly handled and replaced // TestTimeContextFor verifies the time-of-day bucketing used for greetings.
func TestLanguageCodeReplacement(t *testing.T) { func TestTimeContextFor(t *testing.T) {
// Test with provided language code cases := []struct {
systemMessage := "User's language preference: '{language}'"
// Test with a specific language code
langValue := "fr"
result := strings.ReplaceAll(systemMessage, "{language}", langValue)
if !strings.Contains(result, "User's language preference: 'fr'") {
t.Errorf("Expected language code 'fr' to be replaced, got: %s", result)
}
// Test with empty language code (should default to "en")
langValue = ""
if langValue == "" {
langValue = "en" // Default to English when language code is not available
}
result = strings.ReplaceAll(systemMessage, "{language}", langValue)
if !strings.Contains(result, "User's language preference: 'en'") {
t.Errorf("Expected default language code 'en' to be used, got: %s", result)
}
}
// TestPremiumStatusReplacement tests that premium status is properly handled and replaced
func TestPremiumStatusReplacement(t *testing.T) {
systemMessage := "User is a {premium_status}"
// Test with premium user
isPremium := true
premiumStatus := "regular user"
if isPremium {
premiumStatus = "premium user"
}
result := strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
if !strings.Contains(result, "User is a premium user") {
t.Errorf("Expected premium status to be replaced with 'premium user', got: %s", result)
}
// Test with regular user
isPremium = false
premiumStatus = "regular user"
if isPremium {
premiumStatus = "premium user"
}
result = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
if !strings.Contains(result, "User is a regular user") {
t.Errorf("Expected premium status to be replaced with 'regular user', got: %s", result)
}
}
// TestTimeContextCalculation tests that time context is correctly calculated for different hours
func TestTimeContextCalculation(t *testing.T) {
// Test cases for different hours
testCases := []struct {
hour int hour int
expected string expected string
}{ }{
{3, "night"}, // Night: hours < 5 or hours >= 22 {3, "night"}, // hours < 5
{5, "morning"}, // Morning: 5 <= hours < 12 {5, "morning"}, // 5 <= h < 12
{12, "afternoon"}, // Afternoon: 12 <= hours < 18 {11, "morning"}, //
{17, "afternoon"}, // Afternoon: 12 <= hours < 18 {12, "afternoon"}, // 12 <= h < 18
{18, "evening"}, // Evening: 18 <= hours < 22 {17, "afternoon"}, //
{21, "evening"}, // Evening: 18 <= hours < 22 {18, "evening"}, // 18 <= h < 22
{22, "night"}, // Night: hours < 5 or hours >= 22 {21, "evening"}, //
{23, "night"}, // Night: hours < 5 or hours >= 22 {22, "night"}, // h >= 22
{23, "night"}, //
} }
for _, tc := range cases {
for _, tc := range testCases { // Build the timestamp in the host's local zone so timeContextFor (which
t.Run(fmt.Sprintf("Hour_%d", tc.hour), func(t *testing.T) { // reads local time) observes exactly tc.hour regardless of the test
// Create a timestamp for the specified hour // machine's timezone.
testTime := time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.UTC) ts := int(time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.Local).Unix())
if got := timeContextFor(ts); got != tc.expected {
// Get the hour directly from the test time to ensure it's what we expect t.Errorf("timeContextFor(hour=%d) = %q, want %q", tc.hour, got, tc.expected)
actualHour := testTime.Hour()
if actualHour != tc.hour {
t.Fatalf("Test setup error: expected hour %d, got %d", tc.hour, actualHour)
} }
// Calculate time context using the same logic as in anthropic.go
var timeContext string
if actualHour >= 5 && actualHour < 12 {
timeContext = "morning"
} else if actualHour >= 12 && actualHour < 18 {
timeContext = "afternoon"
} else if actualHour >= 18 && actualHour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
// Check if the calculated time context matches the expected value
if timeContext != tc.expected {
t.Errorf("For hour %d: expected time context '%s', got '%s'",
actualHour, tc.expected, timeContext)
}
})
} }
} }
// TestSystemMessagePlaceholderReplacement tests that all placeholders are correctly replaced // TestBuildUserContext verifies the per-turn context block reflects the user's
func TestSystemMessagePlaceholderReplacement(t *testing.T) { // details and applies the documented fallbacks.
systemMessage := "The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n" + func TestBuildUserContext(t *testing.T) {
"User's language preference: '{language}'\n" + noon := int(time.Date(2025, 5, 15, 12, 0, 0, 0, time.Local).Unix())
"User is a {premium_status}\n" +
"It's currently {time_context} in your timezone"
// Set up test data // Fully-populated premium user.
username := "testuser" got := buildUserContext("alice", "Alice", "Smith", true, "de", noon)
firstName := "Test" for _, want := range []string{"Alice Smith", "@alice", "Preferred language: de", "premium user", "afternoon"} {
lastName := "User" if !strings.Contains(got, want) {
isPremium := true t.Errorf("buildUserContext premium: missing %q in:\n%s", want, got)
languageCode := "de" }
}
// Create a timestamp for a specific hour (e.g., 14:00 = afternoon) // Missing name/username/language fall back; non-premium reads "regular user".
testTime := time.Date(2025, 5, 15, 14, 0, 0, 0, time.UTC) got = buildUserContext("", "", "", false, "", noon)
messageTime := int(testTime.Unix()) for _, want := range []string{"User: unknown (Telegram @unknown)", "Preferred language: en", "regular user"} {
if !strings.Contains(got, want) {
t.Errorf("buildUserContext fallback: missing %q in:\n%s", want, got)
}
}
// Handle username placeholder // First name only (no last name) must not leave a trailing space in the name.
usernameValue := username got = buildUserContext("bob", "Bob", "", false, "en", noon)
if username == "" { if !strings.Contains(got, "User: Bob (Telegram @bob)") {
usernameValue = "unknown" t.Errorf("buildUserContext firstname-only: got:\n%s", got)
}
systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue)
// Handle firstname placeholder
firstnameValue := firstName
if firstName == "" {
firstnameValue = "unknown"
}
systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue)
// Handle lastname placeholder
lastnameValue := lastName
if lastName == "" {
lastnameValue = ""
}
systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue)
// Handle language code placeholder
langValue := languageCode
if languageCode == "" {
langValue = "en"
}
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)
// Check that all placeholders were replaced correctly
if !strings.Contains(systemMessage, "username 'testuser'") {
t.Errorf("Username not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "display name 'Test User'") {
t.Errorf("Display name not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "language preference: 'de'") {
t.Errorf("Language preference not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "User is a premium user") {
t.Errorf("Premium status not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "It's currently afternoon in your timezone") {
t.Errorf("Time context not replaced correctly, got: %s", systemMessage)
} }
} }
-6
View File
@@ -355,12 +355,6 @@ func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion
return blocks return blocks
} }
func (b *Bot) isNewChat(chatID int64) bool {
var count int64
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
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. // roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name.
func roleHasScope(role Role, scope string) bool { func roleHasScope(role Role, scope string) bool {
for _, s := range role.Scopes { for _, s := range role.Scopes {
+2 -5
View File
@@ -15,10 +15,7 @@
"temperature": 0.7, "temperature": 0.7,
"debug_screening": false, "debug_screening": false,
"system_prompts": { "system_prompts": {
"default": "You are a helpful assistant.", "custom_instructions": "You are Atom, a helpful assistant texting through a limited Telegram interface with a 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. The system cuts off anything longer than 15 words.\n\n- Address the user by their first name, and reply in their preferred language (both are in the conversation context).\n- Use time-appropriate greetings based on the user's local time of day.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'. Prefer replying in this language when talking to '{username}'.\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.", "respond_with_emojis": "The user's message contains only emoji. Reply using only emoji."
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
} }
} }
Binary file not shown.
+8 -12
View File
@@ -13,7 +13,7 @@ import (
"golang.org/x/sync/errgroup" "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, isNewChat, isOwner bool, businessConnectionID string) { 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 ElevenLabs is not configured, respond with text — consistent with all other error paths.
if b.config.ElevenLabsAPIKey == "" { if b.config.ElevenLabsAPIKey == "" {
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
@@ -59,7 +59,7 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
// Voice path passes nil for onSegment: tool-call narration across multiple // Voice path passes nil for onSegment: tool-call narration across multiple
// TTS clips would be jarring, so we accumulate everything and synthesize one // TTS clips would be jarring, so we accumulate everything and synthesize one
// audio clip from the joined text. // audio clip from the joined text.
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil) response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err) ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
@@ -125,7 +125,6 @@ func (b *Bot) handlePhotoMessage(
isPremium bool, isPremium bool,
languageCode string, languageCode string,
messageTime int, messageTime int,
isNewChat, isOwner bool,
businessConnectionID string, businessConnectionID string,
) { ) {
if len(items) == 0 { if len(items) == 0 {
@@ -203,7 +202,7 @@ func (b *Bot) handlePhotoMessage(
// Phase 3: stream Anthropic's reply, same shape as the text path. // Phase 3: stream Anthropic's reply, same shape as the text path.
contextMessages := b.prepareContextMessages(chatMemory) contextMessages := b.prepareContextMessages(chatMemory)
joined, err := b.getAnthropicResponse( joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isNewChat, isOwner, false, ctx, chatID, contextMessages, false,
username, firstName, lastName, isPremium, languageCode, messageTime, username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error { func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID) return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
@@ -292,9 +291,6 @@ 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).
isNewChatFlag := b.isNewChat(chatID)
// Determine if the user is the owner — needed up-front so the album buffer // Determine if the user is the owner — needed up-front so the album buffer
// can capture it alongside other per-turn metadata. // can capture it alongside other per-turn metadata.
var isOwner bool var isOwner bool
@@ -323,7 +319,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// the flush dispatches to handlePhotoMessage. // the flush dispatches to handlePhotoMessage.
if message.MediaGroupID != "" && len(message.Photo) > 0 { if message.MediaGroupID != "" && len(message.Photo) > 0 {
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName, b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID) isPremium, languageCode, messageTime, businessConnectionID)
return return
} }
if len(message.Photo) > 0 { if len(message.Photo) > 0 {
@@ -334,7 +330,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
b.handlePhotoMessage(ctx, []*models.Message{message}, b.handlePhotoMessage(ctx, []*models.Message{message},
chatID, userID, username, firstName, lastName, chatID, userID, username, firstName, lastName,
isPremium, languageCode, messageTime, isPremium, languageCode, messageTime,
isNewChatFlag, isOwner, businessConnectionID) businessConnectionID)
return return
} }
@@ -490,7 +486,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Check if the message contains a voice note (context is built inside the handler // 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). // after the transcript replaces the placeholder, so it must not be built here).
if message.Voice != nil { if message.Voice != nil {
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID) b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID)
return return
} }
@@ -518,7 +514,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// calls (text → pause for tool → text → pause → text), rather than a long // calls (text → pause for tool → text → pause → text), rather than a long
// upfront wait followed by all bubbles at once. // upfront wait followed by all bubbles at once.
joined, err := b.getAnthropicResponse( joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, ctx, chatID, contextMessages, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime, username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error { func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID) return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
@@ -578,7 +574,7 @@ func (b *Bot) generateStickerResponse(ctx context.Context, message Message, cont
messageTime := int(message.Timestamp.Unix()) messageTime := int(message.Timestamp.Unix())
// Sticker reactions are casual chit-chat; tool use is unusual here, so // 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. // pass nil for onSegment and return the joined text for a single bubble.
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime, nil) response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, true, message.Username, "", "", false, "", messageTime, nil)
if err != nil { if err != nil {
return "", err return "", err
} }