mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-06-29 22:07:12 +00:00
Compare commits
3 Commits
v1.0.0
..
a2cc252e8f
| Author | SHA1 | Date | |
|---|---|---|---|
| a2cc252e8f | |||
| d97a2c3132 | |||
| 8c699ab70a |
@@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
always_on: true
|
|
||||||
trigger: always_on
|
|
||||||
applyTo: "**"
|
|
||||||
description: Snyk Security At Inception
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project security best practices
|
|
||||||
|
|
||||||
- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
|
|
||||||
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
|
|
||||||
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
|
|
||||||
- Repeat this process until no new issues are found.
|
|
||||||
@@ -7,38 +7,18 @@ on:
|
|||||||
branches: [ main ]
|
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.10
|
version: v2.12.2
|
||||||
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
|
||||||
@@ -49,10 +29,9 @@ 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@master
|
- uses: securego/gosec@v2.26.1
|
||||||
with:
|
with:
|
||||||
args: ./...
|
args: ./...
|
||||||
|
|||||||
+1
-1
@@ -15,4 +15,4 @@ bot.db
|
|||||||
|
|
||||||
# All config files except for the default
|
# All config files except for the default
|
||||||
config/*
|
config/*
|
||||||
!config/default.json
|
!config/default.json
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,8 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
|
|||||||
|
|
||||||
## Design Considerations
|
## Design Considerations
|
||||||
|
|
||||||
- AI-powered
|
- AI-powered (Anthropic Claude)
|
||||||
|
- Voice message support (ElevenLabs STT + TTS) — optional, enabled per bot via config
|
||||||
- Supports multiple bot profiles
|
- Supports multiple bot profiles
|
||||||
- Uses SQLite for persistence
|
- Uses SQLite for persistence
|
||||||
- Implements rate limiting and user management
|
- Implements rate limiting and user management
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-telegram/bot/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// albumFlushWindow is the debounce delay before a buffered Telegram media_group
|
||||||
|
// is flushed as a single coalesced user turn. 1s matches the de-facto community
|
||||||
|
// standard across the dominant third-party album plugins (aiogram-media-group,
|
||||||
|
// DieTime/telegraf-media-group) and sits above the sub-100ms values documented
|
||||||
|
// as lossy under network jitter (openclaw#1811). Telegram has no official
|
||||||
|
// "album complete" signal, so timeout-based flush is the only option.
|
||||||
|
const albumFlushWindow = 1 * time.Second
|
||||||
|
|
||||||
|
// pendingAlbum holds a Telegram media_group as its items arrive, plus the
|
||||||
|
// per-user metadata captured from the first item. All items in an album share
|
||||||
|
// the same chat/user, so we record metadata once and reuse it at flush time.
|
||||||
|
type pendingAlbum struct {
|
||||||
|
items []*models.Message
|
||||||
|
// Metadata captured from the first arriving item. Albums always come from
|
||||||
|
// the same user/chat, so these are stable across the buffering window.
|
||||||
|
chatID, userID int64
|
||||||
|
username, firstName, lastName, languageCode string
|
||||||
|
isPremium bool
|
||||||
|
messageTime int
|
||||||
|
isNewChat, isOwner bool
|
||||||
|
businessConnectionID string
|
||||||
|
// timer flushes the album after albumFlushWindow with no further arrivals.
|
||||||
|
// Each new arrival stops the previous timer (best-effort) and installs a
|
||||||
|
// fresh one — the standard debounce pattern.
|
||||||
|
timer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferAlbumItem appends an incoming Telegram album item to the per-MediaGroupID
|
||||||
|
// buffer. On first arrival it captures the user/chat metadata and starts the
|
||||||
|
// flush timer; on subsequent arrivals it appends the item and extends the timer.
|
||||||
|
// The 1s debounce gives the rest of the album time to arrive over the network.
|
||||||
|
func (b *Bot) bufferAlbumItem(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *models.Message,
|
||||||
|
chatID, userID int64,
|
||||||
|
username, firstName, lastName string,
|
||||||
|
isPremium bool,
|
||||||
|
languageCode string,
|
||||||
|
messageTime int,
|
||||||
|
isNewChat, isOwner bool,
|
||||||
|
businessConnectionID string,
|
||||||
|
) {
|
||||||
|
b.albumBuffersMu.Lock()
|
||||||
|
defer b.albumBuffersMu.Unlock()
|
||||||
|
|
||||||
|
album, exists := b.albumBuffers[msg.MediaGroupID]
|
||||||
|
if !exists {
|
||||||
|
album = &pendingAlbum{
|
||||||
|
chatID: chatID,
|
||||||
|
userID: userID,
|
||||||
|
username: username,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
isPremium: isPremium,
|
||||||
|
languageCode: languageCode,
|
||||||
|
messageTime: messageTime,
|
||||||
|
isNewChat: isNewChat,
|
||||||
|
isOwner: isOwner,
|
||||||
|
businessConnectionID: businessConnectionID,
|
||||||
|
}
|
||||||
|
b.albumBuffers[msg.MediaGroupID] = album
|
||||||
|
}
|
||||||
|
album.items = append(album.items, msg)
|
||||||
|
|
||||||
|
// Stop the previous timer best-effort; even if it already fired the race
|
||||||
|
// is benign because flushAlbum removes the map entry under the lock — a
|
||||||
|
// late arrival would simply seed a fresh album.
|
||||||
|
if album.timer != nil {
|
||||||
|
album.timer.Stop()
|
||||||
|
}
|
||||||
|
mediaGroupID := msg.MediaGroupID
|
||||||
|
album.timer = time.AfterFunc(albumFlushWindow, func() {
|
||||||
|
b.flushAlbum(ctx, mediaGroupID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushAlbum is called by the flush timer (or by code that needs to force-flush
|
||||||
|
// during shutdown). It removes the album from the buffer, sorts items by
|
||||||
|
// message_id (Telegram does not guarantee in-order arrival), runs the rate-limit
|
||||||
|
// check once, and dispatches to handlePhotoMessage.
|
||||||
|
func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) {
|
||||||
|
b.albumBuffersMu.Lock()
|
||||||
|
album, exists := b.albumBuffers[mediaGroupID]
|
||||||
|
if !exists {
|
||||||
|
b.albumBuffersMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(b.albumBuffers, mediaGroupID)
|
||||||
|
items := album.items
|
||||||
|
captured := *album // copy fields for use after unlock
|
||||||
|
b.albumBuffersMu.Unlock()
|
||||||
|
|
||||||
|
// Sort by Telegram message_id: items in an album arrive as separate Updates
|
||||||
|
// over the network and may interleave. Sorting restores the user's intended
|
||||||
|
// order before we hand them to Claude.
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||||
|
|
||||||
|
// Rate-limit fires once per coalesced album, not once per item.
|
||||||
|
if !b.checkRateLimits(captured.userID) {
|
||||||
|
b.sendRateLimitExceededMessage(ctx, captured.chatID, captured.businessConnectionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.handlePhotoMessage(
|
||||||
|
ctx, items,
|
||||||
|
captured.chatID, captured.userID,
|
||||||
|
captured.username, captured.firstName, captured.lastName,
|
||||||
|
captured.isPremium, captured.languageCode, captured.messageTime,
|
||||||
|
captured.isNewChat, captured.isOwner,
|
||||||
|
captured.businessConnectionID,
|
||||||
|
)
|
||||||
|
}
|
||||||
+195
-38
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/liushuangls/go-anthropic/v2"
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go/packages/param"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrModelNotFound is returned when the configured Anthropic model is no longer available
|
// ErrModelNotFound is returned when the configured Anthropic model is no longer available
|
||||||
@@ -15,7 +17,24 @@ import (
|
|||||||
// actionable message to admins/owners while keeping the response vague for regular users.
|
// actionable message to admins/owners while keeping the response vague for regular users.
|
||||||
var ErrModelNotFound = errors.New("model not found or deprecated")
|
var ErrModelNotFound = errors.New("model not found or deprecated")
|
||||||
|
|
||||||
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) {
|
// maxFileNotFoundRetries caps the runtime 404 self-heal loop. If multiple
|
||||||
|
// referenced file_ids are gone from Anthropic simultaneously (admin purge, AUP
|
||||||
|
// enforcement, etc.), we strip them one at a time and retry. Three attempts
|
||||||
|
// covers all realistic cascades without leaving the call hanging indefinitely.
|
||||||
|
const maxFileNotFoundRetries = 3
|
||||||
|
|
||||||
|
// getAnthropicResponse streams the model's response. Each completed text block
|
||||||
|
// is delivered to onSegment as soon as the model finishes writing it — so the
|
||||||
|
// caller can send segments to Telegram with natural rhythm around tool calls,
|
||||||
|
// rather than batched at the very end of the turn. onSegment may be nil for
|
||||||
|
// callers that only want the joined text (voice TTS, sticker reactions, etc.).
|
||||||
|
// The returned string is every text segment joined by blank lines.
|
||||||
|
//
|
||||||
|
// chatID is required for the runtime 404 self-heal: when Anthropic returns
|
||||||
|
// "File not found:" for a referenced file_id, the dead file_id is stripped
|
||||||
|
// from this chat's in-memory ChatMemory and the corresponding DB rows are
|
||||||
|
// stamped FilesCleanedAt so a reconciliation job can finish the cleanup.
|
||||||
|
func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
|
||||||
// Use prompts from config
|
// Use prompts from config
|
||||||
var systemMessage string
|
var systemMessage string
|
||||||
if isNewChat {
|
if isNewChat {
|
||||||
@@ -87,54 +106,192 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Mes
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
|
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
|
||||||
for i, msg := range messages {
|
|
||||||
for _, content := range msg.Content {
|
|
||||||
if content.Type == anthropic.MessagesContentTypeText {
|
|
||||||
InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, content.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the roles are correct
|
params := anthropic.BetaMessageNewParams{
|
||||||
for i := range messages {
|
Model: b.config.Model,
|
||||||
switch messages[i].Role {
|
|
||||||
case anthropic.RoleUser:
|
|
||||||
messages[i].Role = anthropic.RoleUser
|
|
||||||
case anthropic.RoleAssistant:
|
|
||||||
messages[i].Role = anthropic.RoleAssistant
|
|
||||||
default:
|
|
||||||
// Default to 'user' if role is unrecognized
|
|
||||||
messages[i].Role = anthropic.RoleUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model := anthropic.Model(b.config.Model)
|
|
||||||
|
|
||||||
// Create the request
|
|
||||||
request := anthropic.MessagesRequest{
|
|
||||||
Model: model, // Now `model` is of type anthropic.Model
|
|
||||||
Messages: messages,
|
|
||||||
System: systemMessage,
|
|
||||||
MaxTokens: 1000,
|
MaxTokens: 1000,
|
||||||
|
Messages: messages,
|
||||||
|
System: []anthropic.BetaTextBlockParam{{Text: systemMessage}},
|
||||||
|
// Files API beta is always on: replayed conversation history may carry
|
||||||
|
// image content blocks that reference file_ids uploaded on prior turns.
|
||||||
|
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply temperature if set in config
|
// Apply temperature if set in config
|
||||||
if b.config.Temperature != nil {
|
if b.config.Temperature != nil {
|
||||||
request.Temperature = b.config.Temperature
|
params.Temperature = param.NewOpt(float64(*b.config.Temperature))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := b.anthropicClient.CreateMessages(ctx, request)
|
// MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta
|
||||||
if err != nil {
|
// requires per-tool filtering on the toolset (Configs + DefaultConfig),
|
||||||
var apiErr *anthropic.APIError
|
// NOT the deprecated per-server tool_configuration block.
|
||||||
if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() {
|
if len(b.config.MCPServers) > 0 {
|
||||||
|
mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers))
|
||||||
|
tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers))
|
||||||
|
for _, s := range b.config.MCPServers {
|
||||||
|
srv := anthropic.BetaRequestMCPServerURLDefinitionParam{
|
||||||
|
Name: s.Name,
|
||||||
|
URL: s.URL,
|
||||||
|
}
|
||||||
|
if s.AuthorizationToken != "" {
|
||||||
|
srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken)
|
||||||
|
}
|
||||||
|
mcpServers = append(mcpServers, srv)
|
||||||
|
|
||||||
|
toolset := &anthropic.BetaMCPToolsetParam{
|
||||||
|
MCPServerName: s.Name,
|
||||||
|
}
|
||||||
|
if len(s.AllowedTools) > 0 {
|
||||||
|
toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{
|
||||||
|
Enabled: param.NewOpt(false),
|
||||||
|
}
|
||||||
|
toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools))
|
||||||
|
for _, tool := range s.AllowedTools {
|
||||||
|
toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{
|
||||||
|
Enabled: param.NewOpt(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset})
|
||||||
|
}
|
||||||
|
params.MCPServers = mcpServers
|
||||||
|
params.Tools = tools
|
||||||
|
params.Betas = 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)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("error creating Anthropic message: %w", err)
|
ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)",
|
||||||
|
b.config.ID, missingFileID, chatID, attempt+1, maxFileNotFoundRetries)
|
||||||
|
b.stripDeadFileIDFromMemory(chatID, missingFileID)
|
||||||
|
if _, cleanupErr := b.markFilesPendingCleanup(ctx, chatID, []string{missingFileID}); cleanupErr != nil {
|
||||||
|
ErrorLogger.Printf("[%s] mark files pending cleanup: %v", b.config.ID, cleanupErr)
|
||||||
|
}
|
||||||
|
params.Messages = b.prepareContextMessages(b.getOrCreateChatMemory(chatID))
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamMessages runs one streaming call against the Beta Messages API,
|
||||||
|
// dispatching each completed text block to onSegment as it arrives. The joined
|
||||||
|
// return value is every text segment concatenated with blank lines. Errors from
|
||||||
|
// the SDK are returned raw; the caller wraps them (model-not-found, file 404
|
||||||
|
// self-heal, etc.).
|
||||||
|
func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNewParams, onSegment func(string) error) (string, error) {
|
||||||
|
stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params)
|
||||||
|
defer func() {
|
||||||
|
if err := stream.Close(); err != nil {
|
||||||
|
ErrorLogger.Printf("[stream] close failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Per-block accumulators. Reset on content_block_start, consumed on
|
||||||
|
// content_block_stop. Only one block is active at a time per the SSE
|
||||||
|
// contract; SDK guarantees deltas arrive between matching start/stop.
|
||||||
|
var (
|
||||||
|
allSegments []string
|
||||||
|
currentKind string
|
||||||
|
currentText strings.Builder
|
||||||
|
currentInputJSON strings.Builder
|
||||||
|
currentTUseName, currentTUseServer, currentTUseID string
|
||||||
|
currentTResultUseID, currentTResultServer string
|
||||||
|
currentTResultIsError bool
|
||||||
|
currentTResultContent string
|
||||||
|
)
|
||||||
|
|
||||||
|
for stream.Next() {
|
||||||
|
e := stream.Current()
|
||||||
|
switch e.Type {
|
||||||
|
case "content_block_start":
|
||||||
|
cbs := e.AsContentBlockStart()
|
||||||
|
currentKind = cbs.ContentBlock.Type
|
||||||
|
currentText.Reset()
|
||||||
|
currentInputJSON.Reset()
|
||||||
|
switch currentKind {
|
||||||
|
case "mcp_tool_use":
|
||||||
|
currentTUseName = cbs.ContentBlock.Name
|
||||||
|
currentTUseServer = cbs.ContentBlock.ServerName
|
||||||
|
currentTUseID = cbs.ContentBlock.ID
|
||||||
|
case "mcp_tool_result":
|
||||||
|
currentTResultUseID = cbs.ContentBlock.ToolUseID
|
||||||
|
currentTResultServer = cbs.ContentBlock.ServerName
|
||||||
|
currentTResultIsError = cbs.ContentBlock.IsError
|
||||||
|
// Tool-result content arrives populated on start (server-side
|
||||||
|
// pre-assembled), not via subsequent deltas like text/JSON.
|
||||||
|
currentTResultContent = cbs.ContentBlock.JSON.Content.Raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_delta":
|
||||||
|
cbd := e.AsContentBlockDelta()
|
||||||
|
switch cbd.Delta.Type {
|
||||||
|
case "text_delta":
|
||||||
|
if currentKind == "text" {
|
||||||
|
currentText.WriteString(cbd.Delta.Text)
|
||||||
|
}
|
||||||
|
case "input_json_delta":
|
||||||
|
if currentKind == "mcp_tool_use" {
|
||||||
|
currentInputJSON.WriteString(cbd.Delta.PartialJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_stop":
|
||||||
|
switch currentKind {
|
||||||
|
case "text":
|
||||||
|
seg := strings.TrimSpace(currentText.String())
|
||||||
|
if seg != "" {
|
||||||
|
allSegments = append(allSegments, seg)
|
||||||
|
if onSegment != nil {
|
||||||
|
if cbErr := onSegment(seg); cbErr != nil {
|
||||||
|
// Log but keep streaming — the model's response
|
||||||
|
// is still inbound; we want it recorded even if
|
||||||
|
// one Telegram send failed.
|
||||||
|
ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "mcp_tool_use":
|
||||||
|
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
|
||||||
|
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
|
||||||
|
case "mcp_tool_result":
|
||||||
|
preview := currentTResultContent
|
||||||
|
if len(preview) > 500 {
|
||||||
|
preview = preview[:500] + "...(truncated)"
|
||||||
|
}
|
||||||
|
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
|
||||||
|
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
|
||||||
|
default:
|
||||||
|
if currentKind != "" {
|
||||||
|
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentKind = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText {
|
if err := stream.Err(); err != nil {
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileNotFoundPrefix is the exact prefix Anthropic uses in its 404 error body
|
||||||
|
// when a referenced file_id no longer exists. Used by extractMissingFileID to
|
||||||
|
// identify the offender for the runtime self-heal path.
|
||||||
|
const fileNotFoundPrefix = "File not found: "
|
||||||
|
|
||||||
|
// formatUploadFilename returns the canonical filename used when uploading a
|
||||||
|
// Telegram photo to the Anthropic Files API. The "tg-" prefix tags the file as
|
||||||
|
// bot-owned so a future reconciliation job can distinguish our uploads from
|
||||||
|
// foreign files in the same workspace. The triple (botID, chatID, tgMessageID)
|
||||||
|
// is unique within Telegram's scope — each photo in an album arrives as a
|
||||||
|
// distinct Telegram message with its own message_id, so collisions across
|
||||||
|
// album items are impossible.
|
||||||
|
func formatUploadFilename(botID uint, chatID int64, tgMessageID int, ext string) string {
|
||||||
|
return fmt.Sprintf("tg-%d-%d-%d.%s", botID, chatID, tgMessageID, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadImageToAnthropic uploads raw image bytes to the Anthropic Files API and
|
||||||
|
// returns the resulting file_id. The filename should follow the formatUploadFilename
|
||||||
|
// convention so the reconciliation job can identify the file as bot-owned.
|
||||||
|
func (b *Bot) uploadImageToAnthropic(ctx context.Context, data []byte, filename, contentType string) (string, error) {
|
||||||
|
resp, err := b.anthropicClient.Beta.Files.Upload(ctx, anthropic.BetaFileUploadParams{
|
||||||
|
File: anthropic.File(bytes.NewReader(data), filename, contentType),
|
||||||
|
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("anthropic files upload: %w", err)
|
||||||
|
}
|
||||||
|
return resp.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFileFromAnthropic removes a file from the Anthropic Files API. A 404
|
||||||
|
// is treated as success — the file is already gone, which is the same effective
|
||||||
|
// outcome the caller wants. This makes the deletion idempotent and safe for the
|
||||||
|
// reconciliation job's retries.
|
||||||
|
func (b *Bot) deleteFileFromAnthropic(ctx context.Context, fileID string) error {
|
||||||
|
_, err := b.anthropicClient.Beta.Files.Delete(ctx, fileID, anthropic.BetaFileDeleteParams{
|
||||||
|
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var apiErr *anthropic.Error
|
||||||
|
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("anthropic files delete %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compensatingDelete fires Delete calls for a set of file_ids that were uploaded
|
||||||
|
// successfully but couldn't be committed downstream. Errors are logged rather
|
||||||
|
// than returned — the caller has already entered an error path, and orphans on
|
||||||
|
// Anthropic are harmless (storage is free until the 500 GB workspace cap and the
|
||||||
|
// reconciliation job will mop them up).
|
||||||
|
func (b *Bot) compensatingDelete(ctx context.Context, fileIDs []string) {
|
||||||
|
for _, fid := range fileIDs {
|
||||||
|
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
|
||||||
|
ErrorLogger.Printf("[%s] compensating delete for %s: %v", b.config.ID, fid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMissingFileID inspects an Anthropic API error and returns the file_id
|
||||||
|
// that triggered a "File not found:" 404, if any. Returns empty string if the
|
||||||
|
// error is not a file-not-found error. Used by the runtime self-heal path to
|
||||||
|
// identify which file_id to strip from replay.
|
||||||
|
func extractMissingFileID(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var apiErr *anthropic.Error
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != http.StatusNotFound {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parseMissingFileIDFromBody(apiErr.RawJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMissingFileIDFromBody pulls a file_id out of a raw "File not found:"
|
||||||
|
// 404 body. Split out from extractMissingFileID so the string-parsing logic
|
||||||
|
// is unit-testable without having to synthesize an *anthropic.Error (whose
|
||||||
|
// JSON.raw field is private to the SDK).
|
||||||
|
func parseMissingFileIDFromBody(raw string) string {
|
||||||
|
idx := strings.Index(raw, fileNotFoundPrefix)
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := raw[idx+len(fileNotFoundPrefix):]
|
||||||
|
// File IDs are file_<base62>; the message embeds them with no surrounding
|
||||||
|
// quotes, so the id ends at the first character outside the alphanumeric +
|
||||||
|
// underscore set.
|
||||||
|
end := strings.IndexFunc(rest, func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') &&
|
||||||
|
(r < 'A' || r > 'Z') &&
|
||||||
|
(r < '0' || r > '9') &&
|
||||||
|
r != '_'
|
||||||
|
})
|
||||||
|
if end == -1 {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardDeleteScope performs the three-step hard-delete pattern on every Message
|
||||||
|
// row matching the given WHERE clause:
|
||||||
|
//
|
||||||
|
// 1. Soft-delete the rows (GORM Delete) — they become invisible to replay
|
||||||
|
// immediately, regardless of how the Anthropic-side cleanup unfolds.
|
||||||
|
// 2. For each row, call Anthropic Files.Delete on its ImageFileIDs. 404 is
|
||||||
|
// treated as success (already gone).
|
||||||
|
// 3. Rows whose file cleanup succeeded are Unscoped().Delete'd. Rows whose
|
||||||
|
// file cleanup failed remain soft-deleted with FilesCleanedAt NULL — the
|
||||||
|
// reconciliation job will retry them.
|
||||||
|
//
|
||||||
|
// This gives hard-delete eventually-consistent semantics across the DB and
|
||||||
|
// Anthropic, while still presenting the user with an instant "history cleared"
|
||||||
|
// outcome (the soft-delete in step 1 hides the rows from any further reads).
|
||||||
|
func (b *Bot) hardDeleteScope(ctx context.Context, query string, args ...interface{}) error {
|
||||||
|
// Unscoped on the scan: include already-soft-deleted rows so a hard-delete
|
||||||
|
// after a prior soft-delete still removes them completely. Matches the
|
||||||
|
// existing "erase and bust all caches" semantics for /clear_hard.
|
||||||
|
var rows []Message
|
||||||
|
if err := b.db.Unscoped().Where(query, args...).Find(&rows).Error; err != nil {
|
||||||
|
return fmt.Errorf("scan rows: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Soft-delete any rows that aren't already soft-deleted (graceful degradation:
|
||||||
|
// if Anthropic-side file cleanup fails, the row stays invisible to replay).
|
||||||
|
// Already-soft-deleted rows are unaffected by Delete without Unscoped.
|
||||||
|
if err := b.db.Where(query, args...).Delete(&Message{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("soft delete: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hardDeletable := make([]uint, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if b.deleteRowFiles(ctx, row) {
|
||||||
|
hardDeletable = append(hardDeletable, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hardDeletable) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := b.db.Unscoped().Where("id IN ?", hardDeletable).Delete(&Message{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("hard delete: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteRowFiles tries to delete every file_id referenced by row from the
|
||||||
|
// Anthropic Files API. Returns true iff all deletes succeeded (or the row had
|
||||||
|
// no images), making the row eligible for hard-delete. False means at least
|
||||||
|
// one delete failed and the row should stay soft-deleted for retry.
|
||||||
|
func (b *Bot) deleteRowFiles(ctx context.Context, row Message) bool {
|
||||||
|
if len(row.ImageFileIDs) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
allOk := true
|
||||||
|
for _, fid := range row.ImageFileIDs {
|
||||||
|
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
|
||||||
|
ErrorLogger.Printf("[%s] anthropic delete %s (row %d): %v", b.config.ID, fid, row.ID, err)
|
||||||
|
allOk = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allOk
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDeadFileIDs returns the subset of src whose ids are NOT in deadSet, and
|
||||||
|
// reports whether any were removed. Empty/nil src yields (empty, false).
|
||||||
|
func stripDeadFileIDs(src []string, deadSet map[string]struct{}) (survivors []string, dirty bool) {
|
||||||
|
survivors = make([]string, 0, len(src))
|
||||||
|
for _, fid := range src {
|
||||||
|
if _, dead := deadSet[fid]; dead {
|
||||||
|
dirty = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
survivors = append(survivors, fid)
|
||||||
|
}
|
||||||
|
return survivors, dirty
|
||||||
|
}
|
||||||
|
|
||||||
|
// markFilesPendingCleanup removes a set of dead file_ids from any stored Message
|
||||||
|
// rows that reference them, and stamps FilesCleanedAt on the affected rows so
|
||||||
|
// the reconciliation job can see they've been touched. Called by the runtime
|
||||||
|
// self-heal path after a "File not found:" 404 surfaces during message-create.
|
||||||
|
// Returns the number of rows updated.
|
||||||
|
func (b *Bot) markFilesPendingCleanup(ctx context.Context, chatID int64, deadFileIDs []string) (int, error) {
|
||||||
|
if len(deadFileIDs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
deadSet := make(map[string]struct{}, len(deadFileIDs))
|
||||||
|
for _, id := range deadFileIDs {
|
||||||
|
deadSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
var rows []Message
|
||||||
|
if err := b.db.WithContext(ctx).
|
||||||
|
Where("bot_id = ? AND chat_id = ? AND image_file_ids IS NOT NULL", b.botID, chatID).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("scan rows for cleanup: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updated := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
survivors, dirty := stripDeadFileIDs(row.ImageFileIDs, deadSet)
|
||||||
|
if !dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(survivors) == 0 {
|
||||||
|
// All files in this row are gone; mark fully cleaned so a future
|
||||||
|
// reconciliation job's `WHERE files_cleaned_at IS NULL` filter
|
||||||
|
// correctly excludes it from retries.
|
||||||
|
row.ImageFileIDs = nil
|
||||||
|
row.FilesCleanedAt = &now
|
||||||
|
} else {
|
||||||
|
// Surviving file_ids are still alive on Anthropic. Leave
|
||||||
|
// FilesCleanedAt NULL so a later death of one of them remains
|
||||||
|
// visible to the reconciliation job's filter.
|
||||||
|
row.ImageFileIDs = survivors
|
||||||
|
}
|
||||||
|
if err := b.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||||
|
return updated, fmt.Errorf("update row %d: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatUploadFilename(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
botID uint
|
||||||
|
chatID int64
|
||||||
|
tgMessageID int
|
||||||
|
ext string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{1, 12345, 42, "jpg", "tg-1-12345-42.jpg"},
|
||||||
|
// Negative chat IDs are how Telegram represents groups/channels —
|
||||||
|
// %d preserves the leading minus, no special handling needed.
|
||||||
|
{7, -1001234567890, 1, "png", "tg-7--1001234567890-1.png"},
|
||||||
|
{0, 0, 0, "webp", "tg-0-0-0.webp"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := formatUploadFilename(tc.botID, tc.chatID, tc.tgMessageID, tc.ext)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMissingFileIDFromBody(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "canonical Anthropic file-not-found body",
|
||||||
|
body: `{"type":"error","error":{"type":"invalid_request_error","message":"File not found: file_011CNha8iCJcU1wXNR6q4V8w"}}`,
|
||||||
|
want: "file_011CNha8iCJcU1wXNR6q4V8w",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing punctuation after the id is excluded",
|
||||||
|
body: `something File not found: file_abc123! more text`,
|
||||||
|
want: "file_abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body without the prefix yields empty",
|
||||||
|
body: `{"type":"error","error":{"message":"Model not found: claude-foo"}}`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "id at the very end of the buffer",
|
||||||
|
body: `File not found: file_xyz789`,
|
||||||
|
want: "file_xyz789",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
body: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, parseMissingFileIDFromBody(tc.body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDeadFileIDs(t *testing.T) {
|
||||||
|
dead := map[string]struct{}{
|
||||||
|
"file_a": {},
|
||||||
|
"file_b": {},
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
wantSurvivors []string
|
||||||
|
wantDirty bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no overlap returns input verbatim",
|
||||||
|
input: []string{"file_x", "file_y"},
|
||||||
|
wantSurvivors: []string{"file_x", "file_y"},
|
||||||
|
wantDirty: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial overlap returns survivors and reports dirty",
|
||||||
|
input: []string{"file_a", "file_x", "file_b", "file_y"},
|
||||||
|
wantSurvivors: []string{"file_x", "file_y"},
|
||||||
|
wantDirty: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all dead returns empty survivors and dirty",
|
||||||
|
input: []string{"file_a", "file_b"},
|
||||||
|
wantSurvivors: []string{},
|
||||||
|
wantDirty: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input is not dirty",
|
||||||
|
input: []string{},
|
||||||
|
wantSurvivors: []string{},
|
||||||
|
wantDirty: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
survivors, dirty := stripDeadFileIDs(tc.input, dead)
|
||||||
|
assert.Equal(t, tc.wantSurvivors, survivors)
|
||||||
|
assert.Equal(t, tc.wantDirty, dirty)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkFilesPendingCleanup(t *testing.T) {
|
||||||
|
b, _ := setupBotForTest(t, 123)
|
||||||
|
chatID := int64(555)
|
||||||
|
|
||||||
|
// Row 1: has dead file_a + alive file_x → should be updated with survivors.
|
||||||
|
row1 := Message{
|
||||||
|
BotID: b.botID,
|
||||||
|
ChatID: chatID,
|
||||||
|
UserID: 777,
|
||||||
|
Username: "u",
|
||||||
|
UserRole: "user",
|
||||||
|
Text: "look at these",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
IsUser: true,
|
||||||
|
ImageFileIDs: []string{"file_a", "file_x"},
|
||||||
|
}
|
||||||
|
assert.NoError(t, b.db.Create(&row1).Error)
|
||||||
|
|
||||||
|
// Row 2: only dead files → ImageFileIDs should become nil.
|
||||||
|
row2 := Message{
|
||||||
|
BotID: b.botID,
|
||||||
|
ChatID: chatID,
|
||||||
|
UserID: 777,
|
||||||
|
Username: "u",
|
||||||
|
UserRole: "user",
|
||||||
|
Text: "screenshot",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
IsUser: true,
|
||||||
|
ImageFileIDs: []string{"file_a", "file_b"},
|
||||||
|
}
|
||||||
|
assert.NoError(t, b.db.Create(&row2).Error)
|
||||||
|
|
||||||
|
// Row 3: no dead files → should be untouched.
|
||||||
|
row3 := Message{
|
||||||
|
BotID: b.botID,
|
||||||
|
ChatID: chatID,
|
||||||
|
UserID: 777,
|
||||||
|
Username: "u",
|
||||||
|
UserRole: "user",
|
||||||
|
Text: "another",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
IsUser: true,
|
||||||
|
ImageFileIDs: []string{"file_x", "file_y"},
|
||||||
|
}
|
||||||
|
assert.NoError(t, b.db.Create(&row3).Error)
|
||||||
|
|
||||||
|
// Row 4: different chat → must NOT be touched even if it references a dead file.
|
||||||
|
row4 := Message{
|
||||||
|
BotID: b.botID,
|
||||||
|
ChatID: 999,
|
||||||
|
UserID: 777,
|
||||||
|
Username: "u",
|
||||||
|
UserRole: "user",
|
||||||
|
Text: "other chat",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
IsUser: true,
|
||||||
|
ImageFileIDs: []string{"file_a"},
|
||||||
|
}
|
||||||
|
assert.NoError(t, b.db.Create(&row4).Error)
|
||||||
|
|
||||||
|
updated, err := b.markFilesPendingCleanup(t.Context(), chatID, []string{"file_a", "file_b"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, updated, "rows 1 and 2 should have been updated")
|
||||||
|
|
||||||
|
// Row 1: only file_x should remain; FilesCleanedAt MUST stay nil because
|
||||||
|
// file_x is still alive on Anthropic and a future death of it must remain
|
||||||
|
// visible to the reconciliation job's `WHERE files_cleaned_at IS NULL` filter.
|
||||||
|
var r1 Message
|
||||||
|
assert.NoError(t, b.db.First(&r1, row1.ID).Error)
|
||||||
|
assert.Equal(t, []string{"file_x"}, r1.ImageFileIDs)
|
||||||
|
assert.Nil(t, r1.FilesCleanedAt)
|
||||||
|
|
||||||
|
// Row 2: all gone → ImageFileIDs nil/empty; FilesCleanedAt set.
|
||||||
|
var r2 Message
|
||||||
|
assert.NoError(t, b.db.First(&r2, row2.ID).Error)
|
||||||
|
assert.Empty(t, r2.ImageFileIDs)
|
||||||
|
assert.NotNil(t, r2.FilesCleanedAt)
|
||||||
|
|
||||||
|
// Row 3: untouched.
|
||||||
|
var r3 Message
|
||||||
|
assert.NoError(t, b.db.First(&r3, row3.ID).Error)
|
||||||
|
assert.Equal(t, []string{"file_x", "file_y"}, r3.ImageFileIDs)
|
||||||
|
assert.Nil(t, r3.FilesCleanedAt)
|
||||||
|
|
||||||
|
// Row 4: untouched despite referencing a dead file — scope is per-chat.
|
||||||
|
var r4 Message
|
||||||
|
assert.NoError(t, b.db.First(&r4, row4.ID).Error)
|
||||||
|
assert.Equal(t, []string{"file_a"}, r4.ImageFileIDs)
|
||||||
|
assert.Nil(t, r4.FilesCleanedAt)
|
||||||
|
}
|
||||||
@@ -8,16 +8,17 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go/option"
|
||||||
"github.com/go-telegram/bot"
|
"github.com/go-telegram/bot"
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
"github.com/liushuangls/go-anthropic/v2"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
tgBot TelegramClient
|
tgBot TelegramClient
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
anthropicClient *anthropic.Client
|
anthropicClient anthropic.Client
|
||||||
chatMemories map[int64]*ChatMemory
|
chatMemories map[int64]*ChatMemory
|
||||||
memorySize int
|
memorySize int
|
||||||
chatMemoriesMu sync.RWMutex
|
chatMemoriesMu sync.RWMutex
|
||||||
@@ -26,6 +27,11 @@ 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
|
||||||
@@ -81,7 +87,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the per-bot Anthropic API key
|
// Use the per-bot Anthropic API key
|
||||||
anthropicClient := anthropic.NewClient(config.AnthropicAPIKey)
|
anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey))
|
||||||
|
|
||||||
b := &Bot{
|
b := &Bot{
|
||||||
db: db,
|
db: db,
|
||||||
@@ -93,6 +99,7 @@ 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 {
|
||||||
@@ -250,6 +257,32 @@ 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()
|
||||||
@@ -264,14 +297,14 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message {
|
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
|
||||||
b.chatMemoriesMu.RLock()
|
b.chatMemoriesMu.RLock()
|
||||||
defer b.chatMemoriesMu.RUnlock()
|
defer b.chatMemoriesMu.RUnlock()
|
||||||
|
|
||||||
// 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", i, msg.IsUser, msg.Text)
|
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: consecutive messages with the same role are permitted.
|
// Note: consecutive messages with the same role are permitted.
|
||||||
@@ -279,29 +312,49 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message
|
|||||||
// returning an error. This can happen after a /clear (which only deletes user
|
// returning an error. This can happen after a /clear (which only deletes user
|
||||||
// messages, leaving assistant messages in the DB) followed by a restart.
|
// messages, leaving assistant messages in the DB) followed by a restart.
|
||||||
// See: https://platform.claude.com/docs/en/api/messages
|
// See: https://platform.claude.com/docs/en/api/messages
|
||||||
var contextMessages []anthropic.Message
|
var contextMessages []anthropic.BetaMessageParam
|
||||||
for _, msg := range chatMemory.Messages {
|
for _, msg := range chatMemory.Messages {
|
||||||
role := anthropic.RoleUser
|
blocks := contentBlocksForMessage(msg)
|
||||||
if !msg.IsUser {
|
if len(blocks) == 0 {
|
||||||
role = anthropic.RoleAssistant
|
// Skip turns that carry neither text nor images.
|
||||||
}
|
|
||||||
|
|
||||||
textContent := strings.TrimSpace(msg.Text)
|
|
||||||
if textContent == "" {
|
|
||||||
// Skip empty messages
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
var param anthropic.BetaMessageParam
|
||||||
contextMessages = append(contextMessages, anthropic.Message{
|
if msg.IsUser {
|
||||||
Role: role,
|
param = anthropic.NewBetaUserMessage(blocks...)
|
||||||
Content: []anthropic.MessageContent{
|
} else {
|
||||||
anthropic.NewTextMessageContent(textContent),
|
param = anthropic.BetaMessageParam{
|
||||||
},
|
Role: anthropic.BetaMessageParamRoleAssistant,
|
||||||
})
|
Content: blocks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contextMessages = append(contextMessages, param)
|
||||||
}
|
}
|
||||||
return contextMessages
|
return contextMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contentBlocksForMessage assembles the Anthropic content blocks representing
|
||||||
|
// one stored Message. Image blocks are emitted before the text block (Anthropic
|
||||||
|
// docs: "Claude works best when images come before text"). Multi-image user
|
||||||
|
// turns prepend each image with an "Image N:" label, as the docs explicitly
|
||||||
|
// recommend for multi-image prompts. Assistant turns carry text only.
|
||||||
|
func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion {
|
||||||
|
var blocks []anthropic.BetaContentBlockParamUnion
|
||||||
|
if msg.IsUser && len(msg.ImageFileIDs) > 0 {
|
||||||
|
multi := len(msg.ImageFileIDs) > 1
|
||||||
|
for i, fileID := range msg.ImageFileIDs {
|
||||||
|
if multi {
|
||||||
|
blocks = append(blocks, anthropic.NewBetaTextBlock(fmt.Sprintf("Image %d:", i+1)))
|
||||||
|
}
|
||||||
|
blocks = append(blocks, anthropic.NewBetaImageBlock(anthropic.BetaFileImageSourceParam{FileID: fileID}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if textContent := strings.TrimSpace(msg.Text); textContent != "" {
|
||||||
|
blocks = append(blocks, anthropic.NewBetaTextBlock(textContent))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) isNewChat(chatID int64) bool {
|
func (b *Bot) isNewChat(chatID int64) bool {
|
||||||
var count int64
|
var count int64
|
||||||
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
|
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
|
||||||
@@ -355,7 +408,7 @@ func (b *Bot) registerAdminCommandsForUser(ctx context.Context, telegramID int64
|
|||||||
allCommands = append(allCommands, adminBotCommands...)
|
allCommands = append(allCommands, adminBotCommands...)
|
||||||
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
|
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
|
||||||
Commands: allCommands,
|
Commands: allCommands,
|
||||||
Scope: &models.BotCommandScopeChatMember{ChatID: telegramID, UserID: telegramID},
|
Scope: &models.BotCommandScopeChat{ChatID: telegramID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
|
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
|
||||||
@@ -378,7 +431,7 @@ func setElevatedCommands(tgBot TelegramClient, users []User) {
|
|||||||
}
|
}
|
||||||
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
|
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
|
||||||
Commands: allCommands,
|
Commands: allCommands,
|
||||||
Scope: &models.BotCommandScopeChatMember{ChatID: u.TelegramID, UserID: u.TelegramID},
|
Scope: &models.BotCommandScopeChat{ChatID: u.TelegramID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
|
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
|
||||||
@@ -448,6 +501,27 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendOneSegment delivers a single Telegram message without touching storage
|
||||||
|
// or chat memory. Used by the streaming response path: each completed text
|
||||||
|
// block fires this helper as it arrives, and the full turn is recorded once
|
||||||
|
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
|
||||||
|
// storage invariant while letting the user see segments with natural rhythm.
|
||||||
|
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
|
||||||
|
params := &bot.SendMessageParams{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
if businessConnectionID != "" {
|
||||||
|
params.BusinessConnectionID = businessConnectionID
|
||||||
|
}
|
||||||
|
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
|
||||||
|
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
|
||||||
|
b.config.ID, chatID, businessConnectionID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// sendStats sends the bot statistics to the specified chat.
|
// sendStats sends the bot statistics to the specified chat.
|
||||||
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
|
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
|
||||||
// If targetUserID is 0, show global stats
|
// If targetUserID is 0, show global stats
|
||||||
@@ -470,6 +544,36 @@ func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetU
|
|||||||
totalMessages,
|
totalMessages,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if b.hasScope(userID, ScopeStatsViewAny) {
|
||||||
|
type topEntry struct {
|
||||||
|
UserID int64
|
||||||
|
MsgCount int64
|
||||||
|
}
|
||||||
|
var top []topEntry
|
||||||
|
if err := b.db.Model(&Message{}).
|
||||||
|
Select("user_id, COUNT(*) as msg_count").
|
||||||
|
Where("bot_id = ? AND is_user = ? AND deleted_at IS NULL", b.botID, true).
|
||||||
|
Group("user_id").
|
||||||
|
Order("msg_count DESC").
|
||||||
|
Limit(3).
|
||||||
|
Scan(&top).Error; err != nil {
|
||||||
|
ErrorLogger.Printf("Error fetching top users: %v", err)
|
||||||
|
} else if len(top) > 0 {
|
||||||
|
statsMessage += "\n\n🏆 Most Active Users:"
|
||||||
|
for i, entry := range top {
|
||||||
|
var u User
|
||||||
|
if err := b.db.Select("username").Where("telegram_id = ? AND bot_id = ?", entry.UserID, b.botID).First(&u).Error; err != nil {
|
||||||
|
u.Username = fmt.Sprintf("ID:%d", entry.UserID)
|
||||||
|
}
|
||||||
|
name := u.Username
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("ID:%d", entry.UserID)
|
||||||
|
}
|
||||||
|
statsMessage += fmt.Sprintf("\n%d. @%s — %d messages", i+1, name, entry.MsgCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send the response through the centralized screen
|
// Send the response through the centralized screen
|
||||||
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
|
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
|
||||||
ErrorLogger.Printf("Error sending stats message: %v", err)
|
ErrorLogger.Printf("Error sending stats message: %v", err)
|
||||||
@@ -634,7 +738,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
userRole := string(anthropic.RoleUser)
|
userRole := "user"
|
||||||
|
|
||||||
// Determine message text based on message type
|
// Determine message text based on message type
|
||||||
messageText := message.Text
|
messageText := message.Text
|
||||||
@@ -645,6 +749,9 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
|
|||||||
messageText = "Sent a sticker."
|
messageText = "Sent a sticker."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if message.Voice != nil {
|
||||||
|
messageText = "[Voice message]"
|
||||||
|
}
|
||||||
|
|
||||||
userMessage := b.createMessage(message.Chat.ID, message.From.ID, message.From.Username, userRole, messageText, true)
|
userMessage := b.createMessage(message.Chat.ID, message.From.ID, message.From.Username, userRole, messageText, true)
|
||||||
|
|
||||||
@@ -688,7 +795,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and store the assistant message
|
// Create and store the assistant message
|
||||||
assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false)
|
assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false)
|
||||||
if err := b.storeMessage(&assistantMessage); err != nil {
|
if err := b.storeMessage(&assistantMessage); err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentBlocksForMessage(t *testing.T) {
|
||||||
|
t.Run("empty message yields no blocks", func(t *testing.T) {
|
||||||
|
blocks := contentBlocksForMessage(Message{IsUser: true})
|
||||||
|
assert.Empty(t, blocks)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user text only yields one text block", func(t *testing.T) {
|
||||||
|
blocks := contentBlocksForMessage(Message{IsUser: true, Text: "hello"})
|
||||||
|
assert.Len(t, blocks, 1)
|
||||||
|
assert.NotNil(t, blocks[0].OfText)
|
||||||
|
assert.Equal(t, "hello", blocks[0].OfText.Text)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user single image without caption — no label, no text", func(t *testing.T) {
|
||||||
|
blocks := contentBlocksForMessage(Message{
|
||||||
|
IsUser: true,
|
||||||
|
ImageFileIDs: []string{"file_solo"},
|
||||||
|
})
|
||||||
|
assert.Len(t, blocks, 1)
|
||||||
|
assert.NotNil(t, blocks[0].OfImage)
|
||||||
|
assert.NotNil(t, blocks[0].OfImage.Source.OfFile)
|
||||||
|
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user single image with caption — image before text", func(t *testing.T) {
|
||||||
|
blocks := contentBlocksForMessage(Message{
|
||||||
|
IsUser: true,
|
||||||
|
Text: "is this right?",
|
||||||
|
ImageFileIDs: []string{"file_solo"},
|
||||||
|
})
|
||||||
|
assert.Len(t, blocks, 2)
|
||||||
|
assert.NotNil(t, blocks[0].OfImage, "image block must come before text per Anthropic guidance")
|
||||||
|
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
|
||||||
|
assert.NotNil(t, blocks[1].OfText)
|
||||||
|
assert.Equal(t, "is this right?", blocks[1].OfText.Text)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user album (multi-image) labels each with Image N:", func(t *testing.T) {
|
||||||
|
blocks := contentBlocksForMessage(Message{
|
||||||
|
IsUser: true,
|
||||||
|
Text: "compare these",
|
||||||
|
ImageFileIDs: []string{"file_a", "file_b", "file_c"},
|
||||||
|
})
|
||||||
|
// Expected layout: text "Image 1:", image a, text "Image 2:", image b, text "Image 3:", image c, text "compare these"
|
||||||
|
assert.Len(t, blocks, 7)
|
||||||
|
assert.Equal(t, "Image 1:", blocks[0].OfText.Text)
|
||||||
|
assert.Equal(t, "file_a", blocks[1].OfImage.Source.OfFile.FileID)
|
||||||
|
assert.Equal(t, "Image 2:", blocks[2].OfText.Text)
|
||||||
|
assert.Equal(t, "file_b", blocks[3].OfImage.Source.OfFile.FileID)
|
||||||
|
assert.Equal(t, "Image 3:", blocks[4].OfText.Text)
|
||||||
|
assert.Equal(t, "file_c", blocks[5].OfImage.Source.OfFile.FileID)
|
||||||
|
assert.Equal(t, "compare these", blocks[6].OfText.Text)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("assistant message with images-set is text-only (defensive)", func(t *testing.T) {
|
||||||
|
// Assistant turns shouldn't carry images, but if they ever do we treat
|
||||||
|
// them as text-only — the model returns text, not images.
|
||||||
|
blocks := contentBlocksForMessage(Message{
|
||||||
|
IsUser: false,
|
||||||
|
Text: "I see your screenshot",
|
||||||
|
ImageFileIDs: []string{"file_should_be_ignored"},
|
||||||
|
})
|
||||||
|
assert.Len(t, blocks, 1)
|
||||||
|
assert.NotNil(t, blocks[0].OfText)
|
||||||
|
assert.Equal(t, "I see your screenshot", blocks[0].OfText.Text)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("whitespace-only text is skipped but images survive", func(t *testing.T) {
|
||||||
|
blocks := contentBlocksForMessage(Message{
|
||||||
|
IsUser: true,
|
||||||
|
Text: " \n ",
|
||||||
|
ImageFileIDs: []string{"file_x"},
|
||||||
|
})
|
||||||
|
assert.Len(t, blocks, 1)
|
||||||
|
assert.NotNil(t, blocks[0].OfImage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDeadFileIDFromMemory(t *testing.T) {
|
||||||
|
b, _ := setupBotForTest(t, 100)
|
||||||
|
chatID := int64(42)
|
||||||
|
|
||||||
|
// Seed in-memory chat memory with three messages.
|
||||||
|
cm := b.getOrCreateChatMemory(chatID)
|
||||||
|
cm.Messages = []Message{
|
||||||
|
{IsUser: true, Text: "first", ImageFileIDs: []string{"file_a", "file_b"}},
|
||||||
|
{IsUser: false, Text: "reply"},
|
||||||
|
{IsUser: true, Text: "third", ImageFileIDs: []string{"file_b", "file_c"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
b.stripDeadFileIDFromMemory(chatID, "file_b")
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"file_a"}, cm.Messages[0].ImageFileIDs, "file_b should be removed from message 1")
|
||||||
|
assert.Empty(t, cm.Messages[1].ImageFileIDs, "assistant message untouched")
|
||||||
|
assert.Equal(t, []string{"file_c"}, cm.Messages[2].ImageFileIDs, "file_b should be removed from message 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDeadFileIDFromMemory_UnknownChatIsNoop(t *testing.T) {
|
||||||
|
b, _ := setupBotForTest(t, 100)
|
||||||
|
// Calling on a chat that was never opened should not panic and should be a no-op.
|
||||||
|
b.stripDeadFileIDFromMemory(99999, "file_anything")
|
||||||
|
// Nothing to assert beyond not crashing.
|
||||||
|
}
|
||||||
@@ -6,41 +6,37 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/liushuangls/go-anthropic/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type BotConfig struct {
|
// MCPServer configures a remote Model Context Protocol server that the Anthropic
|
||||||
ID string `json:"id"`
|
// API will connect to on behalf of this bot. AllowedTools, when non-empty, limits
|
||||||
TelegramToken string `json:"telegram_token"`
|
// which server-exposed tools the model may invoke.
|
||||||
MemorySize int `json:"memory_size"`
|
type MCPServer struct {
|
||||||
MessagePerHour int `json:"messages_per_hour"`
|
Name string `json:"name"`
|
||||||
MessagePerDay int `json:"messages_per_day"`
|
URL string `json:"url"`
|
||||||
TempBanDuration string `json:"temp_ban_duration"`
|
AuthorizationToken string `json:"authorization_token,omitempty"`
|
||||||
Model anthropic.Model `json:"model"`
|
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||||
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
|
|
||||||
SystemPrompts map[string]string `json:"system_prompts"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
OwnerTelegramID int64 `json:"owner_telegram_id"`
|
|
||||||
AnthropicAPIKey string `json:"anthropic_api_key"`
|
|
||||||
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
|
|
||||||
ConfigFilePath string `json:"-"` // Set at load time; not serialized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom unmarshalling to handle anthropic.Model
|
type BotConfig struct {
|
||||||
func (c *BotConfig) UnmarshalJSON(data []byte) error {
|
ID string `json:"id"`
|
||||||
type Alias BotConfig
|
TelegramToken string `json:"telegram_token"`
|
||||||
aux := &struct {
|
MemorySize int `json:"memory_size"`
|
||||||
Model string `json:"model"`
|
MessagePerHour int `json:"messages_per_hour"`
|
||||||
*Alias
|
MessagePerDay int `json:"messages_per_day"`
|
||||||
}{
|
TempBanDuration string `json:"temp_ban_duration"`
|
||||||
Alias: (*Alias)(c),
|
Model string `json:"model"`
|
||||||
}
|
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
|
||||||
if err := json.Unmarshal(data, &aux); err != nil {
|
SystemPrompts map[string]string `json:"system_prompts"`
|
||||||
return err
|
Active bool `json:"active"`
|
||||||
}
|
OwnerTelegramID int64 `json:"owner_telegram_id"`
|
||||||
c.Model = anthropic.Model(aux.Model)
|
AnthropicAPIKey string `json:"anthropic_api_key"`
|
||||||
return nil
|
ElevenLabsAPIKey string `json:"elevenlabs_api_key"`
|
||||||
|
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
|
||||||
|
ElevenLabsModel string `json:"elevenlabs_model"`
|
||||||
|
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
|
||||||
|
MCPServers []MCPServer `json:"mcp_servers,omitempty"`
|
||||||
|
ConfigFilePath string `json:"-"` // Set at load time; not serialized
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateConfigPath ensures the file path is within the allowed directory
|
// validateConfigPath ensures the file path is within the allowed directory
|
||||||
@@ -199,7 +195,6 @@ func (c *BotConfig) Reload(configDir, filename string) error {
|
|||||||
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
|
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Model = anthropic.Model(c.Model)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +226,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
|
|||||||
return fmt.Errorf("failed to write config: %w", err)
|
return fmt.Errorf("failed to write config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Model = anthropic.Model(newModel)
|
c.Model = newModel
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -4,16 +4,19 @@
|
|||||||
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"owner_telegram_id": 111111111,
|
"owner_telegram_id": 111111111,
|
||||||
"anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY",
|
"anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY",
|
||||||
|
"elevenlabs_api_key": "",
|
||||||
|
"elevenlabs_voice_id": "",
|
||||||
|
"elevenlabs_model": "",
|
||||||
"memory_size": 10,
|
"memory_size": 10,
|
||||||
"messages_per_hour": 20,
|
"messages_per_hour": 20,
|
||||||
"messages_per_day": 100,
|
"messages_per_day": 100,
|
||||||
"temp_ban_duration": "24h",
|
"temp_ban_duration": "24h",
|
||||||
"model": "claude-3-5-haiku-latest",
|
"model": "claude-haiku-4-5",
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"debug_screening": false,
|
"debug_screening": false,
|
||||||
"system_prompts": {
|
"system_prompts": {
|
||||||
"default": "You are a helpful assistant.",
|
"default": "You are a helpful assistant.",
|
||||||
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'\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.",
|
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'. Prefer replying in this language when talking to '{username}'.\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
|
||||||
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
|
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
|
||||||
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
|
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
|
||||||
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
|
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
|
||||||
|
|||||||
+1
-3
@@ -6,8 +6,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/liushuangls/go-anthropic/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set up loggers
|
// Set up loggers
|
||||||
@@ -38,7 +36,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
|
|||||||
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedModel := anthropic.Model("claude-v1")
|
expectedModel := "claude-v1"
|
||||||
if config.Model != expectedModel {
|
if config.Model != expectedModel {
|
||||||
t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
|
t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -71,7 +71,7 @@ func createDefaultScopes(db *gorm.DB) error {
|
|||||||
ScopeStatsViewOwn, ScopeStatsViewAny,
|
ScopeStatsViewOwn, ScopeStatsViewAny,
|
||||||
ScopeHistoryClearOwn, ScopeHistoryClearAny,
|
ScopeHistoryClearOwn, ScopeHistoryClearAny,
|
||||||
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
|
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
|
||||||
ScopeModelSet, ScopeUserPromote,
|
ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
|
||||||
}
|
}
|
||||||
for _, name := range all {
|
for _, name := range all {
|
||||||
if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil {
|
if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil {
|
||||||
@@ -88,7 +88,7 @@ func createDefaultScopes(db *gorm.DB) error {
|
|||||||
ScopeStatsViewOwn, ScopeStatsViewAny,
|
ScopeStatsViewOwn, ScopeStatsViewAny,
|
||||||
ScopeHistoryClearOwn, ScopeHistoryClearAny,
|
ScopeHistoryClearOwn, ScopeHistoryClearAny,
|
||||||
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
|
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
|
||||||
ScopeModelSet, ScopeUserPromote,
|
ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
|
||||||
}
|
}
|
||||||
assignments := map[string][]string{
|
assignments := map[string][]string{
|
||||||
"user": userScopes,
|
"user": userScopes,
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
elevenLabsTTSURL = "https://api.elevenlabs.io/v1/text-to-speech/"
|
||||||
|
elevenLabsSTTURL = "https://api.elevenlabs.io/v1/speech-to-text"
|
||||||
|
elevenLabsDefaultModel = "eleven_multilingual_v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateSpeech converts text to an mp3 audio stream via ElevenLabs TTS.
|
||||||
|
func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error) {
|
||||||
|
model := b.config.ElevenLabsModel
|
||||||
|
if model == "" {
|
||||||
|
model = elevenLabsDefaultModel
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"text": text,
|
||||||
|
"model_id": model,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("elevenlabs TTS marshal error: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
elevenLabsTTSURL+b.config.ElevenLabsVoiceID, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("elevenlabs TTS request error: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("xi-api-key", b.config.ElevenLabsAPIKey)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
errBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcribeVoice downloads a Telegram voice file and transcribes it via ElevenLabs STT.
|
||||||
|
// Uses a direct multipart HTTP call instead of the SDK wrapper to avoid a bug in the
|
||||||
|
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
|
||||||
|
// string with Content-Type: application/json, which ElevenLabs rejects with 400.
|
||||||
|
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
|
||||||
|
// 1. Resolve and download the voice file from Telegram via the shared helper.
|
||||||
|
audioBytes, err := b.downloadTelegramFile(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build multipart body with binary audio — bypasses SDK encoding issues.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
if err := mw.WriteField("model_id", "scribe_v1"); err != nil {
|
||||||
|
return "", fmt.Errorf("multipart write error: %w", err)
|
||||||
|
}
|
||||||
|
part, err := mw.CreateFormFile("file", "audio.ogg")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("multipart create file error: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, bytes.NewReader(audioBytes)); err != nil {
|
||||||
|
return "", fmt.Errorf("multipart copy error: %w", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("multipart close error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. POST to ElevenLabs STT.
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
elevenLabsSTTURL, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create STT request error: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("xi-api-key", b.config.ElevenLabsAPIKey)
|
||||||
|
|
||||||
|
sttResp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("elevenlabs STT request error: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sttResp.Body.Close() }()
|
||||||
|
|
||||||
|
if sttResp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(sttResp.Body)
|
||||||
|
return "", fmt.Errorf("elevenlabs STT error: status %d: %s", sttResp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(sttResp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("elevenlabs STT decode error: %w", err)
|
||||||
|
}
|
||||||
|
return result.Text, nil
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -3,21 +3,35 @@ module github.com/HugeFrog24/go-telegram-bot
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-telegram/bot v1.19.0
|
github.com/anthropics/anthropic-sdk-go v1.45.0
|
||||||
github.com/liushuangls/go-anthropic/v2 v2.17.1
|
github.com/go-telegram/bot v1.20.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/sync v0.20.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.1
|
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.13.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/mailru/easyjson v0.9.2 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
github.com/tidwall/gjson v1.19.0 // indirect
|
||||||
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,69 @@
|
|||||||
|
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
|
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||||
|
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 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/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg=
|
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||||
github.com/go-telegram/bot v1.19.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
|
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
|
||||||
|
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||||
|
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||||
|
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/liushuangls/go-anthropic/v2 v2.17.1 h1:ca3oFzgQHs9/mJr+xx2XFQIYcQLM2rDCqieUx0g+8p4=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/liushuangls/go-anthropic/v2 v2.17.1/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||||
|
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
|
||||||
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
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=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
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/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=
|
||||||
|
|||||||
+311
-33
@@ -7,22 +7,252 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
"github.com/go-telegram/bot"
|
"github.com/go-telegram/bot"
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
"github.com/liushuangls/go-anthropic/v2"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) {
|
||||||
|
// If ElevenLabs is not configured, respond with text — consistent with all other error paths.
|
||||||
|
if b.config.ElevenLabsAPIKey == "" {
|
||||||
|
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
|
||||||
|
ErrorLogger.Printf("Error sending voice-unsupported message: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !b.hasScope(userID, ScopeTTSUse) {
|
||||||
|
if err := b.sendResponse(ctx, chatID, "You don't have permission to use voice features.", businessConnectionID); err != nil {
|
||||||
|
ErrorLogger.Printf("Error sending permission denied message: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript, err := b.transcribeVoice(ctx, message.Voice.FileID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorLogger.Printf("Error transcribing voice message from user %d: %v", userID, err)
|
||||||
|
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't understand your voice message.", businessConnectionID); err != nil {
|
||||||
|
ErrorLogger.Printf("Error sending transcription error message: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the stored "[Voice message]" placeholder with the actual transcript,
|
||||||
|
// keeping the audit record intact while giving the LLM meaningful context.
|
||||||
|
if err := b.db.Model(&userMsg).Update("text", transcript).Error; err != nil {
|
||||||
|
ErrorLogger.Printf("Error updating voice transcript in DB: %v", err)
|
||||||
|
}
|
||||||
|
b.chatMemoriesMu.Lock()
|
||||||
|
if mem, exists := b.chatMemories[chatID]; exists {
|
||||||
|
for i := len(mem.Messages) - 1; i >= 0; i-- {
|
||||||
|
if mem.Messages[i].ID == userMsg.ID {
|
||||||
|
mem.Messages[i].Text = transcript
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.chatMemoriesMu.Unlock()
|
||||||
|
|
||||||
|
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||||
|
contextMessages := b.prepareContextMessages(chatMemory)
|
||||||
|
// Voice path passes nil for onSegment: tool-call narration across multiple
|
||||||
|
// TTS clips would be jarring, so we accumulate everything and synthesize one
|
||||||
|
// audio clip from the joined text.
|
||||||
|
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
|
||||||
|
if err != nil {
|
||||||
|
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
|
||||||
|
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
|
||||||
|
ErrorLogger.Printf("Error sending anthropic error response: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioReader, err := b.generateSpeech(ctx, response)
|
||||||
|
if err != nil {
|
||||||
|
// TTS failed — fall back to text so the user still gets a reply.
|
||||||
|
ErrorLogger.Printf("Error generating speech, falling back to text: %v", err)
|
||||||
|
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
|
||||||
|
ErrorLogger.Printf("Error sending text fallback: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the assistant response before sending.
|
||||||
|
if _, err := b.screenOutgoingMessage(chatID, response); err != nil {
|
||||||
|
ErrorLogger.Printf("Error storing assistant voice response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &bot.SendAudioParams{
|
||||||
|
ChatID: chatID,
|
||||||
|
Audio: &models.InputFileUpload{Filename: "response.mp3", Data: audioReader},
|
||||||
|
}
|
||||||
|
if businessConnectionID != "" {
|
||||||
|
params.BusinessConnectionID = businessConnectionID
|
||||||
|
}
|
||||||
|
if _, err := b.tgBot.SendAudio(ctx, params); err != nil {
|
||||||
|
ErrorLogger.Printf("Error sending audio to chat %d: %v", chatID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadPhotoFromItem downloads the largest PhotoSize from a Telegram message
|
||||||
|
// item and uploads it to the Anthropic Files API tagged with the bot's filename
|
||||||
|
// convention. Telegram serves photos as JPEG regardless of the user's original
|
||||||
|
// format, so the content-type is fixed.
|
||||||
|
func (b *Bot) uploadPhotoFromItem(ctx context.Context, item *models.Message, chatID int64) (string, error) {
|
||||||
|
photo := largestPhotoSize(item.Photo)
|
||||||
|
data, err := b.downloadTelegramFile(ctx, photo.FileID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("download %s: %w", photo.FileID, err)
|
||||||
|
}
|
||||||
|
filename := formatUploadFilename(b.botID, chatID, item.ID, "jpg")
|
||||||
|
return b.uploadImageToAnthropic(ctx, data, filename, "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePhotoMessage processes a user turn that contains one or more photos —
|
||||||
|
// either a single photo or a Telegram media_group (album) coalesced upstream.
|
||||||
|
// For albums the caller passes the items sorted by message_id. Each item's
|
||||||
|
// largest PhotoSize is downloaded and uploaded to the Anthropic Files API; the
|
||||||
|
// resulting file_ids are persisted on a single Message row representing the
|
||||||
|
// whole user turn. On any upload failure, compensating deletes fire against
|
||||||
|
// already-uploaded file_ids and the DB row is not written — orphans on
|
||||||
|
// Anthropic are preferred over poisoned DB references.
|
||||||
|
func (b *Bot) handlePhotoMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
items []*models.Message,
|
||||||
|
chatID, userID int64,
|
||||||
|
username, firstName, lastName string,
|
||||||
|
isPremium bool,
|
||||||
|
languageCode string,
|
||||||
|
messageTime int,
|
||||||
|
isNewChat, isOwner bool,
|
||||||
|
businessConnectionID string,
|
||||||
|
) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: download + upload each photo in parallel. Album latency collapses
|
||||||
|
// from N*RTT to ~max(t_i) — relevant for the multi-screenshot use case.
|
||||||
|
// Caption capture happens in the sequential loop (one item carries it; order
|
||||||
|
// is preserved upstream via flushAlbum's sort by message_id). uploaded[i]
|
||||||
|
// matches items[i] so file_ids stay in user-intended order; non-photo items
|
||||||
|
// leave their slot empty and are compacted out before commit.
|
||||||
|
uploaded := make([]string, len(items))
|
||||||
|
caption := ""
|
||||||
|
g, gctx := errgroup.WithContext(ctx)
|
||||||
|
for i, item := range items {
|
||||||
|
if item.Caption != "" {
|
||||||
|
caption = item.Caption
|
||||||
|
}
|
||||||
|
if len(item.Photo) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i, item := i, item
|
||||||
|
g.Go(func() error {
|
||||||
|
fileID, err := b.uploadPhotoFromItem(gctx, item, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uploaded[i] = fileID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
ErrorLogger.Printf("[%s] photo upload failed: %v", b.config.ID, err)
|
||||||
|
var successful []string
|
||||||
|
for _, fid := range uploaded {
|
||||||
|
if fid != "" {
|
||||||
|
successful = append(successful, fid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.compensatingDelete(ctx, successful)
|
||||||
|
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I couldn't process one of your images.", businessConnectionID); sendErr != nil {
|
||||||
|
ErrorLogger.Printf("Error sending photo failure message: %v", sendErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalUploaded := make([]string, 0, len(uploaded))
|
||||||
|
for _, fid := range uploaded {
|
||||||
|
if fid != "" {
|
||||||
|
finalUploaded = append(finalUploaded, fid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(finalUploaded) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: commit Message row. getOrCreateChatMemory MUST run before
|
||||||
|
// storeMessage — on a cold cache, the get-or-create hydrates from DB, and
|
||||||
|
// hydrating after the insert would re-load the just-stored row, causing
|
||||||
|
// addMessageToChatMemory below to produce a duplicate user turn. Mirrors
|
||||||
|
// the ordering used by screenIncomingMessage for the same reason.
|
||||||
|
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||||
|
userMessage := b.createMessage(chatID, userID, username, "user", caption, true)
|
||||||
|
userMessage.ImageFileIDs = finalUploaded
|
||||||
|
if err := b.storeMessage(&userMessage); err != nil {
|
||||||
|
b.compensatingDelete(ctx, finalUploaded)
|
||||||
|
ErrorLogger.Printf("[%s] store photo message failed: %v", b.config.ID, err)
|
||||||
|
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I had trouble saving your message.", businessConnectionID); sendErr != nil {
|
||||||
|
ErrorLogger.Printf("Error sending store failure message: %v", sendErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.addMessageToChatMemory(chatMemory, userMessage)
|
||||||
|
|
||||||
|
// Phase 3: stream Anthropic's reply, same shape as the text path.
|
||||||
|
contextMessages := b.prepareContextMessages(chatMemory)
|
||||||
|
joined, err := b.getAnthropicResponse(
|
||||||
|
ctx, chatID, contextMessages, isNewChat, isOwner, false,
|
||||||
|
username, firstName, lastName, isPremium, languageCode, messageTime,
|
||||||
|
func(seg string) error {
|
||||||
|
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ErrorLogger.Printf("Error getting Anthropic response for photo: %v", err)
|
||||||
|
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
|
||||||
|
ErrorLogger.Printf("Error sending anthropic error response: %v", sendErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
|
||||||
|
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
|
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
|
||||||
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
|
// fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they
|
||||||
// always get the generic fallback to avoid leaking internal details.
|
// can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything
|
||||||
|
// else. Regular users always get the generic fallback to avoid leaking internal details.
|
||||||
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
|
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
|
||||||
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
|
isElevated := b.hasScope(userID, ScopeModelSet)
|
||||||
|
|
||||||
|
if errors.Is(err, ErrModelNotFound) && isElevated {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
|
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
|
||||||
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
|
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
|
||||||
b.config.Model,
|
b.config.Model,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isElevated {
|
||||||
|
var apiErr *anthropic.Error
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
body := apiErr.RawJSON()
|
||||||
|
if len(body) > 800 {
|
||||||
|
body = body[:800] + "...(truncated)"
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
|
||||||
|
if apiErr.RequestID != "" {
|
||||||
|
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Non-API errors (network, context cancel, etc.) — show the Go error text.
|
||||||
|
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return "I'm sorry, I'm having trouble processing your request right now."
|
return "I'm sorry, I'm having trouble processing your request right now."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +295,10 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
|||||||
// Check if it's a new chat (before storing the message so the flag is accurate).
|
// Check if it's a new chat (before storing the message so the flag is accurate).
|
||||||
isNewChatFlag := b.isNewChat(chatID)
|
isNewChatFlag := b.isNewChat(chatID)
|
||||||
|
|
||||||
// Screen incoming message (store to DB + add to chat memory)
|
// Determine if the user is the owner — needed up-front so the album buffer
|
||||||
userMsg, err := b.screenIncomingMessage(message)
|
// can capture it alongside other per-turn metadata.
|
||||||
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
|
||||||
err = b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error
|
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
|
||||||
if err == nil {
|
|
||||||
isOwner = true
|
isOwner = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +317,34 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Photo routing bypasses screenIncomingMessage entirely: handlePhotoMessage
|
||||||
|
// owns its own DB-row creation (one row per coalesced user turn, holding
|
||||||
|
// all uploaded file_ids). Album items go through the 1s buffer first; only
|
||||||
|
// the flush dispatches to handlePhotoMessage.
|
||||||
|
if message.MediaGroupID != "" && len(message.Photo) > 0 {
|
||||||
|
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
|
||||||
|
isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(message.Photo) > 0 {
|
||||||
|
if !b.checkRateLimits(userID) {
|
||||||
|
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.handlePhotoMessage(ctx, []*models.Message{message},
|
||||||
|
chatID, userID, username, firstName, lastName,
|
||||||
|
isPremium, languageCode, messageTime,
|
||||||
|
isNewChatFlag, isOwner, businessConnectionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen incoming message (store to DB + add to chat memory) — text/voice/sticker only.
|
||||||
|
userMsg, err := b.screenIncomingMessage(message)
|
||||||
|
if err != nil {
|
||||||
|
ErrorLogger.Printf("Error storing user message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the message is a command — applies on every message, including the very first.
|
// 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 {
|
||||||
@@ -236,6 +487,13 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the message contains a voice note (context is built inside the handler
|
||||||
|
// after the transcript replaces the placeholder, so it must not be built here).
|
||||||
|
if message.Voice != nil {
|
||||||
|
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build context once — shared by the sticker and text response paths.
|
// Build context once — shared by the sticker and text response paths.
|
||||||
chatMemory := b.getOrCreateChatMemory(chatID)
|
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||||
contextMessages := b.prepareContextMessages(chatMemory)
|
contextMessages := b.prepareContextMessages(chatMemory)
|
||||||
@@ -255,17 +513,31 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
|||||||
// Determine if the text contains only emojis
|
// Determine if the text contains only emojis
|
||||||
isEmojiOnly := isOnlyEmojis(text)
|
isEmojiOnly := isOnlyEmojis(text)
|
||||||
|
|
||||||
// Get response from Anthropic
|
// Stream Anthropic's reply, sending each completed text block to Telegram
|
||||||
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime)
|
// as it arrives — gives the conversational rhythm Claude uses around tool
|
||||||
|
// calls (text → pause for tool → text → pause → text), rather than a long
|
||||||
|
// upfront wait followed by all bubbles at once.
|
||||||
|
joined, err := b.getAnthropicResponse(
|
||||||
|
ctx, chatID, contextMessages, isNewChatFlag, isOwner, isEmojiOnly,
|
||||||
|
username, firstName, lastName, isPremium, languageCode, messageTime,
|
||||||
|
func(seg string) error {
|
||||||
|
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
|
||||||
|
},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
|
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
|
||||||
response = b.anthropicErrorResponse(err, userID)
|
// Errors go out as a single message — no need to fan out a one-line error.
|
||||||
|
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
|
||||||
|
ErrorLogger.Printf("Error sending response: %v", sendErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the response
|
// Record the full turn once, at end-of-stream. Same 1-reply-per-prompt
|
||||||
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
|
// invariant as the non-streaming path: one DB row, one answered_on stamp,
|
||||||
ErrorLogger.Printf("Error sending response: %v", err)
|
// one chat-memory entry containing the joined segments.
|
||||||
return
|
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
|
||||||
|
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +547,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) {
|
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) {
|
||||||
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
|
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
|
||||||
|
|
||||||
// Generate AI response about the sticker
|
// Generate AI response about the sticker
|
||||||
@@ -299,12 +571,14 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) {
|
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) {
|
||||||
// contextMessages already contains the sticker turn (added by screenIncomingMessage as
|
// contextMessages already contains the sticker turn (added by screenIncomingMessage as
|
||||||
// "Sent a sticker: <emoji>"), so the full conversation history is preserved.
|
// "Sent a sticker: <emoji>"), so the full conversation history is preserved.
|
||||||
if message.StickerFileID != "" {
|
if message.StickerFileID != "" {
|
||||||
messageTime := int(message.Timestamp.Unix())
|
messageTime := int(message.Timestamp.Unix())
|
||||||
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime)
|
// Sticker reactions are casual chit-chat; tool use is unusual here, so
|
||||||
|
// pass nil for onSegment and return the joined text for a single bubble.
|
||||||
|
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -358,23 +632,27 @@ 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 {
|
||||||
// Permanently delete messages
|
// Hard delete routes through hardDeleteScope, which orchestrates the
|
||||||
|
// soft-delete → Anthropic Files.Delete → Unscoped().Delete dance. Rows
|
||||||
|
// whose Anthropic-side file cleanup fails stay soft-deleted for the
|
||||||
|
// reconciliation job to retry.
|
||||||
if targetUserID == currentUserID {
|
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.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error
|
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID)
|
||||||
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
|
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.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
|
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID)
|
||||||
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
|
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
|
||||||
} else {
|
} else {
|
||||||
// Bot-wide: delete all of the user's own messages across every chat, then delete
|
// Bot-wide: user's own messages across every chat plus assistant
|
||||||
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
|
// responses in their DM chat (where chat_id == user_id by Telegram
|
||||||
err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
|
// convention). The two clauses are collapsed into one OR-WHERE so
|
||||||
if err == nil {
|
// the helper's three-step pattern covers both in a single pass.
|
||||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
|
err = b.hardDeleteScope(ctx,
|
||||||
}
|
"bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))",
|
||||||
|
b.botID, targetUserID, targetUserID, false)
|
||||||
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
|
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-17
@@ -64,22 +64,24 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
userID int64
|
// userID 123 is the configured owner; any other ID is a regular user.
|
||||||
isOwner bool
|
userID int64
|
||||||
wantResp string
|
// wantSubstr must appear in both the Telegram-sent text and the DB-stored
|
||||||
|
// response. Owners (model:set scope) see the raw API error; regular users
|
||||||
|
// get the generic fallback. Substring (not exact) so the test stays robust
|
||||||
|
// against the SDK's evolving error wording for non-API errors.
|
||||||
|
wantSubstr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Owner First Message",
|
name: "Owner First Message",
|
||||||
userID: 123, // owner's ID
|
userID: 123,
|
||||||
isOwner: true,
|
wantSubstr: "Anthropic call failed:",
|
||||||
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Regular User First Message",
|
name: "Regular User First Message",
|
||||||
userID: 456,
|
userID: 456,
|
||||||
isOwner: false,
|
wantSubstr: "I'm sorry, I'm having trouble processing your request right now.",
|
||||||
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
|||||||
// Setup mock response expectations for error case to test fallback messages
|
// Setup mock response expectations for error case to test fallback messages
|
||||||
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
|
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
|
||||||
assert.Equal(t, tc.userID, params.ChatID)
|
assert.Equal(t, tc.userID, params.ChatID)
|
||||||
assert.Equal(t, tc.wantResp, params.Text)
|
assert.Contains(t, params.Text, tc.wantSubstr)
|
||||||
return &models.Message{}, nil
|
return &models.Message{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +114,13 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
|||||||
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
|
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify response was stored
|
// Verify response was stored (most recent assistant message in this chat).
|
||||||
var respMsg Message
|
var respMsg Message
|
||||||
err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error
|
err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false).
|
||||||
|
Order("timestamp DESC").
|
||||||
|
First(&respMsg).Error
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, respMsg.Text, tc.wantSubstr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -720,11 +725,22 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
|
|||||||
wantMissing: "/set_model",
|
wantMissing: "/set_model",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "owner receives generic message for non-model error",
|
// Non-model errors (network, plain errors, API errors other than 404)
|
||||||
|
// surface to anyone with model:set scope so admins/owners can diagnose.
|
||||||
|
name: "owner receives elevated detail for non-API error",
|
||||||
err: otherErr,
|
err: otherErr,
|
||||||
userID: 123,
|
userID: 123,
|
||||||
|
wantSubstr: "Anthropic call failed:",
|
||||||
|
wantMissing: "I'm sorry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Regular users keep getting the generic fallback for any non-model error
|
||||||
|
// to avoid leaking internal details.
|
||||||
|
name: "regular user receives generic message for non-model error",
|
||||||
|
err: otherErr,
|
||||||
|
userID: 789,
|
||||||
wantSubstr: "I'm sorry",
|
wantSubstr: "I'm sorry",
|
||||||
wantMissing: "/set_model",
|
wantMissing: "Anthropic call failed",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ 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 {
|
||||||
@@ -60,6 +68,7 @@ const (
|
|||||||
ScopeHistoryClearHardAny = "history:clear_hard:any"
|
ScopeHistoryClearHardAny = "history:clear_hard:any"
|
||||||
ScopeModelSet = "model:set"
|
ScopeModelSet = "model:set"
|
||||||
ScopeUserPromote = "user:promote"
|
ScopeUserPromote = "user:promote"
|
||||||
|
ScopeTTSUse = "tts:use"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Scope struct {
|
type Scope struct {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
// TelegramClient defines the methods required from the Telegram bot.
|
// TelegramClient defines the methods required from the Telegram bot.
|
||||||
type TelegramClient interface {
|
type TelegramClient interface {
|
||||||
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
||||||
|
SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
|
||||||
SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
||||||
|
GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
|
||||||
|
FileDownloadLink(f *models.File) string
|
||||||
Start(ctx context.Context)
|
Start(ctx context.Context)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-3
@@ -12,9 +12,12 @@ import (
|
|||||||
// MockTelegramClient is a mock implementation of TelegramClient for testing.
|
// MockTelegramClient is a mock implementation of TelegramClient for testing.
|
||||||
type MockTelegramClient struct {
|
type MockTelegramClient struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
||||||
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
SendAudioFunc func(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
|
||||||
StartFunc func(ctx context.Context)
|
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
||||||
|
GetFileFunc func(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
|
||||||
|
FileDownloadLinkFunc func(f *models.File) string
|
||||||
|
StartFunc func(ctx context.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage mocks sending a message.
|
// SendMessage mocks sending a message.
|
||||||
@@ -37,6 +40,30 @@ func (m *MockTelegramClient) SetMyCommands(ctx context.Context, params *bot.SetM
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAudio mocks sending an audio message.
|
||||||
|
func (m *MockTelegramClient) SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error) {
|
||||||
|
if m.SendAudioFunc != nil {
|
||||||
|
return m.SendAudioFunc(ctx, params)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile mocks retrieving file info from Telegram.
|
||||||
|
func (m *MockTelegramClient) GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error) {
|
||||||
|
if m.GetFileFunc != nil {
|
||||||
|
return m.GetFileFunc(ctx, params)
|
||||||
|
}
|
||||||
|
return &models.File{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileDownloadLink mocks building the file download URL.
|
||||||
|
func (m *MockTelegramClient) FileDownloadLink(f *models.File) string {
|
||||||
|
if m.FileDownloadLinkFunc != nil {
|
||||||
|
return m.FileDownloadLinkFunc(f)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Start mocks starting the Telegram client.
|
// Start mocks starting the Telegram client.
|
||||||
func (m *MockTelegramClient) Start(ctx context.Context) {
|
func (m *MockTelegramClient) Start(ctx context.Context) {
|
||||||
if m.StartFunc != nil {
|
if m.StartFunc != nil {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
tgbot "github.com/go-telegram/bot"
|
||||||
|
"github.com/go-telegram/bot/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// largestPhotoSize returns the highest-resolution PhotoSize from the slice
|
||||||
|
// Telegram returns for a single photo. Telegram pre-renders each upload at
|
||||||
|
// several resolutions; we want the largest for vision quality. Falls back to
|
||||||
|
// the zero value when the slice is empty (caller should guard upstream).
|
||||||
|
func largestPhotoSize(photos []models.PhotoSize) models.PhotoSize {
|
||||||
|
if len(photos) == 0 {
|
||||||
|
return models.PhotoSize{}
|
||||||
|
}
|
||||||
|
largest := photos[0]
|
||||||
|
largestArea := largest.Width * largest.Height
|
||||||
|
for i := 1; i < len(photos); i++ {
|
||||||
|
area := photos[i].Width * photos[i].Height
|
||||||
|
if area > largestArea {
|
||||||
|
largest = photos[i]
|
||||||
|
largestArea = area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return largest
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadTelegramFile resolves a Telegram file_id via the bot API, fetches the
|
||||||
|
// download URL, and reads the bytes into memory. The two-step dance (GetFile +
|
||||||
|
// fetch via FileDownloadLink) is required by Telegram's protocol — direct
|
||||||
|
// downloads aren't possible from file_id alone. Buffered into []byte because
|
||||||
|
// downstream callers (multipart uploads to ElevenLabs and Anthropic) re-read
|
||||||
|
// the body; streaming would require either tee-ing or a temp file.
|
||||||
|
func (b *Bot) downloadTelegramFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||||
|
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram GetFile %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram download request %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram download %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("telegram download %s: status %d", fileID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram download read %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-telegram/bot/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLargestPhotoSize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
photos []models.PhotoSize
|
||||||
|
wantFileID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ascending sizes — last is largest",
|
||||||
|
photos: []models.PhotoSize{
|
||||||
|
{FileID: "thumb", Width: 90, Height: 90},
|
||||||
|
{FileID: "small", Width: 320, Height: 320},
|
||||||
|
{FileID: "full", Width: 1280, Height: 720},
|
||||||
|
},
|
||||||
|
wantFileID: "full",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "descending sizes — first is largest",
|
||||||
|
photos: []models.PhotoSize{
|
||||||
|
{FileID: "full", Width: 1280, Height: 720},
|
||||||
|
{FileID: "small", Width: 320, Height: 320},
|
||||||
|
{FileID: "thumb", Width: 90, Height: 90},
|
||||||
|
},
|
||||||
|
wantFileID: "full",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single photo",
|
||||||
|
photos: []models.PhotoSize{
|
||||||
|
{FileID: "solo", Width: 800, Height: 600},
|
||||||
|
},
|
||||||
|
wantFileID: "solo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty slice returns zero value (caller guards upstream)",
|
||||||
|
photos: []models.PhotoSize{},
|
||||||
|
wantFileID: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := largestPhotoSize(tc.photos)
|
||||||
|
assert.Equal(t, tc.wantFileID, got.FileID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user