diff --git a/README.md b/README.md index 1d50c2a..4c80c8b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,21 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP 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 To enable the bot to start automatically on system boot and run in the background, set up a systemd unit. diff --git a/album_buffer.go b/album_buffer.go index 578cde7..f654541 100644 --- a/album_buffer.go +++ b/album_buffer.go @@ -27,7 +27,6 @@ type pendingAlbum struct { username, firstName, lastName, languageCode string isPremium bool messageTime int - isNewChat, isOwner bool businessConnectionID string // timer flushes the album after albumFlushWindow with no further arrivals. // Each new arrival stops the previous timer (best-effort) and installs a @@ -47,7 +46,6 @@ func (b *Bot) bufferAlbumItem( isPremium bool, languageCode string, messageTime int, - isNewChat, isOwner bool, businessConnectionID string, ) { b.albumBuffersMu.Lock() @@ -64,8 +62,6 @@ func (b *Bot) bufferAlbumItem( isPremium: isPremium, languageCode: languageCode, messageTime: messageTime, - isNewChat: isNewChat, - isOwner: isOwner, businessConnectionID: businessConnectionID, } b.albumBuffers[msg.MediaGroupID] = album @@ -116,7 +112,6 @@ func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) { captured.chatID, captured.userID, captured.username, captured.firstName, captured.lastName, captured.isPremium, captured.languageCode, captured.messageTime, - captured.isNewChat, captured.isOwner, captured.businessConnectionID, ) } diff --git a/anthropic.go b/anthropic.go index 2b9d8e4..c64b51e 100644 --- a/anthropic.go +++ b/anthropic.go @@ -34,75 +34,20 @@ const maxFileNotFoundRetries = 3 // "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 // 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) { - // 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"] - } +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) { + // The system prompt is the single authored behavior driver. It is assembled + // as a cached static block (custom_instructions) followed by a per-turn + // dynamic tail. Prompt caching keys on a byte-identical prefix, so the static + // block must not contain anything that changes between requests — all + // per-turn data (who we're talking to, the time of day, the emoji-only rule) + // lives in the trailing block, AFTER the cache breakpoint. + // + // An empty custom_instructions means no system prompt at all: the System + // field is omitted entirely (not sent as a blank block), giving the model's + // unmodified "vanilla" behavior. This matters because the Anthropic API + // rejects a system array containing an empty/whitespace-only text block, so + // omission is the only correct way to express "no system prompt". + staticPrompt := strings.TrimSpace(b.config.SystemPrompts["custom_instructions"]) // Debug logging 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, MaxTokens: 1000, Messages: messages, - System: []anthropic.BetaTextBlockParam{{Text: systemMessage}}, // Files API beta is always on: replayed conversation history may carry // image content blocks that reference file_ids uploaded on prior turns. 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\n" + rule + "\n" + } + } + if tail = strings.TrimSpace(tail); tail != "" { + blocks = append(blocks, anthropic.BetaTextBlockParam{Text: tail}) + } + params.System = blocks + } + // Apply temperature if set in config if b.config.Temperature != nil { 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) } +// 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, // dispatching each completed text block to onSegment as it arrives. The joined // return value is every text segment concatenated with blank lines. Errors from diff --git a/anthropic_files_test.go b/anthropic_files_test.go index 618ee3c..3a73328 100644 --- a/anthropic_files_test.go +++ b/anthropic_files_test.go @@ -72,10 +72,10 @@ func TestStripDeadFileIDs(t *testing.T) { "file_b": {}, } cases := []struct { - name string - input []string + name string + input []string wantSurvivors []string - wantDirty bool + wantDirty bool }{ { name: "no overlap returns input verbatim", diff --git a/anthropic_test.go b/anthropic_test.go index cf82305..10f61f0 100644 --- a/anthropic_test.go +++ b/anthropic_test.go @@ -1,197 +1,62 @@ package main import ( - "fmt" "strings" "testing" "time" ) -// TestLanguageCodeReplacement tests that language code is properly handled and replaced -func TestLanguageCodeReplacement(t *testing.T) { - // Test with provided language code - 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 { +// TestTimeContextFor verifies the time-of-day bucketing used for greetings. +func TestTimeContextFor(t *testing.T) { + cases := []struct { hour int expected string }{ - {3, "night"}, // Night: hours < 5 or hours >= 22 - {5, "morning"}, // Morning: 5 <= hours < 12 - {12, "afternoon"}, // Afternoon: 12 <= hours < 18 - {17, "afternoon"}, // Afternoon: 12 <= hours < 18 - {18, "evening"}, // Evening: 18 <= hours < 22 - {21, "evening"}, // Evening: 18 <= hours < 22 - {22, "night"}, // Night: hours < 5 or hours >= 22 - {23, "night"}, // Night: hours < 5 or hours >= 22 + {3, "night"}, // hours < 5 + {5, "morning"}, // 5 <= h < 12 + {11, "morning"}, // + {12, "afternoon"}, // 12 <= h < 18 + {17, "afternoon"}, // + {18, "evening"}, // 18 <= h < 22 + {21, "evening"}, // + {22, "night"}, // h >= 22 + {23, "night"}, // } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("Hour_%d", tc.hour), func(t *testing.T) { - // Create a timestamp for the specified hour - testTime := time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.UTC) - - // Get the hour directly from the test time to ensure it's what we expect - 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) - } - }) + for _, tc := range cases { + // Build the timestamp in the host's local zone so timeContextFor (which + // reads local time) observes exactly tc.hour regardless of the test + // machine's timezone. + ts := int(time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.Local).Unix()) + if got := timeContextFor(ts); got != tc.expected { + t.Errorf("timeContextFor(hour=%d) = %q, want %q", tc.hour, got, tc.expected) + } } } -// TestSystemMessagePlaceholderReplacement tests that all placeholders are correctly replaced -func TestSystemMessagePlaceholderReplacement(t *testing.T) { - systemMessage := "The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n" + - "User's language preference: '{language}'\n" + - "User is a {premium_status}\n" + - "It's currently {time_context} in your timezone" +// TestBuildUserContext verifies the per-turn context block reflects the user's +// details and applies the documented fallbacks. +func TestBuildUserContext(t *testing.T) { + noon := int(time.Date(2025, 5, 15, 12, 0, 0, 0, time.Local).Unix()) - // Set up test data - username := "testuser" - firstName := "Test" - lastName := "User" - isPremium := true - languageCode := "de" + // Fully-populated premium user. + got := buildUserContext("alice", "Alice", "Smith", true, "de", noon) + for _, want := range []string{"Alice Smith", "@alice", "Preferred language: de", "premium user", "afternoon"} { + if !strings.Contains(got, want) { + t.Errorf("buildUserContext premium: missing %q in:\n%s", want, got) + } + } - // Create a timestamp for a specific hour (e.g., 14:00 = afternoon) - testTime := time.Date(2025, 5, 15, 14, 0, 0, 0, time.UTC) - messageTime := int(testTime.Unix()) + // Missing name/username/language fall back; non-premium reads "regular user". + got = buildUserContext("", "", "", false, "", noon) + 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 - usernameValue := username - if username == "" { - usernameValue = "unknown" - } - 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) + // First name only (no last name) must not leave a trailing space in the name. + got = buildUserContext("bob", "Bob", "", false, "en", noon) + if !strings.Contains(got, "User: Bob (Telegram @bob)") { + t.Errorf("buildUserContext firstname-only: got:\n%s", got) } } diff --git a/bot.go b/bot.go index caf75c5..c5993cc 100644 --- a/bot.go +++ b/bot.go @@ -355,12 +355,6 @@ func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion 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. func roleHasScope(role Role, scope string) bool { for _, s := range role.Scopes { diff --git a/config/default.json b/config/default.json index 1065d94..e2ec1be 100644 --- a/config/default.json +++ b/config/default.json @@ -15,10 +15,7 @@ "temperature": 0.7, "debug_screening": false, "system_prompts": { - "default": "You are a helpful assistant.", - "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.", - "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." + "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.", + "respond_with_emojis": "The user's message contains only emoji. Reply using only emoji." } } diff --git a/go-telegram-bot.exe b/go-telegram-bot.exe deleted file mode 100644 index c2249d4..0000000 Binary files a/go-telegram-bot.exe and /dev/null differ diff --git a/handlers.go b/handlers.go index f44d5c6..2e3ff87 100644 --- a/handlers.go +++ b/handlers.go @@ -13,7 +13,7 @@ import ( "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 b.config.ElevenLabsAPIKey == "" { 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 // 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, 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 { ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err) if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil { @@ -125,7 +125,6 @@ func (b *Bot) handlePhotoMessage( isPremium bool, languageCode string, messageTime int, - isNewChat, isOwner bool, businessConnectionID string, ) { if len(items) == 0 { @@ -203,7 +202,7 @@ func (b *Bot) handlePhotoMessage( // Phase 3: stream Anthropic's reply, same shape as the text path. contextMessages := b.prepareContextMessages(chatMemory) joined, err := b.getAnthropicResponse( - ctx, chatID, contextMessages, isNewChat, isOwner, false, + ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, func(seg string) error { 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 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 // can capture it alongside other per-turn metadata. 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. if message.MediaGroupID != "" && len(message.Photo) > 0 { b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName, - isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID) + isPremium, languageCode, messageTime, businessConnectionID) return } 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}, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, - isNewChatFlag, isOwner, businessConnectionID) + businessConnectionID) 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 // 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) + b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID) 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 // upfront wait followed by all bubbles at once. joined, err := b.getAnthropicResponse( - ctx, chatID, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, + ctx, chatID, contextMessages, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime, func(seg string) error { 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()) // 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, 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 { return "", err }