Compare commits

..

7 Commits

Author SHA1 Message Date
HugeFrog24 44fcd02d9a Style and security 2026-03-05 04:50:36 +01:00
HugeFrog24 d8d0da4704 Upgrade dependencies
Added tests, revised logging

Removed dependency on env file

Try reformatting unit file

Comments clarification

Added readme

Added readme
2024-10-23 22:06:56 +02:00
HugeFrog24 c8af457af1 MVP
md formatting doesnt work yet

Started implementing owner feature

Add .gitattributes to enforce LF line endings

Temporary commit before merge

Updated owner management

Updated json and gitignore

Proceed with role management

Again, CI

Fix some lint errors

Implemented screening

Per-bot API keys implemented

Use getRoleByName func

Fix unused imports

Upgrade actions

rm unused function

Upgrade action

Fix unaddressed errors
2024-10-23 22:06:55 +02:00
HugeFrog24 e5532df7f9 Handle business messages 2024-10-20 15:52:41 +02:00
HugeFrog24 0ab56448c7 Multibot finished 2024-10-13 16:41:03 +02:00
HugeFrog24 9f2b3df4c8 Concern separation 2024-10-13 02:58:18 +02:00
HugeFrog24 41c9b8075b Created user-role system 2024-10-13 01:36:56 +02:00
26 changed files with 214 additions and 1726 deletions
+14
View File
@@ -0,0 +1,14 @@
---
alwaysApply: true
always_on: true
trigger: always_on
applyTo: "**"
description: Snyk Security At Inception
---
# Project security best practices
- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
- Repeat this process until no new issues are found.
+23 -2
View File
@@ -7,18 +7,38 @@ on:
branches: [ main ]
jobs:
# Common setup job that other jobs can depend on
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.0'
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go mod tidy
# Lint job
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.10
args: --timeout 5m
# Test job
test:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -29,9 +49,10 @@ jobs:
# Security scan job
security:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: securego/gosec@v2.26.1
- uses: securego/gosec@master
with:
args: ./...
+3
View File
@@ -0,0 +1,3 @@
{
"mcpServers": {}
}
+1 -2
View File
@@ -4,8 +4,7 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
## Design Considerations
- AI-powered (Anthropic Claude)
- Voice message support (ElevenLabs STT + TTS) — optional, enabled per bot via config
- AI-powered
- Supports multiple bot profiles
- Uses SQLite for persistence
- Implements rate limiting and user management
-122
View File
@@ -1,122 +0,0 @@
package main
import (
"context"
"sort"
"time"
"github.com/go-telegram/bot/models"
)
// albumFlushWindow is the debounce delay before a buffered Telegram media_group
// is flushed as a single coalesced user turn. 1s matches the de-facto community
// standard across the dominant third-party album plugins (aiogram-media-group,
// DieTime/telegraf-media-group) and sits above the sub-100ms values documented
// as lossy under network jitter (openclaw#1811). Telegram has no official
// "album complete" signal, so timeout-based flush is the only option.
const albumFlushWindow = 1 * time.Second
// pendingAlbum holds a Telegram media_group as its items arrive, plus the
// per-user metadata captured from the first item. All items in an album share
// the same chat/user, so we record metadata once and reuse it at flush time.
type pendingAlbum struct {
items []*models.Message
// Metadata captured from the first arriving item. Albums always come from
// the same user/chat, so these are stable across the buffering window.
chatID, userID int64
username, firstName, lastName, languageCode string
isPremium bool
messageTime int
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,
)
}
+38 -195
View File
@@ -4,12 +4,10 @@ import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/packages/param"
"github.com/liushuangls/go-anthropic/v2"
)
// ErrModelNotFound is returned when the configured Anthropic model is no longer available
@@ -17,24 +15,7 @@ import (
// actionable message to admins/owners while keeping the response vague for regular users.
var ErrModelNotFound = errors.New("model not found or deprecated")
// 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) {
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) {
// Use prompts from config
var systemMessage string
if isNewChat {
@@ -106,192 +87,54 @@ func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages [
// Debug logging
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)
}
}
}
params := anthropic.BetaMessageNewParams{
Model: b.config.Model,
MaxTokens: 1000,
// Ensure the roles are correct
for i := range messages {
switch messages[i].Role {
case anthropic.RoleUser:
messages[i].Role = anthropic.RoleUser
case anthropic.RoleAssistant:
messages[i].Role = anthropic.RoleAssistant
default:
// Default to 'user' if role is unrecognized
messages[i].Role = anthropic.RoleUser
}
}
model := anthropic.Model(b.config.Model)
// Create the request
request := anthropic.MessagesRequest{
Model: model, // Now `model` is of type anthropic.Model
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},
System: systemMessage,
MaxTokens: 1000,
}
// Apply temperature if set in config
if b.config.Temperature != nil {
params.Temperature = param.NewOpt(float64(*b.config.Temperature))
request.Temperature = b.config.Temperature
}
// MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta
// requires per-tool filtering on the toolset (Configs + DefaultConfig),
// NOT the deprecated per-server tool_configuration block.
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.
resp, err := b.anthropicClient.CreateMessages(ctx, request)
if err != nil {
var apiErr *anthropic.APIError
if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() {
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
}
ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)",
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 = ""
}
return "", fmt.Errorf("error creating Anthropic message: %w", err)
}
if err := stream.Err(); err != nil {
return "", err
}
if len(allSegments) == 0 {
if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText {
return "", fmt.Errorf("unexpected response format from Anthropic")
}
return strings.Join(allSegments, "\n\n"), nil
return resp.Content[0].GetText(), nil
}
-242
View File
@@ -1,242 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/anthropics/anthropic-sdk-go"
)
// fileNotFoundPrefix is the exact prefix Anthropic uses in its 404 error body
// when a referenced file_id no longer exists. Used by extractMissingFileID to
// identify the offender for the runtime self-heal path.
const fileNotFoundPrefix = "File not found: "
// formatUploadFilename returns the canonical filename used when uploading a
// Telegram photo to the Anthropic Files API. The "tg-" prefix tags the file as
// bot-owned so a future reconciliation job can distinguish our uploads from
// foreign files in the same workspace. The triple (botID, chatID, tgMessageID)
// is unique within Telegram's scope — each photo in an album arrives as a
// distinct Telegram message with its own message_id, so collisions across
// album items are impossible.
func formatUploadFilename(botID uint, chatID int64, tgMessageID int, ext string) string {
return fmt.Sprintf("tg-%d-%d-%d.%s", botID, chatID, tgMessageID, ext)
}
// uploadImageToAnthropic uploads raw image bytes to the Anthropic Files API and
// returns the resulting file_id. The filename should follow the formatUploadFilename
// convention so the reconciliation job can identify the file as bot-owned.
func (b *Bot) uploadImageToAnthropic(ctx context.Context, data []byte, filename, contentType string) (string, error) {
resp, err := b.anthropicClient.Beta.Files.Upload(ctx, anthropic.BetaFileUploadParams{
File: anthropic.File(bytes.NewReader(data), filename, contentType),
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
})
if err != nil {
return "", fmt.Errorf("anthropic files upload: %w", err)
}
return resp.ID, nil
}
// deleteFileFromAnthropic removes a file from the Anthropic Files API. A 404
// is treated as success — the file is already gone, which is the same effective
// outcome the caller wants. This makes the deletion idempotent and safe for the
// reconciliation job's retries.
func (b *Bot) deleteFileFromAnthropic(ctx context.Context, fileID string) error {
_, err := b.anthropicClient.Beta.Files.Delete(ctx, fileID, anthropic.BetaFileDeleteParams{
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
})
if err == nil {
return nil
}
var apiErr *anthropic.Error
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("anthropic files delete %s: %w", fileID, err)
}
// compensatingDelete fires Delete calls for a set of file_ids that were uploaded
// successfully but couldn't be committed downstream. Errors are logged rather
// than returned — the caller has already entered an error path, and orphans on
// Anthropic are harmless (storage is free until the 500 GB workspace cap and the
// reconciliation job will mop them up).
func (b *Bot) compensatingDelete(ctx context.Context, fileIDs []string) {
for _, fid := range fileIDs {
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
ErrorLogger.Printf("[%s] compensating delete for %s: %v", b.config.ID, fid, err)
}
}
}
// extractMissingFileID inspects an Anthropic API error and returns the file_id
// that triggered a "File not found:" 404, if any. Returns empty string if the
// error is not a file-not-found error. Used by the runtime self-heal path to
// identify which file_id to strip from replay.
func extractMissingFileID(err error) string {
if err == nil {
return ""
}
var apiErr *anthropic.Error
if !errors.As(err, &apiErr) {
return ""
}
if apiErr.StatusCode != http.StatusNotFound {
return ""
}
return parseMissingFileIDFromBody(apiErr.RawJSON())
}
// parseMissingFileIDFromBody pulls a file_id out of a raw "File not found:"
// 404 body. Split out from extractMissingFileID so the string-parsing logic
// is unit-testable without having to synthesize an *anthropic.Error (whose
// JSON.raw field is private to the SDK).
func parseMissingFileIDFromBody(raw string) string {
idx := strings.Index(raw, fileNotFoundPrefix)
if idx == -1 {
return ""
}
rest := raw[idx+len(fileNotFoundPrefix):]
// File IDs are file_<base62>; the message embeds them with no surrounding
// quotes, so the id ends at the first character outside the alphanumeric +
// underscore set.
end := strings.IndexFunc(rest, func(r rune) bool {
return (r < 'a' || r > 'z') &&
(r < 'A' || r > 'Z') &&
(r < '0' || r > '9') &&
r != '_'
})
if end == -1 {
return rest
}
return rest[:end]
}
// hardDeleteScope performs the three-step hard-delete pattern on every Message
// row matching the given WHERE clause:
//
// 1. Soft-delete the rows (GORM Delete) — they become invisible to replay
// immediately, regardless of how the Anthropic-side cleanup unfolds.
// 2. For each row, call Anthropic Files.Delete on its ImageFileIDs. 404 is
// treated as success (already gone).
// 3. Rows whose file cleanup succeeded are Unscoped().Delete'd. Rows whose
// file cleanup failed remain soft-deleted with FilesCleanedAt NULL — the
// reconciliation job will retry them.
//
// This gives hard-delete eventually-consistent semantics across the DB and
// Anthropic, while still presenting the user with an instant "history cleared"
// outcome (the soft-delete in step 1 hides the rows from any further reads).
func (b *Bot) hardDeleteScope(ctx context.Context, query string, args ...interface{}) error {
// Unscoped on the scan: include already-soft-deleted rows so a hard-delete
// after a prior soft-delete still removes them completely. Matches the
// existing "erase and bust all caches" semantics for /clear_hard.
var rows []Message
if err := b.db.Unscoped().Where(query, args...).Find(&rows).Error; err != nil {
return fmt.Errorf("scan rows: %w", err)
}
if len(rows) == 0 {
return nil
}
// Soft-delete any rows that aren't already soft-deleted (graceful degradation:
// if Anthropic-side file cleanup fails, the row stays invisible to replay).
// Already-soft-deleted rows are unaffected by Delete without Unscoped.
if err := b.db.Where(query, args...).Delete(&Message{}).Error; err != nil {
return fmt.Errorf("soft delete: %w", err)
}
hardDeletable := make([]uint, 0, len(rows))
for _, row := range rows {
if b.deleteRowFiles(ctx, row) {
hardDeletable = append(hardDeletable, row.ID)
}
}
if len(hardDeletable) == 0 {
return nil
}
if err := b.db.Unscoped().Where("id IN ?", hardDeletable).Delete(&Message{}).Error; err != nil {
return fmt.Errorf("hard delete: %w", err)
}
return nil
}
// deleteRowFiles tries to delete every file_id referenced by row from the
// Anthropic Files API. Returns true iff all deletes succeeded (or the row had
// no images), making the row eligible for hard-delete. False means at least
// one delete failed and the row should stay soft-deleted for retry.
func (b *Bot) deleteRowFiles(ctx context.Context, row Message) bool {
if len(row.ImageFileIDs) == 0 {
return true
}
allOk := true
for _, fid := range row.ImageFileIDs {
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
ErrorLogger.Printf("[%s] anthropic delete %s (row %d): %v", b.config.ID, fid, row.ID, err)
allOk = false
}
}
return allOk
}
// stripDeadFileIDs returns the subset of src whose ids are NOT in deadSet, and
// reports whether any were removed. Empty/nil src yields (empty, false).
func stripDeadFileIDs(src []string, deadSet map[string]struct{}) (survivors []string, dirty bool) {
survivors = make([]string, 0, len(src))
for _, fid := range src {
if _, dead := deadSet[fid]; dead {
dirty = true
continue
}
survivors = append(survivors, fid)
}
return survivors, dirty
}
// markFilesPendingCleanup removes a set of dead file_ids from any stored Message
// rows that reference them, and stamps FilesCleanedAt on the affected rows so
// the reconciliation job can see they've been touched. Called by the runtime
// self-heal path after a "File not found:" 404 surfaces during message-create.
// Returns the number of rows updated.
func (b *Bot) markFilesPendingCleanup(ctx context.Context, chatID int64, deadFileIDs []string) (int, error) {
if len(deadFileIDs) == 0 {
return 0, nil
}
deadSet := make(map[string]struct{}, len(deadFileIDs))
for _, id := range deadFileIDs {
deadSet[id] = struct{}{}
}
var rows []Message
if err := b.db.WithContext(ctx).
Where("bot_id = ? AND chat_id = ? AND image_file_ids IS NOT NULL", b.botID, chatID).
Find(&rows).Error; err != nil {
return 0, fmt.Errorf("scan rows for cleanup: %w", err)
}
now := time.Now()
updated := 0
for _, row := range rows {
survivors, dirty := stripDeadFileIDs(row.ImageFileIDs, deadSet)
if !dirty {
continue
}
if len(survivors) == 0 {
// All files in this row are gone; mark fully cleaned so a future
// reconciliation job's `WHERE files_cleaned_at IS NULL` filter
// correctly excludes it from retries.
row.ImageFileIDs = nil
row.FilesCleanedAt = &now
} else {
// Surviving file_ids are still alive on Anthropic. Leave
// FilesCleanedAt NULL so a later death of one of them remains
// visible to the reconciliation job's filter.
row.ImageFileIDs = survivors
}
if err := b.db.WithContext(ctx).Save(&row).Error; err != nil {
return updated, fmt.Errorf("update row %d: %w", row.ID, err)
}
updated++
}
return updated, nil
}
-203
View File
@@ -1,203 +0,0 @@
package main
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFormatUploadFilename(t *testing.T) {
cases := []struct {
botID uint
chatID int64
tgMessageID int
ext string
want string
}{
{1, 12345, 42, "jpg", "tg-1-12345-42.jpg"},
// Negative chat IDs are how Telegram represents groups/channels —
// %d preserves the leading minus, no special handling needed.
{7, -1001234567890, 1, "png", "tg-7--1001234567890-1.png"},
{0, 0, 0, "webp", "tg-0-0-0.webp"},
}
for _, tc := range cases {
got := formatUploadFilename(tc.botID, tc.chatID, tc.tgMessageID, tc.ext)
assert.Equal(t, tc.want, got)
}
}
func TestParseMissingFileIDFromBody(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{
name: "canonical Anthropic file-not-found body",
body: `{"type":"error","error":{"type":"invalid_request_error","message":"File not found: file_011CNha8iCJcU1wXNR6q4V8w"}}`,
want: "file_011CNha8iCJcU1wXNR6q4V8w",
},
{
name: "trailing punctuation after the id is excluded",
body: `something File not found: file_abc123! more text`,
want: "file_abc123",
},
{
name: "body without the prefix yields empty",
body: `{"type":"error","error":{"message":"Model not found: claude-foo"}}`,
want: "",
},
{
name: "id at the very end of the buffer",
body: `File not found: file_xyz789`,
want: "file_xyz789",
},
{
name: "empty body",
body: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, parseMissingFileIDFromBody(tc.body))
})
}
}
func TestStripDeadFileIDs(t *testing.T) {
dead := map[string]struct{}{
"file_a": {},
"file_b": {},
}
cases := []struct {
name string
input []string
wantSurvivors []string
wantDirty bool
}{
{
name: "no overlap returns input verbatim",
input: []string{"file_x", "file_y"},
wantSurvivors: []string{"file_x", "file_y"},
wantDirty: false,
},
{
name: "partial overlap returns survivors and reports dirty",
input: []string{"file_a", "file_x", "file_b", "file_y"},
wantSurvivors: []string{"file_x", "file_y"},
wantDirty: true,
},
{
name: "all dead returns empty survivors and dirty",
input: []string{"file_a", "file_b"},
wantSurvivors: []string{},
wantDirty: true,
},
{
name: "empty input is not dirty",
input: []string{},
wantSurvivors: []string{},
wantDirty: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
survivors, dirty := stripDeadFileIDs(tc.input, dead)
assert.Equal(t, tc.wantSurvivors, survivors)
assert.Equal(t, tc.wantDirty, dirty)
})
}
}
func TestMarkFilesPendingCleanup(t *testing.T) {
b, _ := setupBotForTest(t, 123)
chatID := int64(555)
// Row 1: has dead file_a + alive file_x → should be updated with survivors.
row1 := Message{
BotID: b.botID,
ChatID: chatID,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "look at these",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_a", "file_x"},
}
assert.NoError(t, b.db.Create(&row1).Error)
// Row 2: only dead files → ImageFileIDs should become nil.
row2 := Message{
BotID: b.botID,
ChatID: chatID,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "screenshot",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_a", "file_b"},
}
assert.NoError(t, b.db.Create(&row2).Error)
// Row 3: no dead files → should be untouched.
row3 := Message{
BotID: b.botID,
ChatID: chatID,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "another",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_x", "file_y"},
}
assert.NoError(t, b.db.Create(&row3).Error)
// Row 4: different chat → must NOT be touched even if it references a dead file.
row4 := Message{
BotID: b.botID,
ChatID: 999,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "other chat",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_a"},
}
assert.NoError(t, b.db.Create(&row4).Error)
updated, err := b.markFilesPendingCleanup(t.Context(), chatID, []string{"file_a", "file_b"})
assert.NoError(t, err)
assert.Equal(t, 2, updated, "rows 1 and 2 should have been updated")
// Row 1: only file_x should remain; FilesCleanedAt MUST stay nil because
// file_x is still alive on Anthropic and a future death of it must remain
// visible to the reconciliation job's `WHERE files_cleaned_at IS NULL` filter.
var r1 Message
assert.NoError(t, b.db.First(&r1, row1.ID).Error)
assert.Equal(t, []string{"file_x"}, r1.ImageFileIDs)
assert.Nil(t, r1.FilesCleanedAt)
// Row 2: all gone → ImageFileIDs nil/empty; FilesCleanedAt set.
var r2 Message
assert.NoError(t, b.db.First(&r2, row2.ID).Error)
assert.Empty(t, r2.ImageFileIDs)
assert.NotNil(t, r2.FilesCleanedAt)
// Row 3: untouched.
var r3 Message
assert.NoError(t, b.db.First(&r3, row3.ID).Error)
assert.Equal(t, []string{"file_x", "file_y"}, r3.ImageFileIDs)
assert.Nil(t, r3.FilesCleanedAt)
// Row 4: untouched despite referencing a dead file — scope is per-chat.
var r4 Message
assert.NoError(t, b.db.First(&r4, row4.ID).Error)
assert.Equal(t, []string{"file_a"}, r4.ImageFileIDs)
assert.Nil(t, r4.FilesCleanedAt)
}
+25 -132
View File
@@ -8,17 +8,16 @@ import (
"sync"
"time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
"gorm.io/gorm"
)
type Bot struct {
tgBot TelegramClient
db *gorm.DB
anthropicClient anthropic.Client
anthropicClient *anthropic.Client
chatMemories map[int64]*ChatMemory
memorySize int
chatMemoriesMu sync.RWMutex
@@ -27,11 +26,6 @@ type Bot struct {
userLimitersMu sync.RWMutex
clock Clock
botID uint // Reference to BotModel.ID
// albumBuffers holds Telegram media_group items as they arrive, keyed by
// MediaGroupID. Each pending album has a 1s flush timer (see album_buffer.go)
// that triggers a single coalesced photo turn once arrivals stop.
albumBuffers map[string]*pendingAlbum
albumBuffersMu sync.Mutex
}
// Helper function to determine message type
@@ -87,7 +81,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
}
// Use the per-bot Anthropic API key
anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey))
anthropicClient := anthropic.NewClient(config.AnthropicAPIKey)
b := &Bot{
db: db,
@@ -99,7 +93,6 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
clock: clock,
botID: botEntry.ID, // Ensure BotModel has ID field
tgBot: tgClient,
albumBuffers: make(map[string]*pendingAlbum),
}
if tgClient == nil {
@@ -257,32 +250,6 @@ func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
return chatMemory
}
// stripDeadFileIDFromMemory removes a single file_id from every message in the
// chat's in-memory ChatMemory. Called by the runtime self-heal in
// getAnthropicResponse after Anthropic 404s for that file_id, so the immediate
// retry (and any subsequent turn replay) won't reference it. The corresponding
// DB rows are stamped separately via markFilesPendingCleanup.
func (b *Bot) stripDeadFileIDFromMemory(chatID int64, deadFileID string) {
b.chatMemoriesMu.Lock()
defer b.chatMemoriesMu.Unlock()
cm, exists := b.chatMemories[chatID]
if !exists {
return
}
for i := range cm.Messages {
if len(cm.Messages[i].ImageFileIDs) == 0 {
continue
}
survivors := make([]string, 0, len(cm.Messages[i].ImageFileIDs))
for _, fid := range cm.Messages[i].ImageFileIDs {
if fid != deadFileID {
survivors = append(survivors, fid)
}
}
cm.Messages[i].ImageFileIDs = survivors
}
}
// addMessageToChatMemory adds a new message to the chat memory, ensuring the memory size is maintained.
func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
b.chatMemoriesMu.Lock()
@@ -297,14 +264,14 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
}
}
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message {
b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock()
// Debug logging
InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages))
for i, msg := range chatMemory.Messages {
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs))
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q", i, msg.IsUser, msg.Text)
}
// Note: consecutive messages with the same role are permitted.
@@ -312,49 +279,29 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
// returning an error. This can happen after a /clear (which only deletes user
// messages, leaving assistant messages in the DB) followed by a restart.
// See: https://platform.claude.com/docs/en/api/messages
var contextMessages []anthropic.BetaMessageParam
var contextMessages []anthropic.Message
for _, msg := range chatMemory.Messages {
blocks := contentBlocksForMessage(msg)
if len(blocks) == 0 {
// Skip turns that carry neither text nor images.
role := anthropic.RoleUser
if !msg.IsUser {
role = anthropic.RoleAssistant
}
textContent := strings.TrimSpace(msg.Text)
if textContent == "" {
// Skip empty messages
continue
}
var param anthropic.BetaMessageParam
if msg.IsUser {
param = anthropic.NewBetaUserMessage(blocks...)
} else {
param = anthropic.BetaMessageParam{
Role: anthropic.BetaMessageParamRoleAssistant,
Content: blocks,
}
}
contextMessages = append(contextMessages, param)
contextMessages = append(contextMessages, anthropic.Message{
Role: role,
Content: []anthropic.MessageContent{
anthropic.NewTextMessageContent(textContent),
},
})
}
return contextMessages
}
// contentBlocksForMessage assembles the Anthropic content blocks representing
// one stored Message. Image blocks are emitted before the text block (Anthropic
// docs: "Claude works best when images come before text"). Multi-image user
// turns prepend each image with an "Image N:" label, as the docs explicitly
// recommend for multi-image prompts. Assistant turns carry text only.
func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion {
var blocks []anthropic.BetaContentBlockParamUnion
if msg.IsUser && len(msg.ImageFileIDs) > 0 {
multi := len(msg.ImageFileIDs) > 1
for i, fileID := range msg.ImageFileIDs {
if multi {
blocks = append(blocks, anthropic.NewBetaTextBlock(fmt.Sprintf("Image %d:", i+1)))
}
blocks = append(blocks, anthropic.NewBetaImageBlock(anthropic.BetaFileImageSourceParam{FileID: fileID}))
}
}
if textContent := strings.TrimSpace(msg.Text); textContent != "" {
blocks = append(blocks, anthropic.NewBetaTextBlock(textContent))
}
return blocks
}
func (b *Bot) isNewChat(chatID int64) bool {
var count int64
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
@@ -408,7 +355,7 @@ func (b *Bot) registerAdminCommandsForUser(ctx context.Context, telegramID int64
allCommands = append(allCommands, adminBotCommands...)
_, err := b.tgBot.SetMyCommands(ctx, &bot.SetMyCommandsParams{
Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: telegramID},
Scope: &models.BotCommandScopeChatMember{ChatID: telegramID, UserID: telegramID},
})
if err != nil {
ErrorLogger.Printf("Failed to register admin commands for user %d: %v", telegramID, err)
@@ -431,7 +378,7 @@ func setElevatedCommands(tgBot TelegramClient, users []User) {
}
_, err := tgBot.SetMyCommands(context.Background(), &bot.SetMyCommandsParams{
Commands: allCommands,
Scope: &models.BotCommandScopeChat{ChatID: u.TelegramID},
Scope: &models.BotCommandScopeChatMember{ChatID: u.TelegramID, UserID: u.TelegramID},
})
if err != nil {
ErrorLogger.Printf("Warning: could not set admin commands for user %d: %v", u.TelegramID, err)
@@ -501,27 +448,6 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
return nil
}
// sendOneSegment delivers a single Telegram message without touching storage
// or chat memory. Used by the streaming response path: each completed text
// block fires this helper as it arrives, and the full turn is recorded once
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
// storage invariant while letting the user see segments with natural rhythm.
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
b.config.ID, chatID, businessConnectionID, err)
return err
}
return nil
}
// sendStats sends the bot statistics to the specified chat.
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
// If targetUserID is 0, show global stats
@@ -544,36 +470,6 @@ func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetU
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
if err := b.sendResponse(ctx, chatID, statsMessage, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending stats message: %v", err)
@@ -738,7 +634,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
}()
}
userRole := "user"
userRole := string(anthropic.RoleUser)
// Determine message text based on message type
messageText := message.Text
@@ -749,9 +645,6 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
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)
@@ -795,7 +688,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
}
// Create and store the assistant message
assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false)
assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false)
if err := b.storeMessage(&assistantMessage); err != nil {
return Message{}, err
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestContentBlocksForMessage(t *testing.T) {
t.Run("empty message yields no blocks", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{IsUser: true})
assert.Empty(t, blocks)
})
t.Run("user text only yields one text block", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{IsUser: true, Text: "hello"})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfText)
assert.Equal(t, "hello", blocks[0].OfText.Text)
})
t.Run("user single image without caption — no label, no text", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
ImageFileIDs: []string{"file_solo"},
})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfImage)
assert.NotNil(t, blocks[0].OfImage.Source.OfFile)
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
})
t.Run("user single image with caption — image before text", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
Text: "is this right?",
ImageFileIDs: []string{"file_solo"},
})
assert.Len(t, blocks, 2)
assert.NotNil(t, blocks[0].OfImage, "image block must come before text per Anthropic guidance")
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
assert.NotNil(t, blocks[1].OfText)
assert.Equal(t, "is this right?", blocks[1].OfText.Text)
})
t.Run("user album (multi-image) labels each with Image N:", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
Text: "compare these",
ImageFileIDs: []string{"file_a", "file_b", "file_c"},
})
// Expected layout: text "Image 1:", image a, text "Image 2:", image b, text "Image 3:", image c, text "compare these"
assert.Len(t, blocks, 7)
assert.Equal(t, "Image 1:", blocks[0].OfText.Text)
assert.Equal(t, "file_a", blocks[1].OfImage.Source.OfFile.FileID)
assert.Equal(t, "Image 2:", blocks[2].OfText.Text)
assert.Equal(t, "file_b", blocks[3].OfImage.Source.OfFile.FileID)
assert.Equal(t, "Image 3:", blocks[4].OfText.Text)
assert.Equal(t, "file_c", blocks[5].OfImage.Source.OfFile.FileID)
assert.Equal(t, "compare these", blocks[6].OfText.Text)
})
t.Run("assistant message with images-set is text-only (defensive)", func(t *testing.T) {
// Assistant turns shouldn't carry images, but if they ever do we treat
// them as text-only — the model returns text, not images.
blocks := contentBlocksForMessage(Message{
IsUser: false,
Text: "I see your screenshot",
ImageFileIDs: []string{"file_should_be_ignored"},
})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfText)
assert.Equal(t, "I see your screenshot", blocks[0].OfText.Text)
})
t.Run("whitespace-only text is skipped but images survive", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
Text: " \n ",
ImageFileIDs: []string{"file_x"},
})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfImage)
})
}
func TestStripDeadFileIDFromMemory(t *testing.T) {
b, _ := setupBotForTest(t, 100)
chatID := int64(42)
// Seed in-memory chat memory with three messages.
cm := b.getOrCreateChatMemory(chatID)
cm.Messages = []Message{
{IsUser: true, Text: "first", ImageFileIDs: []string{"file_a", "file_b"}},
{IsUser: false, Text: "reply"},
{IsUser: true, Text: "third", ImageFileIDs: []string{"file_b", "file_c"}},
}
b.stripDeadFileIDFromMemory(chatID, "file_b")
assert.Equal(t, []string{"file_a"}, cm.Messages[0].ImageFileIDs, "file_b should be removed from message 1")
assert.Empty(t, cm.Messages[1].ImageFileIDs, "assistant message untouched")
assert.Equal(t, []string{"file_c"}, cm.Messages[2].ImageFileIDs, "file_b should be removed from message 3")
}
func TestStripDeadFileIDFromMemory_UnknownChatIsNoop(t *testing.T) {
b, _ := setupBotForTest(t, 100)
// Calling on a chat that was never opened should not panic and should be a no-op.
b.stripDeadFileIDFromMemory(99999, "file_anything")
// Nothing to assert beyond not crashing.
}
+33 -28
View File
@@ -6,37 +6,41 @@ import (
"os"
"path/filepath"
"strings"
"github.com/liushuangls/go-anthropic/v2"
)
// MCPServer configures a remote Model Context Protocol server that the Anthropic
// API will connect to on behalf of this bot. AllowedTools, when non-empty, limits
// which server-exposed tools the model may invoke.
type MCPServer struct {
Name string `json:"name"`
URL string `json:"url"`
AuthorizationToken string `json:"authorization_token,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
type BotConfig struct {
ID string `json:"id"`
TelegramToken string `json:"telegram_token"`
MemorySize int `json:"memory_size"`
MessagePerHour int `json:"messages_per_hour"`
MessagePerDay int `json:"messages_per_day"`
TempBanDuration string `json:"temp_ban_duration"`
Model anthropic.Model `json:"model"`
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
}
type BotConfig struct {
ID string `json:"id"`
TelegramToken string `json:"telegram_token"`
MemorySize int `json:"memory_size"`
MessagePerHour int `json:"messages_per_hour"`
MessagePerDay int `json:"messages_per_day"`
TempBanDuration string `json:"temp_ban_duration"`
Model string `json:"model"`
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"`
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
// Custom unmarshalling to handle anthropic.Model
func (c *BotConfig) UnmarshalJSON(data []byte) error {
type Alias BotConfig
aux := &struct {
Model string `json:"model"`
*Alias
}{
Alias: (*Alias)(c),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
c.Model = anthropic.Model(aux.Model)
return nil
}
// validateConfigPath ensures the file path is within the allowed directory
@@ -195,6 +199,7 @@ func (c *BotConfig) Reload(configDir, filename string) error {
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
}
c.Model = anthropic.Model(c.Model)
return nil
}
@@ -226,6 +231,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
return fmt.Errorf("failed to write config: %w", err)
}
c.Model = newModel
c.Model = anthropic.Model(newModel)
return nil
}
+2 -5
View File
@@ -4,19 +4,16 @@
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
"owner_telegram_id": 111111111,
"anthropic_api_key": "YOUR_SPECIFIC_ANTHROPIC_API_KEY",
"elevenlabs_api_key": "",
"elevenlabs_voice_id": "",
"elevenlabs_model": "",
"memory_size": 10,
"messages_per_hour": 20,
"messages_per_day": 100,
"temp_ban_duration": "24h",
"model": "claude-haiku-4-5",
"model": "claude-3-5-haiku-latest",
"temperature": 0.7,
"debug_screening": false,
"system_prompts": {
"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}'. 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.",
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/liushuangls/go-anthropic/v2"
)
// Set up loggers
@@ -36,7 +38,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
expectedModel := "claude-v1"
expectedModel := anthropic.Model("claude-v1")
if config.Model != expectedModel {
t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
}
+2 -2
View File
@@ -71,7 +71,7 @@ func createDefaultScopes(db *gorm.DB) error {
ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
ScopeModelSet, ScopeUserPromote,
}
for _, name := range all {
if err := db.FirstOrCreate(&Scope{}, Scope{Name: name}).Error; err != nil {
@@ -88,7 +88,7 @@ func createDefaultScopes(db *gorm.DB) error {
ScopeStatsViewOwn, ScopeStatsViewAny,
ScopeHistoryClearOwn, ScopeHistoryClearAny,
ScopeHistoryClearHardOwn, ScopeHistoryClearHardAny,
ScopeModelSet, ScopeUserPromote, ScopeTTSUse,
ScopeModelSet, ScopeUserPromote,
}
assignments := map[string][]string{
"user": userScopes,
-107
View File
@@ -1,107 +0,0 @@
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.
+5 -19
View File
@@ -3,35 +3,21 @@ module github.com/HugeFrog24/go-telegram-bot
go 1.26.0
require (
github.com/anthropics/anthropic-sdk-go v1.45.0
github.com/go-telegram/bot v1.20.0
github.com/go-telegram/bot v1.19.0
github.com/liushuangls/go-anthropic/v2 v2.17.1
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
golang.org/x/time v0.15.0
golang.org/x/time v0.14.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
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/invopop/jsonschema v0.13.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tidwall/gjson v1.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
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+11 -53
View File
@@ -1,69 +1,27 @@
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/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/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg=
github.com/go-telegram/bot v1.19.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/liushuangls/go-anthropic/v2 v2.17.1 h1:ca3oFzgQHs9/mJr+xx2XFQIYcQLM2rDCqieUx0g+8p4=
github.com/liushuangls/go-anthropic/v2 v2.17.1/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
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=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+33 -311
View File
@@ -7,252 +7,22 @@ import (
"strconv"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"golang.org/x/sync/errgroup"
"github.com/liushuangls/go-anthropic/v2"
)
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, 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
// fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they
// can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything
// else. Regular users always get the generic fallback to avoid leaking internal details.
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
// always get the generic fallback to avoid leaking internal details.
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
isElevated := b.hasScope(userID, ScopeModelSet)
if errors.Is(err, ErrModelNotFound) && isElevated {
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
return fmt.Sprintf(
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
b.config.Model,
)
}
if isElevated {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
body := apiErr.RawJSON()
if len(body) > 800 {
body = body[:800] + "...(truncated)"
}
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
if apiErr.RequestID != "" {
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
}
return out
}
// Non-API errors (network, context cancel, etc.) — show the Go error text.
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
}
return "I'm sorry, I'm having trouble processing your request right now."
}
@@ -295,10 +65,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Check if it's a new chat (before storing the message so the flag is accurate).
isNewChatFlag := b.isNewChat(chatID)
// Determine if the user is the owner — needed up-front so the album buffer
// can capture it alongside other per-turn metadata.
// Screen incoming message (store to DB + add to chat memory)
userMsg, err := b.screenIncomingMessage(message)
if err != nil {
ErrorLogger.Printf("Error storing user message: %v", err)
return
}
// Determine if the user is the owner
var isOwner bool
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
err = b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error
if err == nil {
isOwner = true
}
@@ -317,34 +94,6 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
}
}
// Photo routing bypasses screenIncomingMessage entirely: handlePhotoMessage
// owns its own DB-row creation (one row per coalesced user turn, holding
// all uploaded file_ids). Album items go through the 1s buffer first; only
// the flush dispatches to handlePhotoMessage.
if message.MediaGroupID != "" && len(message.Photo) > 0 {
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
return
}
if len(message.Photo) > 0 {
if !b.checkRateLimits(userID) {
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
return
}
b.handlePhotoMessage(ctx, []*models.Message{message},
chatID, userID, username, firstName, lastName,
isPremium, languageCode, messageTime,
isNewChatFlag, isOwner, businessConnectionID)
return
}
// Screen incoming message (store to DB + add to chat memory) — text/voice/sticker only.
userMsg, err := b.screenIncomingMessage(message)
if err != nil {
ErrorLogger.Printf("Error storing user message: %v", err)
return
}
// Check if the message is a command — applies on every message, including the very first.
if message.Entities != nil {
for _, entity := range message.Entities {
@@ -487,13 +236,6 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
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.
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
@@ -513,31 +255,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the text contains only emojis
isEmojiOnly := isOnlyEmojis(text)
// Stream Anthropic's reply, sending each completed text block to Telegram
// as it arrives — gives the conversational rhythm Claude uses around tool
// calls (text → pause for tool → text → pause → text), rather than a long
// upfront wait followed by all bubbles at once.
joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isNewChatFlag, isOwner, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
// Get response from Anthropic
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
// 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
response = b.anthropicErrorResponse(err, userID)
}
// Record the full turn once, at end-of-stream. Same 1-reply-per-prompt
// invariant as the non-streaming path: one DB row, one answered_on stamp,
// one chat-memory entry containing the joined segments.
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
// Send the response
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
ErrorLogger.Printf("Error sending response: %v", err)
return
}
}
@@ -547,7 +275,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
}
}
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) {
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) {
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
// Generate AI response about the sticker
@@ -571,14 +299,12 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
}
}
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) {
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) {
// contextMessages already contains the sticker turn (added by screenIncomingMessage as
// "Sent a sticker: <emoji>"), so the full conversation history is preserved.
if message.StickerFileID != "" {
messageTime := int(message.Timestamp.Unix())
// 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)
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime)
if err != nil {
return "", err
}
@@ -632,27 +358,23 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
// useful for group moderation ("/clear <userID> <chatID>").
var err error
if hardDelete {
// 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.
// Permanently delete messages
if targetUserID == currentUserID {
// Own history — delete ALL messages (user + assistant) in the current chat.
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID)
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
} else {
if targetChatID != 0 {
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat.
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID)
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
} else {
// Bot-wide: user's own messages across every chat plus assistant
// responses in their DM chat (where chat_id == user_id by Telegram
// convention). The two clauses are collapsed into one OR-WHERE so
// the helper's three-step pattern covers both in a single pass.
err = b.hardDeleteScope(ctx,
"bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))",
b.botID, targetUserID, targetUserID, false)
// Bot-wide: delete all of the user's own messages across every chat, then delete
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
if err == nil {
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
}
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
}
}
+17 -33
View File
@@ -64,24 +64,22 @@ func TestHandleUpdate_NewChat(t *testing.T) {
assert.NoError(t, err)
testCases := []struct {
name string
// userID 123 is the configured owner; any other ID is a regular user.
userID int64
// 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 string
userID int64
isOwner bool
wantResp string
}{
{
name: "Owner First Message",
userID: 123,
wantSubstr: "Anthropic call failed:",
name: "Owner First Message",
userID: 123, // owner's ID
isOwner: true,
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
},
{
name: "Regular User First Message",
userID: 456,
wantSubstr: "I'm sorry, I'm having trouble processing your request right now.",
name: "Regular User First Message",
userID: 456,
isOwner: false,
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
},
}
@@ -90,7 +88,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
// Setup mock response expectations for error case to test fallback messages
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
assert.Equal(t, tc.userID, params.ChatID)
assert.Contains(t, params.Text, tc.wantSubstr)
assert.Equal(t, tc.wantResp, params.Text)
return &models.Message{}, nil
}
@@ -114,13 +112,10 @@ func TestHandleUpdate_NewChat(t *testing.T) {
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
assert.NoError(t, err)
// Verify response was stored (most recent assistant message in this chat).
// Verify response was stored
var respMsg Message
err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false).
Order("timestamp DESC").
First(&respMsg).Error
err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error
assert.NoError(t, err)
assert.Contains(t, respMsg.Text, tc.wantSubstr)
})
}
}
@@ -725,22 +720,11 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
wantMissing: "/set_model",
},
{
// 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",
name: "owner receives generic message for non-model error",
err: otherErr,
userID: 123,
wantSubstr: "Anthropic call failed:",
wantMissing: "I'm sorry",
},
{
// Regular users keep getting the generic fallback for any non-model error
// to avoid leaking internal details.
name: "regular user receives generic message for non-model error",
err: otherErr,
userID: 789,
wantSubstr: "I'm sorry",
wantMissing: "Anthropic call failed",
wantMissing: "/set_model",
},
}
-9
View File
@@ -42,14 +42,6 @@ type Message struct {
StickerEmoji string // Store the emoji associated with the sticker
DeletedAt gorm.DeletedAt `gorm:"index"` // Add soft delete field
AnsweredOn *time.Time `gorm:"index"` // Tracks when a user message was answered (NULL for assistant messages and unanswered user messages)
// ImageFileIDs holds Anthropic Files API file_ids for photos attached to this turn.
// Plural for albums (Telegram media_group), single-element for one photo.
ImageFileIDs []string `gorm:"type:text;serializer:json"`
// FilesCleanedAt is set after a cleanup job deletes the corresponding files from
// Anthropic's side. NULL means files are still alive on Anthropic.
// Combined with DeletedAt this lets a reconciliation job find rows whose files
// are pending cleanup vs rows whose files have already been removed.
FilesCleanedAt *time.Time `gorm:"index"`
}
type ChatMemory struct {
@@ -68,7 +60,6 @@ const (
ScopeHistoryClearHardAny = "history:clear_hard:any"
ScopeModelSet = "model:set"
ScopeUserPromote = "user:promote"
ScopeTTSUse = "tts:use"
)
type Scope struct {
-3
View File
@@ -11,9 +11,6 @@ import (
// TelegramClient defines the methods required from the Telegram bot.
type TelegramClient interface {
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)
GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
FileDownloadLink(f *models.File) string
Start(ctx context.Context)
}
+3 -30
View File
@@ -12,12 +12,9 @@ import (
// MockTelegramClient is a mock implementation of TelegramClient for testing.
type MockTelegramClient struct {
mock.Mock
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
SendAudioFunc func(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
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)
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
StartFunc func(ctx context.Context)
}
// SendMessage mocks sending a message.
@@ -40,30 +37,6 @@ func (m *MockTelegramClient) SetMyCommands(ctx context.Context, params *bot.SetM
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.
func (m *MockTelegramClient) Start(ctx context.Context) {
if m.StartFunc != nil {
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// largestPhotoSize returns the highest-resolution PhotoSize from the slice
// Telegram returns for a single photo. Telegram pre-renders each upload at
// several resolutions; we want the largest for vision quality. Falls back to
// the zero value when the slice is empty (caller should guard upstream).
func largestPhotoSize(photos []models.PhotoSize) models.PhotoSize {
if len(photos) == 0 {
return models.PhotoSize{}
}
largest := photos[0]
largestArea := largest.Width * largest.Height
for i := 1; i < len(photos); i++ {
area := photos[i].Width * photos[i].Height
if area > largestArea {
largest = photos[i]
largestArea = area
}
}
return largest
}
// downloadTelegramFile resolves a Telegram file_id via the bot API, fetches the
// download URL, and reads the bytes into memory. The two-step dance (GetFile +
// fetch via FileDownloadLink) is required by Telegram's protocol — direct
// downloads aren't possible from file_id alone. Buffered into []byte because
// downstream callers (multipart uploads to ElevenLabs and Anthropic) re-read
// the body; streaming would require either tee-ing or a temp file.
func (b *Bot) downloadTelegramFile(ctx context.Context, fileID string) ([]byte, error) {
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
if err != nil {
return nil, fmt.Errorf("telegram GetFile %s: %w", fileID, err)
}
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram download request %s: %w", fileID, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram download %s: %w", fileID, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("telegram download %s: status %d", fileID, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("telegram download read %s: %w", fileID, err)
}
return data, nil
}
-53
View File
@@ -1,53 +0,0 @@
package main
import (
"testing"
"github.com/go-telegram/bot/models"
"github.com/stretchr/testify/assert"
)
func TestLargestPhotoSize(t *testing.T) {
cases := []struct {
name string
photos []models.PhotoSize
wantFileID string
}{
{
name: "ascending sizes — last is largest",
photos: []models.PhotoSize{
{FileID: "thumb", Width: 90, Height: 90},
{FileID: "small", Width: 320, Height: 320},
{FileID: "full", Width: 1280, Height: 720},
},
wantFileID: "full",
},
{
name: "descending sizes — first is largest",
photos: []models.PhotoSize{
{FileID: "full", Width: 1280, Height: 720},
{FileID: "small", Width: 320, Height: 320},
{FileID: "thumb", Width: 90, Height: 90},
},
wantFileID: "full",
},
{
name: "single photo",
photos: []models.PhotoSize{
{FileID: "solo", Width: 800, Height: 600},
},
wantFileID: "solo",
},
{
name: "empty slice returns zero value (caller guards upstream)",
photos: []models.PhotoSize{},
wantFileID: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := largestPhotoSize(tc.photos)
assert.Equal(t, tc.wantFileID, got.FileID)
})
}
}