mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-06-29 22:07:12 +00:00
Compare commits
3 Commits
main
...
4fc9d8a5c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc9d8a5c5 | |||
| 7c74d91bbb | |||
| b22b8b98fe |
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
+100
-25
@@ -17,7 +17,13 @@ import (
|
||||
// actionable message to admins/owners while keeping the response vague for regular users.
|
||||
var ErrModelNotFound = errors.New("model not found or deprecated")
|
||||
|
||||
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) (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
|
||||
var systemMessage string
|
||||
if isNewChat {
|
||||
@@ -139,8 +145,97 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet
|
||||
params.Betas = []anthropic.AnthropicBeta{anthropic.AnthropicBetaMCPClient2025_11_20}
|
||||
}
|
||||
|
||||
resp, err := b.anthropicClient.Beta.Messages.New(ctx, params)
|
||||
if err != nil {
|
||||
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)
|
||||
@@ -148,28 +243,8 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet
|
||||
return "", fmt.Errorf("error creating Anthropic message: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
sb.WriteString(block.Text)
|
||||
case "mcp_tool_use":
|
||||
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
|
||||
block.ServerName, block.Name, block.ID, string(block.Input))
|
||||
case "mcp_tool_result":
|
||||
preview := block.JSON.Content.Raw()
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "...(truncated)"
|
||||
}
|
||||
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
|
||||
block.ToolUseID, block.ServerName, block.IsError, preview)
|
||||
default:
|
||||
InfoLogger.Printf("[mcp] block type=%q (unhandled)", block.Type)
|
||||
}
|
||||
}
|
||||
out := sb.String()
|
||||
if out == "" {
|
||||
if len(allSegments) == 0 {
|
||||
return "", fmt.Errorf("unexpected response format from Anthropic")
|
||||
}
|
||||
return out, nil
|
||||
return strings.Join(allSegments, "\n\n"), nil
|
||||
}
|
||||
|
||||
@@ -449,6 +449,27 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
|
||||
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.
|
||||
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
|
||||
// If targetUserID is 0, show global stats
|
||||
|
||||
Binary file not shown.
+52
-12
@@ -55,7 +55,10 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
|
||||
|
||||
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||
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 {
|
||||
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
|
||||
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
|
||||
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
|
||||
// always get the generic fallback to avoid leaking internal details.
|
||||
// fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they
|
||||
// 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 {
|
||||
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
|
||||
isElevated := b.hasScope(userID, ScopeModelSet)
|
||||
|
||||
if errors.Is(err, ErrModelNotFound) && isElevated {
|
||||
return fmt.Sprintf(
|
||||
"⚠️ 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",
|
||||
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."
|
||||
}
|
||||
|
||||
@@ -340,17 +364,31 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
// Determine if the text contains only emojis
|
||||
isEmojiOnly := isOnlyEmojis(text)
|
||||
|
||||
// Get response from Anthropic
|
||||
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime)
|
||||
// Stream Anthropic's reply, sending each completed text block to Telegram
|
||||
// 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 {
|
||||
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
|
||||
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
|
||||
ErrorLogger.Printf("Error sending response: %v", err)
|
||||
return
|
||||
// Record the full turn once, at end-of-stream. Same 1-reply-per-prompt
|
||||
// invariant as the non-streaming path: one DB row, one answered_on stamp,
|
||||
// one chat-memory entry containing the joined segments.
|
||||
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
|
||||
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +427,9 @@ func (b *Bot) generateStickerResponse(ctx context.Context, message Message, cont
|
||||
// "Sent a sticker: <emoji>"), so the full conversation history is preserved.
|
||||
if message.StickerFileID != "" {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+28
-12
@@ -65,21 +65,23 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
// userID 123 is the configured owner; any other ID is a regular user.
|
||||
userID int64
|
||||
isOwner bool
|
||||
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",
|
||||
userID: 123, // owner's ID
|
||||
isOwner: true,
|
||||
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
|
||||
userID: 123,
|
||||
wantSubstr: "Anthropic call failed:",
|
||||
},
|
||||
{
|
||||
name: "Regular User First Message",
|
||||
userID: 456,
|
||||
isOwner: false,
|
||||
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
|
||||
wantSubstr: "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
|
||||
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify response was stored
|
||||
// Verify response was stored (most recent assistant message in this chat).
|
||||
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.Contains(t, respMsg.Text, tc.wantSubstr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -720,11 +725,22 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
|
||||
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,
|
||||
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",
|
||||
wantMissing: "/set_model",
|
||||
wantMissing: "Anthropic call failed",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user