Compare commits

..

4 Commits

Author SHA1 Message Date
HugeFrog24 bb4d462695 OK 2026-03-05 08:46:15 +01:00
HugeFrog24 265f6676d8 Design 2026-03-05 08:41:48 +01:00
HugeFrog24 9f2b3df4c8 Concern separation 2024-10-13 02:58:18 +02:00
HugeFrog24 41c9b8075b Created user-role system 2024-10-13 01:36:56 +02:00
24 changed files with 476 additions and 1643 deletions
+14
View File
@@ -0,0 +1,14 @@
---
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.
+23 -2
View File
@@ -7,18 +7,38 @@ on:
branches: [ main ] branches: [ main ]
jobs: 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 job
lint: lint:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: golangci/golangci-lint-action@v9 - uses: golangci/golangci-lint-action@v9
with: with:
version: v2.12.2 version: v2.10
args: --timeout 5m args: --timeout 5m
# Test job # Test job
test: test:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -29,9 +49,10 @@ jobs:
# Security scan job # Security scan job
security: security:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: securego/gosec@v2.26.1 - uses: securego/gosec@master
with: with:
args: ./... args: ./...
+3
View File
@@ -0,0 +1,3 @@
{
"mcpServers": {}
}
-15
View File
@@ -52,21 +52,6 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
go build -o telegram-bot go build -o telegram-bot
``` ```
## Trying Out New Behavior Safely
Want to experiment with a different personality, tone, or set of instructions without disturbing the bot your users already talk to? Run a second, separate bot just for testing.
Each bot profile is its own config file with its own Telegram token, and bots are fully independent — separate identity, separate chat history, separate settings. So a "test twin" is quick to set up:
1. Create a new bot with [@BotFather](https://t.me/BotFather) and copy its token.
2. Copy your existing config to a new file, e.g. `cp config/mybot.json config/mybot-test.json`.
3. In the new file, paste the new token, give it a different `id`, and edit `system_prompts` to try your changes.
4. Start it alongside your main bot. Chat with the test bot, tweak its prompt, and restart the test bot to try again — your real users never see the experiments.
5. Happy with the result? Copy the same change into your main bot's config and restart it.
> [!NOTE]
> A test bot always needs its **own** token. Telegram only lets one running bot listen on a given token, so you can't point a second copy at your live bot — give the twin its own @BotFather bot instead.
## Systemd Unit Setup ## Systemd Unit Setup
To enable the bot to start automatically on system boot and run in the background, set up a systemd unit. To enable the bot to start automatically on system boot and run in the background, set up a systemd unit.
-117
View File
@@ -1,117 +0,0 @@
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
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,
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,
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.businessConnectionID,
)
}
+101 -318
View File
@@ -4,13 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/anthropics/anthropic-sdk-go" "github.com/liushuangls/go-anthropic/v2"
"github.com/anthropics/anthropic-sdk-go/packages/param"
) )
// ErrModelNotFound is returned when the configured Anthropic model is no longer available // ErrModelNotFound is returned when the configured Anthropic model is no longer available
@@ -18,340 +15,126 @@ import (
// actionable message to admins/owners while keeping the response vague for regular users. // actionable message to admins/owners while keeping the response vague for regular users.
var ErrModelNotFound = errors.New("model not found or deprecated") var ErrModelNotFound = errors.New("model not found or deprecated")
// maxFileNotFoundRetries caps the runtime 404 self-heal loop. If multiple func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) {
// referenced file_ids are gone from Anthropic simultaneously (admin purge, AUP // Use prompts from config
// enforcement, etc.), we strip them one at a time and retry. Three attempts var systemMessage string
// covers all realistic cascades without leaving the call hanging indefinitely. if isNewChat {
const maxFileNotFoundRetries = 3 systemMessage = b.config.SystemPrompts["new_chat"]
} else {
systemMessage = b.config.SystemPrompts["continue_conversation"]
}
// mcpUnsupportedSentinel is the placeholder text Anthropic's server-side MCP // Combine default prompt with custom instructions
// connector substitutes when a tool result can't be serialized into a supported systemMessage = b.config.SystemPrompts["default"] + " " + b.config.SystemPrompts["custom_instructions"] + " " + systemMessage
// content block. It arrives inside a normal mcp_tool_result with is_error=false,
// so it is otherwise indistinguishable from success — every empty-result issue
// across the ecosystem (strands #2122, openai-agents #1035, opencode #15371)
// shows the same is_error=false on these, so we substring-match the text rather
// than rely on the error flag. Observed trigger: an MCP server returns an empty
// content array for a zero-result query (e.g. Outline list_documents with no
// match), which the connector can't serialize. Substring (not the full
// sentence) so a minor wording change upstream doesn't silently break detection.
//
// Scope: this catches the SOFT variant only — a streamed mcp_tool_result whose
// content is the sentinel. The HARD variant (e.g. an unsupported image media
// type) is a 400 that aborts the whole stream and never produces a result
// block, so it surfaces via streamMessages' error return, not here.
const mcpUnsupportedSentinel = "format not currently supported by the Anthropic API"
// mcpUnsupportedCount tallies sentinel hits across the whole process lifetime // Handle username placeholder
// (all bots, all MCP servers) so the true rate is visible — the chat masks most usernameValue := username
// of them because the model often degrades gracefully. The per-hit ERROR line if username == "" {
// carries the bot ID and server name for attribution; this is just the running usernameValue = "unknown" // Use "unknown" when username is not available
// total. }
var mcpUnsupportedCount atomic.Uint64 systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue)
// mcpCall pairs a tool_use's server+name+input so a later unsupported result — // Handle firstname placeholder
// which arrives in a SEPARATE block linked only by tool_use_id — can name the firstnameValue := firstName
// server and query that triggered it. server matters once a bot configures more if firstName == "" {
// than one MCP server (the config supports a slice), otherwise the log can't say firstnameValue = "unknown" // Use "unknown" when first name is not available
// which server choked. }
type mcpCall struct{ server, name, input string } systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue)
// getAnthropicResponse streams the model's response. Each completed text block // Handle lastname placeholder
// is delivered to onSegment as soon as the model finishes writing it — so the lastnameValue := lastName
// caller can send segments to Telegram with natural rhythm around tool calls, if lastName == "" {
// rather than batched at the very end of the turn. onSegment may be nil for lastnameValue = "" // Empty string when last name is not available
// callers that only want the joined text (voice TTS, sticker reactions, etc.). }
// The returned string is every text segment joined by blank lines. systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue)
//
// chatID is required for the runtime 404 self-heal: when Anthropic returns // Handle language code placeholder
// "File not found:" for a referenced file_id, the dead file_id is stripped langValue := languageCode
// from this chat's in-memory ChatMemory and the corresponding DB rows are if languageCode == "" {
// stamped FilesCleanedAt so a reconciliation job can finish the cleanup. langValue = "en" // Default to English when language code is not available
func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) { }
// The system prompt is the single authored behavior driver. It is assembled systemMessage = strings.ReplaceAll(systemMessage, "{language}", langValue)
// as a cached static block (custom_instructions) followed by a per-turn
// dynamic tail. Prompt caching keys on a byte-identical prefix, so the static // Handle premium status
// block must not contain anything that changes between requests — all premiumStatus := "regular user"
// per-turn data (who we're talking to, the time of day, the emoji-only rule) if isPremium {
// lives in the trailing block, AFTER the cache breakpoint. premiumStatus = "premium user"
// }
// An empty custom_instructions means no system prompt at all: the System systemMessage = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
// field is omitted entirely (not sent as a blank block), giving the model's
// unmodified "vanilla" behavior. This matters because the Anthropic API // Handle time awareness
// rejects a system array containing an empty/whitespace-only text block, so timeObj := time.Unix(int64(messageTime), 0)
// omission is the only correct way to express "no system prompt". hour := timeObj.Hour()
staticPrompt := strings.TrimSpace(b.config.SystemPrompts["custom_instructions"]) var timeContext string
if hour >= 5 && hour < 12 {
timeContext = "morning"
} else if hour >= 12 && hour < 18 {
timeContext = "afternoon"
} else if hour >= 18 && hour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext)
if !isOwner {
systemMessage += " " + b.config.SystemPrompts["avoid_sensitive"]
}
if isEmojiOnly {
systemMessage += " " + b.config.SystemPrompts["respond_with_emojis"]
}
// Debug logging // Debug logging
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
for i, msg := range messages {
params := anthropic.BetaMessageNewParams{ for _, content := range msg.Content {
Model: b.config.Model, if content.Type == anthropic.MessagesContentTypeText {
MaxTokens: 1000, InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, *content.Text)
Messages: messages,
// Files API beta is always on: replayed conversation history may carry
// image content blocks that reference file_ids uploaded on prior turns.
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
}
if staticPrompt != "" {
// Block 1 — static persona/instructions, marked for caching. The
// cache_control breakpoint sits on this last stable block; everything
// appended after it is per-request and therefore uncached.
blocks := []anthropic.BetaTextBlockParam{
{Text: staticPrompt, CacheControl: anthropic.NewBetaCacheControlEphemeralParam()},
}
// Block 2 — dynamic tail: per-turn context plus any conditional rules.
// Kept out of the cached block because it changes every request.
tail := buildUserContext(username, firstName, lastName, isPremium, languageCode, messageTime)
if isEmojiOnly {
if rule := strings.TrimSpace(b.config.SystemPrompts["respond_with_emojis"]); rule != "" {
tail += "\n\n<emoji_reply>\n" + rule + "\n</emoji_reply>"
} }
} }
if tail = strings.TrimSpace(tail); tail != "" { }
blocks = append(blocks, anthropic.BetaTextBlockParam{Text: tail})
// Ensure the roles are correct
for i := range messages {
switch messages[i].Role {
case anthropic.RoleUser:
messages[i].Role = anthropic.RoleUser
case anthropic.RoleAssistant:
messages[i].Role = anthropic.RoleAssistant
default:
// Default to 'user' if role is unrecognized
messages[i].Role = anthropic.RoleUser
} }
params.System = blocks }
model := anthropic.Model(b.config.Model)
// Create the request
request := anthropic.MessagesRequest{
Model: model, // Now `model` is of type anthropic.Model
Messages: messages,
System: systemMessage,
MaxTokens: 1000,
} }
// Apply temperature if set in config // Apply temperature if set in config
if b.config.Temperature != nil { if b.config.Temperature != nil {
params.Temperature = param.NewOpt(float64(*b.config.Temperature)) request.Temperature = b.config.Temperature
} }
// MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta resp, err := b.anthropicClient.CreateMessages(ctx, request)
// requires per-tool filtering on the toolset (Configs + DefaultConfig), if err != nil {
// NOT the deprecated per-server tool_configuration block. var apiErr *anthropic.APIError
if len(b.config.MCPServers) > 0 { if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() {
mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers))
tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers))
for _, s := range b.config.MCPServers {
srv := anthropic.BetaRequestMCPServerURLDefinitionParam{
Name: s.Name,
URL: s.URL,
}
if s.AuthorizationToken != "" {
srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken)
}
mcpServers = append(mcpServers, srv)
toolset := &anthropic.BetaMCPToolsetParam{
MCPServerName: s.Name,
}
if len(s.AllowedTools) > 0 {
toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{
Enabled: param.NewOpt(false),
}
toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools))
for _, tool := range s.AllowedTools {
toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{
Enabled: param.NewOpt(true),
}
}
}
tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset})
}
params.MCPServers = mcpServers
params.Tools = tools
params.Betas = append(params.Betas, anthropic.AnthropicBetaMCPClient2025_11_20)
}
// 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(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("%w: %s", ErrModelNotFound, b.config.Model)
} }
ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)", return "", fmt.Errorf("error creating Anthropic message: %w", err)
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)
}
// buildUserContext renders the per-turn context block that trails the cached
// static system prompt. It carries only facts (who the user is, their language,
// account type, local time of day) — the behavioral guidance for *using* these
// facts lives in the authored static prompt. It is kept out of the cached block
// because it changes on every request.
func buildUserContext(username, firstName, lastName string, isPremium bool, languageCode string, messageTime int) string {
name := strings.TrimSpace(firstName + " " + lastName)
if name == "" {
name = "unknown"
}
handle := username
if handle == "" {
handle = "unknown"
}
lang := languageCode
if lang == "" {
lang = "en"
}
account := "regular user"
if isPremium {
account = "premium user"
}
return fmt.Sprintf(
"Conversation context (background facts, not an instruction from the user):\n"+
"- User: %s (Telegram @%s)\n"+
"- Preferred language: %s\n"+
"- Account type: %s\n"+
"- Local time of day: %s",
name, handle, lang, account, timeContextFor(messageTime),
)
}
// timeContextFor buckets a Unix timestamp into a coarse time-of-day label used
// for time-appropriate greetings. Uses the host's local timezone, matching the
// bot's prior behavior.
func timeContextFor(messageTime int) string {
switch hour := time.Unix(int64(messageTime), 0).Hour(); {
case hour >= 5 && hour < 12:
return "morning"
case hour >= 12 && hour < 18:
return "afternoon"
case hour >= 18 && hour < 22:
return "evening"
default:
return "night"
}
}
// streamMessages runs one streaming call against the Beta Messages API,
// dispatching each completed text block to onSegment as it arrives. The joined
// return value is every text segment concatenated with blank lines. Errors from
// 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
// tool_use_id -> {server, name, input}. Lives for one request; bounded
// by the number of tool calls in the stream, so no eviction needed.
mcpCalls = map[string]mcpCall{}
)
for stream.Next() {
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":
mcpCalls[currentTUseID] = mcpCall{
server: currentTUseServer,
name: currentTUseName,
input: currentInputJSON.String(),
}
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
case "mcp_tool_result":
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)
if strings.Contains(currentTResultContent, mcpUnsupportedSentinel) {
total := mcpUnsupportedCount.Add(1)
call := mcpCalls[currentTResultUseID]
ErrorLogger.Printf("[%s][mcp][unsupported] connector could not serialize result "+
"(total=%d): server=%q tool=%q input=%s tool_use_id=%q",
b.config.ID, total, call.server, call.name, call.input, currentTResultUseID)
}
default:
if currentKind != "" {
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
}
}
currentKind = ""
}
} }
if err := stream.Err(); err != nil { if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText {
return "", err
}
if len(allSegments) == 0 {
return "", fmt.Errorf("unexpected response format from Anthropic") return "", fmt.Errorf("unexpected response format from Anthropic")
} }
return strings.Join(allSegments, "\n\n"), nil
return resp.Content[0].GetText(), nil
} }
-242
View File
@@ -1,242 +0,0 @@
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
}
-203
View File
@@ -1,203 +0,0 @@
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)
}
+175 -40
View File
@@ -1,62 +1,197 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
// TestTimeContextFor verifies the time-of-day bucketing used for greetings. // TestLanguageCodeReplacement tests that language code is properly handled and replaced
func TestTimeContextFor(t *testing.T) { func TestLanguageCodeReplacement(t *testing.T) {
cases := []struct { // Test with provided language code
systemMessage := "User's language preference: '{language}'"
// Test with a specific language code
langValue := "fr"
result := strings.ReplaceAll(systemMessage, "{language}", langValue)
if !strings.Contains(result, "User's language preference: 'fr'") {
t.Errorf("Expected language code 'fr' to be replaced, got: %s", result)
}
// Test with empty language code (should default to "en")
langValue = ""
if langValue == "" {
langValue = "en" // Default to English when language code is not available
}
result = strings.ReplaceAll(systemMessage, "{language}", langValue)
if !strings.Contains(result, "User's language preference: 'en'") {
t.Errorf("Expected default language code 'en' to be used, got: %s", result)
}
}
// TestPremiumStatusReplacement tests that premium status is properly handled and replaced
func TestPremiumStatusReplacement(t *testing.T) {
systemMessage := "User is a {premium_status}"
// Test with premium user
isPremium := true
premiumStatus := "regular user"
if isPremium {
premiumStatus = "premium user"
}
result := strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
if !strings.Contains(result, "User is a premium user") {
t.Errorf("Expected premium status to be replaced with 'premium user', got: %s", result)
}
// Test with regular user
isPremium = false
premiumStatus = "regular user"
if isPremium {
premiumStatus = "premium user"
}
result = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
if !strings.Contains(result, "User is a regular user") {
t.Errorf("Expected premium status to be replaced with 'regular user', got: %s", result)
}
}
// TestTimeContextCalculation tests that time context is correctly calculated for different hours
func TestTimeContextCalculation(t *testing.T) {
// Test cases for different hours
testCases := []struct {
hour int hour int
expected string expected string
}{ }{
{3, "night"}, // hours < 5 {3, "night"}, // Night: hours < 5 or hours >= 22
{5, "morning"}, // 5 <= h < 12 {5, "morning"}, // Morning: 5 <= hours < 12
{11, "morning"}, // {12, "afternoon"}, // Afternoon: 12 <= hours < 18
{12, "afternoon"}, // 12 <= h < 18 {17, "afternoon"}, // Afternoon: 12 <= hours < 18
{17, "afternoon"}, // {18, "evening"}, // Evening: 18 <= hours < 22
{18, "evening"}, // 18 <= h < 22 {21, "evening"}, // Evening: 18 <= hours < 22
{21, "evening"}, // {22, "night"}, // Night: hours < 5 or hours >= 22
{22, "night"}, // h >= 22 {23, "night"}, // Night: hours < 5 or hours >= 22
{23, "night"}, //
} }
for _, tc := range cases {
// Build the timestamp in the host's local zone so timeContextFor (which for _, tc := range testCases {
// reads local time) observes exactly tc.hour regardless of the test t.Run(fmt.Sprintf("Hour_%d", tc.hour), func(t *testing.T) {
// machine's timezone. // Create a timestamp for the specified hour
ts := int(time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.Local).Unix()) testTime := time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.UTC)
if got := timeContextFor(ts); got != tc.expected {
t.Errorf("timeContextFor(hour=%d) = %q, want %q", tc.hour, got, tc.expected) // Get the hour directly from the test time to ensure it's what we expect
} actualHour := testTime.Hour()
if actualHour != tc.hour {
t.Fatalf("Test setup error: expected hour %d, got %d", tc.hour, actualHour)
}
// Calculate time context using the same logic as in anthropic.go
var timeContext string
if actualHour >= 5 && actualHour < 12 {
timeContext = "morning"
} else if actualHour >= 12 && actualHour < 18 {
timeContext = "afternoon"
} else if actualHour >= 18 && actualHour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
// Check if the calculated time context matches the expected value
if timeContext != tc.expected {
t.Errorf("For hour %d: expected time context '%s', got '%s'",
actualHour, tc.expected, timeContext)
}
})
} }
} }
// TestBuildUserContext verifies the per-turn context block reflects the user's // TestSystemMessagePlaceholderReplacement tests that all placeholders are correctly replaced
// details and applies the documented fallbacks. func TestSystemMessagePlaceholderReplacement(t *testing.T) {
func TestBuildUserContext(t *testing.T) { systemMessage := "The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n" +
noon := int(time.Date(2025, 5, 15, 12, 0, 0, 0, time.Local).Unix()) "User's language preference: '{language}'\n" +
"User is a {premium_status}\n" +
"It's currently {time_context} in your timezone"
// Fully-populated premium user. // Set up test data
got := buildUserContext("alice", "Alice", "Smith", true, "de", noon) username := "testuser"
for _, want := range []string{"Alice Smith", "@alice", "Preferred language: de", "premium user", "afternoon"} { firstName := "Test"
if !strings.Contains(got, want) { lastName := "User"
t.Errorf("buildUserContext premium: missing %q in:\n%s", want, got) isPremium := true
} languageCode := "de"
// Create a timestamp for a specific hour (e.g., 14:00 = afternoon)
testTime := time.Date(2025, 5, 15, 14, 0, 0, 0, time.UTC)
messageTime := int(testTime.Unix())
// Handle username placeholder
usernameValue := username
if username == "" {
usernameValue = "unknown"
} }
systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue)
// Missing name/username/language fall back; non-premium reads "regular user". // Handle firstname placeholder
got = buildUserContext("", "", "", false, "", noon) firstnameValue := firstName
for _, want := range []string{"User: unknown (Telegram @unknown)", "Preferred language: en", "regular user"} { if firstName == "" {
if !strings.Contains(got, want) { firstnameValue = "unknown"
t.Errorf("buildUserContext fallback: missing %q in:\n%s", want, got)
}
} }
systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue)
// First name only (no last name) must not leave a trailing space in the name. // Handle lastname placeholder
got = buildUserContext("bob", "Bob", "", false, "en", noon) lastnameValue := lastName
if !strings.Contains(got, "User: Bob (Telegram @bob)") { if lastName == "" {
t.Errorf("buildUserContext firstname-only: got:\n%s", got) lastnameValue = ""
}
systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue)
// Handle language code placeholder
langValue := languageCode
if languageCode == "" {
langValue = "en"
}
systemMessage = strings.ReplaceAll(systemMessage, "{language}", langValue)
// Handle premium status
premiumStatus := "regular user"
if isPremium {
premiumStatus = "premium user"
}
systemMessage = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
// Handle time awareness
timeObj := time.Unix(int64(messageTime), 0)
hour := timeObj.Hour()
var timeContext string
if hour >= 5 && hour < 12 {
timeContext = "morning"
} else if hour >= 12 && hour < 18 {
timeContext = "afternoon"
} else if hour >= 18 && hour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext)
// Check that all placeholders were replaced correctly
if !strings.Contains(systemMessage, "username 'testuser'") {
t.Errorf("Username not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "display name 'Test User'") {
t.Errorf("Display name not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "language preference: 'de'") {
t.Errorf("Language preference not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "User is a premium user") {
t.Errorf("Premium status not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "It's currently afternoon in your timezone") {
t.Errorf("Time context not replaced correctly, got: %s", systemMessage)
} }
} }
+32 -100
View File
@@ -8,18 +8,17 @@ import (
"sync" "sync"
"time" "time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Bot struct { type Bot struct {
tgBot TelegramClient tgBot TelegramClient
db *gorm.DB db *gorm.DB
anthropicClient anthropic.Client anthropicClient *anthropic.Client
chatMemories map[int64]*ChatMemory chatMemories map[int64]*ChatMemory
memorySize int memorySize int
chatMemoriesMu sync.RWMutex chatMemoriesMu sync.RWMutex
config BotConfig config BotConfig
@@ -27,11 +26,6 @@ type Bot struct {
userLimitersMu sync.RWMutex userLimitersMu sync.RWMutex
clock Clock clock Clock
botID uint // Reference to BotModel.ID 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 // Helper function to determine message type
@@ -87,11 +81,11 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
} }
// Use the per-bot Anthropic API key // Use the per-bot Anthropic API key
anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey)) anthropicClient := anthropic.NewClient(config.AnthropicAPIKey)
b := &Bot{ b := &Bot{
db: db, db: db,
anthropicClient: anthropicClient, anthropicClient: anthropicClient,
chatMemories: make(map[int64]*ChatMemory), chatMemories: make(map[int64]*ChatMemory),
memorySize: config.MemorySize, memorySize: config.MemorySize,
config: config, config: config,
@@ -99,7 +93,6 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
clock: clock, clock: clock,
botID: botEntry.ID, // Ensure BotModel has ID field botID: botEntry.ID, // Ensure BotModel has ID field
tgBot: tgClient, tgBot: tgClient,
albumBuffers: make(map[string]*pendingAlbum),
} }
if tgClient == nil { if tgClient == nil {
@@ -257,32 +250,6 @@ func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
return 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. // addMessageToChatMemory adds a new message to the chat memory, ensuring the memory size is maintained.
func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) { func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
b.chatMemoriesMu.Lock() b.chatMemoriesMu.Lock()
@@ -297,14 +264,14 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
} }
} }
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam { func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message {
b.chatMemoriesMu.RLock() b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock() defer b.chatMemoriesMu.RUnlock()
// Debug logging // Debug logging
InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages)) InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages))
for i, msg := range chatMemory.Messages { for i, msg := range chatMemory.Messages {
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs)) InfoLogger.Printf("Message %d: IsUser=%v, Text=%q", i, msg.IsUser, msg.Text)
} }
// Note: consecutive messages with the same role are permitted. // Note: consecutive messages with the same role are permitted.
@@ -312,47 +279,33 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
// returning an error. This can happen after a /clear (which only deletes user // returning an error. This can happen after a /clear (which only deletes user
// messages, leaving assistant messages in the DB) followed by a restart. // messages, leaving assistant messages in the DB) followed by a restart.
// See: https://platform.claude.com/docs/en/api/messages // See: https://platform.claude.com/docs/en/api/messages
var contextMessages []anthropic.BetaMessageParam var contextMessages []anthropic.Message
for _, msg := range chatMemory.Messages { for _, msg := range chatMemory.Messages {
blocks := contentBlocksForMessage(msg) role := anthropic.RoleUser
if len(blocks) == 0 { if !msg.IsUser {
// Skip turns that carry neither text nor images. role = anthropic.RoleAssistant
}
textContent := strings.TrimSpace(msg.Text)
if textContent == "" {
// Skip empty messages
continue continue
} }
var param anthropic.BetaMessageParam
if msg.IsUser { contextMessages = append(contextMessages, anthropic.Message{
param = anthropic.NewBetaUserMessage(blocks...) Role: role,
} else { Content: []anthropic.MessageContent{
param = anthropic.BetaMessageParam{ anthropic.NewTextMessageContent(textContent),
Role: anthropic.BetaMessageParamRoleAssistant, },
Content: blocks, })
}
}
contextMessages = append(contextMessages, param)
} }
return contextMessages return contextMessages
} }
// contentBlocksForMessage assembles the Anthropic content blocks representing func (b *Bot) isNewChat(chatID int64) bool {
// one stored Message. Image blocks are emitted before the text block (Anthropic var count int64
// docs: "Claude works best when images come before text"). Multi-image user b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
// turns prepend each image with an "Image N:" label, as the docs explicitly return count == 0 // Only consider a chat new if it has 0 messages
// 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
} }
// roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name. // roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name.
@@ -495,27 +448,6 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
return nil return nil
} }
// sendOneSegment delivers a single Telegram message without touching storage
// or chat memory. Used by the streaming response path: each completed text
// block fires this helper as it arrives, and the full turn is recorded once
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
// storage invariant while letting the user see segments with natural rhythm.
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
b.config.ID, chatID, businessConnectionID, err)
return err
}
return nil
}
// sendStats sends the bot statistics to the specified chat. // sendStats sends the bot statistics to the specified chat.
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) { func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
// If targetUserID is 0, show global stats // If targetUserID is 0, show global stats
@@ -732,7 +664,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
}() }()
} }
userRole := "user" userRole := string(anthropic.RoleUser)
// Determine message text based on message type // Determine message text based on message type
messageText := message.Text messageText := message.Text
@@ -789,7 +721,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
} }
// Create and store the assistant message // Create and store the assistant message
assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false) assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false)
if err := b.storeMessage(&assistantMessage); err != nil { if err := b.storeMessage(&assistantMessage); err != nil {
return Message{}, err return Message{}, err
} }
-111
View File
@@ -1,111 +0,0 @@
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.
}
+33 -25
View File
@@ -6,37 +6,44 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/liushuangls/go-anthropic/v2"
) )
// MCPServer configures a remote Model Context Protocol server that the Anthropic
// API will connect to on behalf of this bot. AllowedTools, when non-empty, limits
// which server-exposed tools the model may invoke.
type MCPServer struct {
Name string `json:"name"`
URL string `json:"url"`
AuthorizationToken string `json:"authorization_token,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
}
type BotConfig struct { type BotConfig struct {
ID string `json:"id"` ID string `json:"id"`
TelegramToken string `json:"telegram_token"` TelegramToken string `json:"telegram_token"`
MemorySize int `json:"memory_size"` MemorySize int `json:"memory_size"`
MessagePerHour int `json:"messages_per_hour"` MessagePerHour int `json:"messages_per_hour"`
MessagePerDay int `json:"messages_per_day"` MessagePerDay int `json:"messages_per_day"`
TempBanDuration string `json:"temp_ban_duration"` TempBanDuration string `json:"temp_ban_duration"`
Model string `json:"model"` Model anthropic.Model `json:"model"`
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0) Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
SystemPrompts map[string]string `json:"system_prompts"` SystemPrompts map[string]string `json:"system_prompts"`
Active bool `json:"active"` Active bool `json:"active"`
OwnerTelegramID int64 `json:"owner_telegram_id"` OwnerTelegramID int64 `json:"owner_telegram_id"`
AnthropicAPIKey string `json:"anthropic_api_key"` AnthropicAPIKey string `json:"anthropic_api_key"`
ElevenLabsAPIKey string `json:"elevenlabs_api_key"` ElevenLabsAPIKey string `json:"elevenlabs_api_key"`
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"` ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
ElevenLabsModel string `json:"elevenlabs_model"` ElevenLabsModel string `json:"elevenlabs_model"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
MCPServers []MCPServer `json:"mcp_servers,omitempty"` ConfigFilePath string `json:"-"` // Set at load time; not serialized
ConfigFilePath string `json:"-"` // Set at load time; not serialized }
// Custom unmarshalling to handle anthropic.Model
func (c *BotConfig) UnmarshalJSON(data []byte) error {
type Alias BotConfig
aux := &struct {
Model string `json:"model"`
*Alias
}{
Alias: (*Alias)(c),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
c.Model = anthropic.Model(aux.Model)
return nil
} }
// validateConfigPath ensures the file path is within the allowed directory // validateConfigPath ensures the file path is within the allowed directory
@@ -195,6 +202,7 @@ func (c *BotConfig) Reload(configDir, filename string) error {
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err) return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
} }
c.Model = anthropic.Model(c.Model)
return nil return nil
} }
@@ -226,6 +234,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
return fmt.Errorf("failed to write config: %w", err) return fmt.Errorf("failed to write config: %w", err)
} }
c.Model = newModel c.Model = anthropic.Model(newModel)
return nil return nil
} }
+5 -2
View File
@@ -15,7 +15,10 @@
"temperature": 0.7, "temperature": 0.7,
"debug_screening": false, "debug_screening": false,
"system_prompts": { "system_prompts": {
"custom_instructions": "You are Atom, a helpful assistant texting through a limited Telegram interface with a 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. The system cuts off anything longer than 15 words.\n\n- Address the user by their first name, and reply in their preferred language (both are in the conversation context).\n- Use time-appropriate greetings based on the user's local time of day.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.", "default": "You are a helpful assistant.",
"respond_with_emojis": "The user's message contains only emoji. Reply using only emoji." "custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
} }
} }
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/liushuangls/go-anthropic/v2"
) )
// Set up loggers // Set up loggers
@@ -36,7 +38,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
t.Fatalf("Failed to unmarshal JSON: %v", err) t.Fatalf("Failed to unmarshal JSON: %v", err)
} }
expectedModel := "claude-v1" expectedModel := anthropic.Model("claude-v1")
if config.Model != expectedModel { if config.Model != expectedModel {
t.Errorf("Expected model %s, got %s", expectedModel, config.Model) t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
} }
+16 -8
View File
@@ -8,11 +8,13 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
tgbot "github.com/go-telegram/bot"
) )
const ( const (
elevenLabsTTSURL = "https://api.elevenlabs.io/v1/text-to-speech/" elevenLabsTTSURL = "https://api.elevenlabs.io/v1/text-to-speech/"
elevenLabsSTTURL = "https://api.elevenlabs.io/v1/speech-to-text" elevenLabsSTTURL = "https://api.elevenlabs.io/v1/speech-to-text"
elevenLabsDefaultModel = "eleven_multilingual_v2" elevenLabsDefaultModel = "eleven_multilingual_v2"
) )
@@ -42,7 +44,7 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
return nil, fmt.Errorf("elevenlabs TTS error: %w", err) return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
defer func() { _ = resp.Body.Close() }() defer resp.Body.Close()
errBody, _ := io.ReadAll(resp.Body) errBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody) return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
} }
@@ -54,11 +56,17 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty // ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
// string with Content-Type: application/json, which ElevenLabs rejects with 400. // string with Content-Type: application/json, which ElevenLabs rejects with 400.
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) { func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
// 1. Resolve and download the voice file from Telegram via the shared helper. // 1. Resolve and download the voice file from Telegram.
audioBytes, err := b.downloadTelegramFile(ctx, fileID) fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("telegram GetFile error: %w", 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. // 2. Build multipart body with binary audio — bypasses SDK encoding issues.
var buf bytes.Buffer var buf bytes.Buffer
@@ -70,7 +78,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
if err != nil { if err != nil {
return "", fmt.Errorf("multipart create file error: %w", err) return "", fmt.Errorf("multipart create file error: %w", err)
} }
if _, err := io.Copy(part, bytes.NewReader(audioBytes)); err != nil { if _, err := io.Copy(part, audioResp.Body); err != nil {
return "", fmt.Errorf("multipart copy error: %w", err) return "", fmt.Errorf("multipart copy error: %w", err)
} }
if err := mw.Close(); err != nil { if err := mw.Close(); err != nil {
@@ -90,7 +98,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
if err != nil { if err != nil {
return "", fmt.Errorf("elevenlabs STT request error: %w", err) return "", fmt.Errorf("elevenlabs STT request error: %w", err)
} }
defer func() { _ = sttResp.Body.Close() }() defer sttResp.Body.Close()
if sttResp.StatusCode != http.StatusOK { if sttResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(sttResp.Body) body, _ := io.ReadAll(sttResp.Body)
Binary file not shown.
+6 -19
View File
@@ -3,37 +3,24 @@ module github.com/HugeFrog24/go-telegram-bot
go 1.26.0 go 1.26.0
require ( require (
github.com/anthropics/anthropic-sdk-go v1.52.0 github.com/go-telegram/bot v1.19.0
github.com/go-telegram/bot v1.21.0 github.com/liushuangls/go-anthropic/v2 v2.17.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.21.0 golang.org/x/time v0.14.0
golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.1
) )
require ( require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.9.2 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/tidwall/gjson v1.19.0 // indirect golang.org/x/text v0.34.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
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+10 -57
View File
@@ -1,24 +1,8 @@
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
github.com/anthropics/anthropic-sdk-go v1.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg=
github.com/anthropics/anthropic-sdk-go v1.52.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/go-telegram/bot v1.19.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -30,61 +14,30 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/liushuangls/go-anthropic/v2 v2.17.1 h1:ca3oFzgQHs9/mJr+xx2XFQIYcQLM2rDCqieUx0g+8p4=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/liushuangls/go-anthropic/v2 v2.17.1/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+37 -226
View File
@@ -7,13 +7,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"golang.org/x/sync/errgroup" "github.com/liushuangls/go-anthropic/v2"
) )
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, businessConnectionID string) { func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) {
// If ElevenLabs is not configured, respond with text — consistent with all other error paths. // If ElevenLabs is not configured, respond with text — consistent with all other error paths.
if b.config.ElevenLabsAPIKey == "" { if b.config.ElevenLabsAPIKey == "" {
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
@@ -56,10 +55,7 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
chatMemory := b.getOrCreateChatMemory(chatID) chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory) contextMessages := b.prepareContextMessages(chatMemory)
// Voice path passes nil for onSegment: tool-call narration across multiple response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime)
// TTS clips would be jarring, so we accumulate everything and synthesize one
// audio clip from the joined text.
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err) ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
@@ -95,163 +91,17 @@ 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,
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, 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 // anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
// fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they // fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
// can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything // always get the generic fallback to avoid leaking internal details.
// else. Regular users always get the generic fallback to avoid leaking internal details.
func (b *Bot) anthropicErrorResponse(err error, userID int64) string { func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
isElevated := b.hasScope(userID, ScopeModelSet) if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
if errors.Is(err, ErrModelNotFound) && isElevated {
return fmt.Sprintf( return fmt.Sprintf(
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+ "⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview", "Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
b.config.Model, b.config.Model,
) )
} }
if isElevated {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
body := apiErr.RawJSON()
if len(body) > 800 {
body = body[:800] + "...(truncated)"
}
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
if apiErr.RequestID != "" {
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
}
return out
}
// Non-API errors (network, context cancel, etc.) — show the Go error text.
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
}
return "I'm sorry, I'm having trouble processing your request right now." return "I'm sorry, I'm having trouble processing your request right now."
} }
@@ -291,8 +141,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
messageTime := message.Date messageTime := message.Date
text := message.Text text := message.Text
// Determine if the user is the owner — needed up-front so the album buffer // Check if it's a new chat (before storing the message so the flag is accurate).
// can capture it alongside other per-turn metadata. 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
var isOwner bool var isOwner bool
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil { if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
isOwner = true isOwner = true
@@ -313,34 +172,6 @@ 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, 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,
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. // Check if the message is a command — applies on every message, including the very first.
if message.Entities != nil { if message.Entities != nil {
for _, entity := range message.Entities { for _, entity := range message.Entities {
@@ -486,7 +317,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Check if the message contains a voice note (context is built inside the handler // Check if the message contains a voice note (context is built inside the handler
// after the transcript replaces the placeholder, so it must not be built here). // after the transcript replaces the placeholder, so it must not be built here).
if message.Voice != nil { if message.Voice != nil {
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID) b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
return return
} }
@@ -509,31 +340,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the text contains only emojis // Determine if the text contains only emojis
isEmojiOnly := isOnlyEmojis(text) isEmojiOnly := isOnlyEmojis(text)
// Stream Anthropic's reply, sending each completed text block to Telegram // Get response from Anthropic
// as it arrives — gives the conversational rhythm Claude uses around tool response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime)
// calls (text → pause for tool → text → pause → text), rather than a long
// upfront wait followed by all bubbles at once.
joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err) ErrorLogger.Printf("Error getting Anthropic response: %v", err)
// Errors go out as a single message — no need to fan out a one-line error. response = b.anthropicErrorResponse(err, userID)
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending response: %v", sendErr)
}
return
} }
// Record the full turn once, at end-of-stream. Same 1-reply-per-prompt // Send the response
// invariant as the non-streaming path: one DB row, one answered_on stamp, if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
// one chat-memory entry containing the joined segments. ErrorLogger.Printf("Error sending response: %v", err)
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil { return
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
} }
} }
@@ -543,7 +360,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
} }
} }
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) { func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) {
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again. // userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
// Generate AI response about the sticker // Generate AI response about the sticker
@@ -567,14 +384,12 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
} }
} }
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) { func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) {
// contextMessages already contains the sticker turn (added by screenIncomingMessage as // contextMessages already contains the sticker turn (added by screenIncomingMessage as
// "Sent a sticker: <emoji>"), so the full conversation history is preserved. // "Sent a sticker: <emoji>"), so the full conversation history is preserved.
if message.StickerFileID != "" { if message.StickerFileID != "" {
messageTime := int(message.Timestamp.Unix()) messageTime := int(message.Timestamp.Unix())
// Sticker reactions are casual chit-chat; tool use is unusual here, so response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime)
// pass nil for onSegment and return the joined text for a single bubble.
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, true, message.Username, "", "", false, "", messageTime, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -628,27 +443,23 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
// useful for group moderation ("/clear <userID> <chatID>"). // useful for group moderation ("/clear <userID> <chatID>").
var err error var err error
if hardDelete { if hardDelete {
// Hard delete routes through hardDeleteScope, which orchestrates the // Permanently delete messages
// 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 { if targetUserID == currentUserID {
// Own history — delete ALL messages (user + assistant) in the current chat. // Own history — delete ALL messages (user + assistant) in the current chat.
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID) err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID) InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
} else { } else {
if targetChatID != 0 { if targetChatID != 0 {
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat. // Chat-scoped: delete ALL messages (user + assistant) in the specified chat.
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID) err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID) InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
} else { } else {
// Bot-wide: user's own messages across every chat plus assistant // Bot-wide: delete all of the user's own messages across every chat, then delete
// responses in their DM chat (where chat_id == user_id by Telegram // assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
// convention). The two clauses are collapsed into one OR-WHERE so err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
// the helper's three-step pattern covers both in a single pass. if err == nil {
err = b.hardDeleteScope(ctx, err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
"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) InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
} }
} }
+17 -33
View File
@@ -64,24 +64,22 @@ func TestHandleUpdate_NewChat(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
// userID 123 is the configured owner; any other ID is a regular user. userID int64
userID int64 isOwner bool
// wantSubstr must appear in both the Telegram-sent text and the DB-stored wantResp string
// response. Owners (model:set scope) see the raw API error; regular users
// get the generic fallback. Substring (not exact) so the test stays robust
// against the SDK's evolving error wording for non-API errors.
wantSubstr string
}{ }{
{ {
name: "Owner First Message", name: "Owner First Message",
userID: 123, userID: 123, // owner's ID
wantSubstr: "Anthropic call failed:", isOwner: true,
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
{ {
name: "Regular User First Message", name: "Regular User First Message",
userID: 456, userID: 456,
wantSubstr: "I'm sorry, I'm having trouble processing your request right now.", isOwner: false,
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
} }
@@ -90,7 +88,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
// Setup mock response expectations for error case to test fallback messages // Setup mock response expectations for error case to test fallback messages
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) { mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
assert.Equal(t, tc.userID, params.ChatID) assert.Equal(t, tc.userID, params.ChatID)
assert.Contains(t, params.Text, tc.wantSubstr) assert.Equal(t, tc.wantResp, params.Text)
return &models.Message{}, nil return &models.Message{}, nil
} }
@@ -114,13 +112,10 @@ func TestHandleUpdate_NewChat(t *testing.T) {
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
// Verify response was stored (most recent assistant message in this chat). // Verify response was stored
var respMsg Message var respMsg Message
err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false). err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error
Order("timestamp DESC").
First(&respMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, respMsg.Text, tc.wantSubstr)
}) })
} }
} }
@@ -725,22 +720,11 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
wantMissing: "/set_model", wantMissing: "/set_model",
}, },
{ {
// Non-model errors (network, plain errors, API errors other than 404) name: "owner receives generic message for non-model error",
// surface to anyone with model:set scope so admins/owners can diagnose.
name: "owner receives elevated detail for non-API error",
err: otherErr, err: otherErr,
userID: 123, userID: 123,
wantSubstr: "Anthropic call failed:",
wantMissing: "I'm sorry",
},
{
// Regular users keep getting the generic fallback for any non-model error
// to avoid leaking internal details.
name: "regular user receives generic message for non-model error",
err: otherErr,
userID: 789,
wantSubstr: "I'm sorry", wantSubstr: "I'm sorry",
wantMissing: "Anthropic call failed", wantMissing: "/set_model",
}, },
} }
-8
View File
@@ -42,14 +42,6 @@ type Message struct {
StickerEmoji string // Store the emoji associated with the sticker StickerEmoji string // Store the emoji associated with the sticker
DeletedAt gorm.DeletedAt `gorm:"index"` // Add soft delete field 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) 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 { type ChatMemory struct {
-62
View File
@@ -1,62 +0,0 @@
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
}
-53
View File
@@ -1,53 +0,0 @@
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)
})
}
}