mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-06-30 06:17:12 +00:00
Compare commits
3 Commits
main
..
27fe9e69ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 27fe9e69ed | |||
| 480764f068 | |||
| 41c9b8075b |
@@ -0,0 +1,14 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
always_on: true
|
||||
trigger: always_on
|
||||
applyTo: "**"
|
||||
description: Snyk Security At Inception
|
||||
---
|
||||
|
||||
# Project security best practices
|
||||
|
||||
- Always run snyk_code_scan tool for new first party code that is generated in a Snyk-supported language.
|
||||
- If any security issues are found based on newly introduced or modified code or dependencies, attempt to fix the issues using the results context from Snyk.
|
||||
- Rescan the code after fixing the issues to ensure that the issues were fixed and that there are no newly introduced issues.
|
||||
- Repeat this process until no new issues are found.
|
||||
@@ -7,18 +7,38 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
# Common setup job that other jobs can depend on
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.0'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go mod tidy
|
||||
|
||||
# Lint job
|
||||
lint:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12.2
|
||||
version: v2.10
|
||||
args: --timeout 5m
|
||||
|
||||
# Test job
|
||||
test:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -29,9 +49,10 @@ jobs:
|
||||
|
||||
# Security scan job
|
||||
security:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: securego/gosec@v2.26.1
|
||||
- uses: securego/gosec@master
|
||||
with:
|
||||
args: ./...
|
||||
|
||||
@@ -16,3 +16,6 @@ bot.db
|
||||
# All config files except for the default
|
||||
config/*
|
||||
!config/default.json
|
||||
|
||||
# test-prompt.ps1 conversation history
|
||||
.test-prompt-history.json
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -52,21 +52,6 @@ A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic AP
|
||||
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
|
||||
|
||||
To enable the bot to start automatically on system boot and run in the background, set up a systemd unit.
|
||||
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// albumFlushWindow is the debounce delay before a buffered Telegram media_group
|
||||
// is flushed as a single coalesced user turn. 1s matches the de-facto community
|
||||
// standard across the dominant third-party album plugins (aiogram-media-group,
|
||||
// DieTime/telegraf-media-group) and sits above the sub-100ms values documented
|
||||
// as lossy under network jitter (openclaw#1811). Telegram has no official
|
||||
// "album complete" signal, so timeout-based flush is the only option.
|
||||
const albumFlushWindow = 1 * time.Second
|
||||
|
||||
// pendingAlbum holds a Telegram media_group as its items arrive, plus the
|
||||
// per-user metadata captured from the first item. All items in an album share
|
||||
// the same chat/user, so we record metadata once and reuse it at flush time.
|
||||
type pendingAlbum struct {
|
||||
items []*models.Message
|
||||
// Metadata captured from the first arriving item. Albums always come from
|
||||
// the same user/chat, so these are stable across the buffering window.
|
||||
chatID, userID int64
|
||||
username, firstName, lastName, languageCode string
|
||||
isPremium bool
|
||||
messageTime int
|
||||
businessConnectionID string
|
||||
// timer flushes the album after albumFlushWindow with no further arrivals.
|
||||
// Each new arrival stops the previous timer (best-effort) and installs a
|
||||
// fresh one — the standard debounce pattern.
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// bufferAlbumItem appends an incoming Telegram album item to the per-MediaGroupID
|
||||
// buffer. On first arrival it captures the user/chat metadata and starts the
|
||||
// flush timer; on subsequent arrivals it appends the item and extends the timer.
|
||||
// The 1s debounce gives the rest of the album time to arrive over the network.
|
||||
func (b *Bot) bufferAlbumItem(
|
||||
ctx context.Context,
|
||||
msg *models.Message,
|
||||
chatID, userID int64,
|
||||
username, firstName, lastName string,
|
||||
isPremium bool,
|
||||
languageCode string,
|
||||
messageTime int,
|
||||
businessConnectionID string,
|
||||
) {
|
||||
b.albumBuffersMu.Lock()
|
||||
defer b.albumBuffersMu.Unlock()
|
||||
|
||||
album, exists := b.albumBuffers[msg.MediaGroupID]
|
||||
if !exists {
|
||||
album = &pendingAlbum{
|
||||
chatID: chatID,
|
||||
userID: userID,
|
||||
username: username,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
isPremium: isPremium,
|
||||
languageCode: languageCode,
|
||||
messageTime: messageTime,
|
||||
businessConnectionID: businessConnectionID,
|
||||
}
|
||||
b.albumBuffers[msg.MediaGroupID] = album
|
||||
}
|
||||
album.items = append(album.items, msg)
|
||||
|
||||
// Stop the previous timer best-effort; even if it already fired the race
|
||||
// is benign because flushAlbum removes the map entry under the lock — a
|
||||
// late arrival would simply seed a fresh album.
|
||||
if album.timer != nil {
|
||||
album.timer.Stop()
|
||||
}
|
||||
mediaGroupID := msg.MediaGroupID
|
||||
album.timer = time.AfterFunc(albumFlushWindow, func() {
|
||||
b.flushAlbum(ctx, mediaGroupID)
|
||||
})
|
||||
}
|
||||
|
||||
// flushAlbum is called by the flush timer (or by code that needs to force-flush
|
||||
// during shutdown). It removes the album from the buffer, sorts items by
|
||||
// message_id (Telegram does not guarantee in-order arrival), runs the rate-limit
|
||||
// check once, and dispatches to handlePhotoMessage.
|
||||
func (b *Bot) flushAlbum(ctx context.Context, mediaGroupID string) {
|
||||
b.albumBuffersMu.Lock()
|
||||
album, exists := b.albumBuffers[mediaGroupID]
|
||||
if !exists {
|
||||
b.albumBuffersMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(b.albumBuffers, mediaGroupID)
|
||||
items := album.items
|
||||
captured := *album // copy fields for use after unlock
|
||||
b.albumBuffersMu.Unlock()
|
||||
|
||||
// Sort by Telegram message_id: items in an album arrive as separate Updates
|
||||
// over the network and may interleave. Sorting restores the user's intended
|
||||
// order before we hand them to Claude.
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
|
||||
// Rate-limit fires once per coalesced album, not once per item.
|
||||
if !b.checkRateLimits(captured.userID) {
|
||||
b.sendRateLimitExceededMessage(ctx, captured.chatID, captured.businessConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
b.handlePhotoMessage(
|
||||
ctx, items,
|
||||
captured.chatID, captured.userID,
|
||||
captured.username, captured.firstName, captured.lastName,
|
||||
captured.isPremium, captured.languageCode, captured.messageTime,
|
||||
captured.businessConnectionID,
|
||||
)
|
||||
}
|
||||
+102
-319
@@ -4,13 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/packages/param"
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
// ErrModelNotFound is returned when the configured Anthropic model is no longer available
|
||||
@@ -18,340 +15,126 @@ import (
|
||||
// actionable message to admins/owners while keeping the response vague for regular users.
|
||||
var ErrModelNotFound = errors.New("model not found or deprecated")
|
||||
|
||||
// maxFileNotFoundRetries caps the runtime 404 self-heal loop. If multiple
|
||||
// referenced file_ids are gone from Anthropic simultaneously (admin purge, AUP
|
||||
// enforcement, etc.), we strip them one at a time and retry. Three attempts
|
||||
// covers all realistic cascades without leaving the call hanging indefinitely.
|
||||
const maxFileNotFoundRetries = 3
|
||||
func (b *Bot) getAnthropicResponse(ctx context.Context, messages []anthropic.Message, isNewChat, isOwner, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int) (string, error) {
|
||||
// Use prompts from config
|
||||
var systemMessage string
|
||||
if isNewChat {
|
||||
systemMessage = b.config.SystemPrompts["new_chat"]
|
||||
} else {
|
||||
systemMessage = b.config.SystemPrompts["continue_conversation"]
|
||||
}
|
||||
|
||||
// mcpUnsupportedSentinel is the placeholder text Anthropic's server-side MCP
|
||||
// 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"
|
||||
// Combine default prompt with custom instructions
|
||||
systemMessage = b.config.SystemPrompts["default"] + " " + b.config.SystemPrompts["custom_instructions"] + " " + systemMessage
|
||||
|
||||
// mcpUnsupportedCount tallies sentinel hits across the whole process lifetime
|
||||
// (all bots, all MCP servers) so the true rate is visible — the chat masks most
|
||||
// of them because the model often degrades gracefully. The per-hit ERROR line
|
||||
// carries the bot ID and server name for attribution; this is just the running
|
||||
// total.
|
||||
var mcpUnsupportedCount atomic.Uint64
|
||||
// Handle username placeholder
|
||||
usernameValue := username
|
||||
if username == "" {
|
||||
usernameValue = "unknown" // Use "unknown" when username is not available
|
||||
}
|
||||
systemMessage = strings.ReplaceAll(systemMessage, "{username}", usernameValue)
|
||||
|
||||
// mcpCall pairs a tool_use's server+name+input so a later unsupported result —
|
||||
// which arrives in a SEPARATE block linked only by tool_use_id — can name the
|
||||
// server and query that triggered it. server matters once a bot configures more
|
||||
// than one MCP server (the config supports a slice), otherwise the log can't say
|
||||
// which server choked.
|
||||
type mcpCall struct{ server, name, input string }
|
||||
// Handle firstname placeholder
|
||||
firstnameValue := firstName
|
||||
if firstName == "" {
|
||||
firstnameValue = "unknown" // Use "unknown" when first name is not available
|
||||
}
|
||||
systemMessage = strings.ReplaceAll(systemMessage, "{firstname}", firstnameValue)
|
||||
|
||||
// getAnthropicResponse streams the model's response. Each completed text block
|
||||
// is delivered to onSegment as soon as the model finishes writing it — so the
|
||||
// caller can send segments to Telegram with natural rhythm around tool calls,
|
||||
// rather than batched at the very end of the turn. onSegment may be nil for
|
||||
// callers that only want the joined text (voice TTS, sticker reactions, etc.).
|
||||
// The returned string is every text segment joined by blank lines.
|
||||
//
|
||||
// chatID is required for the runtime 404 self-heal: when Anthropic returns
|
||||
// "File not found:" for a referenced file_id, the dead file_id is stripped
|
||||
// from this chat's in-memory ChatMemory and the corresponding DB rows are
|
||||
// stamped FilesCleanedAt so a reconciliation job can finish the cleanup.
|
||||
func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) {
|
||||
// The system prompt is the single authored behavior driver. It is assembled
|
||||
// as a cached static block (custom_instructions) followed by a per-turn
|
||||
// dynamic tail. Prompt caching keys on a byte-identical prefix, so the static
|
||||
// block must not contain anything that changes between requests — all
|
||||
// per-turn data (who we're talking to, the time of day, the emoji-only rule)
|
||||
// lives in the trailing block, AFTER the cache breakpoint.
|
||||
//
|
||||
// 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
|
||||
// unmodified "vanilla" behavior. This matters because the Anthropic API
|
||||
// rejects a system array containing an empty/whitespace-only text block, so
|
||||
// omission is the only correct way to express "no system prompt".
|
||||
staticPrompt := strings.TrimSpace(b.config.SystemPrompts["custom_instructions"])
|
||||
// Handle lastname placeholder
|
||||
lastnameValue := lastName
|
||||
if lastName == "" {
|
||||
lastnameValue = "" // Empty string when last name is not available
|
||||
}
|
||||
systemMessage = strings.ReplaceAll(systemMessage, "{lastname}", lastnameValue)
|
||||
|
||||
// Handle language code placeholder
|
||||
langValue := languageCode
|
||||
if languageCode == "" {
|
||||
langValue = "en" // Default to English when language code is not available
|
||||
}
|
||||
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)
|
||||
|
||||
if !isOwner {
|
||||
systemMessage += " " + b.config.SystemPrompts["avoid_sensitive"]
|
||||
}
|
||||
|
||||
if isEmojiOnly {
|
||||
systemMessage += " " + b.config.SystemPrompts["respond_with_emojis"]
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
InfoLogger.Printf("Sending %d messages to Anthropic", len(messages))
|
||||
for i, msg := range messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.Type == anthropic.MessagesContentTypeText {
|
||||
InfoLogger.Printf("Message %d: Role=%v, Text=%v", i, msg.Role, *content.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params := anthropic.BetaMessageNewParams{
|
||||
Model: b.config.Model,
|
||||
MaxTokens: 1000,
|
||||
// Ensure the roles are correct
|
||||
for i := range messages {
|
||||
switch messages[i].Role {
|
||||
case anthropic.RoleUser:
|
||||
messages[i].Role = anthropic.RoleUser
|
||||
case anthropic.RoleAssistant:
|
||||
messages[i].Role = anthropic.RoleAssistant
|
||||
default:
|
||||
// Default to 'user' if role is unrecognized
|
||||
messages[i].Role = anthropic.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
model := anthropic.Model(b.config.Model)
|
||||
|
||||
// Create the request
|
||||
request := anthropic.MessagesRequest{
|
||||
Model: model, // Now `model` is of type anthropic.Model
|
||||
Messages: messages,
|
||||
// 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})
|
||||
}
|
||||
params.System = blocks
|
||||
System: systemMessage,
|
||||
MaxTokens: 1000,
|
||||
}
|
||||
|
||||
// Apply temperature if set in config
|
||||
if b.config.Temperature != nil {
|
||||
params.Temperature = param.NewOpt(float64(*b.config.Temperature))
|
||||
request.Temperature = b.config.Temperature
|
||||
}
|
||||
|
||||
// MCP servers + matching toolset entries. The mcp-client-2025-11-20 beta
|
||||
// requires per-tool filtering on the toolset (Configs + DefaultConfig),
|
||||
// NOT the deprecated per-server tool_configuration block.
|
||||
if len(b.config.MCPServers) > 0 {
|
||||
mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers))
|
||||
tools := make([]anthropic.BetaToolUnionParam, 0, len(b.config.MCPServers))
|
||||
for _, s := range b.config.MCPServers {
|
||||
srv := anthropic.BetaRequestMCPServerURLDefinitionParam{
|
||||
Name: s.Name,
|
||||
URL: s.URL,
|
||||
}
|
||||
if s.AuthorizationToken != "" {
|
||||
srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken)
|
||||
}
|
||||
mcpServers = append(mcpServers, srv)
|
||||
|
||||
toolset := &anthropic.BetaMCPToolsetParam{
|
||||
MCPServerName: s.Name,
|
||||
}
|
||||
if len(s.AllowedTools) > 0 {
|
||||
toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{
|
||||
Enabled: param.NewOpt(false),
|
||||
}
|
||||
toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools))
|
||||
for _, tool := range s.AllowedTools {
|
||||
toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{
|
||||
Enabled: param.NewOpt(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset})
|
||||
}
|
||||
params.MCPServers = mcpServers
|
||||
params.Tools = tools
|
||||
params.Betas = append(params.Betas, anthropic.AnthropicBetaMCPClient2025_11_20)
|
||||
}
|
||||
|
||||
// Streaming + 404 self-heal loop. A "File not found:" 404 from Anthropic
|
||||
// (admin purge, AUP enforcement, accidental delete elsewhere) is caught
|
||||
// here: the offending file_id is stripped from in-memory ChatMemory + the
|
||||
// affected DB rows are stamped for the reconciliation job, and the call is
|
||||
// re-issued. The loop caps at maxFileNotFoundRetries so cascading deletions
|
||||
// can't pin the call indefinitely.
|
||||
for attempt := 0; attempt < maxFileNotFoundRetries; attempt++ {
|
||||
joined, streamErr := b.streamMessages(ctx, params, onSegment)
|
||||
if streamErr == nil {
|
||||
return joined, nil
|
||||
}
|
||||
var apiErr *anthropic.Error
|
||||
if !errors.As(streamErr, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
|
||||
return "", fmt.Errorf("error creating Anthropic message: %w", streamErr)
|
||||
}
|
||||
missingFileID := extractMissingFileID(streamErr)
|
||||
if missingFileID == "" {
|
||||
// 404 without a "File not found:" body — interpret as model-not-found,
|
||||
// matching the legacy behavior pre-Files-API.
|
||||
resp, err := b.anthropicClient.CreateMessages(ctx, request)
|
||||
if err != nil {
|
||||
var apiErr *anthropic.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.IsNotFoundErr() {
|
||||
return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model)
|
||||
}
|
||||
ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)",
|
||||
b.config.ID, missingFileID, chatID, attempt+1, maxFileNotFoundRetries)
|
||||
b.stripDeadFileIDFromMemory(chatID, missingFileID)
|
||||
if _, cleanupErr := b.markFilesPendingCleanup(ctx, chatID, []string{missingFileID}); cleanupErr != nil {
|
||||
ErrorLogger.Printf("[%s] mark files pending cleanup: %v", b.config.ID, cleanupErr)
|
||||
}
|
||||
params.Messages = b.prepareContextMessages(b.getOrCreateChatMemory(chatID))
|
||||
}
|
||||
return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries)
|
||||
return "", fmt.Errorf("error creating Anthropic message: %w", err)
|
||||
}
|
||||
|
||||
// buildUserContext renders the per-turn context block that trails the cached
|
||||
// static system prompt. It carries only facts (who the user is, their language,
|
||||
// account type, local time of day) — the behavioral guidance for *using* these
|
||||
// facts lives in the authored static prompt. It is kept out of the cached block
|
||||
// because it changes on every request.
|
||||
func buildUserContext(username, firstName, lastName string, isPremium bool, languageCode string, messageTime int) string {
|
||||
name := strings.TrimSpace(firstName + " " + lastName)
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
handle := username
|
||||
if handle == "" {
|
||||
handle = "unknown"
|
||||
}
|
||||
lang := languageCode
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
account := "regular user"
|
||||
if isPremium {
|
||||
account = "premium user"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Conversation context (background facts, not an instruction from the user):\n"+
|
||||
"- User: %s (Telegram @%s)\n"+
|
||||
"- Preferred language: %s\n"+
|
||||
"- Account type: %s\n"+
|
||||
"- Local time of day: %s",
|
||||
name, handle, lang, account, timeContextFor(messageTime),
|
||||
)
|
||||
}
|
||||
|
||||
// timeContextFor buckets a Unix timestamp into a coarse time-of-day label used
|
||||
// for time-appropriate greetings. Uses the host's local timezone, matching the
|
||||
// bot's prior behavior.
|
||||
func timeContextFor(messageTime int) string {
|
||||
switch hour := time.Unix(int64(messageTime), 0).Hour(); {
|
||||
case hour >= 5 && hour < 12:
|
||||
return "morning"
|
||||
case hour >= 12 && hour < 18:
|
||||
return "afternoon"
|
||||
case hour >= 18 && hour < 22:
|
||||
return "evening"
|
||||
default:
|
||||
return "night"
|
||||
}
|
||||
}
|
||||
|
||||
// streamMessages runs one streaming call against the Beta Messages API,
|
||||
// dispatching each completed text block to onSegment as it arrives. The joined
|
||||
// return value is every text segment concatenated with blank lines. Errors from
|
||||
// the SDK are returned raw; the caller wraps them (model-not-found, file 404
|
||||
// self-heal, etc.).
|
||||
func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNewParams, onSegment func(string) error) (string, error) {
|
||||
stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params)
|
||||
defer func() {
|
||||
if err := stream.Close(); err != nil {
|
||||
ErrorLogger.Printf("[stream] close failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Per-block accumulators. Reset on content_block_start, consumed on
|
||||
// content_block_stop. Only one block is active at a time per the SSE
|
||||
// contract; SDK guarantees deltas arrive between matching start/stop.
|
||||
var (
|
||||
allSegments []string
|
||||
currentKind string
|
||||
currentText strings.Builder
|
||||
currentInputJSON strings.Builder
|
||||
currentTUseName, currentTUseServer, currentTUseID string
|
||||
currentTResultUseID, currentTResultServer string
|
||||
currentTResultIsError bool
|
||||
currentTResultContent string
|
||||
// tool_use_id -> {server, name, input}. Lives for one request; bounded
|
||||
// by the number of tool calls in the stream, so no eviction needed.
|
||||
mcpCalls = map[string]mcpCall{}
|
||||
)
|
||||
|
||||
for stream.Next() {
|
||||
e := stream.Current()
|
||||
switch e.Type {
|
||||
case "content_block_start":
|
||||
cbs := e.AsContentBlockStart()
|
||||
currentKind = cbs.ContentBlock.Type
|
||||
currentText.Reset()
|
||||
currentInputJSON.Reset()
|
||||
switch currentKind {
|
||||
case "mcp_tool_use":
|
||||
currentTUseName = cbs.ContentBlock.Name
|
||||
currentTUseServer = cbs.ContentBlock.ServerName
|
||||
currentTUseID = cbs.ContentBlock.ID
|
||||
case "mcp_tool_result":
|
||||
currentTResultUseID = cbs.ContentBlock.ToolUseID
|
||||
currentTResultServer = cbs.ContentBlock.ServerName
|
||||
currentTResultIsError = cbs.ContentBlock.IsError
|
||||
// Tool-result content arrives populated on start (server-side
|
||||
// pre-assembled), not via subsequent deltas like text/JSON.
|
||||
currentTResultContent = cbs.ContentBlock.JSON.Content.Raw()
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
cbd := e.AsContentBlockDelta()
|
||||
switch cbd.Delta.Type {
|
||||
case "text_delta":
|
||||
if currentKind == "text" {
|
||||
currentText.WriteString(cbd.Delta.Text)
|
||||
}
|
||||
case "input_json_delta":
|
||||
if currentKind == "mcp_tool_use" {
|
||||
currentInputJSON.WriteString(cbd.Delta.PartialJSON)
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
switch currentKind {
|
||||
case "text":
|
||||
seg := strings.TrimSpace(currentText.String())
|
||||
if seg != "" {
|
||||
allSegments = append(allSegments, seg)
|
||||
if onSegment != nil {
|
||||
if cbErr := onSegment(seg); cbErr != nil {
|
||||
// Log but keep streaming — the model's response
|
||||
// is still inbound; we want it recorded even if
|
||||
// one Telegram send failed.
|
||||
ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "mcp_tool_use":
|
||||
mcpCalls[currentTUseID] = mcpCall{
|
||||
server: currentTUseServer,
|
||||
name: currentTUseName,
|
||||
input: currentInputJSON.String(),
|
||||
}
|
||||
InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s",
|
||||
currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String())
|
||||
case "mcp_tool_result":
|
||||
preview := currentTResultContent
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "...(truncated)"
|
||||
}
|
||||
InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s",
|
||||
currentTResultUseID, currentTResultServer, currentTResultIsError, preview)
|
||||
if strings.Contains(currentTResultContent, mcpUnsupportedSentinel) {
|
||||
total := mcpUnsupportedCount.Add(1)
|
||||
call := mcpCalls[currentTResultUseID]
|
||||
ErrorLogger.Printf("[%s][mcp][unsupported] connector could not serialize result "+
|
||||
"(total=%d): server=%q tool=%q input=%s tool_use_id=%q",
|
||||
b.config.ID, total, call.server, call.name, call.input, currentTResultUseID)
|
||||
}
|
||||
default:
|
||||
if currentKind != "" {
|
||||
InfoLogger.Printf("[mcp] block type=%q (unhandled)", currentKind)
|
||||
}
|
||||
}
|
||||
currentKind = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(allSegments) == 0 {
|
||||
if len(resp.Content) == 0 || resp.Content[0].Type != anthropic.MessagesContentTypeText {
|
||||
return "", fmt.Errorf("unexpected response format from Anthropic")
|
||||
}
|
||||
return strings.Join(allSegments, "\n\n"), nil
|
||||
|
||||
return resp.Content[0].GetText(), nil
|
||||
}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
)
|
||||
|
||||
// fileNotFoundPrefix is the exact prefix Anthropic uses in its 404 error body
|
||||
// when a referenced file_id no longer exists. Used by extractMissingFileID to
|
||||
// identify the offender for the runtime self-heal path.
|
||||
const fileNotFoundPrefix = "File not found: "
|
||||
|
||||
// formatUploadFilename returns the canonical filename used when uploading a
|
||||
// Telegram photo to the Anthropic Files API. The "tg-" prefix tags the file as
|
||||
// bot-owned so a future reconciliation job can distinguish our uploads from
|
||||
// foreign files in the same workspace. The triple (botID, chatID, tgMessageID)
|
||||
// is unique within Telegram's scope — each photo in an album arrives as a
|
||||
// distinct Telegram message with its own message_id, so collisions across
|
||||
// album items are impossible.
|
||||
func formatUploadFilename(botID uint, chatID int64, tgMessageID int, ext string) string {
|
||||
return fmt.Sprintf("tg-%d-%d-%d.%s", botID, chatID, tgMessageID, ext)
|
||||
}
|
||||
|
||||
// uploadImageToAnthropic uploads raw image bytes to the Anthropic Files API and
|
||||
// returns the resulting file_id. The filename should follow the formatUploadFilename
|
||||
// convention so the reconciliation job can identify the file as bot-owned.
|
||||
func (b *Bot) uploadImageToAnthropic(ctx context.Context, data []byte, filename, contentType string) (string, error) {
|
||||
resp, err := b.anthropicClient.Beta.Files.Upload(ctx, anthropic.BetaFileUploadParams{
|
||||
File: anthropic.File(bytes.NewReader(data), filename, contentType),
|
||||
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("anthropic files upload: %w", err)
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// deleteFileFromAnthropic removes a file from the Anthropic Files API. A 404
|
||||
// is treated as success — the file is already gone, which is the same effective
|
||||
// outcome the caller wants. This makes the deletion idempotent and safe for the
|
||||
// reconciliation job's retries.
|
||||
func (b *Bot) deleteFileFromAnthropic(ctx context.Context, fileID string) error {
|
||||
_, err := b.anthropicClient.Beta.Files.Delete(ctx, fileID, anthropic.BetaFileDeleteParams{
|
||||
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14},
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("anthropic files delete %s: %w", fileID, err)
|
||||
}
|
||||
|
||||
// compensatingDelete fires Delete calls for a set of file_ids that were uploaded
|
||||
// successfully but couldn't be committed downstream. Errors are logged rather
|
||||
// than returned — the caller has already entered an error path, and orphans on
|
||||
// Anthropic are harmless (storage is free until the 500 GB workspace cap and the
|
||||
// reconciliation job will mop them up).
|
||||
func (b *Bot) compensatingDelete(ctx context.Context, fileIDs []string) {
|
||||
for _, fid := range fileIDs {
|
||||
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
|
||||
ErrorLogger.Printf("[%s] compensating delete for %s: %v", b.config.ID, fid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractMissingFileID inspects an Anthropic API error and returns the file_id
|
||||
// that triggered a "File not found:" 404, if any. Returns empty string if the
|
||||
// error is not a file-not-found error. Used by the runtime self-heal path to
|
||||
// identify which file_id to strip from replay.
|
||||
func extractMissingFileID(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var apiErr *anthropic.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
return ""
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusNotFound {
|
||||
return ""
|
||||
}
|
||||
return parseMissingFileIDFromBody(apiErr.RawJSON())
|
||||
}
|
||||
|
||||
// parseMissingFileIDFromBody pulls a file_id out of a raw "File not found:"
|
||||
// 404 body. Split out from extractMissingFileID so the string-parsing logic
|
||||
// is unit-testable without having to synthesize an *anthropic.Error (whose
|
||||
// JSON.raw field is private to the SDK).
|
||||
func parseMissingFileIDFromBody(raw string) string {
|
||||
idx := strings.Index(raw, fileNotFoundPrefix)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
rest := raw[idx+len(fileNotFoundPrefix):]
|
||||
// File IDs are file_<base62>; the message embeds them with no surrounding
|
||||
// quotes, so the id ends at the first character outside the alphanumeric +
|
||||
// underscore set.
|
||||
end := strings.IndexFunc(rest, func(r rune) bool {
|
||||
return (r < 'a' || r > 'z') &&
|
||||
(r < 'A' || r > 'Z') &&
|
||||
(r < '0' || r > '9') &&
|
||||
r != '_'
|
||||
})
|
||||
if end == -1 {
|
||||
return rest
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// hardDeleteScope performs the three-step hard-delete pattern on every Message
|
||||
// row matching the given WHERE clause:
|
||||
//
|
||||
// 1. Soft-delete the rows (GORM Delete) — they become invisible to replay
|
||||
// immediately, regardless of how the Anthropic-side cleanup unfolds.
|
||||
// 2. For each row, call Anthropic Files.Delete on its ImageFileIDs. 404 is
|
||||
// treated as success (already gone).
|
||||
// 3. Rows whose file cleanup succeeded are Unscoped().Delete'd. Rows whose
|
||||
// file cleanup failed remain soft-deleted with FilesCleanedAt NULL — the
|
||||
// reconciliation job will retry them.
|
||||
//
|
||||
// This gives hard-delete eventually-consistent semantics across the DB and
|
||||
// Anthropic, while still presenting the user with an instant "history cleared"
|
||||
// outcome (the soft-delete in step 1 hides the rows from any further reads).
|
||||
func (b *Bot) hardDeleteScope(ctx context.Context, query string, args ...interface{}) error {
|
||||
// Unscoped on the scan: include already-soft-deleted rows so a hard-delete
|
||||
// after a prior soft-delete still removes them completely. Matches the
|
||||
// existing "erase and bust all caches" semantics for /clear_hard.
|
||||
var rows []Message
|
||||
if err := b.db.Unscoped().Where(query, args...).Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("scan rows: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Soft-delete any rows that aren't already soft-deleted (graceful degradation:
|
||||
// if Anthropic-side file cleanup fails, the row stays invisible to replay).
|
||||
// Already-soft-deleted rows are unaffected by Delete without Unscoped.
|
||||
if err := b.db.Where(query, args...).Delete(&Message{}).Error; err != nil {
|
||||
return fmt.Errorf("soft delete: %w", err)
|
||||
}
|
||||
|
||||
hardDeletable := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if b.deleteRowFiles(ctx, row) {
|
||||
hardDeletable = append(hardDeletable, row.ID)
|
||||
}
|
||||
}
|
||||
if len(hardDeletable) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := b.db.Unscoped().Where("id IN ?", hardDeletable).Delete(&Message{}).Error; err != nil {
|
||||
return fmt.Errorf("hard delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRowFiles tries to delete every file_id referenced by row from the
|
||||
// Anthropic Files API. Returns true iff all deletes succeeded (or the row had
|
||||
// no images), making the row eligible for hard-delete. False means at least
|
||||
// one delete failed and the row should stay soft-deleted for retry.
|
||||
func (b *Bot) deleteRowFiles(ctx context.Context, row Message) bool {
|
||||
if len(row.ImageFileIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
allOk := true
|
||||
for _, fid := range row.ImageFileIDs {
|
||||
if err := b.deleteFileFromAnthropic(ctx, fid); err != nil {
|
||||
ErrorLogger.Printf("[%s] anthropic delete %s (row %d): %v", b.config.ID, fid, row.ID, err)
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
return allOk
|
||||
}
|
||||
|
||||
// stripDeadFileIDs returns the subset of src whose ids are NOT in deadSet, and
|
||||
// reports whether any were removed. Empty/nil src yields (empty, false).
|
||||
func stripDeadFileIDs(src []string, deadSet map[string]struct{}) (survivors []string, dirty bool) {
|
||||
survivors = make([]string, 0, len(src))
|
||||
for _, fid := range src {
|
||||
if _, dead := deadSet[fid]; dead {
|
||||
dirty = true
|
||||
continue
|
||||
}
|
||||
survivors = append(survivors, fid)
|
||||
}
|
||||
return survivors, dirty
|
||||
}
|
||||
|
||||
// markFilesPendingCleanup removes a set of dead file_ids from any stored Message
|
||||
// rows that reference them, and stamps FilesCleanedAt on the affected rows so
|
||||
// the reconciliation job can see they've been touched. Called by the runtime
|
||||
// self-heal path after a "File not found:" 404 surfaces during message-create.
|
||||
// Returns the number of rows updated.
|
||||
func (b *Bot) markFilesPendingCleanup(ctx context.Context, chatID int64, deadFileIDs []string) (int, error) {
|
||||
if len(deadFileIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
deadSet := make(map[string]struct{}, len(deadFileIDs))
|
||||
for _, id := range deadFileIDs {
|
||||
deadSet[id] = struct{}{}
|
||||
}
|
||||
var rows []Message
|
||||
if err := b.db.WithContext(ctx).
|
||||
Where("bot_id = ? AND chat_id = ? AND image_file_ids IS NOT NULL", b.botID, chatID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, fmt.Errorf("scan rows for cleanup: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
updated := 0
|
||||
for _, row := range rows {
|
||||
survivors, dirty := stripDeadFileIDs(row.ImageFileIDs, deadSet)
|
||||
if !dirty {
|
||||
continue
|
||||
}
|
||||
if len(survivors) == 0 {
|
||||
// All files in this row are gone; mark fully cleaned so a future
|
||||
// reconciliation job's `WHERE files_cleaned_at IS NULL` filter
|
||||
// correctly excludes it from retries.
|
||||
row.ImageFileIDs = nil
|
||||
row.FilesCleanedAt = &now
|
||||
} else {
|
||||
// Surviving file_ids are still alive on Anthropic. Leave
|
||||
// FilesCleanedAt NULL so a later death of one of them remains
|
||||
// visible to the reconciliation job's filter.
|
||||
row.ImageFileIDs = survivors
|
||||
}
|
||||
if err := b.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return updated, fmt.Errorf("update row %d: %w", row.ID, err)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatUploadFilename(t *testing.T) {
|
||||
cases := []struct {
|
||||
botID uint
|
||||
chatID int64
|
||||
tgMessageID int
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{1, 12345, 42, "jpg", "tg-1-12345-42.jpg"},
|
||||
// Negative chat IDs are how Telegram represents groups/channels —
|
||||
// %d preserves the leading minus, no special handling needed.
|
||||
{7, -1001234567890, 1, "png", "tg-7--1001234567890-1.png"},
|
||||
{0, 0, 0, "webp", "tg-0-0-0.webp"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := formatUploadFilename(tc.botID, tc.chatID, tc.tgMessageID, tc.ext)
|
||||
assert.Equal(t, tc.want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingFileIDFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "canonical Anthropic file-not-found body",
|
||||
body: `{"type":"error","error":{"type":"invalid_request_error","message":"File not found: file_011CNha8iCJcU1wXNR6q4V8w"}}`,
|
||||
want: "file_011CNha8iCJcU1wXNR6q4V8w",
|
||||
},
|
||||
{
|
||||
name: "trailing punctuation after the id is excluded",
|
||||
body: `something File not found: file_abc123! more text`,
|
||||
want: "file_abc123",
|
||||
},
|
||||
{
|
||||
name: "body without the prefix yields empty",
|
||||
body: `{"type":"error","error":{"message":"Model not found: claude-foo"}}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "id at the very end of the buffer",
|
||||
body: `File not found: file_xyz789`,
|
||||
want: "file_xyz789",
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, parseMissingFileIDFromBody(tc.body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDeadFileIDs(t *testing.T) {
|
||||
dead := map[string]struct{}{
|
||||
"file_a": {},
|
||||
"file_b": {},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
input []string
|
||||
wantSurvivors []string
|
||||
wantDirty bool
|
||||
}{
|
||||
{
|
||||
name: "no overlap returns input verbatim",
|
||||
input: []string{"file_x", "file_y"},
|
||||
wantSurvivors: []string{"file_x", "file_y"},
|
||||
wantDirty: false,
|
||||
},
|
||||
{
|
||||
name: "partial overlap returns survivors and reports dirty",
|
||||
input: []string{"file_a", "file_x", "file_b", "file_y"},
|
||||
wantSurvivors: []string{"file_x", "file_y"},
|
||||
wantDirty: true,
|
||||
},
|
||||
{
|
||||
name: "all dead returns empty survivors and dirty",
|
||||
input: []string{"file_a", "file_b"},
|
||||
wantSurvivors: []string{},
|
||||
wantDirty: true,
|
||||
},
|
||||
{
|
||||
name: "empty input is not dirty",
|
||||
input: []string{},
|
||||
wantSurvivors: []string{},
|
||||
wantDirty: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
survivors, dirty := stripDeadFileIDs(tc.input, dead)
|
||||
assert.Equal(t, tc.wantSurvivors, survivors)
|
||||
assert.Equal(t, tc.wantDirty, dirty)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkFilesPendingCleanup(t *testing.T) {
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
chatID := int64(555)
|
||||
|
||||
// Row 1: has dead file_a + alive file_x → should be updated with survivors.
|
||||
row1 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: chatID,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "look at these",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_a", "file_x"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row1).Error)
|
||||
|
||||
// Row 2: only dead files → ImageFileIDs should become nil.
|
||||
row2 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: chatID,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "screenshot",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_a", "file_b"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row2).Error)
|
||||
|
||||
// Row 3: no dead files → should be untouched.
|
||||
row3 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: chatID,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "another",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_x", "file_y"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row3).Error)
|
||||
|
||||
// Row 4: different chat → must NOT be touched even if it references a dead file.
|
||||
row4 := Message{
|
||||
BotID: b.botID,
|
||||
ChatID: 999,
|
||||
UserID: 777,
|
||||
Username: "u",
|
||||
UserRole: "user",
|
||||
Text: "other chat",
|
||||
Timestamp: time.Now(),
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_a"},
|
||||
}
|
||||
assert.NoError(t, b.db.Create(&row4).Error)
|
||||
|
||||
updated, err := b.markFilesPendingCleanup(t.Context(), chatID, []string{"file_a", "file_b"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, updated, "rows 1 and 2 should have been updated")
|
||||
|
||||
// Row 1: only file_x should remain; FilesCleanedAt MUST stay nil because
|
||||
// file_x is still alive on Anthropic and a future death of it must remain
|
||||
// visible to the reconciliation job's `WHERE files_cleaned_at IS NULL` filter.
|
||||
var r1 Message
|
||||
assert.NoError(t, b.db.First(&r1, row1.ID).Error)
|
||||
assert.Equal(t, []string{"file_x"}, r1.ImageFileIDs)
|
||||
assert.Nil(t, r1.FilesCleanedAt)
|
||||
|
||||
// Row 2: all gone → ImageFileIDs nil/empty; FilesCleanedAt set.
|
||||
var r2 Message
|
||||
assert.NoError(t, b.db.First(&r2, row2.ID).Error)
|
||||
assert.Empty(t, r2.ImageFileIDs)
|
||||
assert.NotNil(t, r2.FilesCleanedAt)
|
||||
|
||||
// Row 3: untouched.
|
||||
var r3 Message
|
||||
assert.NoError(t, b.db.First(&r3, row3.ID).Error)
|
||||
assert.Equal(t, []string{"file_x", "file_y"}, r3.ImageFileIDs)
|
||||
assert.Nil(t, r3.FilesCleanedAt)
|
||||
|
||||
// Row 4: untouched despite referencing a dead file — scope is per-chat.
|
||||
var r4 Message
|
||||
assert.NoError(t, b.db.First(&r4, row4.ID).Error)
|
||||
assert.Equal(t, []string{"file_a"}, r4.ImageFileIDs)
|
||||
assert.Nil(t, r4.FilesCleanedAt)
|
||||
}
|
||||
+176
-41
@@ -1,62 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTimeContextFor verifies the time-of-day bucketing used for greetings.
|
||||
func TestTimeContextFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
// TestLanguageCodeReplacement tests that language code is properly handled and replaced
|
||||
func TestLanguageCodeReplacement(t *testing.T) {
|
||||
// Test with provided language code
|
||||
systemMessage := "User's language preference: '{language}'"
|
||||
|
||||
// Test with a specific language code
|
||||
langValue := "fr"
|
||||
result := strings.ReplaceAll(systemMessage, "{language}", langValue)
|
||||
|
||||
if !strings.Contains(result, "User's language preference: 'fr'") {
|
||||
t.Errorf("Expected language code 'fr' to be replaced, got: %s", result)
|
||||
}
|
||||
|
||||
// Test with empty language code (should default to "en")
|
||||
langValue = ""
|
||||
if langValue == "" {
|
||||
langValue = "en" // Default to English when language code is not available
|
||||
}
|
||||
result = strings.ReplaceAll(systemMessage, "{language}", langValue)
|
||||
|
||||
if !strings.Contains(result, "User's language preference: 'en'") {
|
||||
t.Errorf("Expected default language code 'en' to be used, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPremiumStatusReplacement tests that premium status is properly handled and replaced
|
||||
func TestPremiumStatusReplacement(t *testing.T) {
|
||||
systemMessage := "User is a {premium_status}"
|
||||
|
||||
// Test with premium user
|
||||
isPremium := true
|
||||
premiumStatus := "regular user"
|
||||
if isPremium {
|
||||
premiumStatus = "premium user"
|
||||
}
|
||||
result := strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
|
||||
|
||||
if !strings.Contains(result, "User is a premium user") {
|
||||
t.Errorf("Expected premium status to be replaced with 'premium user', got: %s", result)
|
||||
}
|
||||
|
||||
// Test with regular user
|
||||
isPremium = false
|
||||
premiumStatus = "regular user"
|
||||
if isPremium {
|
||||
premiumStatus = "premium user"
|
||||
}
|
||||
result = strings.ReplaceAll(systemMessage, "{premium_status}", premiumStatus)
|
||||
|
||||
if !strings.Contains(result, "User is a regular user") {
|
||||
t.Errorf("Expected premium status to be replaced with 'regular user', got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeContextCalculation tests that time context is correctly calculated for different hours
|
||||
func TestTimeContextCalculation(t *testing.T) {
|
||||
// Test cases for different hours
|
||||
testCases := []struct {
|
||||
hour int
|
||||
expected string
|
||||
}{
|
||||
{3, "night"}, // hours < 5
|
||||
{5, "morning"}, // 5 <= h < 12
|
||||
{11, "morning"}, //
|
||||
{12, "afternoon"}, // 12 <= h < 18
|
||||
{17, "afternoon"}, //
|
||||
{18, "evening"}, // 18 <= h < 22
|
||||
{21, "evening"}, //
|
||||
{22, "night"}, // h >= 22
|
||||
{23, "night"}, //
|
||||
{3, "night"}, // Night: hours < 5 or hours >= 22
|
||||
{5, "morning"}, // Morning: 5 <= hours < 12
|
||||
{12, "afternoon"}, // Afternoon: 12 <= hours < 18
|
||||
{17, "afternoon"}, // Afternoon: 12 <= hours < 18
|
||||
{18, "evening"}, // Evening: 18 <= hours < 22
|
||||
{21, "evening"}, // Evening: 18 <= hours < 22
|
||||
{22, "night"}, // Night: hours < 5 or hours >= 22
|
||||
{23, "night"}, // Night: hours < 5 or hours >= 22
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// Build the timestamp in the host's local zone so timeContextFor (which
|
||||
// reads local time) observes exactly tc.hour regardless of the test
|
||||
// machine's timezone.
|
||||
ts := int(time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.Local).Unix())
|
||||
if got := timeContextFor(ts); got != tc.expected {
|
||||
t.Errorf("timeContextFor(hour=%d) = %q, want %q", tc.hour, got, tc.expected)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("Hour_%d", tc.hour), func(t *testing.T) {
|
||||
// Create a timestamp for the specified hour
|
||||
testTime := time.Date(2025, 5, 15, tc.hour, 0, 0, 0, time.UTC)
|
||||
|
||||
// Get the hour directly from the test time to ensure it's what we expect
|
||||
actualHour := testTime.Hour()
|
||||
if actualHour != tc.hour {
|
||||
t.Fatalf("Test setup error: expected hour %d, got %d", tc.hour, actualHour)
|
||||
}
|
||||
|
||||
// Calculate time context using the same logic as in anthropic.go
|
||||
var timeContext string
|
||||
if actualHour >= 5 && actualHour < 12 {
|
||||
timeContext = "morning"
|
||||
} else if actualHour >= 12 && actualHour < 18 {
|
||||
timeContext = "afternoon"
|
||||
} else if actualHour >= 18 && actualHour < 22 {
|
||||
timeContext = "evening"
|
||||
} else {
|
||||
timeContext = "night"
|
||||
}
|
||||
|
||||
// Check if the calculated time context matches the expected value
|
||||
if timeContext != tc.expected {
|
||||
t.Errorf("For hour %d: expected time context '%s', got '%s'",
|
||||
actualHour, tc.expected, timeContext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUserContext verifies the per-turn context block reflects the user's
|
||||
// details and applies the documented fallbacks.
|
||||
func TestBuildUserContext(t *testing.T) {
|
||||
noon := int(time.Date(2025, 5, 15, 12, 0, 0, 0, time.Local).Unix())
|
||||
// TestSystemMessagePlaceholderReplacement tests that all placeholders are correctly replaced
|
||||
func TestSystemMessagePlaceholderReplacement(t *testing.T) {
|
||||
systemMessage := "The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n" +
|
||||
"User's language preference: '{language}'\n" +
|
||||
"User is a {premium_status}\n" +
|
||||
"It's currently {time_context} in your timezone"
|
||||
|
||||
// Fully-populated premium user.
|
||||
got := buildUserContext("alice", "Alice", "Smith", true, "de", noon)
|
||||
for _, want := range []string{"Alice Smith", "@alice", "Preferred language: de", "premium user", "afternoon"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("buildUserContext premium: missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// Set up test data
|
||||
username := "testuser"
|
||||
firstName := "Test"
|
||||
lastName := "User"
|
||||
isPremium := true
|
||||
languageCode := "de"
|
||||
|
||||
// Missing name/username/language fall back; non-premium reads "regular user".
|
||||
got = buildUserContext("", "", "", false, "", noon)
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Create a timestamp for a specific hour (e.g., 14:00 = afternoon)
|
||||
testTime := time.Date(2025, 5, 15, 14, 0, 0, 0, time.UTC)
|
||||
messageTime := int(testTime.Unix())
|
||||
|
||||
// First name only (no last name) must not leave a trailing space in the name.
|
||||
got = buildUserContext("bob", "Bob", "", false, "en", noon)
|
||||
if !strings.Contains(got, "User: Bob (Telegram @bob)") {
|
||||
t.Errorf("buildUserContext firstname-only: got:\n%s", got)
|
||||
// Handle username placeholder
|
||||
usernameValue := username
|
||||
if username == "" {
|
||||
usernameValue = "unknown"
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,16 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
tgBot TelegramClient
|
||||
db *gorm.DB
|
||||
anthropicClient anthropic.Client
|
||||
anthropicClient *anthropic.Client
|
||||
chatMemories map[int64]*ChatMemory
|
||||
memorySize int
|
||||
chatMemoriesMu sync.RWMutex
|
||||
@@ -27,11 +26,6 @@ type Bot struct {
|
||||
userLimitersMu sync.RWMutex
|
||||
clock Clock
|
||||
botID uint // Reference to BotModel.ID
|
||||
// albumBuffers holds Telegram media_group items as they arrive, keyed by
|
||||
// MediaGroupID. Each pending album has a 1s flush timer (see album_buffer.go)
|
||||
// that triggers a single coalesced photo turn once arrivals stop.
|
||||
albumBuffers map[string]*pendingAlbum
|
||||
albumBuffersMu sync.Mutex
|
||||
}
|
||||
|
||||
// Helper function to determine message type
|
||||
@@ -87,7 +81,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
|
||||
}
|
||||
|
||||
// Use the per-bot Anthropic API key
|
||||
anthropicClient := anthropic.NewClient(option.WithAPIKey(config.AnthropicAPIKey))
|
||||
anthropicClient := anthropic.NewClient(config.AnthropicAPIKey)
|
||||
|
||||
b := &Bot{
|
||||
db: db,
|
||||
@@ -99,7 +93,6 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
|
||||
clock: clock,
|
||||
botID: botEntry.ID, // Ensure BotModel has ID field
|
||||
tgBot: tgClient,
|
||||
albumBuffers: make(map[string]*pendingAlbum),
|
||||
}
|
||||
|
||||
if tgClient == nil {
|
||||
@@ -257,32 +250,6 @@ func (b *Bot) getOrCreateChatMemory(chatID int64) *ChatMemory {
|
||||
return chatMemory
|
||||
}
|
||||
|
||||
// stripDeadFileIDFromMemory removes a single file_id from every message in the
|
||||
// chat's in-memory ChatMemory. Called by the runtime self-heal in
|
||||
// getAnthropicResponse after Anthropic 404s for that file_id, so the immediate
|
||||
// retry (and any subsequent turn replay) won't reference it. The corresponding
|
||||
// DB rows are stamped separately via markFilesPendingCleanup.
|
||||
func (b *Bot) stripDeadFileIDFromMemory(chatID int64, deadFileID string) {
|
||||
b.chatMemoriesMu.Lock()
|
||||
defer b.chatMemoriesMu.Unlock()
|
||||
cm, exists := b.chatMemories[chatID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
for i := range cm.Messages {
|
||||
if len(cm.Messages[i].ImageFileIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
survivors := make([]string, 0, len(cm.Messages[i].ImageFileIDs))
|
||||
for _, fid := range cm.Messages[i].ImageFileIDs {
|
||||
if fid != deadFileID {
|
||||
survivors = append(survivors, fid)
|
||||
}
|
||||
}
|
||||
cm.Messages[i].ImageFileIDs = survivors
|
||||
}
|
||||
}
|
||||
|
||||
// addMessageToChatMemory adds a new message to the chat memory, ensuring the memory size is maintained.
|
||||
func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
|
||||
b.chatMemoriesMu.Lock()
|
||||
@@ -297,14 +264,14 @@ func (b *Bot) addMessageToChatMemory(chatMemory *ChatMemory, message Message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMessageParam {
|
||||
func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.Message {
|
||||
b.chatMemoriesMu.RLock()
|
||||
defer b.chatMemoriesMu.RUnlock()
|
||||
|
||||
// Debug logging
|
||||
InfoLogger.Printf("Chat memory contains %d messages", len(chatMemory.Messages))
|
||||
for i, msg := range chatMemory.Messages {
|
||||
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q Images=%d", i, msg.IsUser, msg.Text, len(msg.ImageFileIDs))
|
||||
InfoLogger.Printf("Message %d: IsUser=%v, Text=%q", i, msg.IsUser, msg.Text)
|
||||
}
|
||||
|
||||
// Note: consecutive messages with the same role are permitted.
|
||||
@@ -312,47 +279,33 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
|
||||
// returning an error. This can happen after a /clear (which only deletes user
|
||||
// messages, leaving assistant messages in the DB) followed by a restart.
|
||||
// See: https://platform.claude.com/docs/en/api/messages
|
||||
var contextMessages []anthropic.BetaMessageParam
|
||||
var contextMessages []anthropic.Message
|
||||
for _, msg := range chatMemory.Messages {
|
||||
blocks := contentBlocksForMessage(msg)
|
||||
if len(blocks) == 0 {
|
||||
// Skip turns that carry neither text nor images.
|
||||
role := anthropic.RoleUser
|
||||
if !msg.IsUser {
|
||||
role = anthropic.RoleAssistant
|
||||
}
|
||||
|
||||
textContent := strings.TrimSpace(msg.Text)
|
||||
if textContent == "" {
|
||||
// Skip empty messages
|
||||
continue
|
||||
}
|
||||
var param anthropic.BetaMessageParam
|
||||
if msg.IsUser {
|
||||
param = anthropic.NewBetaUserMessage(blocks...)
|
||||
} else {
|
||||
param = anthropic.BetaMessageParam{
|
||||
Role: anthropic.BetaMessageParamRoleAssistant,
|
||||
Content: blocks,
|
||||
}
|
||||
}
|
||||
contextMessages = append(contextMessages, param)
|
||||
|
||||
contextMessages = append(contextMessages, anthropic.Message{
|
||||
Role: role,
|
||||
Content: []anthropic.MessageContent{
|
||||
anthropic.NewTextMessageContent(textContent),
|
||||
},
|
||||
})
|
||||
}
|
||||
return contextMessages
|
||||
}
|
||||
|
||||
// contentBlocksForMessage assembles the Anthropic content blocks representing
|
||||
// one stored Message. Image blocks are emitted before the text block (Anthropic
|
||||
// docs: "Claude works best when images come before text"). Multi-image user
|
||||
// turns prepend each image with an "Image N:" label, as the docs explicitly
|
||||
// recommend for multi-image prompts. Assistant turns carry text only.
|
||||
func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion {
|
||||
var blocks []anthropic.BetaContentBlockParamUnion
|
||||
if msg.IsUser && len(msg.ImageFileIDs) > 0 {
|
||||
multi := len(msg.ImageFileIDs) > 1
|
||||
for i, fileID := range msg.ImageFileIDs {
|
||||
if multi {
|
||||
blocks = append(blocks, anthropic.NewBetaTextBlock(fmt.Sprintf("Image %d:", i+1)))
|
||||
}
|
||||
blocks = append(blocks, anthropic.NewBetaImageBlock(anthropic.BetaFileImageSourceParam{FileID: fileID}))
|
||||
}
|
||||
}
|
||||
if textContent := strings.TrimSpace(msg.Text); textContent != "" {
|
||||
blocks = append(blocks, anthropic.NewBetaTextBlock(textContent))
|
||||
}
|
||||
return blocks
|
||||
func (b *Bot) isNewChat(chatID int64) bool {
|
||||
var count int64
|
||||
b.db.Model(&Message{}).Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Count(&count)
|
||||
return count == 0 // Only consider a chat new if it has 0 messages
|
||||
}
|
||||
|
||||
// roleHasScope reports whether role (with pre-loaded Scopes) contains the given scope name.
|
||||
@@ -495,27 +448,6 @@ func (b *Bot) sendResponse(ctx context.Context, chatID int64, text string, busin
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendOneSegment delivers a single Telegram message without touching storage
|
||||
// or chat memory. Used by the streaming response path: each completed text
|
||||
// block fires this helper as it arrives, and the full turn is recorded once
|
||||
// at end-of-stream via screenOutgoingMessage. Keeps the 1-reply-per-prompt
|
||||
// storage invariant while letting the user see segments with natural rhythm.
|
||||
func (b *Bot) sendOneSegment(ctx context.Context, chatID int64, text, businessConnectionID string) error {
|
||||
params := &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
}
|
||||
if businessConnectionID != "" {
|
||||
params.BusinessConnectionID = businessConnectionID
|
||||
}
|
||||
if _, err := b.tgBot.SendMessage(ctx, params); err != nil {
|
||||
ErrorLogger.Printf("[%s] Error sending segment to chat %d with BusinessConnectionID %s: %v",
|
||||
b.config.ID, chatID, businessConnectionID, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendStats sends the bot statistics to the specified chat.
|
||||
func (b *Bot) sendStats(ctx context.Context, chatID int64, userID int64, targetUserID int64, businessConnectionID string) {
|
||||
// If targetUserID is 0, show global stats
|
||||
@@ -732,7 +664,7 @@ func (b *Bot) screenIncomingMessage(message *models.Message) (Message, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
userRole := "user"
|
||||
userRole := string(anthropic.RoleUser)
|
||||
|
||||
// Determine message text based on message type
|
||||
messageText := message.Text
|
||||
@@ -789,7 +721,7 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
|
||||
}
|
||||
|
||||
// Create and store the assistant message
|
||||
assistantMessage := b.createMessage(chatID, 0, "", "assistant", response, false)
|
||||
assistantMessage := b.createMessage(chatID, 0, "", string(anthropic.RoleAssistant), response, false)
|
||||
if err := b.storeMessage(&assistantMessage); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContentBlocksForMessage(t *testing.T) {
|
||||
t.Run("empty message yields no blocks", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{IsUser: true})
|
||||
assert.Empty(t, blocks)
|
||||
})
|
||||
|
||||
t.Run("user text only yields one text block", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{IsUser: true, Text: "hello"})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfText)
|
||||
assert.Equal(t, "hello", blocks[0].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("user single image without caption — no label, no text", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
ImageFileIDs: []string{"file_solo"},
|
||||
})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfImage)
|
||||
assert.NotNil(t, blocks[0].OfImage.Source.OfFile)
|
||||
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
|
||||
})
|
||||
|
||||
t.Run("user single image with caption — image before text", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
Text: "is this right?",
|
||||
ImageFileIDs: []string{"file_solo"},
|
||||
})
|
||||
assert.Len(t, blocks, 2)
|
||||
assert.NotNil(t, blocks[0].OfImage, "image block must come before text per Anthropic guidance")
|
||||
assert.Equal(t, "file_solo", blocks[0].OfImage.Source.OfFile.FileID)
|
||||
assert.NotNil(t, blocks[1].OfText)
|
||||
assert.Equal(t, "is this right?", blocks[1].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("user album (multi-image) labels each with Image N:", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
Text: "compare these",
|
||||
ImageFileIDs: []string{"file_a", "file_b", "file_c"},
|
||||
})
|
||||
// Expected layout: text "Image 1:", image a, text "Image 2:", image b, text "Image 3:", image c, text "compare these"
|
||||
assert.Len(t, blocks, 7)
|
||||
assert.Equal(t, "Image 1:", blocks[0].OfText.Text)
|
||||
assert.Equal(t, "file_a", blocks[1].OfImage.Source.OfFile.FileID)
|
||||
assert.Equal(t, "Image 2:", blocks[2].OfText.Text)
|
||||
assert.Equal(t, "file_b", blocks[3].OfImage.Source.OfFile.FileID)
|
||||
assert.Equal(t, "Image 3:", blocks[4].OfText.Text)
|
||||
assert.Equal(t, "file_c", blocks[5].OfImage.Source.OfFile.FileID)
|
||||
assert.Equal(t, "compare these", blocks[6].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("assistant message with images-set is text-only (defensive)", func(t *testing.T) {
|
||||
// Assistant turns shouldn't carry images, but if they ever do we treat
|
||||
// them as text-only — the model returns text, not images.
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: false,
|
||||
Text: "I see your screenshot",
|
||||
ImageFileIDs: []string{"file_should_be_ignored"},
|
||||
})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfText)
|
||||
assert.Equal(t, "I see your screenshot", blocks[0].OfText.Text)
|
||||
})
|
||||
|
||||
t.Run("whitespace-only text is skipped but images survive", func(t *testing.T) {
|
||||
blocks := contentBlocksForMessage(Message{
|
||||
IsUser: true,
|
||||
Text: " \n ",
|
||||
ImageFileIDs: []string{"file_x"},
|
||||
})
|
||||
assert.Len(t, blocks, 1)
|
||||
assert.NotNil(t, blocks[0].OfImage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripDeadFileIDFromMemory(t *testing.T) {
|
||||
b, _ := setupBotForTest(t, 100)
|
||||
chatID := int64(42)
|
||||
|
||||
// Seed in-memory chat memory with three messages.
|
||||
cm := b.getOrCreateChatMemory(chatID)
|
||||
cm.Messages = []Message{
|
||||
{IsUser: true, Text: "first", ImageFileIDs: []string{"file_a", "file_b"}},
|
||||
{IsUser: false, Text: "reply"},
|
||||
{IsUser: true, Text: "third", ImageFileIDs: []string{"file_b", "file_c"}},
|
||||
}
|
||||
|
||||
b.stripDeadFileIDFromMemory(chatID, "file_b")
|
||||
|
||||
assert.Equal(t, []string{"file_a"}, cm.Messages[0].ImageFileIDs, "file_b should be removed from message 1")
|
||||
assert.Empty(t, cm.Messages[1].ImageFileIDs, "assistant message untouched")
|
||||
assert.Equal(t, []string{"file_c"}, cm.Messages[2].ImageFileIDs, "file_b should be removed from message 3")
|
||||
}
|
||||
|
||||
func TestStripDeadFileIDFromMemory_UnknownChatIsNoop(t *testing.T) {
|
||||
b, _ := setupBotForTest(t, 100)
|
||||
// Calling on a chat that was never opened should not panic and should be a no-op.
|
||||
b.stripDeadFileIDFromMemory(99999, "file_anything")
|
||||
// Nothing to assert beyond not crashing.
|
||||
}
|
||||
@@ -6,17 +6,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
type BotConfig struct {
|
||||
ID string `json:"id"`
|
||||
@@ -25,7 +17,7 @@ type BotConfig struct {
|
||||
MessagePerHour int `json:"messages_per_hour"`
|
||||
MessagePerDay int `json:"messages_per_day"`
|
||||
TempBanDuration string `json:"temp_ban_duration"`
|
||||
Model string `json:"model"`
|
||||
Model anthropic.Model `json:"model"`
|
||||
Temperature *float32 `json:"temperature,omitempty"` // Controls creativity vs determinism (0.0-1.0)
|
||||
SystemPrompts map[string]string `json:"system_prompts"`
|
||||
Active bool `json:"active"`
|
||||
@@ -35,10 +27,25 @@ type BotConfig struct {
|
||||
ElevenLabsVoiceID string `json:"elevenlabs_voice_id"`
|
||||
ElevenLabsModel string `json:"elevenlabs_model"`
|
||||
DebugScreening bool `json:"debug_screening"` // Enable detailed screening logs
|
||||
MCPServers []MCPServer `json:"mcp_servers,omitempty"`
|
||||
ConfigFilePath string `json:"-"` // Set at load time; not serialized
|
||||
}
|
||||
|
||||
// Custom unmarshalling to handle anthropic.Model
|
||||
func (c *BotConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias BotConfig
|
||||
aux := &struct {
|
||||
Model string `json:"model"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(c),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Model = anthropic.Model(aux.Model)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfigPath ensures the file path is within the allowed directory
|
||||
func validateConfigPath(configDir, filename string) (string, error) {
|
||||
// Clean the paths to remove any . or .. components
|
||||
@@ -195,6 +202,7 @@ func (c *BotConfig) Reload(configDir, filename string) error {
|
||||
return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err)
|
||||
}
|
||||
|
||||
c.Model = anthropic.Model(c.Model)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,6 +234,6 @@ func (c *BotConfig) PersistModel(newModel string) error {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
c.Model = newModel
|
||||
c.Model = anthropic.Model(newModel)
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-2
@@ -15,7 +15,10 @@
|
||||
"temperature": 0.7,
|
||||
"debug_screening": false,
|
||||
"system_prompts": {
|
||||
"custom_instructions": "You are Atom, a helpful assistant texting through a limited Telegram interface with a 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. The system cuts off anything longer than 15 words.\n\n- Address the user by their first name, and reply in their preferred language (both are in the conversation context).\n- Use time-appropriate greetings based on the user's local time of day.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
|
||||
"respond_with_emojis": "The user's message contains only emoji. Reply using only emoji."
|
||||
"default": "You are a helpful assistant.",
|
||||
"custom_instructions": "You are texting through a limited Telegram interface with 15-word maximum. Write like texting a friend - use shorthand, skip grammar, use slang/abbreviations. System cuts off anything longer than 15 words.\n\n- Your name is Atom.\n- The user you're talking to has username '{username}' and display name '{firstname} {lastname}'.\n- User's language preference: '{language}'. Prefer replying in this language when talking to '{username}'.\n- User is a {premium_status}\n- It's currently {time_context} in your timezone. Use appropriate time-based greetings and address the user by name.\n- If a user asks about buying apples, inform them that we don't sell apples.\n- When asked for a joke, tell a clean, family-friendly joke about programming or technology.\n- If someone inquires about our services, explain that we offer AI-powered chatbot solutions.\n- For any questions about pricing, direct users to contact our sales team at sales@example.com.\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
|
||||
"continue_conversation": "Continuing our conversation. Remember previous context if relevant.",
|
||||
"avoid_sensitive": "Avoid discussing sensitive topics or providing harmful information.",
|
||||
"respond_with_emojis": "Since the user sent only emojis, respond using emojis only."
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
// Set up loggers
|
||||
@@ -36,7 +38,7 @@ func TestBotConfig_UnmarshalJSON(t *testing.T) { //NOSONAR go:S100 -- underscore
|
||||
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
expectedModel := "claude-v1"
|
||||
expectedModel := anthropic.Model("claude-v1")
|
||||
if config.Model != expectedModel {
|
||||
t.Errorf("Expected model %s, got %s", expectedModel, config.Model)
|
||||
}
|
||||
|
||||
+14
-6
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,7 +44,7 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
|
||||
return nil, fmt.Errorf("elevenlabs TTS error: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer resp.Body.Close()
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("elevenlabs TTS error: status %d: %s", resp.StatusCode, errBody)
|
||||
}
|
||||
@@ -54,11 +56,17 @@ func (b *Bot) generateSpeech(ctx context.Context, text string) (io.Reader, error
|
||||
// ogen-generated encoder: AdditionalFormats (nil slice) is always written as an empty
|
||||
// string with Content-Type: application/json, which ElevenLabs rejects with 400.
|
||||
func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error) {
|
||||
// 1. Resolve and download the voice file from Telegram via the shared helper.
|
||||
audioBytes, err := b.downloadTelegramFile(ctx, fileID)
|
||||
// 1. Resolve and download the voice file from Telegram.
|
||||
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("telegram GetFile error: %w", err)
|
||||
}
|
||||
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
|
||||
audioResp, err := http.Get(downloadURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice download error: %w", err)
|
||||
}
|
||||
defer audioResp.Body.Close()
|
||||
|
||||
// 2. Build multipart body with binary audio — bypasses SDK encoding issues.
|
||||
var buf bytes.Buffer
|
||||
@@ -70,7 +78,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("multipart create file error: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, bytes.NewReader(audioBytes)); err != nil {
|
||||
if _, err := io.Copy(part, audioResp.Body); err != nil {
|
||||
return "", fmt.Errorf("multipart copy error: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
@@ -90,7 +98,7 @@ func (b *Bot) transcribeVoice(ctx context.Context, fileID string) (string, error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("elevenlabs STT request error: %w", err)
|
||||
}
|
||||
defer func() { _ = sttResp.Body.Close() }()
|
||||
defer sttResp.Body.Close()
|
||||
|
||||
if sttResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(sttResp.Body)
|
||||
|
||||
Binary file not shown.
@@ -3,37 +3,24 @@ module github.com/HugeFrog24/go-telegram-bot
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.52.0
|
||||
github.com/go-telegram/bot v1.21.0
|
||||
github.com/go-telegram/bot v1.20.0
|
||||
github.com/liushuangls/go-anthropic/v2 v2.20.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/invopop/jsonschema v0.14.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.47 // indirect
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tidwall/gjson v1.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
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
github.com/anthropics/anthropic-sdk-go v1.45.0 h1:rWnpyBpm9OAm97jyH5bi6W4SRCwJeNY/RyhaJ7CHSUI=
|
||||
github.com/anthropics/anthropic-sdk-go v1.45.0/go.mod h1:bx5vWuHFuGPkELH8Z4KUiNSohFnUwScdpTyr+50myPo=
|
||||
github.com/anthropics/anthropic-sdk-go v1.52.0 h1:1TB9jt4DN87VMwS/hB1VK26tYzK0ipEOtqPaPGFtJQg=
|
||||
github.com/anthropics/anthropic-sdk-go v1.52.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
|
||||
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -30,61 +14,34 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/liushuangls/go-anthropic/v2 v2.19.0 h1:CpDSGzRUmlONfAQh8MrBiNupCDAyGpVoQIJkcAx77h8=
|
||||
github.com/liushuangls/go-anthropic/v2 v2.19.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
|
||||
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
|
||||
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
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/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
+37
-226
@@ -7,13 +7,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, businessConnectionID string) {
|
||||
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, isNewChat, isOwner bool, businessConnectionID string) {
|
||||
// If ElevenLabs is not configured, respond with text — consistent with all other error paths.
|
||||
if b.config.ElevenLabsAPIKey == "" {
|
||||
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
|
||||
@@ -56,10 +55,7 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
|
||||
|
||||
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||
contextMessages := b.prepareContextMessages(chatMemory)
|
||||
// Voice path passes nil for onSegment: tool-call narration across multiple
|
||||
// TTS clips would be jarring, so we accumulate everything and synthesize one
|
||||
// audio clip from the joined text.
|
||||
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil)
|
||||
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChat, isOwner, false, username, firstName, lastName, isPremium, languageCode, messageTime)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
|
||||
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
|
||||
@@ -95,163 +91,17 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
|
||||
}
|
||||
}
|
||||
|
||||
// uploadPhotoFromItem downloads the largest PhotoSize from a Telegram message
|
||||
// item and uploads it to the Anthropic Files API tagged with the bot's filename
|
||||
// convention. Telegram serves photos as JPEG regardless of the user's original
|
||||
// format, so the content-type is fixed.
|
||||
func (b *Bot) uploadPhotoFromItem(ctx context.Context, item *models.Message, chatID int64) (string, error) {
|
||||
photo := largestPhotoSize(item.Photo)
|
||||
data, err := b.downloadTelegramFile(ctx, photo.FileID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download %s: %w", photo.FileID, err)
|
||||
}
|
||||
filename := formatUploadFilename(b.botID, chatID, item.ID, "jpg")
|
||||
return b.uploadImageToAnthropic(ctx, data, filename, "image/jpeg")
|
||||
}
|
||||
|
||||
// handlePhotoMessage processes a user turn that contains one or more photos —
|
||||
// either a single photo or a Telegram media_group (album) coalesced upstream.
|
||||
// For albums the caller passes the items sorted by message_id. Each item's
|
||||
// largest PhotoSize is downloaded and uploaded to the Anthropic Files API; the
|
||||
// resulting file_ids are persisted on a single Message row representing the
|
||||
// whole user turn. On any upload failure, compensating deletes fire against
|
||||
// already-uploaded file_ids and the DB row is not written — orphans on
|
||||
// Anthropic are preferred over poisoned DB references.
|
||||
func (b *Bot) handlePhotoMessage(
|
||||
ctx context.Context,
|
||||
items []*models.Message,
|
||||
chatID, userID int64,
|
||||
username, firstName, lastName string,
|
||||
isPremium bool,
|
||||
languageCode string,
|
||||
messageTime int,
|
||||
businessConnectionID string,
|
||||
) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: download + upload each photo in parallel. Album latency collapses
|
||||
// from N*RTT to ~max(t_i) — relevant for the multi-screenshot use case.
|
||||
// Caption capture happens in the sequential loop (one item carries it; order
|
||||
// is preserved upstream via flushAlbum's sort by message_id). uploaded[i]
|
||||
// matches items[i] so file_ids stay in user-intended order; non-photo items
|
||||
// leave their slot empty and are compacted out before commit.
|
||||
uploaded := make([]string, len(items))
|
||||
caption := ""
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
for i, item := range items {
|
||||
if item.Caption != "" {
|
||||
caption = item.Caption
|
||||
}
|
||||
if len(item.Photo) == 0 {
|
||||
continue
|
||||
}
|
||||
i, item := i, item
|
||||
g.Go(func() error {
|
||||
fileID, err := b.uploadPhotoFromItem(gctx, item, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploaded[i] = fileID
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
ErrorLogger.Printf("[%s] photo upload failed: %v", b.config.ID, err)
|
||||
var successful []string
|
||||
for _, fid := range uploaded {
|
||||
if fid != "" {
|
||||
successful = append(successful, fid)
|
||||
}
|
||||
}
|
||||
b.compensatingDelete(ctx, successful)
|
||||
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I couldn't process one of your images.", businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending photo failure message: %v", sendErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
finalUploaded := make([]string, 0, len(uploaded))
|
||||
for _, fid := range uploaded {
|
||||
if fid != "" {
|
||||
finalUploaded = append(finalUploaded, fid)
|
||||
}
|
||||
}
|
||||
if len(finalUploaded) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: commit Message row. getOrCreateChatMemory MUST run before
|
||||
// storeMessage — on a cold cache, the get-or-create hydrates from DB, and
|
||||
// hydrating after the insert would re-load the just-stored row, causing
|
||||
// addMessageToChatMemory below to produce a duplicate user turn. Mirrors
|
||||
// the ordering used by screenIncomingMessage for the same reason.
|
||||
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||
userMessage := b.createMessage(chatID, userID, username, "user", caption, true)
|
||||
userMessage.ImageFileIDs = finalUploaded
|
||||
if err := b.storeMessage(&userMessage); err != nil {
|
||||
b.compensatingDelete(ctx, finalUploaded)
|
||||
ErrorLogger.Printf("[%s] store photo message failed: %v", b.config.ID, err)
|
||||
if sendErr := b.sendResponse(ctx, chatID, "Sorry, I had trouble saving your message.", businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending store failure message: %v", sendErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
b.addMessageToChatMemory(chatMemory, userMessage)
|
||||
|
||||
// Phase 3: stream Anthropic's reply, same shape as the text path.
|
||||
contextMessages := b.prepareContextMessages(chatMemory)
|
||||
joined, err := b.getAnthropicResponse(
|
||||
ctx, chatID, contextMessages, false,
|
||||
username, firstName, lastName, isPremium, languageCode, messageTime,
|
||||
func(seg string) error {
|
||||
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error getting Anthropic response for photo: %v", err)
|
||||
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending anthropic error response: %v", sendErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
|
||||
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// anthropicErrorResponse returns the message to send back to the user when getAnthropicResponse
|
||||
// fails. Admins and owners (anyone with model:set scope) receive the underlying API error so they
|
||||
// can act on it — actionable hint for model-deprecation, raw status+body+request-id for everything
|
||||
// else. Regular users always get the generic fallback to avoid leaking internal details.
|
||||
// fails. Admins and owners receive an actionable hint when the model is deprecated; regular users
|
||||
// always get the generic fallback to avoid leaking internal details.
|
||||
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
|
||||
isElevated := b.hasScope(userID, ScopeModelSet)
|
||||
|
||||
if errors.Is(err, ErrModelNotFound) && isElevated {
|
||||
if errors.Is(err, ErrModelNotFound) && b.hasScope(userID, ScopeModelSet) {
|
||||
return fmt.Sprintf(
|
||||
"⚠️ Model `%s` is no longer available (deprecated or removed by Anthropic).\n"+
|
||||
"Use /set_model <model-id> to switch. Current models: https://platform.claude.com/docs/en/about-claude/models/overview",
|
||||
b.config.Model,
|
||||
)
|
||||
}
|
||||
|
||||
if isElevated {
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
body := apiErr.RawJSON()
|
||||
if len(body) > 800 {
|
||||
body = body[:800] + "...(truncated)"
|
||||
}
|
||||
out := fmt.Sprintf("⚠️ Anthropic API error %d:\n%s", apiErr.StatusCode, body)
|
||||
if apiErr.RequestID != "" {
|
||||
out += fmt.Sprintf("\nRequest-ID: %s", apiErr.RequestID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Non-API errors (network, context cancel, etc.) — show the Go error text.
|
||||
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
|
||||
}
|
||||
|
||||
return "I'm sorry, I'm having trouble processing your request right now."
|
||||
}
|
||||
|
||||
@@ -291,8 +141,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
messageTime := message.Date
|
||||
text := message.Text
|
||||
|
||||
// Determine if the user is the owner — needed up-front so the album buffer
|
||||
// can capture it alongside other per-turn metadata.
|
||||
// Check if it's a new chat (before storing the message so the flag is accurate).
|
||||
isNewChatFlag := b.isNewChat(chatID)
|
||||
|
||||
// Screen incoming message (store to DB + add to chat memory)
|
||||
userMsg, err := b.screenIncomingMessage(message)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error storing user message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if the user is the owner
|
||||
var isOwner bool
|
||||
if b.db.Where("telegram_id = ? AND bot_id = ? AND is_owner = ?", userID, b.botID, true).First(&User{}).Error == nil {
|
||||
isOwner = true
|
||||
@@ -313,34 +172,6 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
}
|
||||
}
|
||||
|
||||
// Photo routing bypasses screenIncomingMessage entirely: handlePhotoMessage
|
||||
// owns its own DB-row creation (one row per coalesced user turn, holding
|
||||
// all uploaded file_ids). Album items go through the 1s buffer first; only
|
||||
// the flush dispatches to handlePhotoMessage.
|
||||
if message.MediaGroupID != "" && len(message.Photo) > 0 {
|
||||
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
|
||||
isPremium, languageCode, messageTime, businessConnectionID)
|
||||
return
|
||||
}
|
||||
if len(message.Photo) > 0 {
|
||||
if !b.checkRateLimits(userID) {
|
||||
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
|
||||
return
|
||||
}
|
||||
b.handlePhotoMessage(ctx, []*models.Message{message},
|
||||
chatID, userID, username, firstName, lastName,
|
||||
isPremium, languageCode, messageTime,
|
||||
businessConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
// Screen incoming message (store to DB + add to chat memory) — text/voice/sticker only.
|
||||
userMsg, err := b.screenIncomingMessage(message)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error storing user message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the message is a command — applies on every message, including the very first.
|
||||
if message.Entities != nil {
|
||||
for _, entity := range message.Entities {
|
||||
@@ -486,7 +317,7 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
// Check if the message contains a voice note (context is built inside the handler
|
||||
// after the transcript replaces the placeholder, so it must not be built here).
|
||||
if message.Voice != nil {
|
||||
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID)
|
||||
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, isNewChatFlag, isOwner, businessConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -509,31 +340,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
|
||||
// Determine if the text contains only emojis
|
||||
isEmojiOnly := isOnlyEmojis(text)
|
||||
|
||||
// Stream Anthropic's reply, sending each completed text block to Telegram
|
||||
// as it arrives — gives the conversational rhythm Claude uses around tool
|
||||
// calls (text → pause for tool → text → pause → text), rather than a long
|
||||
// upfront wait followed by all bubbles at once.
|
||||
joined, err := b.getAnthropicResponse(
|
||||
ctx, chatID, contextMessages, isEmojiOnly,
|
||||
username, firstName, lastName, isPremium, languageCode, messageTime,
|
||||
func(seg string) error {
|
||||
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
|
||||
},
|
||||
)
|
||||
// Get response from Anthropic
|
||||
response, err := b.getAnthropicResponse(ctx, contextMessages, isNewChatFlag, isOwner, isEmojiOnly, username, firstName, lastName, isPremium, languageCode, messageTime)
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
|
||||
// Errors go out as a single message — no need to fan out a one-line error.
|
||||
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
|
||||
ErrorLogger.Printf("Error sending response: %v", sendErr)
|
||||
}
|
||||
return
|
||||
response = b.anthropicErrorResponse(err, userID)
|
||||
}
|
||||
|
||||
// Record the full turn once, at end-of-stream. Same 1-reply-per-prompt
|
||||
// invariant as the non-streaming path: one DB row, one answered_on stamp,
|
||||
// one chat-memory entry containing the joined segments.
|
||||
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
|
||||
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
|
||||
// Send the response
|
||||
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
|
||||
ErrorLogger.Printf("Error sending response: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +360,7 @@ func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, bu
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) {
|
||||
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.Message, businessConnectionID string) {
|
||||
// userMessage was already screened (stored + added to memory) by handleUpdate — do not call screenIncomingMessage again.
|
||||
|
||||
// Generate AI response about the sticker
|
||||
@@ -567,14 +384,12 @@ func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessag
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam) (string, error) {
|
||||
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.Message) (string, error) {
|
||||
// contextMessages already contains the sticker turn (added by screenIncomingMessage as
|
||||
// "Sent a sticker: <emoji>"), so the full conversation history is preserved.
|
||||
if message.StickerFileID != "" {
|
||||
messageTime := int(message.Timestamp.Unix())
|
||||
// Sticker reactions are casual chit-chat; tool use is unusual here, so
|
||||
// pass nil for onSegment and return the joined text for a single bubble.
|
||||
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, true, message.Username, "", "", false, "", messageTime, nil)
|
||||
response, err := b.getAnthropicResponse(ctx, contextMessages, false, false, true, message.Username, "", "", false, "", messageTime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -628,27 +443,23 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
|
||||
// useful for group moderation ("/clear <userID> <chatID>").
|
||||
var err error
|
||||
if hardDelete {
|
||||
// Hard delete routes through hardDeleteScope, which orchestrates the
|
||||
// soft-delete → Anthropic Files.Delete → Unscoped().Delete dance. Rows
|
||||
// whose Anthropic-side file cleanup fails stay soft-deleted for the
|
||||
// reconciliation job to retry.
|
||||
// Permanently delete messages
|
||||
if targetUserID == currentUserID {
|
||||
// Own history — delete ALL messages (user + assistant) in the current chat.
|
||||
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID)
|
||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error
|
||||
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
|
||||
} else {
|
||||
if targetChatID != 0 {
|
||||
// Chat-scoped: delete ALL messages (user + assistant) in the specified chat.
|
||||
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID)
|
||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
|
||||
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
|
||||
} else {
|
||||
// Bot-wide: user's own messages across every chat plus assistant
|
||||
// responses in their DM chat (where chat_id == user_id by Telegram
|
||||
// convention). The two clauses are collapsed into one OR-WHERE so
|
||||
// the helper's three-step pattern covers both in a single pass.
|
||||
err = b.hardDeleteScope(ctx,
|
||||
"bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))",
|
||||
b.botID, targetUserID, targetUserID, false)
|
||||
// Bot-wide: delete all of the user's own messages across every chat, then delete
|
||||
// assistant messages from their DM chat (where chat_id == user_id by Telegram convention).
|
||||
err = b.db.Unscoped().Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
|
||||
if err == nil {
|
||||
err = b.db.Unscoped().Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
|
||||
}
|
||||
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-28
@@ -65,23 +65,21 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
// userID 123 is the configured owner; any other ID is a regular user.
|
||||
userID int64
|
||||
// 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
|
||||
isOwner bool
|
||||
wantResp string
|
||||
}{
|
||||
{
|
||||
name: "Owner First Message",
|
||||
userID: 123,
|
||||
wantSubstr: "Anthropic call failed:",
|
||||
userID: 123, // owner's ID
|
||||
isOwner: true,
|
||||
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
|
||||
},
|
||||
{
|
||||
name: "Regular User First Message",
|
||||
userID: 456,
|
||||
wantSubstr: "I'm sorry, I'm having trouble processing your request right now.",
|
||||
isOwner: false,
|
||||
wantResp: "I'm sorry, I'm having trouble processing your request right now.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -90,7 +88,7 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
||||
// Setup mock response expectations for error case to test fallback messages
|
||||
mockTgClient.SendMessageFunc = func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) {
|
||||
assert.Equal(t, tc.userID, params.ChatID)
|
||||
assert.Contains(t, params.Text, tc.wantSubstr)
|
||||
assert.Equal(t, tc.wantResp, params.Text)
|
||||
return &models.Message{}, nil
|
||||
}
|
||||
|
||||
@@ -114,13 +112,10 @@ func TestHandleUpdate_NewChat(t *testing.T) {
|
||||
err := db.Where("chat_id = ? AND user_id = ? AND text = ?", tc.userID, tc.userID, "Hello").First(&storedMsg).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify response was stored (most recent assistant message in this chat).
|
||||
// Verify response was stored
|
||||
var respMsg Message
|
||||
err = db.Where("chat_id = ? AND is_user = ?", tc.userID, false).
|
||||
Order("timestamp DESC").
|
||||
First(&respMsg).Error
|
||||
err = db.Where("chat_id = ? AND is_user = ? AND text = ?", tc.userID, false, tc.wantResp).First(&respMsg).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, respMsg.Text, tc.wantSubstr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -725,22 +720,11 @@ func TestAnthropicErrorResponse(t *testing.T) { //NOSONAR go:S100 -- underscore
|
||||
wantMissing: "/set_model",
|
||||
},
|
||||
{
|
||||
// Non-model errors (network, plain errors, API errors other than 404)
|
||||
// surface to anyone with model:set scope so admins/owners can diagnose.
|
||||
name: "owner receives elevated detail for non-API error",
|
||||
name: "owner receives generic message for non-model error",
|
||||
err: otherErr,
|
||||
userID: 123,
|
||||
wantSubstr: "Anthropic call failed:",
|
||||
wantMissing: "I'm sorry",
|
||||
},
|
||||
{
|
||||
// Regular users keep getting the generic fallback for any non-model error
|
||||
// to avoid leaking internal details.
|
||||
name: "regular user receives generic message for non-model error",
|
||||
err: otherErr,
|
||||
userID: 789,
|
||||
wantSubstr: "I'm sorry",
|
||||
wantMissing: "Anthropic call failed",
|
||||
wantMissing: "/set_model",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -42,14 +42,6 @@ type Message struct {
|
||||
StickerEmoji string // Store the emoji associated with the sticker
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"` // Add soft delete field
|
||||
AnsweredOn *time.Time `gorm:"index"` // Tracks when a user message was answered (NULL for assistant messages and unanswered user messages)
|
||||
// ImageFileIDs holds Anthropic Files API file_ids for photos attached to this turn.
|
||||
// Plural for albums (Telegram media_group), single-element for one photo.
|
||||
ImageFileIDs []string `gorm:"type:text;serializer:json"`
|
||||
// FilesCleanedAt is set after a cleanup job deletes the corresponding files from
|
||||
// Anthropic's side. NULL means files are still alive on Anthropic.
|
||||
// Combined with DeletedAt this lets a reconciliation job find rows whose files
|
||||
// are pending cleanup vs rows whose files have already been removed.
|
||||
FilesCleanedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
type ChatMemory struct {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// largestPhotoSize returns the highest-resolution PhotoSize from the slice
|
||||
// Telegram returns for a single photo. Telegram pre-renders each upload at
|
||||
// several resolutions; we want the largest for vision quality. Falls back to
|
||||
// the zero value when the slice is empty (caller should guard upstream).
|
||||
func largestPhotoSize(photos []models.PhotoSize) models.PhotoSize {
|
||||
if len(photos) == 0 {
|
||||
return models.PhotoSize{}
|
||||
}
|
||||
largest := photos[0]
|
||||
largestArea := largest.Width * largest.Height
|
||||
for i := 1; i < len(photos); i++ {
|
||||
area := photos[i].Width * photos[i].Height
|
||||
if area > largestArea {
|
||||
largest = photos[i]
|
||||
largestArea = area
|
||||
}
|
||||
}
|
||||
return largest
|
||||
}
|
||||
|
||||
// downloadTelegramFile resolves a Telegram file_id via the bot API, fetches the
|
||||
// download URL, and reads the bytes into memory. The two-step dance (GetFile +
|
||||
// fetch via FileDownloadLink) is required by Telegram's protocol — direct
|
||||
// downloads aren't possible from file_id alone. Buffered into []byte because
|
||||
// downstream callers (multipart uploads to ElevenLabs and Anthropic) re-read
|
||||
// the body; streaming would require either tee-ing or a temp file.
|
||||
func (b *Bot) downloadTelegramFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||
fileInfo, err := b.tgBot.GetFile(ctx, &tgbot.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram GetFile %s: %w", fileID, err)
|
||||
}
|
||||
downloadURL := b.tgBot.FileDownloadLink(fileInfo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram download request %s: %w", fileID, err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram download %s: %w", fileID, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("telegram download %s: status %d", fileID, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram download read %s: %w", fileID, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLargestPhotoSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
photos []models.PhotoSize
|
||||
wantFileID string
|
||||
}{
|
||||
{
|
||||
name: "ascending sizes — last is largest",
|
||||
photos: []models.PhotoSize{
|
||||
{FileID: "thumb", Width: 90, Height: 90},
|
||||
{FileID: "small", Width: 320, Height: 320},
|
||||
{FileID: "full", Width: 1280, Height: 720},
|
||||
},
|
||||
wantFileID: "full",
|
||||
},
|
||||
{
|
||||
name: "descending sizes — first is largest",
|
||||
photos: []models.PhotoSize{
|
||||
{FileID: "full", Width: 1280, Height: 720},
|
||||
{FileID: "small", Width: 320, Height: 320},
|
||||
{FileID: "thumb", Width: 90, Height: 90},
|
||||
},
|
||||
wantFileID: "full",
|
||||
},
|
||||
{
|
||||
name: "single photo",
|
||||
photos: []models.PhotoSize{
|
||||
{FileID: "solo", Width: 800, Height: 600},
|
||||
},
|
||||
wantFileID: "solo",
|
||||
},
|
||||
{
|
||||
name: "empty slice returns zero value (caller guards upstream)",
|
||||
photos: []models.PhotoSize{},
|
||||
wantFileID: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := largestPhotoSize(tc.photos)
|
||||
assert.Equal(t, tc.wantFileID, got.FileID)
|
||||
})
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<#
|
||||
.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"
|
||||
}
|
||||
Reference in New Issue
Block a user