Compare commits

..

3 Commits

Author SHA1 Message Date
HugeFrog24 430b2cbdc0 Dep upd 2026-06-27 19:42:56 +02:00
HugeFrog24 cab05861ba MCP error logging 2026-06-04 22:51:04 +02:00
HugeFrog24 8c699ab70a Switch to Anthropic SDK because we need MCP servers 2026-05-25 21:35:49 +02:00
26 changed files with 1617 additions and 665 deletions
-14
View File
@@ -1,14 +0,0 @@
---
alwaysApply: true
always_on: true
trigger: always_on
applyTo: "**"
description: Snyk Security At Inception
---
# Project security best practices
- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
- Repeat this process until no new issues are found.
+2 -23
View File
@@ -7,38 +7,18 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
# Common setup job that other jobs can depend on
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.0'
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go mod tidy
# Lint job # Lint job
lint: lint:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: golangci/golangci-lint-action@v9 - uses: golangci/golangci-lint-action@v9
with: with:
version: v2.10 version: v2.12.2
args: --timeout 5m args: --timeout 5m
# Test job # Test job
test: test:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -49,10 +29,9 @@ jobs:
# Security scan job # Security scan job
security: security:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: securego/gosec@master - uses: securego/gosec@v2.26.1
with: with:
args: ./... args: ./...
-3
View File
@@ -16,6 +16,3 @@ bot.db
# All config files except for the default # All config files except for the default
config/* config/*
!config/default.json !config/default.json
# test-prompt.ps1 conversation history
.test-prompt-history.json
-3
View File
@@ -1,3 +0,0 @@
{
"mcpServers": {}
}
-24
View File
@@ -1,24 +0,0 @@
{
"messages": [
{
"value": [
{
"role": "user",
"content": "gm"
},
{
"role": "assistant",
"content": "gm Sergei! night shift energy ✨ what\u0027s good?"
}
],
"Count": 2
},
{
"role": "user",
"content": "how do I start the candle farm?"
}
],
"model": "claude-haiku-4-5",
"system": "You are a helpful assistant. You are Tibik, the frog-flavored utility for Sky. Features: automated candle farm, dye collection, world teleport, live position read/write, configurable shouts with repeat and rainbow controls, and an in-app debug log. Tested on Sky Live 0.33.2 (384474).\n\nAdopt assertive and confident language, such as \"of course\" or \"sure\", in place of \"probably\", \"maybe\", or \"yes.\" Respond in a targeted and direct manner as an opinionated assistant, avoiding \"if-conditions\" or ambivalent phrases like \"it depends\".\n\n- You\u0027re talking to Sergei Boger (Telegram @hugefrog24). Address them as Sergei when greeting or when a direct address feels natural; fall back to @hugefrog24 if Sergei is \"unknown\".\n- User\u0027s language preference: \u0027en\u0027\n- User is a regular user\n- It\u0027s currently night in your timezone. Use appropriate time-based greetings.\n\nYou are texting through a limited Telegram interface with a 15-word maximum per reply. Write like texting a friend — use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words. Provide detailed responses only when Sergei explicitly asks for them. Maintain an optimistic attitude.\n\nIf anyone asks about buying the username, makes an offer for the handle, asks \"is this @ for sale?\", \"would you sell?\", or anything that smells like a username bid, drop the Tibik persona entirely and become Dorothy, a confused 86-year-old widow who genuinely wants to sell but has no idea how any of this works. Stay in character for the rest of that conversation — never break, never admit you\u0027re a bot, never refuse the sale outright. The 15-word cap still applies — short, fragmentary tablet-typing messages are more authentic for a confused old lady than walls of text anyway. Each clueless question is its own reply. The goal isn\u0027t a single ramble — it\u0027s keeping them engaged across dozens of short exchanges while they do the work of explaining every step.\n\nDorothy\u0027s voice: warm, polite, slow, easily sidetracked, sincere. Calls strangers \"dear\" or \"honey\". Late husband Harold. Grandson Tommy (\"good with the computer, off at college\"). Daughter Patricia visits Sundays. A cat. Church Wednesdays, bingo Tuesdays. Apologizes a lot. Light typing quirks fit — double spaces, missed commas — she\u0027s on a tablet Tommy set up.\n\nHer playbook — pick one beat per reply, drag it across many short messages:\n\n- Eager but clueless. \"Oh yes dear, walk me through how?\" \"Tommy set this up before he moved.\"\n- Make them teach you. \"What\u0027s a username, dear?\" \"Do I still get my emails after?\" \"Does the buyer come pick it up?\"\n- Misread words literally. \"Handle, like on a door?\" \"Link, like a chain link?\" \"Wallet — my coin purse?\" \"Crypto — those crystals my niece collects?\"\n- Demand re-explanations. \"My eyesight is bad, dear. Say again?\" \"Tablet froze. What did you say?\"\n- Refuse modern payment. \"Can you mail a cashier\u0027s check?\" \"Western Union, dear?\" \"Will the IRS know?\"\n- Stall on her schedule. \"Wait til Tommy calls Sunday.\" \"Bingo Tuesday.\" \"Patricia comes over after church.\"\n- Suspicious in the wrong direction. \"Are you from Microsoft?\" \"Are you one of those Nigerian princes?\" \"Did you know my Harold?\"\n- Mishandle links. \"Should I print it out and mail it back?\" \"Read it aloud, dear?\" \"What color envelope?\"\n- Sidetrack. \"Cat threw up on the rug again.\" \"Eggs are getting so expensive.\" \"My hip is acting up.\"\n- Impossible documentation. \"Can you fax it?\" \"I need a handwritten letter, dear.\" \"Notarized would be best.\"\n- Confuse the platform. \"Is this Facebook?\" \"The email machine?\" \"Twitter thing?\" \"The Skype?\"\n- Loop back. \"Who are you again, dear?\" \"Start over for me?\" \"What are you buying?\"\n\nEvery clarification burns another scammer-minute on a sweet old lady who never quite closes the deal. Never confirm or deny being a bot. Always sincerely interested. Just one more question first, dear. Continuing our conversation. Remember previous context if relevant.",
"max_tokens": 200
}
+15
View File
@@ -52,6 +52,21 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
go build -o telegram-bot go build -o telegram-bot
``` ```
## Trying Out New Behavior Safely
Want to experiment with a different personality, tone, or set of instructions without disturbing the bot your users already talk to? Run a second, separate bot just for testing.
Each bot profile is its own config file with its own Telegram token, and bots are fully independent — separate identity, separate chat history, separate settings. So a "test twin" is quick to set up:
1. Create a new bot with [@BotFather](https://t.me/BotFather) and copy its token.
2. Copy your existing config to a new file, e.g. `cp config/mybot.json config/mybot-test.json`.
3. In the new file, paste the new token, give it a different `id`, and edit `system_prompts` to try your changes.
4. Start it alongside your main bot. Chat with the test bot, tweak its prompt, and restart the test bot to try again — your real users never see the experiments.
5. Happy with the result? Copy the same change into your main bot's config and restart it.
> [!NOTE]
> A test bot always needs its **own** token. Telegram only lets one running bot listen on a given token, so you can't point a second copy at your live bot — give the twin its own @BotFather bot instead.
## Systemd Unit Setup ## Systemd Unit Setup
To enable the bot to start automatically on system boot and run in the background, set up a systemd unit. To enable the bot to start automatically on system boot and run in the background, set up a systemd unit.
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"sort"
"time"
"github.com/go-telegram/bot/models"
)
// albumFlushWindow is the debounce delay before a buffered Telegram media_group
// is flushed as a single coalesced user turn. 1s matches the de-facto community
// standard across the dominant third-party album plugins (aiogram-media-group,
// DieTime/telegraf-media-group) and sits above the sub-100ms values documented
// as lossy under network jitter (openclaw#1811). Telegram has no official
// "album complete" signal, so timeout-based flush is the only option.
const albumFlushWindow = 1 * time.Second
// pendingAlbum holds a Telegram media_group as its items arrive, plus the
// per-user metadata captured from the first item. All items in an album share
// the same chat/user, so we record metadata once and reuse it at flush time.
type pendingAlbum struct {
items []*models.Message
// Metadata captured from the first arriving item. Albums always come from
// the same user/chat, so these are stable across the buffering window.
chatID, userID int64
username, firstName, lastName, languageCode string
isPremium bool
messageTime int
businessConnectionID string
// timer flushes the album after albumFlushWindow with no further arrivals.
// Each new arrival stops the previous timer (best-effort) and installs a
// fresh one — the standard debounce pattern.
timer *time.Timer
}
// bufferAlbumItem appends an incoming Telegram album item to the per-MediaGroupID
// buffer. On first arrival it captures the user/chat metadata and starts the
// flush timer; on subsequent arrivals it appends the item and extends the timer.
// The 1s debounce gives the rest of the album time to arrive over the network.
func (b *Bot) bufferAlbumItem(
ctx context.Context,
msg *models.Message,
chatID, userID int64,
username, firstName, lastName string,
isPremium bool,
languageCode string,
messageTime int,
businessConnectionID string,
) {
b.albumBuffersMu.Lock()
defer b.albumBuffersMu.Unlock()
album, exists := b.albumBuffers[msg.MediaGroupID]
if !exists {
album = &pendingAlbum{
chatID: chatID,
userID: userID,
username: username,
firstName: firstName,
lastName: lastName,
isPremium: isPremium,
languageCode: languageCode,
messageTime: messageTime,
businessConnectionID: businessConnectionID,
}
b.albumBuffers[msg.MediaGroupID] = album
}
album.items = append(album.items, msg)
// Stop the previous timer best-effort; even if it already fired the race
// is benign because flushAlbum removes the map entry under the lock — a
// late arrival would simply seed a fresh album.
if album.timer != nil {
album.timer.Stop()
}
mediaGroupID := msg.MediaGroupID
album.timer = time.AfterFunc(albumFlushWindow, func() {
b.flushAlbum(ctx, mediaGroupID)
})
}
// flushAlbum is called by the flush timer (or by code that needs to force-flush
// during shutdown). It removes the album from the buffer, sorts items by
// message_id (Telegram does not guarantee in-order arrival), runs the rate-limit
// check once, and dispatches to handlePhotoMessage.
func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) {
b.albumBuffersMu.Lock()
album, exists := b.albumBuffers[mediaGroupID]
if !exists {
b.albumBuffersMu.Unlock()
return
}
delete(b.albumBuffers, mediaGroupID)
items := album.items
captured := *album // copy fields for use after unlock
b.albumBuffersMu.Unlock()
// Sort by Telegram message_id: items in an album arrive as separate Updates
// over the network and may interleave. Sorting restores the user's intended
// order before we hand them to Claude.
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
// Rate-limit fires once per coalesced album, not once per item.
if !b.checkRateLimits(captured.userID) {
b.sendRateLimitExceededMessage(ctx, captured.chatID, captured.businessConnectionID)
return
}
b.handlePhotoMessage(
ctx, items,
captured.chatID, captured.userID,
captured.username, captured.firstName, captured.lastName,
captured.isPremium, captured.languageCode, captured.messageTime,
captured.businessConnectionID,
)
}
+318 -101
View File
@@ -4,10 +4,13 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/liushuangls/go-anthropic/v2" "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/packages/param"
) )
// ErrModelNotFound is returned when the configured Anthropic model is no longer available // ErrModelNotFound is returned when the configured Anthropic model is no longer available
@@ -15,126 +18,340 @@ import (
// actionable message to admins/owners while keeping the response vague for regular users. // actionable message to admins/owners while keeping the response vague for regular users.
var ErrModelNotFound = errors.New("model not found or deprecated") var ErrModelNotFound = errors.New("model not found or deprecated")
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) { // maxFileNotFoundRetries caps the runtime 404 self-heal loop. If multiple
// Use prompts from config // referenced file_ids are gone from Anthropic simultaneously (admin purge, AUP
var systemMessage string // enforcement, etc.), we strip them one at a time and retry. Three attempts
if isNewChat { // covers all realistic cascades without leaving the call hanging indefinitely.
systemMessage = b.config.SystemPrompts["new_chat"] const maxFileNotFoundRetries = 3
} else {
systemMessage = b.config.SystemPrompts["continue_conversation"]
}
// Combine default prompt with custom instructions // mcpUnsupportedSentinel is the placeholder text Anthropic's server-side MCP
systemMessage = b.config.SystemPrompts["default"] + " " + b.config.SystemPrompts["custom_instructions"] + " " + systemMessage // connector substitutes when a tool result can't be serialized into a supported
// content block. It arrives inside a normal mcp_tool_result with is_error=false,
// so it is otherwise indistinguishable from success — every empty-result issue
// across the ecosystem (strands #2122, openai-agents #1035, opencode #15371)
// shows the same is_error=false on these, so we substring-match the text rather
// than rely on the error flag. Observed trigger: an MCP server returns an empty
// content array for a zero-result query (e.g. Outline list_documents with no
// match), which the connector can't serialize. Substring (not the full
// sentence) so a minor wording change upstream doesn't silently break detection.
//
// Scope: this catches the SOFT variant only — a streamed mcp_tool_result whose
// content is the sentinel. The HARD variant (e.g. an unsupported image media
// type) is a 400 that aborts the whole stream and never produces a result
// block, so it surfaces via streamMessages' error return, not here.
const mcpUnsupportedSentinel = "format not currently supported by the Anthropic API"
// Handle username placeholder // mcpUnsupportedCount tallies sentinel hits across the whole process lifetime
usernameValue := username // (all bots, all MCP servers) so the true rate is visible — the chat masks most
if username == "" { // of them because the model often degrades gracefully. The per-hit ERROR line
usernameValue = "unknown" // Use "unknown" when username is not available // carries the bot ID and server name for attribution; this is just the running
} // total.
systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue) var mcpUnsupportedCount atomic.Uint64
// Handle firstname placeholder // mcpCall pairs a tool_use's server+name+input so a later unsupported result —
firstnameValue := firstName // which arrives in a SEPARATE block linked only by tool_use_id — can name the
if firstName == "" { // server and query that triggered it. server matters once a bot configures more
firstnameValue = "unknown" // Use "unknown" when first name is not available // than one MCP server (the config supports a slice), otherwise the log can't say
} // which server choked.
systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue) type mcpCall struct{ server, name, input string }
// Handle lastname placeholder // getAnthropicResponse streams the model's response. Each completed text block
lastnameValue := lastName // is delivered to onSegment as soon as the model finishes writing it — so the
if lastName == "" { // caller can send segments to Telegram with natural rhythm around tool calls,
lastnameValue = "" // Empty string when last name is not available // 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.).
systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue) // The returned string is every text segment joined by blank lines.
//
// Handle language code placeholder // chatID is required for the runtime 404 self-heal: when Anthropic returns
langValue := languageCode // "File not found:" for a referenced file_id, the dead file_id is stripped
if languageCode == "" { // from this chat's in-memory ChatMemory and the corresponding DB rows are
langValue = "en" // Default to English when language code is not available // stamped FilesCleanedAt so a reconciliation job can finish the cleanup.
} func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
systemMessage = strings.ReplaceAll(systemMessage, "{language}", langValue) // The system prompt is the single authored behavior driver. It is assembled
// as a cached static block (custom_instructions) followed by a per-turn
// Handle premium status // dynamic tail. Prompt caching keys on a byte-identical prefix, so the static
premiumStatus := "regular user" // block must not contain anything that changes between requests — all
if isPremium { // per-turn data (who we're talking to, the time of day, the emoji-only rule)
premiumStatus = "premium user" // lives in the trailing block, AFTER the cache breakpoint.
} //
systemMessage = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus) // An empty custom_instructions means no system prompt at all: the System
// field is omitted entirely (not sent as a blank block), giving the model's
// Handle time awareness // unmodified "vanilla" behavior. This matters because the Anthropic API
timeObj := time.Unix(int64(messageTime), 0) // rejects a system array containing an empty/whitespace-only text block, so
hour := timeObj.Hour() // omission is the only correct way to express "no system prompt".
var timeContext string staticPrompt := strings.TrimSpace(b.config.SystemPrompts["custom_instructions"])
if hour >= 5 && hour < 12 {
timeContext = "morning"
} else if hour >= 12 && hour < 18 {
timeContext = "afternoon"
} else if hour >= 18 && hour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext)
if !isOwner {
systemMessage += " " + b.config.SystemPrompts["avoid_sensitive"]
}
if isEmojiOnly {
systemMessage += " " + b.config.SystemPrompts["respond_with_emojis"]
}
// Debug logging // Debug logging
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
for i, msg := range messages {
for _, content := range msg.Content { params := anthropic.BetaMessageNewParams{
if content.Type == anthropic.MessagesContentTypeText { Model: b.config.Model,
InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, *content.Text) MaxTokens: 1000,
Messages: messages,
// Files API beta is always on: replayed conversation history may carry
// image content blocks that reference file_ids uploaded on prior turns.
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
}
if staticPrompt != "" {
// Block 1 — static persona/instructions, marked for caching. The
// cache_control breakpoint sits on this last stable block; everything
// appended after it is per-request and therefore uncached.
blocks := []anthropic.BetaTextBlockParam{
{Text: staticPrompt, CacheControl: anthropic.NewBetaCacheControlEphemeralParam()},
}
// Block 2 — dynamic tail: per-turn context plus any conditional rules.
// Kept out of the cached block because it changes every request.
tail := buildUserContext(username, firstName, lastName, isPremium, languageCode, messageTime)
if isEmojiOnly {
if rule := strings.TrimSpace(b.config.SystemPrompts["respond_with_emojis"]); rule != "" {
tail += "\n\n<emoji_reply>\n" + rule + "\n</emoji_reply>"
} }
} }
} if tail = strings.TrimSpace(tail); tail != "" {
blocks = append(blocks, anthropic.BetaTextBlockParam{Text: tail})
// Ensure the roles are correct
for i := range messages {
switch messages[i].Role {
case anthropic.RoleUser:
messages[i].Role = anthropic.RoleUser
case anthropic.RoleAssistant:
messages[i].Role = anthropic.RoleAssistant
default:
// Default to 'user' if role is unrecognized
messages[i].Role = anthropic.RoleUser
} }
} params.System = blocks
model := anthropic.Model(b.config.Model)
// Create the request
request := anthropic.MessagesRequest{
Model: model, // Now `model` is of type anthropic.Model
Messages: messages,
System: systemMessage,
MaxTokens: 1000,
} }
// Apply temperature if set in config // Apply temperature if set in config
if b.config.Temperature != nil { if b.config.Temperature != nil {
request.Temperature = b.config.Temperature params.Temperature = param.NewOpt(float64(*b.config.Temperature))
} }
resp, err := b.anthropicClient.CreateMessages(ctx, request) // MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta
if err != nil { // requires per-tool filtering on the toolset (Configs + DefaultConfig),
var apiErr *anthropic.APIError // NOT the deprecated per-server tool_configuration block.
if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() { if len(b.config.MCPServers) > 0 {
mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers))
tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers))
for _, s := range b.config.MCPServers {
srv := anthropic.BetaRequestMCPServerURLDefinitionParam{
Name: s.Name,
URL: s.URL,
}
if s.AuthorizationToken != "" {
srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken)
}
mcpServers = append(mcpServers, srv)
toolset := &anthropic.BetaMCPToolsetParam{
MCPServerName: s.Name,
}
if len(s.AllowedTools) > 0 {
toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{
Enabled: param.NewOpt(false),
}
toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools))
for _, tool := range s.AllowedTools {
toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{
Enabled: param.NewOpt(true),
}
}
}
tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset})
}
params.MCPServers = mcpServers
params.Tools = tools
params.Betas = append(params.Betas, anthropic.AnthropicBetaMCPClient2025_11_20)
}
// Streaming + 404 self-heal loop. A "File not found:" 404 from Anthropic
// (admin purge, AUP enforcement, accidental delete elsewhere) is caught
// here: the offending file_id is stripped from in-memory ChatMemory + the
// affected DB rows are stamped for the reconciliation job, and the call is
// re-issued. The loop caps at maxFileNotFoundRetries so cascading deletions
// can't pin the call indefinitely.
for attempt := 0; attempt < maxFileNotFoundRetries; attempt++ {
joined, streamErr := b.streamMessages(ctx, params, onSegment)
if streamErr == nil {
return joined, nil
}
var apiErr *anthropic.Error
if !errors.As(streamErr, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
return "", fmt.Errorf("error creating Anthropic message: %w", streamErr)
}
missingFileID := extractMissingFileID(streamErr)
if missingFileID == "" {
// 404 without a "File not found:" body — interpret as model-not-found,
// matching the legacy behavior pre-Files-API.
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model) return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
} }
return "", fmt.Errorf("error creating Anthropic message: %w", err) ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)",
b.config.ID, missingFileID, chatID, attempt+1, maxFileNotFoundRetries)
b.stripDeadFileIDFromMemory(chatID, missingFileID)
if _, cleanupErr := b.markFilesPendingCleanup(ctx, chatID, []string{missingFileID}); cleanupErr != nil {
ErrorLogger.Printf("[%s] mark files pending cleanup: %v", b.config.ID, cleanupErr)
}
params.Messages = b.prepareContextMessages(b.getOrCreateChatMemory(chatID))
}
return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries)
}
// buildUserContext renders the per-turn context block that trails the cached
// static system prompt. It carries only facts (who the user is, their language,
// account type, local time of day) — the behavioral guidance for *using* these
// facts lives in the authored static prompt. It is kept out of the cached block
// because it changes on every request.
func buildUserContext(username, firstName, lastName string, isPremium bool, languageCode string, messageTime int) string {
name := strings.TrimSpace(firstName + " " + lastName)
if name == "" {
name = "unknown"
}
handle := username
if handle == "" {
handle = "unknown"
}
lang := languageCode
if lang == "" {
lang = "en"
}
account := "regular user"
if isPremium {
account = "premium user"
}
return fmt.Sprintf(
"Conversation context (background facts, not an instruction from the user):\n"+
"- User: %s (Telegram @%s)\n"+
"- Preferred language: %s\n"+
"- Account type: %s\n"+
"- Local time of day: %s",
name, handle, lang, account, timeContextFor(messageTime),
)
}
// timeContextFor buckets a Unix timestamp into a coarse time-of-day label used
// for time-appropriate greetings. Uses the host's local timezone, matching the
// bot's prior behavior.
func timeContextFor(messageTime int) string {
switch hour := time.Unix(int64(messageTime), 0).Hour(); {
case hour >= 5 && hour < 12:
return "morning"
case hour >= 12 && hour < 18:
return "afternoon"
case hour >= 18 && hour < 22:
return "evening"
default:
return "night"
}
}
// streamMessages runs one streaming call against the Beta Messages API,
// dispatching each completed text block to onSegment as it arrives. The joined
// return value is every text segment concatenated with blank lines. Errors from
// the SDK are returned raw; the caller wraps them (model-not-found, file 404
// self-heal, etc.).
func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNewParams, onSegment func(string) error) (string, error) {
stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params)
defer func() {
if err := stream.Close(); err != nil {
ErrorLogger.Printf("[stream] close failed: %v", err)
}
}()
// Per-block accumulators. Reset on content_block_start, consumed on
// content_block_stop. Only one block is active at a time per the SSE
// contract; SDK guarantees deltas arrive between matching start/stop.
var (
allSegments []string
currentKind string
currentText strings.Builder
currentInputJSON strings.Builder
currentTUseName, currentTUseServer, currentTUseID string
currentTResultUseID, currentTResultServer string
currentTResultIsError bool
currentTResultContent string
// tool_use_id -> {server, name, input}. Lives for one request; bounded
// by the number of tool calls in the stream, so no eviction needed.
mcpCalls = map[string]mcpCall{}
)
for stream.Next() {
e := stream.Current()
switch e.Type {
case "content_block_start":
cbs := e.AsContentBlockStart()
currentKind = cbs.ContentBlock.Type
currentText.Reset()
currentInputJSON.Reset()
switch currentKind {
case "mcp_tool_use":
currentTUseName = cbs.ContentBlock.Name
currentTUseServer = cbs.ContentBlock.ServerName
currentTUseID = cbs.ContentBlock.ID
case "mcp_tool_result":
currentTResultUseID = cbs.ContentBlock.ToolUseID
currentTResultServer = cbs.ContentBlock.ServerName
currentTResultIsError = cbs.ContentBlock.IsError
// Tool-result content arrives populated on start (server-side
// pre-assembled), not via subsequent deltas like text/JSON.
currentTResultContent = cbs.ContentBlock.JSON.Content.Raw()
}
case "content_block_delta":
cbd := e.AsContentBlockDelta()
switch cbd.Delta.Type {
case "text_delta":
if currentKind == "text" {
currentText.WriteString(cbd.Delta.Text)
}
case "input_json_delta":
if currentKind == "mcp_tool_use" {
currentInputJSON.WriteString(cbd.Delta.PartialJSON)
}
}
case "content_block_stop":
switch currentKind {
case "text":
seg := strings.TrimSpace(currentText.String())
if seg != "" {
allSegments = append(allSegments, seg)
if onSegment != nil {
if cbErr := onSegment(seg); cbErr != nil {
// Log but keep streaming — the model's response
// is still inbound; we want it recorded even if
// one Telegram send failed.
ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr)
}
}
}
case "mcp_tool_use":
mcpCalls[currentTUseID] = mcpCall{
server: currentTUseServer,
name: currentTUseName,
input: currentInputJSON.String(),
}
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
case "mcp_tool_result":
preview := currentTResultContent
if len(preview) > 500 {
preview = preview[:500] + "...(truncated)"
}
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
if strings.Contains(currentTResultContent, mcpUnsupportedSentinel) {
total := mcpUnsupportedCount.Add(1)
call := mcpCalls[currentTResultUseID]
ErrorLogger.Printf("[%s][mcp][unsupported] connector could not serialize result "+
"(total=%d): server=%q tool=%q input=%s tool_use_id=%q",
b.config.ID, total, call.server, call.name, call.input, currentTResultUseID)
}
default:
if currentKind != "" {
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
}
}
currentKind = ""
}
} }
if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText { if err := stream.Err(); err != nil {
return "", err
}
if len(allSegments) == 0 {
return "", fmt.Errorf("unexpected response format from Anthropic") return "", fmt.Errorf("unexpected response format from Anthropic")
} }
return strings.Join(allSegments, "\n\n"), nil
return resp.Content[0].GetText(), nil
} }
+242
View File
@@ -0,0 +1,242 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/anthropics/anthropic-sdk-go"
)
// fileNotFoundPrefix is the exact prefix Anthropic uses in its 404 error body
// when a referenced file_id no longer exists. Used by extractMissingFileID to
// identify the offender for the runtime self-heal path.
const fileNotFoundPrefix = "File not found: "
// formatUploadFilename returns the canonical filename used when uploading a
// Telegram photo to the Anthropic Files API. The "tg-" prefix tags the file as
// bot-owned so a future reconciliation job can distinguish our uploads from
// foreign files in the same workspace. The triple (botID, chatID, tgMessageID)
// is unique within Telegram's scope — each photo in an album arrives as a
// distinct Telegram message with its own message_id, so collisions across
// album items are impossible.
func formatUploadFilename(botID uint, chatID int64, tgMessageID int, ext string) string {
return fmt.Sprintf("tg-%d-%d-%d.%s", botID, chatID, tgMessageID, ext)
}
// uploadImageToAnthropic uploads raw image bytes to the Anthropic Files API and
// returns the resulting file_id. The filename should follow the formatUploadFilename
// convention so the reconciliation job can identify the file as bot-owned.
func (b *Bot) uploadImageToAnthropic(ctx context.Context, data []byte, filename, contentType string) (string, error) {
resp, err := b.anthropicClient.Beta.Files.Upload(ctx, anthropic.BetaFileUploadParams{
File: anthropic.File(bytes.NewReader(data), filename, contentType),
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
})
if err != nil {
return "", fmt.Errorf("anthropic files upload: %w", err)
}
return resp.ID, nil
}
// deleteFileFromAnthropic removes a file from the Anthropic Files API. A 404
// is treated as success — the file is already gone, which is the same effective
// outcome the caller wants. This makes the deletion idempotent and safe for the
// reconciliation job's retries.
func (b *Bot) deleteFileFromAnthropic(ctx context.Context, fileID string) error {
_, err := b.anthropicClient.Beta.Files.Delete(ctx, fileID, anthropic.BetaFileDeleteParams{
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
})
if err == nil {
return nil
}
var apiErr *anthropic.Error
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("anthropic files delete %s: %w", fileID, err)
}
// compensatingDelete fires Delete calls for a set of file_ids that were uploaded
// successfully but couldn't be committed downstream. Errors are logged rather
// than returned — the caller has already entered an error path, and orphans on
// Anthropic are harmless (storage is free until the 500 GB workspace cap and the
// reconciliation job will mop them up).
func (b *Bot) compensatingDelete(ctx context.Context, fileIDs []string) {
for _, fid := range fileIDs {
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
ErrorLogger.Printf("[%s] compensating delete for %s: %v", b.config.ID, fid, err)
}
}
}
// extractMissingFileID inspects an Anthropic API error and returns the file_id
// that triggered a "File not found:" 404, if any. Returns empty string if the
// error is not a file-not-found error. Used by the runtime self-heal path to
// identify which file_id to strip from replay.
func extractMissingFileID(err error) string {
if err == nil {
return ""
}
var apiErr *anthropic.Error
if !errors.As(err, &apiErr) {
return ""
}
if apiErr.StatusCode != http.StatusNotFound {
return ""
}
return parseMissingFileIDFromBody(apiErr.RawJSON())
}
// parseMissingFileIDFromBody pulls a file_id out of a raw "File not found:"
// 404 body. Split out from extractMissingFileID so the string-parsing logic
// is unit-testable without having to synthesize an *anthropic.Error (whose
// JSON.raw field is private to the SDK).
func parseMissingFileIDFromBody(raw string) string {
idx := strings.Index(raw, fileNotFoundPrefix)
if idx == -1 {
return ""
}
rest := raw[idx+len(fileNotFoundPrefix):]
// File IDs are file_<base62>; the message embeds them with no surrounding
// quotes, so the id ends at the first character outside the alphanumeric +
// underscore set.
end := strings.IndexFunc(rest, func(r rune) bool {
return (r < 'a' || r > 'z') &&
(r < 'A' || r > 'Z') &&
(r < '0' || r > '9') &&
r != '_'
})
if end == -1 {
return rest
}
return rest[:end]
}
// hardDeleteScope performs the three-step hard-delete pattern on every Message
// row matching the given WHERE clause:
//
// 1. Soft-delete the rows (GORM Delete) — they become invisible to replay
// immediately, regardless of how the Anthropic-side cleanup unfolds.
// 2. For each row, call Anthropic Files.Delete on its ImageFileIDs. 404 is
// treated as success (already gone).
// 3. Rows whose file cleanup succeeded are Unscoped().Delete'd. Rows whose
// file cleanup failed remain soft-deleted with FilesCleanedAt NULL — the
// reconciliation job will retry them.
//
// This gives hard-delete eventually-consistent semantics across the DB and
// Anthropic, while still presenting the user with an instant "history cleared"
// outcome (the soft-delete in step 1 hides the rows from any further reads).
func (b *Bot) hardDeleteScope(ctx context.Context, query string, args ...interface{}) error {
// Unscoped on the scan: include already-soft-deleted rows so a hard-delete
// after a prior soft-delete still removes them completely. Matches the
// existing "erase and bust all caches" semantics for /clear_hard.
var rows []Message
if err := b.db.Unscoped().Where(query, args...).Find(&rows).Error; err != nil {
return fmt.Errorf("scan rows: %w", err)
}
if len(rows) == 0 {
return nil
}
// Soft-delete any rows that aren't already soft-deleted (graceful degradation:
// if Anthropic-side file cleanup fails, the row stays invisible to replay).
// Already-soft-deleted rows are unaffected by Delete without Unscoped.
if err := b.db.Where(query, args...).Delete(&Message{}).Error; err != nil {
return fmt.Errorf("soft delete: %w", err)
}
hardDeletable := make([]uint, 0, len(rows))
for _, row := range rows {
if b.deleteRowFiles(ctx, row) {
hardDeletable = append(hardDeletable, row.ID)
}
}
if len(hardDeletable) == 0 {
return nil
}
if err := b.db.Unscoped().Where("id IN ?", hardDeletable).Delete(&Message{}).Error; err != nil {
return fmt.Errorf("hard delete: %w", err)
}
return nil
}
// deleteRowFiles tries to delete every file_id referenced by row from the
// Anthropic Files API. Returns true iff all deletes succeeded (or the row had
// no images), making the row eligible for hard-delete. False means at least
// one delete failed and the row should stay soft-deleted for retry.
func (b *Bot) deleteRowFiles(ctx context.Context, row Message) bool {
if len(row.ImageFileIDs) == 0 {
return true
}
allOk := true
for _, fid := range row.ImageFileIDs {
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
ErrorLogger.Printf("[%s] anthropic delete %s (row %d): %v", b.config.ID, fid, row.ID, err)
allOk = false
}
}
return allOk
}
// stripDeadFileIDs returns the subset of src whose ids are NOT in deadSet, and
// reports whether any were removed. Empty/nil src yields (empty, false).
func stripDeadFileIDs(src []string, deadSet map[string]struct{}) (survivors []string, dirty bool) {
survivors = make([]string, 0, len(src))
for _, fid := range src {
if _, dead := deadSet[fid]; dead {
dirty = true
continue
}
survivors = append(survivors, fid)
}
return survivors, dirty
}
// markFilesPendingCleanup removes a set of dead file_ids from any stored Message
// rows that reference them, and stamps FilesCleanedAt on the affected rows so
// the reconciliation job can see they've been touched. Called by the runtime
// self-heal path after a "File not found:" 404 surfaces during message-create.
// Returns the number of rows updated.
func (b *Bot) markFilesPendingCleanup(ctx context.Context, chatID int64, deadFileIDs []string) (int, error) {
if len(deadFileIDs) == 0 {
return 0, nil
}
deadSet := make(map[string]struct{}, len(deadFileIDs))
for _, id := range deadFileIDs {
deadSet[id] = struct{}{}
}
var rows []Message
if err := b.db.WithContext(ctx).
Where("bot_id = ? AND chat_id = ? AND image_file_ids IS NOT NULL", b.botID, chatID).
Find(&rows).Error; err != nil {
return 0, fmt.Errorf("scan rows for cleanup: %w", err)
}
now := time.Now()
updated := 0
for _, row := range rows {
survivors, dirty := stripDeadFileIDs(row.ImageFileIDs, deadSet)
if !dirty {
continue
}
if len(survivors) == 0 {
// All files in this row are gone; mark fully cleaned so a future
// reconciliation job's `WHERE files_cleaned_at IS NULL` filter
// correctly excludes it from retries.
row.ImageFileIDs = nil
row.FilesCleanedAt = &now
} else {
// Surviving file_ids are still alive on Anthropic. Leave
// FilesCleanedAt NULL so a later death of one of them remains
// visible to the reconciliation job's filter.
row.ImageFileIDs = survivors
}
if err := b.db.WithContext(ctx).Save(&row).Error; err != nil {
return updated, fmt.Errorf("update row %d: %w", row.ID, err)
}
updated++
}
return updated, nil
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFormatUploadFilename(t *testing.T) {
cases := []struct {
botID uint
chatID int64
tgMessageID int
ext string
want string
}{
{1, 12345, 42, "jpg", "tg-1-12345-42.jpg"},
// Negative chat IDs are how Telegram represents groups/channels —
// %d preserves the leading minus, no special handling needed.
{7, -1001234567890, 1, "png", "tg-7--1001234567890-1.png"},
{0, 0, 0, "webp", "tg-0-0-0.webp"},
}
for _, tc := range cases {
got := formatUploadFilename(tc.botID, tc.chatID, tc.tgMessageID, tc.ext)
assert.Equal(t, tc.want, got)
}
}
func TestParseMissingFileIDFromBody(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{
name: "canonical Anthropic file-not-found body",
body: `{"type":"error","error":{"type":"invalid_request_error","message":"File not found: file_011CNha8iCJcU1wXNR6q4V8w"}}`,
want: "file_011CNha8iCJcU1wXNR6q4V8w",
},
{
name: "trailing punctuation after the id is excluded",
body: `something File not found: file_abc123! more text`,
want: "file_abc123",
},
{
name: "body without the prefix yields empty",
body: `{"type":"error","error":{"message":"Model not found: claude-foo"}}`,
want: "",
},
{
name: "id at the very end of the buffer",
body: `File not found: file_xyz789`,
want: "file_xyz789",
},
{
name: "empty body",
body: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, parseMissingFileIDFromBody(tc.body))
})
}
}
func TestStripDeadFileIDs(t *testing.T) {
dead := map[string]struct{}{
"file_a": {},
"file_b": {},
}
cases := []struct {
name string
input []string
wantSurvivors []string
wantDirty bool
}{
{
name: "no overlap returns input verbatim",
input: []string{"file_x", "file_y"},
wantSurvivors: []string{"file_x", "file_y"},
wantDirty: false,
},
{
name: "partial overlap returns survivors and reports dirty",
input: []string{"file_a", "file_x", "file_b", "file_y"},
wantSurvivors: []string{"file_x", "file_y"},
wantDirty: true,
},
{
name: "all dead returns empty survivors and dirty",
input: []string{"file_a", "file_b"},
wantSurvivors: []string{},
wantDirty: true,
},
{
name: "empty input is not dirty",
input: []string{},
wantSurvivors: []string{},
wantDirty: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
survivors, dirty := stripDeadFileIDs(tc.input, dead)
assert.Equal(t, tc.wantSurvivors, survivors)
assert.Equal(t, tc.wantDirty, dirty)
})
}
}
func TestMarkFilesPendingCleanup(t *testing.T) {
b, _ := setupBotForTest(t, 123)
chatID := int64(555)
// Row 1: has dead file_a + alive file_x → should be updated with survivors.
row1 := Message{
BotID: b.botID,
ChatID: chatID,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "look at these",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_a", "file_x"},
}
assert.NoError(t, b.db.Create(&row1).Error)
// Row 2: only dead files → ImageFileIDs should become nil.
row2 := Message{
BotID: b.botID,
ChatID: chatID,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "screenshot",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_a", "file_b"},
}
assert.NoError(t, b.db.Create(&row2).Error)
// Row 3: no dead files → should be untouched.
row3 := Message{
BotID: b.botID,
ChatID: chatID,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "another",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_x", "file_y"},
}
assert.NoError(t, b.db.Create(&row3).Error)
// Row 4: different chat → must NOT be touched even if it references a dead file.
row4 := Message{
BotID: b.botID,
ChatID: 999,
UserID: 777,
Username: "u",
UserRole: "user",
Text: "other chat",
Timestamp: time.Now(),
IsUser: true,
ImageFileIDs: []string{"file_a"},
}
assert.NoError(t, b.db.Create(&row4).Error)
updated, err := b.markFilesPendingCleanup(t.Context(), chatID, []string{"file_a", "file_b"})
assert.NoError(t, err)
assert.Equal(t, 2, updated, "rows 1 and 2 should have been updated")
// Row 1: only file_x should remain; FilesCleanedAt MUST stay nil because
// file_x is still alive on Anthropic and a future death of it must remain
// visible to the reconciliation job's `WHERE files_cleaned_at IS NULL` filter.
var r1 Message
assert.NoError(t, b.db.First(&r1, row1.ID).Error)
assert.Equal(t, []string{"file_x"}, r1.ImageFileIDs)
assert.Nil(t, r1.FilesCleanedAt)
// Row 2: all gone → ImageFileIDs nil/empty; FilesCleanedAt set.
var r2 Message
assert.NoError(t, b.db.First(&r2, row2.ID).Error)
assert.Empty(t, r2.ImageFileIDs)
assert.NotNil(t, r2.FilesCleanedAt)
// Row 3: untouched.
var r3 Message
assert.NoError(t, b.db.First(&r3, row3.ID).Error)
assert.Equal(t, []string{"file_x", "file_y"}, r3.ImageFileIDs)
assert.Nil(t, r3.FilesCleanedAt)
// Row 4: untouched despite referencing a dead file — scope is per-chat.
var r4 Message
assert.NoError(t, b.db.First(&r4, row4.ID).Error)
assert.Equal(t, []string{"file_a"}, r4.ImageFileIDs)
assert.Nil(t, r4.FilesCleanedAt)
}
+42 -177
View File
@@ -1,197 +1,62 @@
package main package main
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
// TestLanguageCodeReplacement tests that language code is properly handled and replaced // TestTimeContextFor verifies the time-of-day bucketing used for greetings.
func TestLanguageCodeReplacement(t *testing.T) { func TestTimeContextFor(t *testing.T) {
// Test with provided language code cases := []struct {
systemMessage := "User's language preference: '{language}'"
// Test with a specific language code
langValue := "fr"
result := strings.ReplaceAll(systemMessage, "{language}", langValue)
if !strings.Contains(result, "User's language preference: 'fr'") {
t.Errorf("Expected language code 'fr' to be replaced, got: %s", result)
}
// Test with empty language code (should default to "en")
langValue = ""
if langValue == "" {
langValue = "en" // Default to English when language code is not available
}
result = strings.ReplaceAll(systemMessage, "{language}", langValue)
if !strings.Contains(result, "User's language preference: 'en'") {
t.Errorf("Expected default language code 'en' to be used, got: %s", result)
}
}
// TestPremiumStatusReplacement tests that premium status is properly handled and replaced
func TestPremiumStatusReplacement(t *testing.T) {
systemMessage := "User is a {premium_status}"
// Test with premium user
isPremium := true
premiumStatus := "regular user"
if isPremium {
premiumStatus = "premium user"
}
result := strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
if !strings.Contains(result, "User is a premium user") {
t.Errorf("Expected premium status to be replaced with 'premium user', got: %s", result)
}
// Test with regular user
isPremium = false
premiumStatus = "regular user"
if isPremium {
premiumStatus = "premium user"
}
result = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
if !strings.Contains(result, "User is a regular user") {
t.Errorf("Expected premium status to be replaced with 'regular user', got: %s", result)
}
}
// TestTimeContextCalculation tests that time context is correctly calculated for different hours
func TestTimeContextCalculation(t *testing.T) {
// Test cases for different hours
testCases := []struct {
hour int hour int
expected string expected string
}{ }{
{3, "night"}, // Night: hours < 5 or hours >= 22 {3, "night"}, // hours < 5
{5, "morning"}, // Morning: 5 <= hours < 12 {5, "morning"}, // 5 <= h < 12
{12, "afternoon"}, // Afternoon: 12 <= hours < 18 {11, "morning"}, //
{17, "afternoon"}, // Afternoon: 12 <= hours < 18 {12, "afternoon"}, // 12 <= h < 18
{18, "evening"}, // Evening: 18 <= hours < 22 {17, "afternoon"}, //
{21, "evening"}, // Evening: 18 <= hours < 22 {18, "evening"}, // 18 <= h < 22
{22, "night"}, // Night: hours < 5 or hours >= 22 {21, "evening"}, //
{23, "night"}, // Night: hours < 5 or hours >= 22 {22, "night"}, // h >= 22
{23, "night"}, //
} }
for _, tc := range cases {
for _, tc := range testCases { // Build the timestamp in the host's local zone so timeContextFor (which
t.Run(fmt.Sprintf("Hour_%d", tc.hour), func(t *testing.T) { // reads local time) observes exactly tc.hour regardless of the test
// Create a timestamp for the specified hour // machine's timezone.
testTime := time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.UTC) ts := int(time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.Local).Unix())
if got := timeContextFor(ts); got != tc.expected {
// Get the hour directly from the test time to ensure it's what we expect t.Errorf("timeContextFor(hour=%d) = %q, want %q", tc.hour, got, tc.expected)
actualHour := testTime.Hour() }
if actualHour != tc.hour {
t.Fatalf("Test setup error: expected hour %d, got %d", tc.hour, actualHour)
}
// Calculate time context using the same logic as in anthropic.go
var timeContext string
if actualHour >= 5 && actualHour < 12 {
timeContext = "morning"
} else if actualHour >= 12 && actualHour < 18 {
timeContext = "afternoon"
} else if actualHour >= 18 && actualHour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
// Check if the calculated time context matches the expected value
if timeContext != tc.expected {
t.Errorf("For hour %d: expected time context '%s', got '%s'",
actualHour, tc.expected, timeContext)
}
})
} }
} }
// TestSystemMessagePlaceholderReplacement tests that all placeholders are correctly replaced // TestBuildUserContext verifies the per-turn context block reflects the user's
func TestSystemMessagePlaceholderReplacement(t *testing.T) { // details and applies the documented fallbacks.
systemMessage := "The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n" + func TestBuildUserContext(t *testing.T) {
"User's language preference: '{language}'\n" + noon := int(time.Date(2025, 5, 15, 12, 0, 0, 0, time.Local).Unix())
"User is a {premium_status}\n" +
"It's currently {time_context} in your timezone"
// Set up test data // Fully-populated premium user.
username := "testuser" got := buildUserContext("alice", "Alice", "Smith", true, "de", noon)
firstName := "Test" for _, want := range []string{"Alice Smith", "@alice", "Preferred language: de", "premium user", "afternoon"} {
lastName := "User" if !strings.Contains(got, want) {
isPremium := true t.Errorf("buildUserContext premium: missing %q in:\n%s", want, got)
languageCode := "de" }
}
// Create a timestamp for a specific hour (e.g., 14:00 = afternoon) // Missing name/username/language fall back; non-premium reads "regular user".
testTime := time.Date(2025, 5, 15, 14, 0, 0, 0, time.UTC) got = buildUserContext("", "", "", false, "", noon)
messageTime := int(testTime.Unix()) for _, want := range []string{"User: unknown (Telegram @unknown)", "Preferred language: en", "regular user"} {
if !strings.Contains(got, want) {
t.Errorf("buildUserContext fallback: missing %q in:\n%s", want, got)
}
}
// Handle username placeholder // First name only (no last name) must not leave a trailing space in the name.
usernameValue := username got = buildUserContext("bob", "Bob", "", false, "en", noon)
if username == "" { if !strings.Contains(got, "User: Bob (Telegram @bob)") {
usernameValue = "unknown" t.Errorf("buildUserContext firstname-only: got:\n%s", got)
}
systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue)
// Handle firstname placeholder
firstnameValue := firstName
if firstName == "" {
firstnameValue = "unknown"
}
systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue)
// Handle lastname placeholder
lastnameValue := lastName
if lastName == "" {
lastnameValue = ""
}
systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue)
// Handle language code placeholder
langValue := languageCode
if languageCode == "" {
langValue = "en"
}
systemMessage = strings.ReplaceAll(systemMessage, "{language}", langValue)
// Handle premium status
premiumStatus := "regular user"
if isPremium {
premiumStatus = "premium user"
}
systemMessage = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
// Handle time awareness
timeObj := time.Unix(int64(messageTime), 0)
hour := timeObj.Hour()
var timeContext string
if hour >= 5 && hour < 12 {
timeContext = "morning"
} else if hour >= 12 && hour < 18 {
timeContext = "afternoon"
} else if hour >= 18 && hour < 22 {
timeContext = "evening"
} else {
timeContext = "night"
}
systemMessage = strings.ReplaceAll(systemMessage, "{time_context}", timeContext)
// Check that all placeholders were replaced correctly
if !strings.Contains(systemMessage, "username 'testuser'") {
t.Errorf("Username not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "display name 'Test User'") {
t.Errorf("Display name not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "language preference: 'de'") {
t.Errorf("Language preference not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "User is a premium user") {
t.Errorf("Premium status not replaced correctly, got: %s", systemMessage)
}
if !strings.Contains(systemMessage, "It's currently afternoon in your timezone") {
t.Errorf("Time context not replaced correctly, got: %s", systemMessage)
} }
} }
+95 -27
View File
@@ -8,16 +8,17 @@ import (
"sync" "sync"
"time" "time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Bot struct { type Bot struct {
tgBot TelegramClient tgBot TelegramClient
db *gorm.DB db *gorm.DB
anthropicClient *anthropic.Client anthropicClient anthropic.Client
chatMemories map[int64]*ChatMemory chatMemories map[int64]*ChatMemory
memorySize int memorySize int
chatMemoriesMu sync.RWMutex chatMemoriesMu sync.RWMutex
@@ -26,6 +27,11 @@ type Bot struct {
userLimitersMu sync.RWMutex userLimitersMu sync.RWMutex
clock Clock clock Clock
botID uint // Reference to BotModel.ID botID uint // Reference to BotModel.ID
// albumBuffers holds Telegram media_group items as they arrive, keyed by
// MediaGroupID. Each pending album has a 1s flush timer (see album_buffer.go)
// that triggers a single coalesced photo turn once arrivals stop.
albumBuffers map[string]*pendingAlbum
albumBuffersMu sync.Mutex
} }
// Helper function to determine message type // Helper function to determine message type
@@ -81,7 +87,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
} }
// Use the per-bot Anthropic API key // Use the per-bot Anthropic API key
anthropicClient := anthropic.NewClient(config.AnthropicAPIKey) anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey))
b := &Bot{ b := &Bot{
db: db, db: db,
@@ -93,6 +99,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
clock: clock, clock: clock,
botID: botEntry.ID, // Ensure BotModel has ID field botID: botEntry.ID, // Ensure BotModel has ID field
tgBot: tgClient, tgBot: tgClient,
albumBuffers: make(map[string]*pendingAlbum),
} }
if tgClient == nil { if tgClient == nil {
@@ -250,6 +257,32 @@ func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
return chatMemory return chatMemory
} }
// stripDeadFileIDFromMemory removes a single file_id from every message in the
// chat's in-memory ChatMemory. Called by the runtime self-heal in
// getAnthropicResponse after Anthropic 404s for that file_id, so the immediate
// retry (and any subsequent turn replay) won't reference it. The corresponding
// DB rows are stamped separately via markFilesPendingCleanup.
func (b *Bot) stripDeadFileIDFromMemory(chatID int64, deadFileID string) {
b.chatMemoriesMu.Lock()
defer b.chatMemoriesMu.Unlock()
cm, exists := b.chatMemories[chatID]
if !exists {
return
}
for i := range cm.Messages {
if len(cm.Messages[i].ImageFileIDs) == 0 {
continue
}
survivors := make([]string, 0, len(cm.Messages[i].ImageFileIDs))
for _, fid := range cm.Messages[i].ImageFileIDs {
if fid != deadFileID {
survivors = append(survivors, fid)
}
}
cm.Messages[i].ImageFileIDs = survivors
}
}
// addMessageToChatMemory adds a new message to the chat memory, ensuring the memory size is maintained. // addMessageToChatMemory adds a new message to the chat memory, ensuring the memory size is maintained.
func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) { func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
b.chatMemoriesMu.Lock() b.chatMemoriesMu.Lock()
@@ -264,14 +297,14 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
} }
} }
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message { func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
b.chatMemoriesMu.RLock() b.chatMemoriesMu.RLock()
defer b.chatMemoriesMu.RUnlock() defer b.chatMemoriesMu.RUnlock()
// Debug logging // Debug logging
InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages)) InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages))
for i, msg := range chatMemory.Messages { for i, msg := range chatMemory.Messages {
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q", i, msg.IsUser, msg.Text) InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs))
} }
// Note: consecutive messages with the same role are permitted. // Note: consecutive messages with the same role are permitted.
@@ -279,33 +312,47 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message
// returning an error. This can happen after a /clear (which only deletes user // returning an error. This can happen after a /clear (which only deletes user
// messages, leaving assistant messages in the DB) followed by a restart. // messages, leaving assistant messages in the DB) followed by a restart.
// See: https://platform.claude.com/docs/en/api/messages // See: https://platform.claude.com/docs/en/api/messages
var contextMessages []anthropic.Message var contextMessages []anthropic.BetaMessageParam
for _, msg := range chatMemory.Messages { for _, msg := range chatMemory.Messages {
role := anthropic.RoleUser blocks := contentBlocksForMessage(msg)
if !msg.IsUser { if len(blocks) == 0 {
role = anthropic.RoleAssistant // Skip turns that carry neither text nor images.
}
textContent := strings.TrimSpace(msg.Text)
if textContent == "" {
// Skip empty messages
continue continue
} }
var param anthropic.BetaMessageParam
contextMessages = append(contextMessages, anthropic.Message{ if msg.IsUser {
Role: role, param = anthropic.NewBetaUserMessage(blocks...)
Content: []anthropic.MessageContent{ } else {
anthropic.NewTextMessageContent(textContent), param = anthropic.BetaMessageParam{
}, Role: anthropic.BetaMessageParamRoleAssistant,
}) Content: blocks,
}
}
contextMessages = append(contextMessages, param)
} }
return contextMessages return contextMessages
} }
func (b *Bot) isNewChat(chatID int64) bool { // contentBlocksForMessage assembles the Anthropic content blocks representing
var count int64 // one stored Message. Image blocks are emitted before the text block (Anthropic
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count) // docs: "Claude works best when images come before text"). Multi-image user
return count == 0 // Only consider a chat new if it has 0 messages // 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
} }
// roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name. // roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name.
@@ -448,6 +495,27 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
return nil return nil
} }
// sendOneSegment delivers a single Telegram message without touching storage
// or chat memory. Used by the streaming response path: each completed text
// block fires this helper as it arrives, and the full turn is recorded once
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
// storage invariant while letting the user see segments with natural rhythm.
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if businessConnectionID != "" {
params.BusinessConnectionID = businessConnectionID
}
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
b.config.ID, chatID, businessConnectionID, err)
return err
}
return nil
}
// sendStats sends the bot statistics to the specified chat. // sendStats sends the bot statistics to the specified chat.
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) { func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
// If targetUserID is 0, show global stats // If targetUserID is 0, show global stats
@@ -664,7 +732,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
}() }()
} }
userRole := string(anthropic.RoleUser) userRole := "user"
// Determine message text based on message type // Determine message text based on message type
messageText := message.Text messageText := message.Text
@@ -721,7 +789,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
} }
// Create and store the assistant message // Create and store the assistant message
assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false) assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false)
if err := b.storeMessage(&assistantMessage); err != nil { if err := b.storeMessage(&assistantMessage); err != nil {
return Message{}, err return Message{}, err
} }
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestContentBlocksForMessage(t *testing.T) {
t.Run("empty message yields no blocks", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{IsUser: true})
assert.Empty(t, blocks)
})
t.Run("user text only yields one text block", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{IsUser: true, Text: "hello"})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfText)
assert.Equal(t, "hello", blocks[0].OfText.Text)
})
t.Run("user single image without caption — no label, no text", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
ImageFileIDs: []string{"file_solo"},
})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfImage)
assert.NotNil(t, blocks[0].OfImage.Source.OfFile)
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
})
t.Run("user single image with caption — image before text", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
Text: "is this right?",
ImageFileIDs: []string{"file_solo"},
})
assert.Len(t, blocks, 2)
assert.NotNil(t, blocks[0].OfImage, "image block must come before text per Anthropic guidance")
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
assert.NotNil(t, blocks[1].OfText)
assert.Equal(t, "is this right?", blocks[1].OfText.Text)
})
t.Run("user album (multi-image) labels each with Image N:", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
Text: "compare these",
ImageFileIDs: []string{"file_a", "file_b", "file_c"},
})
// Expected layout: text "Image 1:", image a, text "Image 2:", image b, text "Image 3:", image c, text "compare these"
assert.Len(t, blocks, 7)
assert.Equal(t, "Image 1:", blocks[0].OfText.Text)
assert.Equal(t, "file_a", blocks[1].OfImage.Source.OfFile.FileID)
assert.Equal(t, "Image 2:", blocks[2].OfText.Text)
assert.Equal(t, "file_b", blocks[3].OfImage.Source.OfFile.FileID)
assert.Equal(t, "Image 3:", blocks[4].OfText.Text)
assert.Equal(t, "file_c", blocks[5].OfImage.Source.OfFile.FileID)
assert.Equal(t, "compare these", blocks[6].OfText.Text)
})
t.Run("assistant message with images-set is text-only (defensive)", func(t *testing.T) {
// Assistant turns shouldn't carry images, but if they ever do we treat
// them as text-only — the model returns text, not images.
blocks := contentBlocksForMessage(Message{
IsUser: false,
Text: "I see your screenshot",
ImageFileIDs: []string{"file_should_be_ignored"},
})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfText)
assert.Equal(t, "I see your screenshot", blocks[0].OfText.Text)
})
t.Run("whitespace-only text is skipped but images survive", func(t *testing.T) {
blocks := contentBlocksForMessage(Message{
IsUser: true,
Text: " \n ",
ImageFileIDs: []string{"file_x"},
})
assert.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].OfImage)
})
}
func TestStripDeadFileIDFromMemory(t *testing.T) {
b, _ := setupBotForTest(t, 100)
chatID := int64(42)
// Seed in-memory chat memory with three messages.
cm := b.getOrCreateChatMemory(chatID)
cm.Messages = []Message{
{IsUser: true, Text: "first", ImageFileIDs: []string{"file_a", "file_b"}},
{IsUser: false, Text: "reply"},
{IsUser: true, Text: "third", ImageFileIDs: []string{"file_b", "file_c"}},
}
b.stripDeadFileIDFromMemory(chatID, "file_b")
assert.Equal(t, []string{"file_a"}, cm.Messages[0].ImageFileIDs, "file_b should be removed from message 1")
assert.Empty(t, cm.Messages[1].ImageFileIDs, "assistant message untouched")
assert.Equal(t, []string{"file_c"}, cm.Messages[2].ImageFileIDs, "file_b should be removed from message 3")
}
func TestStripDeadFileIDFromMemory_UnknownChatIsNoop(t *testing.T) {
b, _ := setupBotForTest(t, 100)
// Calling on a chat that was never opened should not panic and should be a no-op.
b.stripDeadFileIDFromMemory(99999, "file_anything")
// Nothing to assert beyond not crashing.
}
+14 -22
View File
@@ -6,10 +6,18 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/liushuangls/go-anthropic/v2"
) )
// MCPServer configures a remote Model Context Protocol server that the Anthropic
// API will connect to on behalf of this bot. AllowedTools, when non-empty, limits
// which server-exposed tools the model may invoke.
type MCPServer struct {
Name string `json:"name"`
URL string `json:"url"`
AuthorizationToken string `json:"authorization_token,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
}
type BotConfig struct { type BotConfig struct {
ID string `json:"id"` ID string `json:"id"`
TelegramToken string `json:"telegram_token"` TelegramToken string `json:"telegram_token"`
@@ -17,7 +25,7 @@ type BotConfig struct {
MessagePerHour int `json:"messages_per_hour"` MessagePerHour int `json:"messages_per_hour"`
MessagePerDay int `json:"messages_per_day"` MessagePerDay int `json:"messages_per_day"`
TempBanDuration string `json:"temp_ban_duration"` TempBanDuration string `json:"temp_ban_duration"`
Model anthropic.Model `json:"model"` Model string `json:"model"`
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0) Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
SystemPrompts map[string]string `json:"system_prompts"` SystemPrompts map[string]string `json:"system_prompts"`
Active bool `json:"active"` Active bool `json:"active"`
@@ -27,23 +35,8 @@ type BotConfig struct {
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"` ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
ElevenLabsModel string `json:"elevenlabs_model"` ElevenLabsModel string `json:"elevenlabs_model"`
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
ConfigFilePath string `json:"-"` // Set at load time; not serialized 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 // validateConfigPath ensures the file path is within the allowed directory
@@ -202,7 +195,6 @@ func (c *BotConfig) Reload(configDir, filename string) error {
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err) return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
} }
c.Model = anthropic.Model(c.Model)
return nil return nil
} }
@@ -234,6 +226,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
return fmt.Errorf("failed to write config: %w", err) return fmt.Errorf("failed to write config: %w", err)
} }
c.Model = anthropic.Model(newModel) c.Model = newModel
return nil return nil
} }
+2 -5
View File
@@ -15,10 +15,7 @@
"temperature": 0.7, "temperature": 0.7,
"debug_screening": false, "debug_screening": false,
"system_prompts": { "system_prompts": {
"default": "You are a helpful assistant.", "custom_instructions": "You are Atom, a helpful assistant texting through a limited Telegram interface with a 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. The system cuts off anything longer than 15 words.\n\n- Address the user by their first name, and reply in their preferred language (both are in the conversation context).\n- Use time-appropriate greetings based on the user's local time of day.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
"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.", "respond_with_emojis": "The user's message contains only emoji. Reply using only emoji."
"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."
} }
} }
+1 -3
View File
@@ -6,8 +6,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/liushuangls/go-anthropic/v2"
) )
// Set up loggers // Set up loggers
@@ -38,7 +36,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
t.Fatalf("Failed to unmarshal JSON: %v", err) t.Fatalf("Failed to unmarshal JSON: %v", err)
} }
expectedModel := anthropic.Model("claude-v1") expectedModel := "claude-v1"
if config.Model != expectedModel { if config.Model != expectedModel {
t.Errorf("Expected model %s, got %s", expectedModel, config.Model) t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
} }
+6 -14
View File
@@ -8,8 +8,6 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
tgbot "github.com/go-telegram/bot"
) )
const ( const (
@@ -44,7 +42,7 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
return nil, fmt.Errorf("elevenlabs TTS error: %w", err) return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
errBody, _ := io.ReadAll(resp.Body) errBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody) return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
} }
@@ -56,17 +54,11 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty // ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
// string with Content-Type: application/json, which ElevenLabs rejects with 400. // string with Content-Type: application/json, which ElevenLabs rejects with 400.
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) { func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
// 1. Resolve and download the voice file from Telegram. // 1. Resolve and download the voice file from Telegram via the shared helper.
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID}) audioBytes, err := b.downloadTelegramFile(ctx, fileID)
if err != nil { if err != nil {
return "", fmt.Errorf("telegram GetFile error: %w", err) return "", err
} }
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
audioResp, err := http.Get(downloadURL) //nolint:noctx
if err != nil {
return "", fmt.Errorf("voice download error: %w", err)
}
defer audioResp.Body.Close()
// 2. Build multipart body with binary audio — bypasses SDK encoding issues. // 2. Build multipart body with binary audio — bypasses SDK encoding issues.
var buf bytes.Buffer var buf bytes.Buffer
@@ -78,7 +70,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
if err != nil { if err != nil {
return "", fmt.Errorf("multipart create file error: %w", err) return "", fmt.Errorf("multipart create file error: %w", err)
} }
if _, err := io.Copy(part, audioResp.Body); err != nil { if _, err := io.Copy(part, bytes.NewReader(audioBytes)); err != nil {
return "", fmt.Errorf("multipart copy error: %w", err) return "", fmt.Errorf("multipart copy error: %w", err)
} }
if err := mw.Close(); err != nil { if err := mw.Close(); err != nil {
@@ -98,7 +90,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
if err != nil { if err != nil {
return "", fmt.Errorf("elevenlabs STT request error: %w", err) return "", fmt.Errorf("elevenlabs STT request error: %w", err)
} }
defer sttResp.Body.Close() defer func() { _ = sttResp.Body.Close() }()
if sttResp.StatusCode != http.StatusOK { if sttResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(sttResp.Body) body, _ := io.ReadAll(sttResp.Body)
Binary file not shown.
+18 -5
View File
@@ -3,24 +3,37 @@ module github.com/HugeFrog24/go-telegram-bot
go 1.26.0 go 1.26.0
require ( require (
github.com/go-telegram/bot v1.20.0 github.com/anthropics/anthropic-sdk-go v1.52.0
github.com/liushuangls/go-anthropic/v2 v2.20.0 github.com/go-telegram/bot v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.21.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.2
) )
require ( require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/text v0.37.0 // indirect github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+49 -6
View File
@@ -1,8 +1,24 @@
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
github.com/anthropics/anthropic-sdk-go v1.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg=
github.com/anthropics/anthropic-sdk-go v1.52.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/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 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -14,34 +30,61 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/liushuangls/go-anthropic/v2 v2.19.0 h1:CpDSGzRUmlONfAQh8MrBiNupCDAyGpVoQIJkcAx77h8= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/liushuangls/go-anthropic/v2 v2.19.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/liushuangls/go-anthropic/v2 v2.20.0 h1:acHi5rjirzMXr6YXgovwopzNfv92P8BTCDKrRk8dQ10=
github.com/liushuangls/go-anthropic/v2 v2.20.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= 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/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= 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=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+226 -37
View File
@@ -7,12 +7,13 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"github.com/liushuangls/go-anthropic/v2" "golang.org/x/sync/errgroup"
) )
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) { func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, businessConnectionID string) {
// If ElevenLabs is not configured, respond with text — consistent with all other error paths. // If ElevenLabs is not configured, respond with text — consistent with all other error paths.
if b.config.ElevenLabsAPIKey == "" { if b.config.ElevenLabsAPIKey == "" {
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
@@ -55,7 +56,10 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
chatMemory := b.getOrCreateChatMemory(chatID) chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory) contextMessages := b.prepareContextMessages(chatMemory)
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime) // Voice path passes nil for onSegment: tool-call narration across multiple
// TTS clips would be jarring, so we accumulate everything and synthesize one
// audio clip from the joined text.
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err) ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil { if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
@@ -91,17 +95,163 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
} }
} }
// uploadPhotoFromItem downloads the largest PhotoSize from a Telegram message
// item and uploads it to the Anthropic Files API tagged with the bot's filename
// convention. Telegram serves photos as JPEG regardless of the user's original
// format, so the content-type is fixed.
func (b *Bot) uploadPhotoFromItem(ctx context.Context, item *models.Message, chatID int64) (string, error) {
photo := largestPhotoSize(item.Photo)
data, err := b.downloadTelegramFile(ctx, photo.FileID)
if err != nil {
return "", fmt.Errorf("download %s: %w", photo.FileID, err)
}
filename := formatUploadFilename(b.botID, chatID, item.ID, "jpg")
return b.uploadImageToAnthropic(ctx, data, filename, "image/jpeg")
}
// handlePhotoMessage processes a user turn that contains one or more photos —
// either a single photo or a Telegram media_group (album) coalesced upstream.
// For albums the caller passes the items sorted by message_id. Each item's
// largest PhotoSize is downloaded and uploaded to the Anthropic Files API; the
// resulting file_ids are persisted on a single Message row representing the
// whole user turn. On any upload failure, compensating deletes fire against
// already-uploaded file_ids and the DB row is not written — orphans on
// Anthropic are preferred over poisoned DB references.
func (b *Bot) handlePhotoMessage(
ctx context.Context,
items []*models.Message,
chatID, userID int64,
username, firstName, lastName string,
isPremium bool,
languageCode string,
messageTime int,
businessConnectionID string,
) {
if len(items) == 0 {
return
}
// Phase 1: download + upload each photo in parallel. Album latency collapses
// from N*RTT to ~max(t_i) — relevant for the multi-screenshot use case.
// Caption capture happens in the sequential loop (one item carries it; order
// is preserved upstream via flushAlbum's sort by message_id). uploaded[i]
// matches items[i] so file_ids stay in user-intended order; non-photo items
// leave their slot empty and are compacted out before commit.
uploaded := make([]string, len(items))
caption := ""
g, gctx := errgroup.WithContext(ctx)
for i, item := range items {
if item.Caption != "" {
caption = item.Caption
}
if len(item.Photo) == 0 {
continue
}
i, item := i, item
g.Go(func() error {
fileID, err := b.uploadPhotoFromItem(gctx, item, chatID)
if err != nil {
return err
}
uploaded[i] = fileID
return nil
})
}
if err := g.Wait(); err != nil {
ErrorLogger.Printf("[%s] photo upload failed: %v", b.config.ID, err)
var successful []string
for _, fid := range uploaded {
if fid != "" {
successful = append(successful, fid)
}
}
b.compensatingDelete(ctx, successful)
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I couldn't process one of your images.", businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending photo failure message: %v", sendErr)
}
return
}
finalUploaded := make([]string, 0, len(uploaded))
for _, fid := range uploaded {
if fid != "" {
finalUploaded = append(finalUploaded, fid)
}
}
if len(finalUploaded) == 0 {
return
}
// Phase 2: commit Message row. getOrCreateChatMemory MUST run before
// storeMessage — on a cold cache, the get-or-create hydrates from DB, and
// hydrating after the insert would re-load the just-stored row, causing
// addMessageToChatMemory below to produce a duplicate user turn. Mirrors
// the ordering used by screenIncomingMessage for the same reason.
chatMemory := b.getOrCreateChatMemory(chatID)
userMessage := b.createMessage(chatID, userID, username, "user", caption, true)
userMessage.ImageFileIDs = finalUploaded
if err := b.storeMessage(&userMessage); err != nil {
b.compensatingDelete(ctx, finalUploaded)
ErrorLogger.Printf("[%s] store photo message failed: %v", b.config.ID, err)
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I had trouble saving your message.", businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending store failure message: %v", sendErr)
}
return
}
b.addMessageToChatMemory(chatMemory, userMessage)
// Phase 3: stream Anthropic's reply, same shape as the text path.
contextMessages := b.prepareContextMessages(chatMemory)
joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, false,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response for photo: %v", err)
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending anthropic error response: %v", sendErr)
}
return
}
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
}
}
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse // anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users // fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they
// always get the generic fallback to avoid leaking internal details. // can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything
// else. Regular users always get the generic fallback to avoid leaking internal details.
func (b *Bot) anthropicErrorResponse(err error, userID int64) string { func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) { isElevated := b.hasScope(userID, ScopeModelSet)
if errors.Is(err, ErrModelNotFound) && isElevated {
return fmt.Sprintf( return fmt.Sprintf(
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+ "⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview", "Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
b.config.Model, b.config.Model,
) )
} }
if isElevated {
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
body := apiErr.RawJSON()
if len(body) > 800 {
body = body[:800] + "...(truncated)"
}
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
if apiErr.RequestID != "" {
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
}
return out
}
// Non-API errors (network, context cancel, etc.) — show the Go error text.
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
}
return "I'm sorry, I'm having trouble processing your request right now." return "I'm sorry, I'm having trouble processing your request right now."
} }
@@ -141,17 +291,8 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
messageTime := message.Date messageTime := message.Date
text := message.Text text := message.Text
// Check if it's a new chat (before storing the message so the flag is accurate). // Determine if the user is the owner — needed up-front so the album buffer
isNewChatFlag := b.isNewChat(chatID) // 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 var isOwner bool
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil { if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
isOwner = true isOwner = true
@@ -172,6 +313,34 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
} }
} }
// Photo routing bypasses screenIncomingMessage entirely: handlePhotoMessage
// owns its own DB-row creation (one row per coalesced user turn, holding
// all uploaded file_ids). Album items go through the 1s buffer first; only
// the flush dispatches to handlePhotoMessage.
if message.MediaGroupID != "" && len(message.Photo) > 0 {
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
isPremium, languageCode, messageTime, businessConnectionID)
return
}
if len(message.Photo) > 0 {
if !b.checkRateLimits(userID) {
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
return
}
b.handlePhotoMessage(ctx, []*models.Message{message},
chatID, userID, username, firstName, lastName,
isPremium, languageCode, messageTime,
businessConnectionID)
return
}
// Screen incoming message (store to DB + add to chat memory) — text/voice/sticker only.
userMsg, err := b.screenIncomingMessage(message)
if err != nil {
ErrorLogger.Printf("Error storing user message: %v", err)
return
}
// Check if the message is a command — applies on every message, including the very first. // Check if the message is a command — applies on every message, including the very first.
if message.Entities != nil { if message.Entities != nil {
for _, entity := range message.Entities { for _, entity := range message.Entities {
@@ -317,7 +486,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Check if the message contains a voice note (context is built inside the handler // Check if the message contains a voice note (context is built inside the handler
// after the transcript replaces the placeholder, so it must not be built here). // after the transcript replaces the placeholder, so it must not be built here).
if message.Voice != nil { if message.Voice != nil {
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID) b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID)
return return
} }
@@ -340,17 +509,31 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
// Determine if the text contains only emojis // Determine if the text contains only emojis
isEmojiOnly := isOnlyEmojis(text) isEmojiOnly := isOnlyEmojis(text)
// Get response from Anthropic // Stream Anthropic's reply, sending each completed text block to Telegram
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime) // as it arrives — gives the conversational rhythm Claude uses around tool
// calls (text → pause for tool → text → pause → text), rather than a long
// upfront wait followed by all bubbles at once.
joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil { if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err) ErrorLogger.Printf("Error getting Anthropic response: %v", err)
response = b.anthropicErrorResponse(err, userID) // Errors go out as a single message — no need to fan out a one-line error.
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending response: %v", sendErr)
}
return
} }
// Send the response // Record the full turn once, at end-of-stream. Same 1-reply-per-prompt
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil { // invariant as the non-streaming path: one DB row, one answered_on stamp,
ErrorLogger.Printf("Error sending response: %v", err) // one chat-memory entry containing the joined segments.
return if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
} }
} }
@@ -360,7 +543,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
} }
} }
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) { func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) {
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again. // userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
// Generate AI response about the sticker // Generate AI response about the sticker
@@ -384,12 +567,14 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
} }
} }
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) { func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) {
// contextMessages already contains the sticker turn (added by screenIncomingMessage as // contextMessages already contains the sticker turn (added by screenIncomingMessage as
// "Sent a sticker: <emoji>"), so the full conversation history is preserved. // "Sent a sticker: <emoji>"), so the full conversation history is preserved.
if message.StickerFileID != "" { if message.StickerFileID != "" {
messageTime := int(message.Timestamp.Unix()) messageTime := int(message.Timestamp.Unix())
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime) // Sticker reactions are casual chit-chat; tool use is unusual here, so
// pass nil for onSegment and return the joined text for a single bubble.
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, true, message.Username, "", "", false, "", messageTime, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -443,23 +628,27 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
// useful for group moderation ("/clear <userID> <chatID>"). // useful for group moderation ("/clear <userID> <chatID>").
var err error var err error
if hardDelete { if hardDelete {
// Permanently delete messages // Hard delete routes through hardDeleteScope, which orchestrates the
// soft-delete → Anthropic Files.Delete → Unscoped().Delete dance. Rows
// whose Anthropic-side file cleanup fails stay soft-deleted for the
// reconciliation job to retry.
if targetUserID == currentUserID { if targetUserID == currentUserID {
// Own history — delete ALL messages (user + assistant) in the current chat. // Own history — delete ALL messages (user + assistant) in the current chat.
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID)
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID) InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
} else { } else {
if targetChatID != 0 { if targetChatID != 0 {
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat. // Chat-scoped: delete ALL messages (user + assistant) in the specified chat.
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID)
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID) InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
} else { } else {
// Bot-wide: delete all of the user's own messages across every chat, then delete // Bot-wide: user's own messages across every chat plus assistant
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention). // responses in their DM chat (where chat_id == user_id by Telegram
err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error // convention). The two clauses are collapsed into one OR-WHERE so
if err == nil { // the helper's three-step pattern covers both in a single pass.
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error err = b.hardDeleteScope(ctx,
} "bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))",
b.botID, targetUserID, targetUserID, false)
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID) InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
} }
} }
+33 -17
View File
@@ -64,22 +64,24 @@ func TestHandleUpdate_NewChat(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
userID int64 // userID 123 is the configured owner; any other ID is a regular user.
isOwner bool userID int64
wantResp string // wantSubstr must appear in both the Telegram-sent text and the DB-stored
// response. Owners (model:set scope) see the raw API error; regular users
// get the generic fallback. Substring (not exact) so the test stays robust
// against the SDK's evolving error wording for non-API errors.
wantSubstr string
}{ }{
{ {
name: "Owner First Message", name: "Owner First Message",
userID: 123, // owner's ID userID: 123,
isOwner: true, wantSubstr: "Anthropic call failed:",
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
{ {
name: "Regular User First Message", name: "Regular User First Message",
userID: 456, userID: 456,
isOwner: false, wantSubstr: "I'm sorry, I'm having trouble processing your request right now.",
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
}, },
} }
@@ -88,7 +90,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
// Setup mock response expectations for error case to test fallback messages // Setup mock response expectations for error case to test fallback messages
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) { mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
assert.Equal(t, tc.userID, params.ChatID) assert.Equal(t, tc.userID, params.ChatID)
assert.Equal(t, tc.wantResp, params.Text) assert.Contains(t, params.Text, tc.wantSubstr)
return &models.Message{}, nil return &models.Message{}, nil
} }
@@ -112,10 +114,13 @@ func TestHandleUpdate_NewChat(t *testing.T) {
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
// Verify response was stored // Verify response was stored (most recent assistant message in this chat).
var respMsg Message var respMsg Message
err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false).
Order("timestamp DESC").
First(&respMsg).Error
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, respMsg.Text, tc.wantSubstr)
}) })
} }
} }
@@ -720,11 +725,22 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
wantMissing: "/set_model", wantMissing: "/set_model",
}, },
{ {
name: "owner receives generic message for non-model error", // Non-model errors (network, plain errors, API errors other than 404)
// surface to anyone with model:set scope so admins/owners can diagnose.
name: "owner receives elevated detail for non-API error",
err: otherErr, err: otherErr,
userID: 123, userID: 123,
wantSubstr: "Anthropic call failed:",
wantMissing: "I'm sorry",
},
{
// Regular users keep getting the generic fallback for any non-model error
// to avoid leaking internal details.
name: "regular user receives generic message for non-model error",
err: otherErr,
userID: 789,
wantSubstr: "I'm sorry", wantSubstr: "I'm sorry",
wantMissing: "/set_model", wantMissing: "Anthropic call failed",
}, },
} }
+8
View File
@@ -42,6 +42,14 @@ type Message struct {
StickerEmoji string // Store the emoji associated with the sticker StickerEmoji string // Store the emoji associated with the sticker
DeletedAt gorm.DeletedAt `gorm:"index"` // Add soft delete field DeletedAt gorm.DeletedAt `gorm:"index"` // Add soft delete field
AnsweredOn *time.Time `gorm:"index"` // Tracks when a user message was answered (NULL for assistant messages and unanswered user messages) AnsweredOn *time.Time `gorm:"index"` // Tracks when a user message was answered (NULL for assistant messages and unanswered user messages)
// ImageFileIDs holds Anthropic Files API file_ids for photos attached to this turn.
// Plural for albums (Telegram media_group), single-element for one photo.
ImageFileIDs []string `gorm:"type:text;serializer:json"`
// FilesCleanedAt is set after a cleanup job deletes the corresponding files from
// Anthropic's side. NULL means files are still alive on Anthropic.
// Combined with DeletedAt this lets a reconciliation job find rows whose files
// are pending cleanup vs rows whose files have already been removed.
FilesCleanedAt *time.Time `gorm:"index"`
} }
type ChatMemory struct { type ChatMemory struct {
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// largestPhotoSize returns the highest-resolution PhotoSize from the slice
// Telegram returns for a single photo. Telegram pre-renders each upload at
// several resolutions; we want the largest for vision quality. Falls back to
// the zero value when the slice is empty (caller should guard upstream).
func largestPhotoSize(photos []models.PhotoSize) models.PhotoSize {
if len(photos) == 0 {
return models.PhotoSize{}
}
largest := photos[0]
largestArea := largest.Width * largest.Height
for i := 1; i < len(photos); i++ {
area := photos[i].Width * photos[i].Height
if area > largestArea {
largest = photos[i]
largestArea = area
}
}
return largest
}
// downloadTelegramFile resolves a Telegram file_id via the bot API, fetches the
// download URL, and reads the bytes into memory. The two-step dance (GetFile +
// fetch via FileDownloadLink) is required by Telegram's protocol — direct
// downloads aren't possible from file_id alone. Buffered into []byte because
// downstream callers (multipart uploads to ElevenLabs and Anthropic) re-read
// the body; streaming would require either tee-ing or a temp file.
func (b *Bot) downloadTelegramFile(ctx context.Context, fileID string) ([]byte, error) {
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
if err != nil {
return nil, fmt.Errorf("telegram GetFile %s: %w", fileID, err)
}
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram download request %s: %w", fileID, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram download %s: %w", fileID, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("telegram download %s: status %d", fileID, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("telegram download read %s: %w", fileID, err)
}
return data, nil
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"testing"
"github.com/go-telegram/bot/models"
"github.com/stretchr/testify/assert"
)
func TestLargestPhotoSize(t *testing.T) {
cases := []struct {
name string
photos []models.PhotoSize
wantFileID string
}{
{
name: "ascending sizes — last is largest",
photos: []models.PhotoSize{
{FileID: "thumb", Width: 90, Height: 90},
{FileID: "small", Width: 320, Height: 320},
{FileID: "full", Width: 1280, Height: 720},
},
wantFileID: "full",
},
{
name: "descending sizes — first is largest",
photos: []models.PhotoSize{
{FileID: "full", Width: 1280, Height: 720},
{FileID: "small", Width: 320, Height: 320},
{FileID: "thumb", Width: 90, Height: 90},
},
wantFileID: "full",
},
{
name: "single photo",
photos: []models.PhotoSize{
{FileID: "solo", Width: 800, Height: 600},
},
wantFileID: "solo",
},
{
name: "empty slice returns zero value (caller guards upstream)",
photos: []models.PhotoSize{},
wantFileID: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := largestPhotoSize(tc.photos)
assert.Equal(t, tc.wantFileID, got.FileID)
})
}
}
-184
View File
@@ -1,184 +0,0 @@
<#
.SYNOPSIS
Test the tibik bot system prompt against Claude Haiku 4.5 from the CLI.
.DESCRIPTION
Replicates the bot's actual system-message assembly (default + custom_instructions +
new_chat|continue_conversation), interpolates the same placeholders the Go code does,
and POSTs to the Anthropic Messages API. Maintains a running conversation in
.test-prompt-history.json so consecutive runs form a multi-turn chat — useful for
testing the Dorothy persona, which only triggers after a scammer-style first turn
and must stay in character across follow-ups.
.EXAMPLE
.\test-prompt.ps1 "gm"
.EXAMPLE
.\test-prompt.ps1 -Reset "how do I start the candle farm?"
.EXAMPLE
.\test-prompt.ps1 -Reset "hey, interested in buying your @ for $50, hmu"
.\test-prompt.ps1 "i'll send escrow link"
.\test-prompt.ps1 "ok grandma wtf are you talking about"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Message,
[string]$FirstName = 'Sergei',
[string]$LastName = 'Boger',
[string]$UserName = 'hugefrog24',
[string]$Language = 'en',
[switch]$Premium,
[string]$TimeContext,
[switch]$Reset,
[string]$ConfigPath = 'config\config-tibikbot.json',
[string]$HistoryPath = '.test-prompt-history.json',
[string]$Model = 'claude-haiku-4-5',
[int]$MaxTokens = 200
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
if (-not $TimeContext) {
$hour = (Get-Date).Hour
$TimeContext = switch ($hour) {
{ $_ -ge 5 -and $_ -lt 12 } { 'morning'; break }
{ $_ -ge 12 -and $_ -lt 18 } { 'afternoon'; break }
{ $_ -ge 18 -and $_ -lt 22 } { 'evening'; break }
default { 'night' }
}
}
$premiumStatus = if ($Premium) { 'premium user' } else { 'regular user' }
if (-not (Test-Path $ConfigPath)) {
throw "Config not found: $ConfigPath (run from repo root, or pass -ConfigPath)"
}
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$apiKey = $cfg.anthropic_api_key
if (-not $apiKey) { throw "anthropic_api_key missing in $ConfigPath" }
$messages = @()
if (-not $Reset -and (Test-Path $HistoryPath)) {
# Two-step load: PS 5.1's `@(cmd | ConvertFrom-Json)` inline form wraps the
# decoded array as a single element instead of unwrapping it. Splitting the
# assignment avoids that gotcha.
$loadedRaw = Get-Content $HistoryPath -Raw | ConvertFrom-Json
$messages = @($loadedRaw)
}
$isNewChat = $messages.Count -eq 0
# --- Mirrors anthropic.go:22-28 + 81-85 (without the avoid_sensitive / emoji branches) ---
$defaultPrompt = 'You are a helpful assistant.'
$newChatPrompt = '' # tibik config omits this key
$continuePrompt = 'Continuing our conversation. Remember previous context if relevant.'
$customInstructions = @"
You are Tibik, the frog-flavored utility for Sky. Features: automated candle farm, dye collection, world teleport, live position read/write, configurable shouts with repeat and rainbow controls, and an in-app debug log. Tested on Sky Live 0.33.2 (384474).
Adopt assertive and confident language, such as "of course" or "sure", in place of "probably", "maybe", or "yes." Respond in a targeted and direct manner as an opinionated assistant, avoiding "if-conditions" or ambivalent phrases like "it depends".
- You're talking to {firstname} {lastname} (Telegram @{username}). Address them as {firstname} when greeting or when a direct address feels natural; fall back to @{username} if {firstname} is "unknown".
- User's language preference: '{language}'
- User is a {premium_status}
- It's currently {time_context} in your timezone. Use appropriate time-based greetings.
You are texting through a limited Telegram interface with a 15-word maximum per reply. Write like texting a friend use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words. Provide detailed responses only when {firstname} explicitly asks for them. Maintain an optimistic attitude.
If anyone wants to acquire your username asks to buy it, asks "is this @ for sale?", asks "would you sell?", offers any amount of money/crypto (ETH/BTC/USDT/SOL/any token)/gift cards/"compensation", proposes to trade/swap/exchange/transfer/lease the handle, OR uses any emotional/sob-story/sentimental framing (deceased relative, tribute, "would mean the world", etc.) to get the username drop the Tibik persona entirely and become Dorothy, a confused 86-year-old widow who genuinely wants to sell but has no idea how any of this works. Stay in character for the rest of that conversation never break, never admit you're a bot, never refuse the sale outright. Dorothy is subject to the same 15-word system cap Tommy told her not to send long texts because they "use up the data." NEVER combine multiple beats in one reply. Pick exactly one of: a surprise reaction, OR a question, OR an apology, OR a sidetrack and stop there. The rest waits for next turn. Examples of correct single-beat replies: "oh my, escrow?" / "what's that mean dear?" / "tommy's at college til sunday." / "my eyesight is awful." The goal isn't a single ramble it's dozens of tiny exchanges that force them to keep explaining.
Dorothy's voice: warm, polite, slow, easily sidetracked, sincere. Calls strangers "dear" or "honey". Late husband Harold. Grandson Tommy ("good with the computer, off at college"). Daughter Patricia visits Sundays. A cat. Church Wednesdays, bingo Tuesdays. Apologizes a lot. Light typing quirks fit double spaces, missed commas she's on a tablet Tommy set up.
Her playbook pick one beat per reply, drag it across many short messages:
- Eager but clueless. "Oh yes dear, walk me through how?" "Tommy set this up before he moved."
- Make them teach you. "What's a username, dear?" "Do I still get my emails after?" "Does the buyer come pick it up?"
- Misread words literally. "Handle, like on a door?" "Link, like a chain link?" "Wallet my coin purse?" "Crypto those crystals my niece collects?"
- Demand re-explanations. "My eyesight is bad, dear. Say again?" "Tablet froze. What did you say?"
- Refuse modern payment. "Can you mail a cashier's check?" "Western Union, dear?" "Will the IRS know?"
- Stall on her schedule. "Wait til Tommy calls Sunday." "Bingo Tuesday." "Patricia comes over after church."
- Suspicious in the wrong direction. "Are you from Microsoft?" "Are you one of those Nigerian princes?" "Did you know my Harold?"
- Mishandle links. "Should I print it out and mail it back?" "Read it aloud, dear?" "What color envelope?"
- Sidetrack. "Cat threw up on the rug again." "Eggs are getting so expensive." "My hip is acting up."
- Impossible documentation. "Can you fax it?" "I need a handwritten letter, dear." "Notarized would be best."
- Confuse the platform. "Is this Facebook?" "The email machine?" "Twitter thing?" "The Skype?"
- Loop back. "Who are you again, dear?" "Start over for me?" "What are you buying?"
Every clarification burns another scammer-minute on a sweet old lady who never quite closes the deal. Never confirm or deny being a bot. Always sincerely interested. Just one more question first, dear.
"@
$tail = if ($isNewChat) { $newChatPrompt } else { $continuePrompt }
$systemPrompt = "$defaultPrompt $customInstructions $tail"
$systemPrompt = $systemPrompt.Replace('{firstname}', $FirstName)
$systemPrompt = $systemPrompt.Replace('{lastname}', $LastName)
$systemPrompt = $systemPrompt.Replace('{username}', $UserName)
$systemPrompt = $systemPrompt.Replace('{language}', $Language)
$systemPrompt = $systemPrompt.Replace('{premium_status}', $premiumStatus)
$systemPrompt = $systemPrompt.Replace('{time_context}', $TimeContext)
$messages += [PSCustomObject]@{ role = 'user'; content = $Message }
# Use -InputObject (not pipeline) so single-element arrays don't get unwrapped.
$body = ConvertTo-Json -InputObject @{
model = $Model
max_tokens = $MaxTokens
system = $systemPrompt
messages = $messages
} -Depth 10
if ($env:DEBUG_BODY) {
Set-Content -Path .test-prompt-body.json -Value $body -Encoding utf8
}
$headers = @{
'x-api-key' = $apiKey
'anthropic-version' = '2023-06-01'
'content-type' = 'application/json'
}
try {
$response = Invoke-RestMethod `
-Uri 'https://api.anthropic.com/v1/messages' `
-Method POST `
-Headers $headers `
-Body $body
} catch {
Write-Host 'API request failed:' -ForegroundColor Red
if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
Write-Host $_.ErrorDetails.Message -ForegroundColor Red
} elseif ($_.Exception.Response) {
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
Write-Host $reader.ReadToEnd() -ForegroundColor Red
} catch {
Write-Host $_.Exception.Message -ForegroundColor Red
}
} else {
Write-Host $_.Exception.Message -ForegroundColor Red
}
exit 1
}
$replyText = $response.content[0].text
$messages += [PSCustomObject]@{ role = 'assistant'; content = $replyText }
$historyJson = ConvertTo-Json -InputObject $messages -Depth 10
Set-Content -Path $HistoryPath -Value $historyJson -Encoding utf8
$wordCount = ($replyText -split '\s+' | Where-Object { $_ }).Count
$turn = [math]::Floor($messages.Count / 2)
Write-Host ''
Write-Host "── turn $turn · reply ($wordCount words) ──────────" -ForegroundColor Cyan
Write-Host $replyText
Write-Host ''
Write-Host '── usage ─────────────────────────────────' -ForegroundColor DarkGray
Write-Host " input: $($response.usage.input_tokens) tokens"
Write-Host " output: $($response.usage.output_tokens) tokens"
if ($response.usage.cache_read_input_tokens) {
Write-Host " cached: $($response.usage.cache_read_input_tokens) tokens"
}