mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-10-27 21:35:26 +00:00
Toolchain and CI: - Go 1.27 (go directive 1.27.0, CI 1.27.3) - actions/checkout v7, actions/setup-go v7 - golangci-lint v2.15.0, gosec v2.30.0 - Dockerfile: drop obsolete -a -installsuffix cgo build flags Go modules: - anthropic-sdk-go v1.71.0 -> v2.3.0 (new /v2 module path, API version 2026-10-21); go-telegram/bot v1.27.0; x/sync v0.24.0; indirects to latest - BetaMessageParam is now a per-role union: build assistant turns with OfAssistant and read content through the role in the cache helper - Pass the Anthropic API key only when set: v2 sends an explicitly passed credential as is, so an empty key became an empty bearer token instead of falling back to the SDK's default credential chain as v1 did API 2026-10-21 / deprecations: - Stop sending temperature (rejected on every model); the config key is still parsed and a startup notice flags it - Name refusals in the empty-response error instead of reporting an unexpected response format - ElevenLabs STT: scribe_v1 (deprecated) -> scribe_v2 - Template config: claude-haiku-5-5 with effort low, temperature removed Docs: replace the unsupported `go get -u` install step, document the ignored temperature key, default-on thinking, and per-model effort defaults.
170 lines
4.3 KiB
Go
170 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go/v2"
|
|
)
|
|
|
|
const fileNotFoundPrefix = "File not found: "
|
|
|
|
func formatUploadFilename(botID uint, chatID int64, tgMessageID int, ext string) string {
|
|
return fmt.Sprintf("tg-%d-%d-%d.%s", botID, chatID, tgMessageID, ext)
|
|
}
|
|
|
|
func (b *Bot) uploadImageToAnthropic(ctx context.Context, data []byte, filename, contentType string) (string, error) {
|
|
resp, err := b.anthropicClient.Files.Upload(ctx, anthropic.FileUploadParams{
|
|
File: anthropic.File(bytes.NewReader(data), filename, contentType),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("anthropic files upload: %w", err)
|
|
}
|
|
return resp.ID, nil
|
|
}
|
|
|
|
func (b *Bot) deleteFileFromAnthropic(ctx context.Context, fileID string) error {
|
|
_, err := b.anthropicClient.Files.Delete(ctx, fileID, anthropic.FileDeleteParams{})
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
func parseMissingFileIDFromBody(raw string) string {
|
|
idx := strings.Index(raw, fileNotFoundPrefix)
|
|
if idx == -1 {
|
|
return ""
|
|
}
|
|
rest := raw[idx+len(fileNotFoundPrefix):]
|
|
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]
|
|
}
|
|
|
|
func (b *Bot) hardDeleteScope(ctx context.Context, query string, args ...interface{}) error {
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
row.ImageFileIDs = nil
|
|
row.FilesCleanedAt = &now
|
|
} else {
|
|
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
|
|
}
|