mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-06-29 22:07:12 +00:00
Optimize prompts
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user