Compare commits

..

2 Commits

Author SHA1 Message Date
HugeFrog24 a2cc252e8f Satisfy linter 2026-05-28 21:01:23 +02:00
HugeFrog24 d97a2c3132 Support for images 2026-05-28 20:54:09 +02:00
11 changed files with 283 additions and 225 deletions
-15
View File
@@ -52,21 +52,6 @@ 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.
+5
View File
@@ -27,6 +27,7 @@ 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
@@ -46,6 +47,7 @@ func (b *Bot) bufferAlbumItem(
isPremium bool,
languageCode string,
messageTime int,
isNewChat, isOwner bool,
businessConnectionID string,
) {
b.albumBuffersMu.Lock()
@@ -62,6 +64,8 @@ func (b *Bot) bufferAlbumItem(
isPremium: isPremium,
languageCode: languageCode,
messageTime: messageTime,
isNewChat: isNewChat,
isOwner: isOwner,
businessConnectionID: businessConnectionID,
}
b.albumBuffers[msg.MediaGroupID] = album
@@ -112,6 +116,7 @@ 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,
)
}
+70 -130
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/anthropics/anthropic-sdk-go"
@@ -24,37 +23,6 @@ var ErrModelNotFound = errors.New("model not found or deprecated")
// covers all realistic cascades without leaving the call hanging indefinitely.
const maxFileNotFoundRetries = 3
// mcpUnsupportedSentinel is the placeholder text Anthropic's server-side MCP
// connector substitutes when a tool result can't be serialized into a supported
// content block. It arrives inside a normal mcp_tool_result with is_error=false,
// so it is otherwise indistinguishable from success — every empty-result issue
// across the ecosystem (strands #2122, openai-agents #1035, opencode #15371)
// shows the same is_error=false on these, so we substring-match the text rather
// than rely on the error flag. Observed trigger: an MCP server returns an empty
// content array for a zero-result query (e.g. Outline list_documents with no
// match), which the connector can't serialize. Substring (not the full
// sentence) so a minor wording change upstream doesn't silently break detection.
//
// Scope: this catches the SOFT variant only — a streamed mcp_tool_result whose
// content is the sentinel. The HARD variant (e.g. an unsupported image media
// type) is a 400 that aborts the whole stream and never produces a result
// block, so it surfaces via streamMessages' error return, not here.
const mcpUnsupportedSentinel = "format not currently supported by the Anthropic API"
// mcpUnsupportedCount tallies sentinel hits across the whole process lifetime
// (all bots, all MCP servers) so the true rate is visible — the chat masks most
// of them because the model often degrades gracefully. The per-hit ERROR line
// carries the bot ID and server name for attribution; this is just the running
// total.
var mcpUnsupportedCount atomic.Uint64
// mcpCall pairs a tool_use's server+name+input so a later unsupported result —
// which arrives in a SEPARATE block linked only by tool_use_id — can name the
// server and query that triggered it. server matters once a bot configures more
// than one MCP server (the config supports a slice), otherwise the log can't say
// which server choked.
type mcpCall struct{ server, name, input string }
// getAnthropicResponse streams the model's response. Each completed text block
// is delivered to onSegment as soon as the model finishes writing it — so the
// caller can send segments to Telegram with natural rhythm around tool calls,
@@ -66,20 +34,75 @@ type mcpCall struct{ server, name, input string }
// "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, 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"])
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"]
}
// Debug logging
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
@@ -88,32 +111,12 @@ 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<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
if b.config.Temperature != nil {
params.Temperature = param.NewOpt(float64(*b.config.Temperature))
@@ -188,54 +191,6 @@ 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
@@ -261,9 +216,6 @@ func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNe
currentTResultUseID, currentTResultServer string
currentTResultIsError bool
currentTResultContent string
// tool_use_id -> {server, name, input}. Lives for one request; bounded
// by the number of tool calls in the stream, so no eviction needed.
mcpCalls = map[string]mcpCall{}
)
for stream.Next() {
@@ -317,11 +269,6 @@ func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNe
}
}
case "mcp_tool_use":
mcpCalls[currentTUseID] = mcpCall{
server: currentTUseServer,
name: currentTUseName,
input: currentInputJSON.String(),
}
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
case "mcp_tool_result":
@@ -331,13 +278,6 @@ func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNe
}
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
if strings.Contains(currentTResultContent, mcpUnsupportedSentinel) {
total := mcpUnsupportedCount.Add(1)
call := mcpCalls[currentTResultUseID]
ErrorLogger.Printf("[%s][mcp][unsupported] connector could not serialize result "+
"(total=%d): server=%q tool=%q input=%s tool_use_id=%q",
b.config.ID, total, call.server, call.name, call.input, currentTResultUseID)
}
default:
if currentKind != "" {
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
+3 -3
View File
@@ -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",
+175 -40
View File
@@ -1,62 +1,197 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
)
// TestTimeContextFor verifies the time-of-day bucketing used for greetings.
func TestTimeContextFor(t *testing.T) {
cases := []struct {
// 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 {
hour int
expected string
}{
{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"}, //
{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
}
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)
}
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)
}
})
}
}
// 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())
// 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"
// 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)
}
// Set up test data
username := "testuser"
firstName := "Test"
lastName := "User"
isPremium := true
languageCode := "de"
// 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())
// Handle username placeholder
usernameValue := username
if username == "" {
usernameValue = "unknown"
}
systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue)
// 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 firstname placeholder
firstnameValue := firstName
if firstName == "" {
firstnameValue = "unknown"
}
systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue)
// 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)
// 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,6 +355,12 @@ 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 {
+5 -2
View File
@@ -15,7 +15,10 @@
"temperature": 0.7,
"debug_screening": false,
"system_prompts": {
"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."
"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."
}
}
Binary file not shown.
+7 -9
View File
@@ -3,26 +3,25 @@ module github.com/HugeFrog24/go-telegram-bot
go 1.26.0
require (
github.com/anthropics/anthropic-sdk-go v1.52.0
github.com/go-telegram/bot v1.21.0
github.com/anthropics/anthropic-sdk-go v1.45.0
github.com/go-telegram/bot v1.20.0
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.21.0
golang.org/x/sync v0.20.0
golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
gorm.io/gorm v1.31.1
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
@@ -32,8 +31,7 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-18
View File
@@ -1,7 +1,5 @@
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
github.com/anthropics/anthropic-sdk-go v1.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg=
github.com/anthropics/anthropic-sdk-go v1.52.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
@@ -13,12 +11,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -34,10 +28,6 @@ github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -63,16 +53,10 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -86,5 +70,3 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+12 -8
View File
@@ -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, 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, isNewChat, isOwner bool, 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, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, isNewChat, isOwner, 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,6 +125,7 @@ func (b *Bot) handlePhotoMessage(
isPremium bool,
languageCode string,
messageTime int,
isNewChat, isOwner bool,
businessConnectionID string,
) {
if len(items) == 0 {
@@ -202,7 +203,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, false,
ctx, chatID, contextMessages, isNewChat, isOwner, false,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
@@ -291,6 +292,9 @@ 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
@@ -319,7 +323,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, businessConnectionID)
isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
return
}
if len(message.Photo) > 0 {
@@ -330,7 +334,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,
businessConnectionID)
isNewChatFlag, isOwner, businessConnectionID)
return
}
@@ -486,7 +490,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, businessConnectionID)
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
return
}
@@ -514,7 +518,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, isEmojiOnly,
ctx, chatID, contextMessages, isNewChatFlag, isOwner, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
@@ -574,7 +578,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, true, message.Username, "", "", false, "", messageTime, nil)
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime, nil)
if err != nil {
return "", err
}