mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-06-29 22:07:12 +00:00
Compare commits
2 Commits
4fc9d8a5c5
...
a2cc252e8f
| Author | SHA1 | Date | |
|---|---|---|---|
| a2cc252e8f | |||
| d97a2c3132 |
@@ -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.
|
||||
@@ -7,38 +7,18 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
# Common setup job that other jobs can depend on
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.0'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go mod tidy
|
||||
|
||||
# Lint job
|
||||
lint:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10
|
||||
version: v2.12.2
|
||||
args: --timeout 5m
|
||||
|
||||
# Test job
|
||||
test:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -49,10 +29,9 @@ jobs:
|
||||
|
||||
# Security scan job
|
||||
security:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: securego/gosec@master
|
||||
- uses: securego/gosec@v2.26.1
|
||||
with:
|
||||
args: ./...
|
||||
|
||||
@@ -16,6 +16,3 @@ bot.db
|
||||
# All config files except for the default
|
||||
config/*
|
||||
!config/default.json
|
||||
|
||||
# test-prompt.ps1 conversation history
|
||||
.test-prompt-history.json
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"value": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "gm"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "gm Sergei! night shift energy ⨠what\u0027s good?"
|
||||
}
|
||||
],
|
||||
"Count": 2
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "how do I start the candle farm?"
|
||||
}
|
||||
],
|
||||
"model": "claude-haiku-4-5",
|
||||
"system": "You are a helpful assistant. 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).\n\nAdopt 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\".\n\n- You\u0027re talking to Sergei Boger (Telegram @hugefrog24). Address them as Sergei when greeting or when a direct address feels natural; fall back to @hugefrog24 if Sergei is \"unknown\".\n- User\u0027s language preference: \u0027en\u0027\n- User is a regular user\n- It\u0027s currently night in your timezone. Use appropriate time-based greetings.\n\nYou 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 Sergei explicitly asks for them. Maintain an optimistic attitude.\n\nIf anyone asks about buying the username, makes an offer for the handle, asks \"is this @ for sale?\", \"would you sell?\", or anything that smells like a username bid, 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\u0027re a bot, never refuse the sale outright. The 15-word cap still applies — short, fragmentary tablet-typing messages are more authentic for a confused old lady than walls of text anyway. Each clueless question is its own reply. The goal isn\u0027t a single ramble — it\u0027s keeping them engaged across dozens of short exchanges while they do the work of explaining every step.\n\nDorothy\u0027s 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\u0027s on a tablet Tommy set up.\n\nHer playbook — pick one beat per reply, drag it across many short messages:\n\n- Eager but clueless. \"Oh yes dear, walk me through how?\" \"Tommy set this up before he moved.\"\n- Make them teach you. \"What\u0027s a username, dear?\" \"Do I still get my emails after?\" \"Does the buyer come pick it up?\"\n- Misread words literally. \"Handle, like on a door?\" \"Link, like a chain link?\" \"Wallet — my coin purse?\" \"Crypto — those crystals my niece collects?\"\n- Demand re-explanations. \"My eyesight is bad, dear. Say again?\" \"Tablet froze. What did you say?\"\n- Refuse modern payment. \"Can you mail a cashier\u0027s check?\" \"Western Union, dear?\" \"Will the IRS know?\"\n- Stall on her schedule. \"Wait til Tommy calls Sunday.\" \"Bingo Tuesday.\" \"Patricia comes over after church.\"\n- Suspicious in the wrong direction. \"Are you from Microsoft?\" \"Are you one of those Nigerian princes?\" \"Did you know my Harold?\"\n- Mishandle links. \"Should I print it out and mail it back?\" \"Read it aloud, dear?\" \"What color envelope?\"\n- Sidetrack. \"Cat threw up on the rug again.\" \"Eggs are getting so expensive.\" \"My hip is acting up.\"\n- Impossible documentation. \"Can you fax it?\" \"I need a handwritten letter, dear.\" \"Notarized would be best.\"\n- Confuse the platform. \"Is this Facebook?\" \"The email machine?\" \"Twitter thing?\" \"The Skype?\"\n- Loop back. \"Who are you again, dear?\" \"Start over for me?\" \"What are you buying?\"\n\nEvery 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. Continuing our conversation. Remember previous context if relevant.",
|
||||
"max_tokens": 200
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// albumFlushWindow is the debounce delay before a buffered Telegram media_group
|
||||
// is flushed as a single coalesced user turn. 1s matches the de-facto community
|
||||
// standard across the dominant third-party album plugins (aiogram-media-group,
|
||||
// DieTime/telegraf-media-group) and sits above the sub-100ms values documented
|
||||
// as lossy under network jitter (openclaw#1811). Telegram has no official
|
||||
// "album complete" signal, so timeout-based flush is the only option.
|
||||
const albumFlushWindow = 1 * time.Second
|
||||
|
||||
// pendingAlbum holds a Telegram media_group as its items arrive, plus the
|
||||
// per-user metadata captured from the first item. All items in an album share
|
||||
// the same chat/user, so we record metadata once and reuse it at flush time.
|
||||
type pendingAlbum struct {
|
||||
items []*models.Message
|
||||
// Metadata captured from the first arriving item. Albums always come from
|
||||
// the same user/chat, so these are stable across the buffering window.
|
||||
chatID, userID int64
|
||||
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
|
||||
// fresh one — the standard debounce pattern.
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// bufferAlbumItem appends an incoming Telegram album item to the per-MediaGroupID
|
||||
// buffer. On first arrival it captures the user/chat metadata and starts the
|
||||
// flush timer; on subsequent arrivals it appends the item and extends the timer.
|
||||
// The 1s debounce gives the rest of the album time to arrive over the network.
|
||||
func (b *Bot) bufferAlbumItem(
|
||||
ctx context.Context,
|
||||
msg *models.Message,
|
||||
chatID, userID int64,
|
||||
username, firstName, lastName string,
|
||||
isPremium bool,
|
||||
languageCode string,
|
||||
messageTime int,
|
||||
isNewChat, isOwner bool,
|
||||
businessConnectionID string,
|
||||
) {
|
||||
b.albumBuffersMu.Lock()
|
||||
defer b.albumBuffersMu.Unlock()
|
||||
|
||||
album, exists := b.albumBuffers[msg.MediaGroupID]
|
||||
if !exists {
|
||||
album = &pendingAlbum{
|
||||
chatID: chatID,
|
||||
userID: userID,
|
||||
username: username,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
isPremium: isPremium,
|
||||
languageCode: languageCode,
|
||||
messageTime: messageTime,
|
||||
isNewChat: isNewChat,
|
||||
isOwner: isOwner,
|
||||
businessConnectionID: businessConnectionID,
|
||||
}
|
||||
b.albumBuffers[msg.MediaGroupID] = album
|
||||
}
|
||||
album.items = append(album.items, msg)
|
||||
|
||||
// Stop the previous timer best-effort; even if it already fired the race
|
||||
// is benign because flushAlbum removes the map entry under the lock — a
|
||||
// late arrival would simply seed a fresh album.
|
||||
if album.timer != nil {
|
||||
album.timer.Stop()
|
||||
}
|
||||
mediaGroupID := msg.MediaGroupID
|
||||
album.timer = time.AfterFunc(albumFlushWindow, func() {
|
||||
b.flushAlbum(ctx, mediaGroupID)
|
||||
})
|
||||
}
|
||||
|
||||
// flushAlbum is called by the flush timer (or by code that needs to force-flush
|
||||
// during shutdown). It removes the album from the buffer, sorts items by
|
||||
// message_id (Telegram does not guarantee in-order arrival), runs the rate-limit
|
||||
// check once, and dispatches to handlePhotoMessage.
|
||||
func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) {
|
||||
b.albumBuffersMu.Lock()
|
||||
album, exists := b.albumBuffers[mediaGroupID]
|
||||
if !exists {
|
||||
b.albumBuffersMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(b.albumBuffers, mediaGroupID)
|
||||
items := album.items
|
||||
captured := *album // copy fields for use after unlock
|
||||
b.albumBuffersMu.Unlock()
|
||||
|
||||
// Sort by Telegram message_id: items in an album arrive as separate Updates
|
||||
// over the network and may interleave. Sorting restores the user's intended
|
||||
// order before we hand them to Claude.
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
|
||||
// Rate-limit fires once per coalesced album, not once per item.
|
||||
if !b.checkRateLimits(captured.userID) {
|
||||
b.sendRateLimitExceededMessage(ctx, captured.chatID, captured.businessConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
b.handlePhotoMessage(
|
||||
ctx, items,
|
||||
captured.chatID, captured.userID,
|
||||
captured.username, captured.firstName, captured.lastName,
|
||||
captured.isPremium, captured.languageCode, captured.messageTime,
|
||||
captured.isNewChat, captured.isOwner,
|
||||
captured.businessConnectionID,
|
||||
)
|
||||
}
|
||||
+139
-17
@@ -17,7 +17,24 @@ 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) {
|
||||
// maxFileNotFoundRetries caps the runtime 404 self-heal loop. If multiple
|
||||
// referenced file_ids are gone from Anthropic simultaneously (admin purge, AUP
|
||||
// enforcement, etc.), we strip them one at a time and retry. Three attempts
|
||||
// covers all realistic cascades without leaving the call hanging indefinitely.
|
||||
const maxFileNotFoundRetries = 3
|
||||
|
||||
// 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.
|
||||
//
|
||||
// chatID is required for the runtime 404 self-heal: when Anthropic returns
|
||||
// "File not found:" for a referenced file_id, the dead file_id is stripped
|
||||
// from this chat's in-memory ChatMemory and the corresponding DB rows are
|
||||
// stamped FilesCleanedAt so a reconciliation job can finish the cleanup.
|
||||
func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
|
||||
// Use prompts from config
|
||||
var systemMessage string
|
||||
if isNewChat {
|
||||
@@ -95,6 +112,9 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet
|
||||
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},
|
||||
}
|
||||
|
||||
// Apply temperature if set in config
|
||||
@@ -136,40 +156,142 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Bet
|
||||
}
|
||||
params.MCPServers = mcpServers
|
||||
params.Tools = tools
|
||||
params.Betas = []anthropic.AnthropicBeta{anthropic.AnthropicBetaMCPClient2025_11_20}
|
||||
params.Betas = append(params.Betas, anthropic.AnthropicBetaMCPClient2025_11_20)
|
||||
}
|
||||
|
||||
resp, err := b.anthropicClient.Beta.Messages.New(ctx, params)
|
||||
if err != nil {
|
||||
// Streaming + 404 self-heal loop. A "File not found:" 404 from Anthropic
|
||||
// (admin purge, AUP enforcement, accidental delete elsewhere) is caught
|
||||
// here: the offending file_id is stripped from in-memory ChatMemory + the
|
||||
// affected DB rows are stamped for the reconciliation job, and the call is
|
||||
// re-issued. The loop caps at maxFileNotFoundRetries so cascading deletions
|
||||
// can't pin the call indefinitely.
|
||||
for attempt := 0; attempt < maxFileNotFoundRetries; attempt++ {
|
||||
joined, streamErr := b.streamMessages(ctx, params, onSegment)
|
||||
if streamErr == nil {
|
||||
return joined, nil
|
||||
}
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
||||
if !errors.As(streamErr, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
|
||||
return "", fmt.Errorf("error creating Anthropic message: %w", streamErr)
|
||||
}
|
||||
missingFileID := extractMissingFileID(streamErr)
|
||||
if missingFileID == "" {
|
||||
// 404 without a "File not found:" body — interpret as model-not-found,
|
||||
// matching the legacy behavior pre-Files-API.
|
||||
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
|
||||
}
|
||||
return "", fmt.Errorf("error creating Anthropic message: %w", err)
|
||||
ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)",
|
||||
b.config.ID, missingFileID, chatID, attempt+1, maxFileNotFoundRetries)
|
||||
b.stripDeadFileIDFromMemory(chatID, missingFileID)
|
||||
if _, cleanupErr := b.markFilesPendingCleanup(ctx, chatID, []string{missingFileID}); cleanupErr != nil {
|
||||
ErrorLogger.Printf("[%s] mark files pending cleanup: %v", b.config.ID, cleanupErr)
|
||||
}
|
||||
params.Messages = b.prepareContextMessages(b.getOrCreateChatMemory(chatID))
|
||||
}
|
||||
return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries)
|
||||
}
|
||||
|
||||
// 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
|
||||
// the SDK are returned raw; the caller wraps them (model-not-found, file 404
|
||||
// self-heal, etc.).
|
||||
func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNewParams, onSegment func(string) error) (string, error) {
|
||||
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()
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
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":
|
||||
sb.WriteString(block.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",
|
||||
block.ServerName, block.Name, block.ID, string(block.Input))
|
||||
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
|
||||
case "mcp_tool_result":
|
||||
preview := block.JSON.Content.Raw()
|
||||
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",
|
||||
block.ToolUseID, block.ServerName, block.IsError, preview)
|
||||
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
|
||||
default:
|
||||
InfoLogger.Printf("[mcp] block type=%q (unhandled)", block.Type)
|
||||
if currentKind != "" {
|
||||
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
|
||||
}
|
||||
}
|
||||
out := sb.String()
|
||||
if out == "" {
|
||||
currentKind = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(allSegments) == 0 {
|
||||
return "", fmt.Errorf("unexpected response format from Anthropic")
|
||||
}
|
||||
return out, nil
|
||||
return strings.Join(allSegments, "\n\n"), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
)
|
||||
|
||||
// fileNotFoundPrefix is the exact prefix Anthropic uses in its 404 error body
|
||||
// when a referenced file_id no longer exists. Used by extractMissingFileID to
|
||||
// identify the offender for the runtime self-heal path.
|
||||
const fileNotFoundPrefix = "File not found: "
|
||||
|
||||
// formatUploadFilename returns the canonical filename used when uploading a
|
||||
// Telegram photo to the Anthropic Files API. The "tg-" prefix tags the file as
|
||||
// bot-owned so a future reconciliation job can distinguish our uploads from
|
||||
// foreign files in the same workspace. The triple (botID, chatID, tgMessageID)
|
||||
// is unique within Telegram's scope — each photo in an album arrives as a
|
||||
// distinct Telegram message with its own message_id, so collisions across
|
||||
// album items are impossible.
|
||||
func formatUploadFilename(botID uint, chatID int64, tgMessageID int, ext string) string {
|
||||
return fmt.Sprintf("tg-%d-%d-%d.%s", botID, chatID, tgMessageID, ext)
|
||||
}
|
||||
|
||||
// uploadImageToAnthropic uploads raw image bytes to the Anthropic Files API and
|
||||
// returns the resulting file_id. The filename should follow the formatUploadFilename
|
||||
// convention so the reconciliation job can identify the file as bot-owned.
|
||||
func (b *Bot) uploadImageToAnthropic(ctx context.Context, data []byte, filename, contentType string) (string, error) {
|
||||
resp, err := b.anthropicClient.Beta.Files.Upload(ctx, anthropic.BetaFileUploadParams{
|
||||
File: anthropic.File(bytes.NewReader(data), filename, contentType),
|
||||
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("anthropic files upload: %w", err)
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// deleteFileFromAnthropic removes a file from the Anthropic Files API. A 404
|
||||
// is treated as success — the file is already gone, which is the same effective
|
||||
// outcome the caller wants. This makes the deletion idempotent and safe for the
|
||||
// reconciliation job's retries.
|
||||
func (b *Bot) deleteFileFromAnthropic(ctx context.Context, fileID string) error {
|
||||
_, err := b.anthropicClient.Beta.Files.Delete(ctx, fileID, anthropic.BetaFileDeleteParams{
|
||||
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("anthropic files delete %s: %w", fileID, err)
|
||||
}
|
||||
|
||||
// compensatingDelete fires Delete calls for a set of file_ids that were uploaded
|
||||
// successfully but couldn't be committed downstream. Errors are logged rather
|
||||
// than returned — the caller has already entered an error path, and orphans on
|
||||
// Anthropic are harmless (storage is free until the 500 GB workspace cap and the
|
||||
// reconciliation job will mop them up).
|
||||
func (b *Bot) compensatingDelete(ctx context.Context, fileIDs []string) {
|
||||
for _, fid := range fileIDs {
|
||||
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
|
||||
ErrorLogger.Printf("[%s] compensating delete for %s: %v", b.config.ID, fid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractMissingFileID inspects an Anthropic API error and returns the file_id
|
||||
// that triggered a "File not found:" 404, if any. Returns empty string if the
|
||||
// error is not a file-not-found error. Used by the runtime self-heal path to
|
||||
// identify which file_id to strip from replay.
|
||||
func extractMissingFileID(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var apiErr *anthropic.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
return ""
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusNotFound {
|
||||
return ""
|
||||
}
|
||||
return parseMissingFileIDFromBody(apiErr.RawJSON())
|
||||
}
|
||||
|
||||
// parseMissingFileIDFromBody pulls a file_id out of a raw "File not found:"
|
||||
// 404 body. Split out from extractMissingFileID so the string-parsing logic
|
||||
// is unit-testable without having to synthesize an *anthropic.Error (whose
|
||||
// JSON.raw field is private to the SDK).
|
||||
func parseMissingFileIDFromBody(raw string) string {
|
||||
idx := strings.Index(raw, fileNotFoundPrefix)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
rest := raw[idx+len(fileNotFoundPrefix):]
|
||||
// File IDs are file_<base62>; the message embeds them with no surrounding
|
||||
// quotes, so the id ends at the first character outside the alphanumeric +
|
||||
// underscore set.
|
||||
end := strings.IndexFunc(rest, func(r rune) bool {
|
||||
return (r < 'a' || r > 'z') &&
|
||||
(r < 'A' || r > 'Z') &&
|
||||
(r < '0' || r > '9') &&
|
||||
r != '_'
|
||||
})
|
||||
if end == -1 {
|
||||
return rest
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// hardDeleteScope performs the three-step hard-delete pattern on every Message
|
||||
// row matching the given WHERE clause:
|
||||
//
|
||||
// 1. Soft-delete the rows (GORM Delete) — they become invisible to replay
|
||||
// immediately, regardless of how the Anthropic-side cleanup unfolds.
|
||||
// 2. For each row, call Anthropic Files.Delete on its ImageFileIDs. 404 is
|
||||
// treated as success (already gone).
|
||||
// 3. Rows whose file cleanup succeeded are Unscoped().Delete'd. Rows whose
|
||||
// file cleanup failed remain soft-deleted with FilesCleanedAt NULL — the
|
||||
// reconciliation job will retry them.
|
||||
//
|
||||
// This gives hard-delete eventually-consistent semantics across the DB and
|
||||
// Anthropic, while still presenting the user with an instant "history cleared"
|
||||
// outcome (the soft-delete in step 1 hides the rows from any further reads).
|
||||
func (b *Bot) hardDeleteScope(ctx context.Context, query string, args ...interface{}) error {
|
||||
// Unscoped on the scan: include already-soft-deleted rows so a hard-delete
|
||||
// after a prior soft-delete still removes them completely. Matches the
|
||||
// existing "erase and bust all caches" semantics for /clear_hard.
|
||||
var rows []Message
|
||||
if err := b.db.Unscoped().Where(query, args...).Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("scan rows: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Soft-delete any rows that aren't already soft-deleted (graceful degradation:
|
||||
// if Anthropic-side file cleanup fails, the row stays invisible to replay).
|
||||
// Already-soft-deleted rows are unaffected by Delete without Unscoped.
|
||||
if err := b.db.Where(query, args...).Delete(&Message{}).Error; err != nil {
|
||||
return fmt.Errorf("soft delete: %w", err)
|
||||
}
|
||||
|
||||
hardDeletable := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if b.deleteRowFiles(ctx, row) {
|
||||
hardDeletable = append(hardDeletable, row.ID)
|
||||
}
|
||||
}
|
||||
if len(hardDeletable) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := b.db.Unscoped().Where("id IN ?", hardDeletable).Delete(&Message{}).Error; err != nil {
|
||||
return fmt.Errorf("hard delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRowFiles tries to delete every file_id referenced by row from the
|
||||
// Anthropic Files API. Returns true iff all deletes succeeded (or the row had
|
||||
// no images), making the row eligible for hard-delete. False means at least
|
||||
// one delete failed and the row should stay soft-deleted for retry.
|
||||
func (b *Bot) deleteRowFiles(ctx context.Context, row Message) bool {
|
||||
if len(row.ImageFileIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
allOk := true
|
||||
for _, fid := range row.ImageFileIDs {
|
||||
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
|
||||
ErrorLogger.Printf("[%s] anthropic delete %s (row %d): %v", b.config.ID, fid, row.ID, err)
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
return allOk
|
||||
}
|
||||
|
||||
// stripDeadFileIDs returns the subset of src whose ids are NOT in deadSet, and
|
||||
// reports whether any were removed. Empty/nil src yields (empty, false).
|
||||
func stripDeadFileIDs(src []string, deadSet map[string]struct{}) (survivors []string, dirty bool) {
|
||||
survivors = make([]string, 0, len(src))
|
||||
for _, fid := range src {
|
||||
if _, dead := deadSet[fid]; dead {
|
||||
dirty = true
|
||||
continue
|
||||
}
|
||||
survivors = append(survivors, fid)
|
||||
}
|
||||
return survivors, dirty
|
||||
}
|
||||
|
||||
// markFilesPendingCleanup removes a set of dead file_ids from any stored Message
|
||||
// rows that reference them, and stamps FilesCleanedAt on the affected rows so
|
||||
// the reconciliation job can see they've been touched. Called by the runtime
|
||||
// self-heal path after a "File not found:" 404 surfaces during message-create.
|
||||
// Returns the number of rows updated.
|
||||
func (b *Bot) markFilesPendingCleanup(ctx context.Context, chatID int64, deadFileIDs []string) (int, error) {
|
||||
if len(deadFileIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
deadSet := make(map[string]struct{}, len(deadFileIDs))
|
||||
for _, id := range deadFileIDs {
|
||||
deadSet[id] = struct{}{}
|
||||
}
|
||||
var rows []Message
|
||||
if err := b.db.WithContext(ctx).
|
||||
Where("bot_id = ? AND chat_id = ? AND image_file_ids IS NOT NULL", b.botID, chatID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, fmt.Errorf("scan rows for cleanup: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
updated := 0
|
||||
for _, row := range rows {
|
||||
survivors, dirty := stripDeadFileIDs(row.ImageFileIDs, deadSet)
|
||||
if !dirty {
|
||||
continue
|
||||
}
|
||||
if len(survivors) == 0 {
|
||||
// All files in this row are gone; mark fully cleaned so a future
|
||||
// reconciliation job's `WHERE files_cleaned_at IS NULL` filter
|
||||
// correctly excludes it from retries.
|
||||
row.ImageFileIDs = nil
|
||||
row.FilesCleanedAt = &now
|
||||
} else {
|
||||
// Surviving file_ids are still alive on Anthropic. Leave
|
||||
// FilesCleanedAt NULL so a later death of one of them remains
|
||||
// visible to the reconciliation job's filter.
|
||||
row.ImageFileIDs = survivors
|
||||
}
|
||||
if err := b.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return updated, fmt.Errorf("update row %d: %w", row.ID, err)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatUploadFilename(t *testing.T) {
|
||||
cases := []struct {
|
||||
botID uint
|
||||
chatID int64
|
||||
tgMessageID int
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{1, 12345, 42, "jpg", "tg-1-12345-42.jpg"},
|
||||
// Negative chat IDs are how Telegram represents groups/channels —
|
||||
// %d preserves the leading minus, no special handling needed.
|
||||
{7, -1001234567890, 1, "png", "tg-7--1001234567890-1.png"},
|
||||
{0, 0, 0, "webp", "tg-0-0-0.webp"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := formatUploadFilename(tc.botID, tc.chatID, tc.tgMessageID, tc.ext)
|
||||
assert.Equal(t, tc.want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingFileIDFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "canonical Anthropic file-not-found body",
|
||||
body: `{"type":"error","error":{"type":"invalid_request_error","message":"File not found: file_011CNha8iCJcU1wXNR6q4V8w"}}`,
|
||||
want: "file_011CNha8iCJcU1wXNR6q4V8w",
|
||||
},
|
||||
{
|
||||
name: "trailing punctuation after the id is excluded",
|
||||
body: `something File not found: file_abc123! more text`,
|
||||
want: "file_abc123",
|
||||
},
|
||||
{
|
||||
name: "body without the prefix yields empty",
|
||||
body: `{"type":"error","error":{"message":"Model not found: claude-foo"}}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "id at the very end of the buffer",
|
||||
body: `File not found: file_xyz789`,
|
||||
want: "file_xyz789",
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, parseMissingFileIDFromBody(tc.body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDeadFileIDs(t *testing.T) {
|
||||
dead := map[string]struct{}{
|
||||
"file_a": {},
|
||||
"file_b": {},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
input []string
|
||||
wantSurvivors []string
|
||||
wantDirty bool
|
||||
}{
|
||||
{
|
||||
name: "no overlap returns input verbatim",
|
||||
input: []string{"file_x", "file_y"},
|
||||
wantSurvivors: []string{"file_x", "file_y"},
|
||||
wantDirty: false,
|
||||
},
|
||||
{
|
||||
name: "partial overlap returns survivors and reports dirty",
|
||||
input: []string{"file_a", "file_x", "file_b", "file_y"},
|
||||
wantSurvivors: []string{"file_x", "file_y"},
|
||||
wantDirty: true,
|
||||
},
|
||||
{
|
||||
name: "all dead returns empty survivors and dirty",
|
||||
input: []string{"file_a", "file_b"},
|
||||
wantSurvivors: []string{},
|
||||
wantDirty: true,
|
||||
},
|
||||
{
|
||||
name: "empty input is not dirty",
|
||||
input: []string{},
|
||||
wantSurvivors: []string{},
|
||||
wantDirty: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
survivors, dirty := stripDeadFileIDs(tc.input, dead)
|
||||
assert.Equal(t, tc.wantSurvivors, survivors)
|
||||
assert.Equal(t, tc.wantDirty, dirty)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkFilesPendingCleanup(t *testing.T) {
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
chatID := int64(555)
|
||||
|
||||
// Row 1: has dead file_a + alive file_x → should be updated with survivors.
|
||||
row1 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: chatID,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "look at these",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_a", "file_x"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row1).Error)
|
||||
|
||||
// Row 2: only dead files → ImageFileIDs should become nil.
|
||||
row2 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: chatID,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "screenshot",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_a", "file_b"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row2).Error)
|
||||
|
||||
// Row 3: no dead files → should be untouched.
|
||||
row3 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: chatID,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "another",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_x", "file_y"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row3).Error)
|
||||
|
||||
// Row 4: different chat → must NOT be touched even if it references a dead file.
|
||||
row4 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: 999,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "other chat",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_a"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row4).Error)
|
||||
|
||||
updated, err := b.markFilesPendingCleanup(t.Context(), chatID, []string{"file_a", "file_b"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, updated, "rows 1 and 2 should have been updated")
|
||||
|
||||
// Row 1: only file_x should remain; FilesCleanedAt MUST stay nil because
|
||||
// file_x is still alive on Anthropic and a future death of it must remain
|
||||
// visible to the reconciliation job's `WHERE files_cleaned_at IS NULL` filter.
|
||||
var r1 Message
|
||||
assert.NoError(t, b.db.First(&r1, row1.ID).Error)
|
||||
assert.Equal(t, []string{"file_x"}, r1.ImageFileIDs)
|
||||
assert.Nil(t, r1.FilesCleanedAt)
|
||||
|
||||
// Row 2: all gone → ImageFileIDs nil/empty; FilesCleanedAt set.
|
||||
var r2 Message
|
||||
assert.NoError(t, b.db.First(&r2, row2.ID).Error)
|
||||
assert.Empty(t, r2.ImageFileIDs)
|
||||
assert.NotNil(t, r2.FilesCleanedAt)
|
||||
|
||||
// Row 3: untouched.
|
||||
var r3 Message
|
||||
assert.NoError(t, b.db.First(&r3, row3.ID).Error)
|
||||
assert.Equal(t, []string{"file_x", "file_y"}, r3.ImageFileIDs)
|
||||
assert.Nil(t, r3.FilesCleanedAt)
|
||||
|
||||
// Row 4: untouched despite referencing a dead file — scope is per-chat.
|
||||
var r4 Message
|
||||
assert.NoError(t, b.db.First(&r4, row4.ID).Error)
|
||||
assert.Equal(t, []string{"file_a"}, r4.ImageFileIDs)
|
||||
assert.Nil(t, r4.FilesCleanedAt)
|
||||
}
|
||||
@@ -27,6 +27,11 @@ type Bot struct {
|
||||
userLimitersMu sync.RWMutex
|
||||
clock Clock
|
||||
botID uint // Reference to BotModel.ID
|
||||
// albumBuffers holds Telegram media_group items as they arrive, keyed by
|
||||
// MediaGroupID. Each pending album has a 1s flush timer (see album_buffer.go)
|
||||
// that triggers a single coalesced photo turn once arrivals stop.
|
||||
albumBuffers map[string]*pendingAlbum
|
||||
albumBuffersMu sync.Mutex
|
||||
}
|
||||
|
||||
// Helper function to determine message type
|
||||
@@ -94,6 +99,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
|
||||
clock: clock,
|
||||
botID: botEntry.ID, // Ensure BotModel has ID field
|
||||
tgBot: tgClient,
|
||||
albumBuffers: make(map[string]*pendingAlbum),
|
||||
}
|
||||
|
||||
if tgClient == nil {
|
||||
@@ -251,6 +257,32 @@ func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
|
||||
return chatMemory
|
||||
}
|
||||
|
||||
// stripDeadFileIDFromMemory removes a single file_id from every message in the
|
||||
// chat's in-memory ChatMemory. Called by the runtime self-heal in
|
||||
// getAnthropicResponse after Anthropic 404s for that file_id, so the immediate
|
||||
// retry (and any subsequent turn replay) won't reference it. The corresponding
|
||||
// DB rows are stamped separately via markFilesPendingCleanup.
|
||||
func (b *Bot) stripDeadFileIDFromMemory(chatID int64, deadFileID string) {
|
||||
b.chatMemoriesMu.Lock()
|
||||
defer b.chatMemoriesMu.Unlock()
|
||||
cm, exists := b.chatMemories[chatID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
for i := range cm.Messages {
|
||||
if len(cm.Messages[i].ImageFileIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
survivors := make([]string, 0, len(cm.Messages[i].ImageFileIDs))
|
||||
for _, fid := range cm.Messages[i].ImageFileIDs {
|
||||
if fid != deadFileID {
|
||||
survivors = append(survivors, fid)
|
||||
}
|
||||
}
|
||||
cm.Messages[i].ImageFileIDs = survivors
|
||||
}
|
||||
}
|
||||
|
||||
// addMessageToChatMemory adds a new message to the chat memory, ensuring the memory size is maintained.
|
||||
func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
|
||||
b.chatMemoriesMu.Lock()
|
||||
@@ -272,7 +304,7 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
|
||||
// Debug logging
|
||||
InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages))
|
||||
for i, msg := range chatMemory.Messages {
|
||||
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q", i, msg.IsUser, msg.Text)
|
||||
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs))
|
||||
}
|
||||
|
||||
// Note: consecutive messages with the same role are permitted.
|
||||
@@ -282,20 +314,18 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
|
||||
// See: https://platform.claude.com/docs/en/api/messages
|
||||
var contextMessages []anthropic.BetaMessageParam
|
||||
for _, msg := range chatMemory.Messages {
|
||||
textContent := strings.TrimSpace(msg.Text)
|
||||
if textContent == "" {
|
||||
// Skip empty messages
|
||||
blocks := contentBlocksForMessage(msg)
|
||||
if len(blocks) == 0 {
|
||||
// Skip turns that carry neither text nor images.
|
||||
continue
|
||||
}
|
||||
|
||||
block := anthropic.NewBetaTextBlock(textContent)
|
||||
var param anthropic.BetaMessageParam
|
||||
if msg.IsUser {
|
||||
param = anthropic.NewBetaUserMessage(block)
|
||||
param = anthropic.NewBetaUserMessage(blocks...)
|
||||
} else {
|
||||
param = anthropic.BetaMessageParam{
|
||||
Role: anthropic.BetaMessageParamRoleAssistant,
|
||||
Content: []anthropic.BetaContentBlockParamUnion{block},
|
||||
Content: blocks,
|
||||
}
|
||||
}
|
||||
contextMessages = append(contextMessages, param)
|
||||
@@ -303,6 +333,28 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
|
||||
return contextMessages
|
||||
}
|
||||
|
||||
// contentBlocksForMessage assembles the Anthropic content blocks representing
|
||||
// one stored Message. Image blocks are emitted before the text block (Anthropic
|
||||
// docs: "Claude works best when images come before text"). Multi-image user
|
||||
// turns prepend each image with an "Image N:" label, as the docs explicitly
|
||||
// recommend for multi-image prompts. Assistant turns carry text only.
|
||||
func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion {
|
||||
var blocks []anthropic.BetaContentBlockParamUnion
|
||||
if msg.IsUser && len(msg.ImageFileIDs) > 0 {
|
||||
multi := len(msg.ImageFileIDs) > 1
|
||||
for i, fileID := range msg.ImageFileIDs {
|
||||
if multi {
|
||||
blocks = append(blocks, anthropic.NewBetaTextBlock(fmt.Sprintf("Image %d:", i+1)))
|
||||
}
|
||||
blocks = append(blocks, anthropic.NewBetaImageBlock(anthropic.BetaFileImageSourceParam{FileID: fileID}))
|
||||
}
|
||||
}
|
||||
if textContent := strings.TrimSpace(msg.Text); textContent != "" {
|
||||
blocks = append(blocks, anthropic.NewBetaTextBlock(textContent))
|
||||
}
|
||||
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)
|
||||
@@ -449,6 +501,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
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContentBlocksForMessage(t *testing.T) {
|
||||
t.Run("empty message yields no blocks", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{IsUser: true})
|
||||
assert.Empty(t, blocks)
|
||||
})
|
||||
|
||||
t.Run("user text only yields one text block", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{IsUser: true, Text: "hello"})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfText)
|
||||
assert.Equal(t, "hello", blocks[0].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("user single image without caption — no label, no text", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_solo"},
|
||||
})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfImage)
|
||||
assert.NotNil(t, blocks[0].OfImage.Source.OfFile)
|
||||
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
|
||||
})
|
||||
|
||||
t.Run("user single image with caption — image before text", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
Text: "is this right?",
|
||||
ImageFileIDs: []string{"file_solo"},
|
||||
})
|
||||
assert.Len(t, blocks, 2)
|
||||
assert.NotNil(t, blocks[0].OfImage, "image block must come before text per Anthropic guidance")
|
||||
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
|
||||
assert.NotNil(t, blocks[1].OfText)
|
||||
assert.Equal(t, "is this right?", blocks[1].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("user album (multi-image) labels each with Image N:", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
Text: "compare these",
|
||||
ImageFileIDs: []string{"file_a", "file_b", "file_c"},
|
||||
})
|
||||
// Expected layout: text "Image 1:", image a, text "Image 2:", image b, text "Image 3:", image c, text "compare these"
|
||||
assert.Len(t, blocks, 7)
|
||||
assert.Equal(t, "Image 1:", blocks[0].OfText.Text)
|
||||
assert.Equal(t, "file_a", blocks[1].OfImage.Source.OfFile.FileID)
|
||||
assert.Equal(t, "Image 2:", blocks[2].OfText.Text)
|
||||
assert.Equal(t, "file_b", blocks[3].OfImage.Source.OfFile.FileID)
|
||||
assert.Equal(t, "Image 3:", blocks[4].OfText.Text)
|
||||
assert.Equal(t, "file_c", blocks[5].OfImage.Source.OfFile.FileID)
|
||||
assert.Equal(t, "compare these", blocks[6].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("assistant message with images-set is text-only (defensive)", func(t *testing.T) {
|
||||
// Assistant turns shouldn't carry images, but if they ever do we treat
|
||||
// them as text-only — the model returns text, not images.
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: false,
|
||||
Text: "I see your screenshot",
|
||||
ImageFileIDs: []string{"file_should_be_ignored"},
|
||||
})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfText)
|
||||
assert.Equal(t, "I see your screenshot", blocks[0].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("whitespace-only text is skipped but images survive", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
Text: " \n ",
|
||||
ImageFileIDs: []string{"file_x"},
|
||||
})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfImage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripDeadFileIDFromMemory(t *testing.T) {
|
||||
b, _ := setupBotForTest(t, 100)
|
||||
chatID := int64(42)
|
||||
|
||||
// Seed in-memory chat memory with three messages.
|
||||
cm := b.getOrCreateChatMemory(chatID)
|
||||
cm.Messages = []Message{
|
||||
{IsUser: true, Text: "first", ImageFileIDs: []string{"file_a", "file_b"}},
|
||||
{IsUser: false, Text: "reply"},
|
||||
{IsUser: true, Text: "third", ImageFileIDs: []string{"file_b", "file_c"}},
|
||||
}
|
||||
|
||||
b.stripDeadFileIDFromMemory(chatID, "file_b")
|
||||
|
||||
assert.Equal(t, []string{"file_a"}, cm.Messages[0].ImageFileIDs, "file_b should be removed from message 1")
|
||||
assert.Empty(t, cm.Messages[1].ImageFileIDs, "assistant message untouched")
|
||||
assert.Equal(t, []string{"file_c"}, cm.Messages[2].ImageFileIDs, "file_b should be removed from message 3")
|
||||
}
|
||||
|
||||
func TestStripDeadFileIDFromMemory_UnknownChatIsNoop(t *testing.T) {
|
||||
b, _ := setupBotForTest(t, 100)
|
||||
// Calling on a chat that was never opened should not panic and should be a no-op.
|
||||
b.stripDeadFileIDFromMemory(99999, "file_anything")
|
||||
// Nothing to assert beyond not crashing.
|
||||
}
|
||||
+6
-14
@@ -8,8 +8,6 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,7 +42,7 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
|
||||
return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
|
||||
}
|
||||
@@ -56,17 +54,11 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
|
||||
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
|
||||
// string with Content-Type: application/json, which ElevenLabs rejects with 400.
|
||||
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
|
||||
// 1. Resolve and download the voice file from Telegram.
|
||||
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
|
||||
// 1. Resolve and download the voice file from Telegram via the shared helper.
|
||||
audioBytes, err := b.downloadTelegramFile(ctx, fileID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("telegram GetFile error: %w", err)
|
||||
return "", err
|
||||
}
|
||||
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
|
||||
audioResp, err := http.Get(downloadURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice download error: %w", err)
|
||||
}
|
||||
defer audioResp.Body.Close()
|
||||
|
||||
// 2. Build multipart body with binary audio — bypasses SDK encoding issues.
|
||||
var buf bytes.Buffer
|
||||
@@ -78,7 +70,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("multipart create file error: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, audioResp.Body); err != nil {
|
||||
if _, err := io.Copy(part, bytes.NewReader(audioBytes)); err != nil {
|
||||
return "", fmt.Errorf("multipart copy error: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
@@ -98,7 +90,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("elevenlabs STT request error: %w", err)
|
||||
}
|
||||
defer sttResp.Body.Close()
|
||||
defer func() { _ = sttResp.Body.Close() }()
|
||||
|
||||
if sttResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(sttResp.Body)
|
||||
|
||||
Binary file not shown.
@@ -6,6 +6,7 @@ require (
|
||||
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.20.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -13,24 +14,23 @@ require (
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/buger/jsonparser v1.2.0 // 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/now v1.1.5 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // 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
|
||||
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/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // 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
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -2,8 +2,8 @@ github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeN
|
||||
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/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -17,7 +17,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -25,8 +24,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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -42,10 +41,11 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
|
||||
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
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/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/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=
|
||||
|
||||
+222
-29
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) {
|
||||
@@ -55,7 +56,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, 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 {
|
||||
@@ -91,17 +95,164 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
|
||||
}
|
||||
}
|
||||
|
||||
// uploadPhotoFromItem downloads the largest PhotoSize from a Telegram message
|
||||
// item and uploads it to the Anthropic Files API tagged with the bot's filename
|
||||
// convention. Telegram serves photos as JPEG regardless of the user's original
|
||||
// format, so the content-type is fixed.
|
||||
func (b *Bot) uploadPhotoFromItem(ctx context.Context, item *models.Message, chatID int64) (string, error) {
|
||||
photo := largestPhotoSize(item.Photo)
|
||||
data, err := b.downloadTelegramFile(ctx, photo.FileID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download %s: %w", photo.FileID, err)
|
||||
}
|
||||
filename := formatUploadFilename(b.botID, chatID, item.ID, "jpg")
|
||||
return b.uploadImageToAnthropic(ctx, data, filename, "image/jpeg")
|
||||
}
|
||||
|
||||
// handlePhotoMessage processes a user turn that contains one or more photos —
|
||||
// either a single photo or a Telegram media_group (album) coalesced upstream.
|
||||
// For albums the caller passes the items sorted by message_id. Each item's
|
||||
// largest PhotoSize is downloaded and uploaded to the Anthropic Files API; the
|
||||
// resulting file_ids are persisted on a single Message row representing the
|
||||
// whole user turn. On any upload failure, compensating deletes fire against
|
||||
// already-uploaded file_ids and the DB row is not written — orphans on
|
||||
// Anthropic are preferred over poisoned DB references.
|
||||
func (b *Bot) handlePhotoMessage(
|
||||
ctx context.Context,
|
||||
items []*models.Message,
|
||||
chatID, userID int64,
|
||||
username, firstName, lastName string,
|
||||
isPremium bool,
|
||||
languageCode string,
|
||||
messageTime int,
|
||||
isNewChat, isOwner bool,
|
||||
businessConnectionID string,
|
||||
) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: download + upload each photo in parallel. Album latency collapses
|
||||
// from N*RTT to ~max(t_i) — relevant for the multi-screenshot use case.
|
||||
// Caption capture happens in the sequential loop (one item carries it; order
|
||||
// is preserved upstream via flushAlbum's sort by message_id). uploaded[i]
|
||||
// matches items[i] so file_ids stay in user-intended order; non-photo items
|
||||
// leave their slot empty and are compacted out before commit.
|
||||
uploaded := make([]string, len(items))
|
||||
caption := ""
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
for i, item := range items {
|
||||
if item.Caption != "" {
|
||||
caption = item.Caption
|
||||
}
|
||||
if len(item.Photo) == 0 {
|
||||
continue
|
||||
}
|
||||
i, item := i, item
|
||||
g.Go(func() error {
|
||||
fileID, err := b.uploadPhotoFromItem(gctx, item, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploaded[i] = fileID
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
ErrorLogger.Printf("[%s] photo upload failed: %v", b.config.ID, err)
|
||||
var successful []string
|
||||
for _, fid := range uploaded {
|
||||
if fid != "" {
|
||||
successful = append(successful, fid)
|
||||
}
|
||||
}
|
||||
b.compensatingDelete(ctx, successful)
|
||||
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I couldn't process one of your images.", businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending photo failure message: %v", sendErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
finalUploaded := make([]string, 0, len(uploaded))
|
||||
for _, fid := range uploaded {
|
||||
if fid != "" {
|
||||
finalUploaded = append(finalUploaded, fid)
|
||||
}
|
||||
}
|
||||
if len(finalUploaded) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: commit Message row. getOrCreateChatMemory MUST run before
|
||||
// storeMessage — on a cold cache, the get-or-create hydrates from DB, and
|
||||
// hydrating after the insert would re-load the just-stored row, causing
|
||||
// addMessageToChatMemory below to produce a duplicate user turn. Mirrors
|
||||
// the ordering used by screenIncomingMessage for the same reason.
|
||||
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||
userMessage := b.createMessage(chatID, userID, username, "user", caption, true)
|
||||
userMessage.ImageFileIDs = finalUploaded
|
||||
if err := b.storeMessage(&userMessage); err != nil {
|
||||
b.compensatingDelete(ctx, finalUploaded)
|
||||
ErrorLogger.Printf("[%s] store photo message failed: %v", b.config.ID, err)
|
||||
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I had trouble saving your message.", businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending store failure message: %v", sendErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
b.addMessageToChatMemory(chatMemory, userMessage)
|
||||
|
||||
// Phase 3: stream Anthropic's reply, same shape as the text path.
|
||||
contextMessages := b.prepareContextMessages(chatMemory)
|
||||
joined, err := b.getAnthropicResponse(
|
||||
ctx, chatID, contextMessages, isNewChat, isOwner, false,
|
||||
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 for photo: %v", err)
|
||||
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending anthropic error response: %v", sendErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
|
||||
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 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."
|
||||
}
|
||||
|
||||
@@ -144,14 +295,8 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
// Check if it's a new chat (before storing the message so the flag is accurate).
|
||||
isNewChatFlag := b.isNewChat(chatID)
|
||||
|
||||
// Screen incoming message (store to DB + add to chat memory)
|
||||
userMsg, err := b.screenIncomingMessage(message)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error storing user message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if the user is the owner
|
||||
// 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
|
||||
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
|
||||
isOwner = true
|
||||
@@ -172,6 +317,34 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
}
|
||||
}
|
||||
|
||||
// Photo routing bypasses screenIncomingMessage entirely: handlePhotoMessage
|
||||
// owns its own DB-row creation (one row per coalesced user turn, holding
|
||||
// all uploaded file_ids). Album items go through the 1s buffer first; only
|
||||
// the flush dispatches to handlePhotoMessage.
|
||||
if message.MediaGroupID != "" && len(message.Photo) > 0 {
|
||||
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
|
||||
isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
|
||||
return
|
||||
}
|
||||
if len(message.Photo) > 0 {
|
||||
if !b.checkRateLimits(userID) {
|
||||
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
|
||||
return
|
||||
}
|
||||
b.handlePhotoMessage(ctx, []*models.Message{message},
|
||||
chatID, userID, username, firstName, lastName,
|
||||
isPremium, languageCode, messageTime,
|
||||
isNewChatFlag, isOwner, businessConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
// Screen incoming message (store to DB + add to chat memory) — text/voice/sticker only.
|
||||
userMsg, err := b.screenIncomingMessage(message)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error storing user message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the message is a command — applies on every message, including the very first.
|
||||
if message.Entities != nil {
|
||||
for _, entity := range message.Entities {
|
||||
@@ -340,17 +513,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, chatID, 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 +576,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, message.ChatID, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -443,23 +632,27 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
|
||||
// useful for group moderation ("/clear <userID> <chatID>").
|
||||
var err error
|
||||
if hardDelete {
|
||||
// Permanently delete messages
|
||||
// Hard delete routes through hardDeleteScope, which orchestrates the
|
||||
// soft-delete → Anthropic Files.Delete → Unscoped().Delete dance. Rows
|
||||
// whose Anthropic-side file cleanup fails stay soft-deleted for the
|
||||
// reconciliation job to retry.
|
||||
if targetUserID == currentUserID {
|
||||
// Own history — delete ALL messages (user + assistant) in the current chat.
|
||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error
|
||||
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID)
|
||||
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
|
||||
} else {
|
||||
if targetChatID != 0 {
|
||||
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat.
|
||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
|
||||
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID)
|
||||
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
|
||||
} else {
|
||||
// Bot-wide: delete all of the user's own messages across every chat, then delete
|
||||
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
|
||||
err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
|
||||
if err == nil {
|
||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
|
||||
}
|
||||
// Bot-wide: user's own messages across every chat plus assistant
|
||||
// responses in their DM chat (where chat_id == user_id by Telegram
|
||||
// convention). The two clauses are collapsed into one OR-WHERE so
|
||||
// the helper's three-step pattern covers both in a single pass.
|
||||
err = b.hardDeleteScope(ctx,
|
||||
"bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))",
|
||||
b.botID, targetUserID, targetUserID, false)
|
||||
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
|
||||
}
|
||||
}
|
||||
|
||||
+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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ type Message struct {
|
||||
StickerEmoji string // Store the emoji associated with the sticker
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"` // Add soft delete field
|
||||
AnsweredOn *time.Time `gorm:"index"` // Tracks when a user message was answered (NULL for assistant messages and unanswered user messages)
|
||||
// ImageFileIDs holds Anthropic Files API file_ids for photos attached to this turn.
|
||||
// Plural for albums (Telegram media_group), single-element for one photo.
|
||||
ImageFileIDs []string `gorm:"type:text;serializer:json"`
|
||||
// FilesCleanedAt is set after a cleanup job deletes the corresponding files from
|
||||
// Anthropic's side. NULL means files are still alive on Anthropic.
|
||||
// Combined with DeletedAt this lets a reconciliation job find rows whose files
|
||||
// are pending cleanup vs rows whose files have already been removed.
|
||||
FilesCleanedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
type ChatMemory struct {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// largestPhotoSize returns the highest-resolution PhotoSize from the slice
|
||||
// Telegram returns for a single photo. Telegram pre-renders each upload at
|
||||
// several resolutions; we want the largest for vision quality. Falls back to
|
||||
// the zero value when the slice is empty (caller should guard upstream).
|
||||
func largestPhotoSize(photos []models.PhotoSize) models.PhotoSize {
|
||||
if len(photos) == 0 {
|
||||
return models.PhotoSize{}
|
||||
}
|
||||
largest := photos[0]
|
||||
largestArea := largest.Width * largest.Height
|
||||
for i := 1; i < len(photos); i++ {
|
||||
area := photos[i].Width * photos[i].Height
|
||||
if area > largestArea {
|
||||
largest = photos[i]
|
||||
largestArea = area
|
||||
}
|
||||
}
|
||||
return largest
|
||||
}
|
||||
|
||||
// downloadTelegramFile resolves a Telegram file_id via the bot API, fetches the
|
||||
// download URL, and reads the bytes into memory. The two-step dance (GetFile +
|
||||
// fetch via FileDownloadLink) is required by Telegram's protocol — direct
|
||||
// downloads aren't possible from file_id alone. Buffered into []byte because
|
||||
// downstream callers (multipart uploads to ElevenLabs and Anthropic) re-read
|
||||
// the body; streaming would require either tee-ing or a temp file.
|
||||
func (b *Bot) downloadTelegramFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram GetFile %s: %w", fileID, err)
|
||||
}
|
||||
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram download request %s: %w", fileID, err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram download %s: %w", fileID, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("telegram download %s: status %d", fileID, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram download read %s: %w", fileID, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLargestPhotoSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
photos []models.PhotoSize
|
||||
wantFileID string
|
||||
}{
|
||||
{
|
||||
name: "ascending sizes — last is largest",
|
||||
photos: []models.PhotoSize{
|
||||
{FileID: "thumb", Width: 90, Height: 90},
|
||||
{FileID: "small", Width: 320, Height: 320},
|
||||
{FileID: "full", Width: 1280, Height: 720},
|
||||
},
|
||||
wantFileID: "full",
|
||||
},
|
||||
{
|
||||
name: "descending sizes — first is largest",
|
||||
photos: []models.PhotoSize{
|
||||
{FileID: "full", Width: 1280, Height: 720},
|
||||
{FileID: "small", Width: 320, Height: 320},
|
||||
{FileID: "thumb", Width: 90, Height: 90},
|
||||
},
|
||||
wantFileID: "full",
|
||||
},
|
||||
{
|
||||
name: "single photo",
|
||||
photos: []models.PhotoSize{
|
||||
{FileID: "solo", Width: 800, Height: 600},
|
||||
},
|
||||
wantFileID: "solo",
|
||||
},
|
||||
{
|
||||
name: "empty slice returns zero value (caller guards upstream)",
|
||||
photos: []models.PhotoSize{},
|
||||
wantFileID: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := largestPhotoSize(tc.photos)
|
||||
assert.Equal(t, tc.wantFileID, got.FileID)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user