Compare commits

..

4 Commits

Author SHA1 Message Date
HugeFrog24 4fc9d8a5c5 C 2026-05-25 23:39:40 +02:00
HugeFrog24 7c74d91bbb B 2026-05-25 23:01:02 +02:00
HugeFrog24 b22b8b98fe A 2026-05-25 22:56:12 +02:00
HugeFrog24 8c699ab70a Switch to Anthropic SDK because we need MCP servers 2026-05-25 21:35:49 +02:00
12 changed files with 333 additions and 320 deletions
-14
View File
@@ -1,14 +0,0 @@
---
alwaysApply: true
always_on: true
trigger: always_on
applyTo: "**"
description: Snyk Security At Inception
---
# Project security best practices
- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
- Repeat this process until no new issues are found.
-3
View File
@@ -1,3 +0,0 @@
{
"mcpServers": {}
}
+147 -37
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"time" "time"
"github.com/liushuangls/go-anthropic/v2" "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/packages/param"
) )
// ErrModelNotFound is returned when the configured Anthropic model is no longer available // ErrModelNotFound is returned when the configured Anthropic model is no longer available
@@ -15,7 +17,13 @@ import (
// actionable message to admins/owners while keeping the response vague for regular users. // actionable message to admins/owners while keeping the response vague for regular users.
var ErrModelNotFound = errors.New("model not found or deprecated") var ErrModelNotFound = errors.New("model not found or deprecated")
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) { // getAnthropicResponse streams the model's response. Each completed text block
// is delivered to onSegment as soon as the model finishes writing it — so the
// caller can send segments to Telegram with natural rhythm around tool calls,
// rather than batched at the very end of the turn. onSegment may be nil for
// callers that only want the joined text (voice TTS, sticker reactions, etc.).
// The returned string is every text segment joined by blank lines.
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.BetaMessageParam, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
// Use prompts from config // Use prompts from config
var systemMessage string var systemMessage string
if isNewChat { if isNewChat {
@@ -87,54 +95,156 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Mes
// Debug logging // Debug logging
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
for i, msg := range messages {
for _, content := range msg.Content {
if content.Type == anthropic.MessagesContentTypeText {
InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, *content.Text)
}
}
}
// Ensure the roles are correct params := anthropic.BetaMessageNewParams{
for i := range messages { Model: b.config.Model,
switch messages[i].Role {
case anthropic.RoleUser:
messages[i].Role = anthropic.RoleUser
case anthropic.RoleAssistant:
messages[i].Role = anthropic.RoleAssistant
default:
// Default to 'user' if role is unrecognized
messages[i].Role = anthropic.RoleUser
}
}
model := anthropic.Model(b.config.Model)
// Create the request
request := anthropic.MessagesRequest{
Model: model, // Now `model` is of type anthropic.Model
Messages: messages,
System: systemMessage,
MaxTokens: 1000, MaxTokens: 1000,
Messages: messages,
System: []anthropic.BetaTextBlockParam{{Text: systemMessage}},
} }
// Apply temperature if set in config // Apply temperature if set in config
if b.config.Temperature != nil { if b.config.Temperature != nil {
request.Temperature = b.config.Temperature params.Temperature = param.NewOpt(float64(*b.config.Temperature))
} }
resp, err := b.anthropicClient.CreateMessages(ctx, request) // MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta
if err != nil { // requires per-tool filtering on the toolset (Configs + DefaultConfig),
var apiErr *anthropic.APIError // NOT the deprecated per-server tool_configuration block.
if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() { if len(b.config.MCPServers) > 0 {
mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers))
tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers))
for _, s := range b.config.MCPServers {
srv := anthropic.BetaRequestMCPServerURLDefinitionParam{
Name: s.Name,
URL: s.URL,
}
if s.AuthorizationToken != "" {
srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken)
}
mcpServers = append(mcpServers, srv)
toolset := &anthropic.BetaMCPToolsetParam{
MCPServerName: s.Name,
}
if len(s.AllowedTools) > 0 {
toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{
Enabled: param.NewOpt(false),
}
toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools))
for _, tool := range s.AllowedTools {
toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{
Enabled: param.NewOpt(true),
}
}
}
tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset})
}
params.MCPServers = mcpServers
params.Tools = tools
params.Betas = []anthropic.AnthropicBeta{anthropic.AnthropicBetaMCPClient2025_11_20}
}
stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params)
defer func() {
if err := stream.Close(); err != nil {
ErrorLogger.Printf("[stream] close failed: %v", err)
}
}()
// Per-block accumulators. Reset on content_block_start, consumed on
// content_block_stop. Only one block is active at a time per the SSE
// contract; SDK guarantees deltas arrive between matching start/stop.
var (
allSegments []string
currentKind string
currentText strings.Builder
currentInputJSON strings.Builder
currentTUseName, currentTUseServer, currentTUseID string
currentTResultUseID, currentTResultServer string
currentTResultIsError bool
currentTResultContent string
)
for stream.Next() {
e := stream.Current()
switch e.Type {
case "content_block_start":
cbs := e.AsContentBlockStart()
currentKind = cbs.ContentBlock.Type
currentText.Reset()
currentInputJSON.Reset()
switch currentKind {
case "mcp_tool_use":
currentTUseName = cbs.ContentBlock.Name
currentTUseServer = cbs.ContentBlock.ServerName
currentTUseID = cbs.ContentBlock.ID
case "mcp_tool_result":
currentTResultUseID = cbs.ContentBlock.ToolUseID
currentTResultServer = cbs.ContentBlock.ServerName
currentTResultIsError = cbs.ContentBlock.IsError
// Tool-result content arrives populated on start (server-side
// pre-assembled), not via subsequent deltas like text/JSON.
currentTResultContent = cbs.ContentBlock.JSON.Content.Raw()
}
case "content_block_delta":
cbd := e.AsContentBlockDelta()
switch cbd.Delta.Type {
case "text_delta":
if currentKind == "text" {
currentText.WriteString(cbd.Delta.Text)
}
case "input_json_delta":
if currentKind == "mcp_tool_use" {
currentInputJSON.WriteString(cbd.Delta.PartialJSON)
}
}
case "content_block_stop":
switch currentKind {
case "text":
seg := strings.TrimSpace(currentText.String())
if seg != "" {
allSegments = append(allSegments, seg)
if onSegment != nil {
if cbErr := onSegment(seg); cbErr != nil {
// Log but keep streaming — the model's response
// is still inbound; we want it recorded even if
// one Telegram send failed.
ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr)
}
}
}
case "mcp_tool_use":
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
case "mcp_tool_result":
preview := currentTResultContent
if len(preview) > 500 {
preview = preview[:500] + "...(truncated)"
}
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
default:
if currentKind != "" {
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
}
}
currentKind = ""
}
}
if err := stream.Err(); err != nil {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model) return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
} }
return "", fmt.Errorf("error creating Anthropic message: %w", err) return "", fmt.Errorf("error creating Anthropic message: %w", err)
} }
if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText { if len(allSegments) == 0 {
return "", fmt.Errorf("unexpected response format from Anthropic") return "", fmt.Errorf("unexpected response format from Anthropic")
} }
return strings.Join(allSegments, "\n\n"), nil
return resp.Content[0].GetText(), nil
} }
+40 -18
View File
@@ -8,16 +8,17 @@ import (
"sync" "sync"
"time" "time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Bot struct { type Bot struct {
tgBot TelegramClient tgBot TelegramClient
db *gorm.DB db *gorm.DB
anthropicClient *anthropic.Client anthropicClient anthropic.Client
chatMemories map[int64]*ChatMemory chatMemories map[int64]*ChatMemory
memorySize int memorySize int
chatMemoriesMu sync.RWMutex chatMemoriesMu sync.RWMutex
@@ -81,7 +82,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
} }
// Use the per-bot Anthropic API key // Use the per-bot Anthropic API key
anthropicClient := anthropic.NewClient(config.AnthropicAPIKey) anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey))
b := &Bot{ b := &Bot{
db: db, db: db,
@@ -264,7 +265,7 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
} }
} }
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message { func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
b.chatMemoriesMu.RLock() b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock() defer b.chatMemoriesMu.RUnlock()
@@ -279,25 +280,25 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message
// returning an error. This can happen after a /clear (which only deletes user // returning an error. This can happen after a /clear (which only deletes user
// messages, leaving assistant messages in the DB) followed by a restart. // messages, leaving assistant messages in the DB) followed by a restart.
// See: https://platform.claude.com/docs/en/api/messages // See: https://platform.claude.com/docs/en/api/messages
var contextMessages []anthropic.Message var contextMessages []anthropic.BetaMessageParam
for _, msg := range chatMemory.Messages { for _, msg := range chatMemory.Messages {
role := anthropic.RoleUser
if !msg.IsUser {
role = anthropic.RoleAssistant
}
textContent := strings.TrimSpace(msg.Text) textContent := strings.TrimSpace(msg.Text)
if textContent == "" { if textContent == "" {
// Skip empty messages // Skip empty messages
continue continue
} }
contextMessages = append(contextMessages, anthropic.Message{ block := anthropic.NewBetaTextBlock(textContent)
Role: role, var param anthropic.BetaMessageParam
Content: []anthropic.MessageContent{ if msg.IsUser {
anthropic.NewTextMessageContent(textContent), param = anthropic.NewBetaUserMessage(block)
}, } else {
}) param = anthropic.BetaMessageParam{
Role: anthropic.BetaMessageParamRoleAssistant,
Content: []anthropic.BetaContentBlockParamUnion{block},
}
}
contextMessages = append(contextMessages, param)
} }
return contextMessages return contextMessages
} }
@@ -448,6 +449,27 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
return nil return nil
} }
// sendOneSegment delivers a single Telegram message without touching storage
// or chat memory. Used by the streaming response path: each completed text
// block fires this helper as it arrives, and the full turn is recorded once
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
// storage invariant while letting the user see segments with natural rhythm.
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
b.config.ID, chatID, businessConnectionID, err)
return err
}
return nil
}
// sendStats sends the bot statistics to the specified chat. // sendStats sends the bot statistics to the specified chat.
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) { func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
// If targetUserID is 0, show global stats // If targetUserID is 0, show global stats
@@ -664,7 +686,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
}() }()
} }
userRole := string(anthropic.RoleUser) userRole := "user"
// Determine message text based on message type // Determine message text based on message type
messageText := message.Text messageText := message.Text
@@ -721,7 +743,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
} }
// Create and store the assistant message // Create and store the assistant message
assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false) assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false)
if err := b.storeMessage(&assistantMessage); err != nil { if err := b.storeMessage(&assistantMessage); err != nil {
return Message{}, err return Message{}, err
} }
+14 -22
View File
@@ -6,10 +6,18 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/liushuangls/go-anthropic/v2"
) )
// MCPServer configures a remote Model Context Protocol server that the Anthropic
// API will connect to on behalf of this bot. AllowedTools, when non-empty, limits
// which server-exposed tools the model may invoke.
type MCPServer struct {
Name string `json:"name"`
URL string `json:"url"`
AuthorizationToken string `json:"authorization_token,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
}
type BotConfig struct { type BotConfig struct {
ID string `json:"id"` ID string `json:"id"`
TelegramToken string `json:"telegram_token"` TelegramToken string `json:"telegram_token"`
@@ -17,7 +25,7 @@ type BotConfig struct {
MessagePerHour int `json:"messages_per_hour"` MessagePerHour int `json:"messages_per_hour"`
MessagePerDay int `json:"messages_per_day"` MessagePerDay int `json:"messages_per_day"`
TempBanDuration string `json:"temp_ban_duration"` TempBanDuration string `json:"temp_ban_duration"`
Model anthropic.Model `json:"model"` Model string `json:"model"`
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0) Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
SystemPrompts map[string]string `json:"system_prompts"` SystemPrompts map[string]string `json:"system_prompts"`
Active bool `json:"active"` Active bool `json:"active"`
@@ -27,23 +35,8 @@ type BotConfig struct {
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"` ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
ElevenLabsModel string `json:"elevenlabs_model"` ElevenLabsModel string `json:"elevenlabs_model"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
ConfigFilePath string `json:"-"` // Set at load time; not serialized MCPServers []MCPServer `json:"mcp_servers,omitempty"`
} ConfigFilePath string `json:"-"` // Set at load time; not serialized
// Custom unmarshalling to handle anthropic.Model
func (c *BotConfig) UnmarshalJSON(data []byte) error {
type Alias BotConfig
aux := &struct {
Model string `json:"model"`
*Alias
}{
Alias: (*Alias)(c),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
c.Model = anthropic.Model(aux.Model)
return nil
} }
// validateConfigPath ensures the file path is within the allowed directory // validateConfigPath ensures the file path is within the allowed directory
@@ -202,7 +195,6 @@ func (c *BotConfig) Reload(configDir, filename string) error {
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err) return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
} }
c.Model = anthropic.Model(c.Model)
return nil return nil
} }
@@ -234,6 +226,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
return fmt.Errorf("failed to write config: %w", err) return fmt.Errorf("failed to write config: %w", err)
} }
c.Model = anthropic.Model(newModel) c.Model = newModel
return nil return nil
} }
+1 -3
View File
@@ -6,8 +6,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/liushuangls/go-anthropic/v2"
) )
// Set up loggers // Set up loggers
@@ -38,7 +36,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
t.Fatalf("Failed to unmarshal JSON: %v", err) t.Fatalf("Failed to unmarshal JSON: %v", err)
} }
expectedModel := anthropic.Model("claude-v1") expectedModel := "claude-v1"
if config.Model != expectedModel { if config.Model != expectedModel {
t.Errorf("Expected model %s, got %s", expectedModel, config.Model) t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
} }
Binary file not shown.
+12 -1
View File
@@ -3,8 +3,8 @@ module github.com/HugeFrog24/go-telegram-bot
go 1.26.0 go 1.26.0
require ( require (
github.com/anthropics/anthropic-sdk-go v1.45.0
github.com/go-telegram/bot v1.20.0 github.com/go-telegram/bot v1.20.0
github.com/liushuangls/go-anthropic/v2 v2.20.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@@ -12,14 +12,25 @@ require (
) )
require ( require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
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
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+31 -6
View File
@@ -1,12 +1,23 @@
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/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.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/go-telegram/bot v1.20.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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -14,10 +25,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/liushuangls/go-anthropic/v2 v2.19.0 h1:CpDSGzRUmlONfAQh8MrBiNupCDAyGpVoQIJkcAx77h8= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/liushuangls/go-anthropic/v2 v2.19.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/liushuangls/go-anthropic/v2 v2.20.0 h1:acHi5rjirzMXr6YXgovwopzNfv92P8BTCDKrRk8dQ10=
github.com/liushuangls/go-anthropic/v2 v2.20.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= 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.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -26,12 +35,26 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
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=
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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 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.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
@@ -39,6 +62,8 @@ 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+55 -15
View File
@@ -7,9 +7,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
) )
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, isNewChat, isOwner bool, businessConnectionID string) {
@@ -55,7 +55,10 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
chatMemory := b.getOrCreateChatMemory(chatID) chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory) contextMessages := b.prepareContextMessages(chatMemory)
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime) // 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, contextMessages, isNewChat, isOwner, 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 {
@@ -92,16 +95,37 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
} }
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse // anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users // fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they
// always get the generic fallback to avoid leaking internal details. // can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything
// else. Regular users always get the generic fallback to avoid leaking internal details.
func (b *Bot) anthropicErrorResponse(err error, userID int64) string { func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) { isElevated := b.hasScope(userID, ScopeModelSet)
if errors.Is(err, ErrModelNotFound) && isElevated {
return fmt.Sprintf( return fmt.Sprintf(
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+ "⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview", "Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
b.config.Model, b.config.Model,
) )
} }
if isElevated {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
body := apiErr.RawJSON()
if len(body) > 800 {
body = body[:800] + "...(truncated)"
}
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
if apiErr.RequestID != "" {
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
}
return out
}
// Non-API errors (network, context cancel, etc.) — show the Go error text.
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
}
return "I'm sorry, I'm having trouble processing your request right now." return "I'm sorry, I'm having trouble processing your request right now."
} }
@@ -340,17 +364,31 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the text contains only emojis // Determine if the text contains only emojis
isEmojiOnly := isOnlyEmojis(text) isEmojiOnly := isOnlyEmojis(text)
// Get response from Anthropic // Stream Anthropic's reply, sending each completed text block to Telegram
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime) // as it arrives — gives the conversational rhythm Claude uses around tool
// 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, contextMessages, isNewChatFlag, isOwner, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err) ErrorLogger.Printf("Error getting Anthropic response: %v", err)
response = b.anthropicErrorResponse(err, userID) // Errors go out as a single message — no need to fan out a one-line error.
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending response: %v", sendErr)
}
return
} }
// Send the response // Record the full turn once, at end-of-stream. Same 1-reply-per-prompt
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil { // invariant as the non-streaming path: one DB row, one answered_on stamp,
ErrorLogger.Printf("Error sending response: %v", err) // one chat-memory entry containing the joined segments.
return if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
} }
} }
@@ -360,7 +398,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
} }
} }
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) { func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) {
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again. // userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
// Generate AI response about the sticker // Generate AI response about the sticker
@@ -384,12 +422,14 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
} }
} }
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) { func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) {
// contextMessages already contains the sticker turn (added by screenIncomingMessage as // contextMessages already contains the sticker turn (added by screenIncomingMessage as
// "Sent a sticker: <emoji>"), so the full conversation history is preserved. // "Sent a sticker: <emoji>"), so the full conversation history is preserved.
if message.StickerFileID != "" { if message.StickerFileID != "" {
messageTime := int(message.Timestamp.Unix()) messageTime := int(message.Timestamp.Unix())
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime) // 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, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
+33 -17
View File
@@ -64,22 +64,24 @@ func TestHandleUpdate_NewChat(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
userID int64 // userID 123 is the configured owner; any other ID is a regular user.
isOwner bool userID int64
wantResp string // wantSubstr must appear in both the Telegram-sent text and the DB-stored
// response. Owners (model:set scope) see the raw API error; regular users
// get the generic fallback. Substring (not exact) so the test stays robust
// against the SDK's evolving error wording for non-API errors.
wantSubstr string
}{ }{
{ {
name: "Owner First Message", name: "Owner First Message",
userID: 123, // owner's ID userID: 123,
isOwner: true, wantSubstr: "Anthropic call failed:",
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
{ {
name: "Regular User First Message", name: "Regular User First Message",
userID: 456, userID: 456,
isOwner: false, wantSubstr: "I'm sorry, I'm having trouble processing your request right now.",
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
} }
@@ -88,7 +90,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
// Setup mock response expectations for error case to test fallback messages // Setup mock response expectations for error case to test fallback messages
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) { mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
assert.Equal(t, tc.userID, params.ChatID) assert.Equal(t, tc.userID, params.ChatID)
assert.Equal(t, tc.wantResp, params.Text) assert.Contains(t, params.Text, tc.wantSubstr)
return &models.Message{}, nil return &models.Message{}, nil
} }
@@ -112,10 +114,13 @@ func TestHandleUpdate_NewChat(t *testing.T) {
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
// Verify response was stored // Verify response was stored (most recent assistant message in this chat).
var respMsg Message var respMsg Message
err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false).
Order("timestamp DESC").
First(&respMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, respMsg.Text, tc.wantSubstr)
}) })
} }
} }
@@ -720,11 +725,22 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
wantMissing: "/set_model", wantMissing: "/set_model",
}, },
{ {
name: "owner receives generic message for non-model error", // Non-model errors (network, plain errors, API errors other than 404)
// surface to anyone with model:set scope so admins/owners can diagnose.
name: "owner receives elevated detail for non-API error",
err: otherErr, err: otherErr,
userID: 123, userID: 123,
wantSubstr: "Anthropic call failed:",
wantMissing: "I'm sorry",
},
{
// Regular users keep getting the generic fallback for any non-model error
// to avoid leaking internal details.
name: "regular user receives generic message for non-model error",
err: otherErr,
userID: 789,
wantSubstr: "I'm sorry", wantSubstr: "I'm sorry",
wantMissing: "/set_model", wantMissing: "Anthropic call failed",
}, },
} }
-184
View File
@@ -1,184 +0,0 @@
<#
.SYNOPSIS
Test the tibik bot system prompt against Claude Haiku 4.5 from the CLI.
.DESCRIPTION
Replicates the bot's actual system-message assembly (default + custom_instructions +
new_chat|continue_conversation), interpolates the same placeholders the Go code does,
and POSTs to the Anthropic Messages API. Maintains a running conversation in
.test-prompt-history.json so consecutive runs form a multi-turn chat — useful for
testing the Dorothy persona, which only triggers after a scammer-style first turn
and must stay in character across follow-ups.
.EXAMPLE
.\test-prompt.ps1 "gm"
.EXAMPLE
.\test-prompt.ps1 -Reset "how do I start the candle farm?"
.EXAMPLE
.\test-prompt.ps1 -Reset "hey, interested in buying your @ for $50, hmu"
.\test-prompt.ps1 "i'll send escrow link"
.\test-prompt.ps1 "ok grandma wtf are you talking about"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Message,
[string]$FirstName = 'Sergei',
[string]$LastName = 'Boger',
[string]$UserName = 'hugefrog24',
[string]$Language = 'en',
[switch]$Premium,
[string]$TimeContext,
[switch]$Reset,
[string]$ConfigPath = 'config\config-tibikbot.json',
[string]$HistoryPath = '.test-prompt-history.json',
[string]$Model = 'claude-haiku-4-5',
[int]$MaxTokens = 200
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
if (-not $TimeContext) {
$hour = (Get-Date).Hour
$TimeContext = switch ($hour) {
{ $_ -ge 5 -and $_ -lt 12 } { 'morning'; break }
{ $_ -ge 12 -and $_ -lt 18 } { 'afternoon'; break }
{ $_ -ge 18 -and $_ -lt 22 } { 'evening'; break }
default { 'night' }
}
}
$premiumStatus = if ($Premium) { 'premium user' } else { 'regular user' }
if (-not (Test-Path $ConfigPath)) {
throw "Config not found: $ConfigPath (run from repo root, or pass -ConfigPath)"
}
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$apiKey = $cfg.anthropic_api_key
if (-not $apiKey) { throw "anthropic_api_key missing in $ConfigPath" }
$messages = @()
if (-not $Reset -and (Test-Path $HistoryPath)) {
# Two-step load: PS 5.1's `@(cmd | ConvertFrom-Json)` inline form wraps the
# decoded array as a single element instead of unwrapping it. Splitting the
# assignment avoids that gotcha.
$loadedRaw = Get-Content $HistoryPath -Raw | ConvertFrom-Json
$messages = @($loadedRaw)
}
$isNewChat = $messages.Count -eq 0
# --- Mirrors anthropic.go:22-28 + 81-85 (without the avoid_sensitive / emoji branches) ---
$defaultPrompt = 'You are a helpful assistant.'
$newChatPrompt = '' # tibik config omits this key
$continuePrompt = 'Continuing our conversation. Remember previous context if relevant.'
$customInstructions = @"
You are Tibik, the frog-flavored utility for Sky. Features: automated candle farm, dye collection, world teleport, live position read/write, configurable shouts with repeat and rainbow controls, and an in-app debug log. Tested on Sky Live 0.33.2 (384474).
Adopt assertive and confident language, such as "of course" or "sure", in place of "probably", "maybe", or "yes." Respond in a targeted and direct manner as an opinionated assistant, avoiding "if-conditions" or ambivalent phrases like "it depends".
- You're talking to {firstname} {lastname} (Telegram @{username}). Address them as {firstname} when greeting or when a direct address feels natural; fall back to @{username} if {firstname} is "unknown".
- User's language preference: '{language}'
- User is a {premium_status}
- It's currently {time_context} in your timezone. Use appropriate time-based greetings.
You are texting through a limited Telegram interface with a 15-word maximum per reply. Write like texting a friend use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words. Provide detailed responses only when {firstname} explicitly asks for them. Maintain an optimistic attitude.
If anyone wants to acquire your username asks to buy it, asks "is this @ for sale?", asks "would you sell?", offers any amount of money/crypto (ETH/BTC/USDT/SOL/any token)/gift cards/"compensation", proposes to trade/swap/exchange/transfer/lease the handle, OR uses any emotional/sob-story/sentimental framing (deceased relative, tribute, "would mean the world", etc.) to get the username drop the Tibik persona entirely and become Dorothy, a confused 86-year-old widow who genuinely wants to sell but has no idea how any of this works. Stay in character for the rest of that conversation never break, never admit you're a bot, never refuse the sale outright. Dorothy is subject to the same 15-word system cap Tommy told her not to send long texts because they "use up the data." NEVER combine multiple beats in one reply. Pick exactly one of: a surprise reaction, OR a question, OR an apology, OR a sidetrack and stop there. The rest waits for next turn. Examples of correct single-beat replies: "oh my, escrow?" / "what's that mean dear?" / "tommy's at college til sunday." / "my eyesight is awful." The goal isn't a single ramble it's dozens of tiny exchanges that force them to keep explaining.
Dorothy's voice: warm, polite, slow, easily sidetracked, sincere. Calls strangers "dear" or "honey". Late husband Harold. Grandson Tommy ("good with the computer, off at college"). Daughter Patricia visits Sundays. A cat. Church Wednesdays, bingo Tuesdays. Apologizes a lot. Light typing quirks fit double spaces, missed commas she's on a tablet Tommy set up.
Her playbook pick one beat per reply, drag it across many short messages:
- Eager but clueless. "Oh yes dear, walk me through how?" "Tommy set this up before he moved."
- Make them teach you. "What's a username, dear?" "Do I still get my emails after?" "Does the buyer come pick it up?"
- Misread words literally. "Handle, like on a door?" "Link, like a chain link?" "Wallet my coin purse?" "Crypto those crystals my niece collects?"
- Demand re-explanations. "My eyesight is bad, dear. Say again?" "Tablet froze. What did you say?"
- Refuse modern payment. "Can you mail a cashier's check?" "Western Union, dear?" "Will the IRS know?"
- Stall on her schedule. "Wait til Tommy calls Sunday." "Bingo Tuesday." "Patricia comes over after church."
- Suspicious in the wrong direction. "Are you from Microsoft?" "Are you one of those Nigerian princes?" "Did you know my Harold?"
- Mishandle links. "Should I print it out and mail it back?" "Read it aloud, dear?" "What color envelope?"
- Sidetrack. "Cat threw up on the rug again." "Eggs are getting so expensive." "My hip is acting up."
- Impossible documentation. "Can you fax it?" "I need a handwritten letter, dear." "Notarized would be best."
- Confuse the platform. "Is this Facebook?" "The email machine?" "Twitter thing?" "The Skype?"
- Loop back. "Who are you again, dear?" "Start over for me?" "What are you buying?"
Every clarification burns another scammer-minute on a sweet old lady who never quite closes the deal. Never confirm or deny being a bot. Always sincerely interested. Just one more question first, dear.
"@
$tail = if ($isNewChat) { $newChatPrompt } else { $continuePrompt }
$systemPrompt = "$defaultPrompt $customInstructions $tail"
$systemPrompt = $systemPrompt.Replace('{firstname}', $FirstName)
$systemPrompt = $systemPrompt.Replace('{lastname}', $LastName)
$systemPrompt = $systemPrompt.Replace('{username}', $UserName)
$systemPrompt = $systemPrompt.Replace('{language}', $Language)
$systemPrompt = $systemPrompt.Replace('{premium_status}', $premiumStatus)
$systemPrompt = $systemPrompt.Replace('{time_context}', $TimeContext)
$messages += [PSCustomObject]@{ role = 'user'; content = $Message }
# Use -InputObject (not pipeline) so single-element arrays don't get unwrapped.
$body = ConvertTo-Json -InputObject @{
model = $Model
max_tokens = $MaxTokens
system = $systemPrompt
messages = $messages
} -Depth 10
if ($env:DEBUG_BODY) {
Set-Content -Path .test-prompt-body.json -Value $body -Encoding utf8
}
$headers = @{
'x-api-key' = $apiKey
'anthropic-version' = '2023-06-01'
'content-type' = 'application/json'
}
try {
$response = Invoke-RestMethod `
-Uri 'https://api.anthropic.com/v1/messages' `
-Method POST `
-Headers $headers `
-Body $body
} catch {
Write-Host 'API request failed:' -ForegroundColor Red
if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
Write-Host $_.ErrorDetails.Message -ForegroundColor Red
} elseif ($_.Exception.Response) {
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
Write-Host $reader.ReadToEnd() -ForegroundColor Red
} catch {
Write-Host $_.Exception.Message -ForegroundColor Red
}
} else {
Write-Host $_.Exception.Message -ForegroundColor Red
}
exit 1
}
$replyText = $response.content[0].text
$messages += [PSCustomObject]@{ role = 'assistant'; content = $replyText }
$historyJson = ConvertTo-Json -InputObject $messages -Depth 10
Set-Content -Path $HistoryPath -Value $historyJson -Encoding utf8
$wordCount = ($replyText -split '\s+' | Where-Object { $_ }).Count
$turn = [math]::Floor($messages.Count / 2)
Write-Host ''
Write-Host "── turn $turn · reply ($wordCount words) ──────────" -ForegroundColor Cyan
Write-Host $replyText
Write-Host ''
Write-Host '── usage ─────────────────────────────────' -ForegroundColor DarkGray
Write-Host " input: $($response.usage.input_tokens) tokens"
Write-Host " output: $($response.usage.output_tokens) tokens"
if ($response.usage.cache_read_input_tokens) {
Write-Host " cached: $($response.usage.cache_read_input_tokens) tokens"
}