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.
773 lines
28 KiB
Go
773 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go/v2"
|
|
"github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, userMsg Message, chatID, userID int64, username, firstName, lastName string, isPremium bool, languageCode string, messageTime int, businessConnectionID string) {
|
|
if b.config.ElevenLabsAPIKey == "" {
|
|
if err := b.sendResponse(ctx, chatID, "I don't understand voice messages.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending voice-unsupported message: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if !b.hasScope(userID, ScopeTTSUse) {
|
|
if err := b.sendResponse(ctx, chatID, "You don't have permission to use voice features.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending permission denied message: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Voice is a committed turn: it waits for any running turn and is never
|
|
// restarted, so a text sent right after a voice note is answered afterwards.
|
|
turn, turnCtx := b.beginTurn(ctx, chatID)
|
|
defer b.endTurn(turn)
|
|
ctx = turnCtx
|
|
|
|
stopTyping := b.startChatAction(ctx, chatID, businessConnectionID, models.ChatActionTyping)
|
|
defer stopTyping()
|
|
|
|
transcript, err := b.transcribeVoice(ctx, message.Voice.FileID)
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error transcribing voice message from user %d: %v", userID, err)
|
|
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't understand your voice message.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending transcription error message: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := b.db.Model(&userMsg).Update("text", transcript).Error; err != nil {
|
|
ErrorLogger.Printf("Error updating voice transcript in DB: %v", err)
|
|
}
|
|
b.chatMemoriesMu.Lock()
|
|
if mem, exists := b.chatMemories[chatID]; exists {
|
|
for i := len(mem.Messages) - 1; i >= 0; i-- {
|
|
if mem.Messages[i].ID == userMsg.ID {
|
|
mem.Messages[i].Text = transcript
|
|
break
|
|
}
|
|
}
|
|
}
|
|
b.chatMemoriesMu.Unlock()
|
|
|
|
contextMessages := b.snapshotForTurn(turn, b.getOrCreateChatMemory(chatID))
|
|
response, err := b.getAnthropicResponse(ctx, chatID, contextMessages, false, username, firstName, lastName, isPremium, languageCode, messageTime, nil, nil)
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error getting Anthropic response for voice: %v", err)
|
|
if err := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending anthropic error response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Switch the indicator once the model is done and synthesis begins, so the
|
|
// client shows "recording audio" rather than "typing" for a voice reply.
|
|
stopTyping()
|
|
stopRecording := b.startChatAction(ctx, chatID, businessConnectionID, models.ChatActionUploadVoice)
|
|
defer stopRecording()
|
|
|
|
audioReader, err := b.generateSpeech(ctx, response)
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error generating speech, falling back to text: %v", err)
|
|
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending text fallback: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if _, err := b.screenOutgoingMessage(chatID, response); err != nil {
|
|
ErrorLogger.Printf("Error storing assistant voice response: %v", err)
|
|
}
|
|
|
|
params := &bot.SendAudioParams{
|
|
ChatID: chatID,
|
|
Audio: &models.InputFileUpload{Filename: "response.mp3", Data: audioReader},
|
|
}
|
|
if businessConnectionID != "" {
|
|
params.BusinessConnectionID = businessConnectionID
|
|
}
|
|
if _, err := b.tgBot.SendAudio(ctx, params); err != nil {
|
|
ErrorLogger.Printf("Error sending audio to chat %d: %v", chatID, err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// The Files API uploads depend on nothing the chat's running turn is doing,
|
|
// so they overlap it instead of queueing behind it. On an album this is the
|
|
// longest wait in the bot, and the typing indicator covers it.
|
|
stopTyping := b.startChatAction(ctx, chatID, businessConnectionID, models.ChatActionTyping)
|
|
finalUploaded, caption, err := b.uploadPhotoItems(ctx, items, chatID, businessConnectionID)
|
|
stopTyping()
|
|
if err != nil || len(finalUploaded) == 0 {
|
|
return
|
|
}
|
|
|
|
req := turnRequest{
|
|
ctx: ctx, chatID: chatID, userID: userID,
|
|
username: username, firstName: firstName, lastName: lastName,
|
|
languageCode: languageCode, isPremium: isPremium, messageTime: messageTime,
|
|
businessConnectionID: businessConnectionID,
|
|
}
|
|
// Photos wait for any running turn and are never restarted.
|
|
turn, turnCtx := b.beginTurn(ctx, chatID)
|
|
|
|
b.runTurn(turn, turnCtx, req, func(ctx context.Context, out replyOutput) (string, error) {
|
|
chatMemory := b.getOrCreateChatMemory(chatID)
|
|
userMessage := b.createMessage(chatID, userID, username, "user", caption, true)
|
|
userMessage.ImageFileIDs = finalUploaded
|
|
userMessage.TelegramMessageID = items[0].ID
|
|
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 "", errTurnSilent
|
|
}
|
|
b.addMessageToChatMemory(chatMemory, userMessage)
|
|
|
|
contextMessages := b.snapshotForTurn(turn, chatMemory)
|
|
return b.getAnthropicResponse(
|
|
ctx, chatID, contextMessages, false,
|
|
username, firstName, lastName, isPremium, languageCode, messageTime,
|
|
out.onSegment, out.onProgress,
|
|
)
|
|
})
|
|
}
|
|
|
|
// uploadPhotoItems uploads every photo in items to the Files API in parallel
|
|
// and returns their file ids with the album caption. On any failure it deletes
|
|
// the uploads that did succeed, tells the user, and returns errTurnSilent.
|
|
func (b *Bot) uploadPhotoItems(ctx context.Context, items []*models.Message, chatID int64, businessConnectionID string) ([]string, string, error) {
|
|
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 nil, "", errTurnSilent
|
|
}
|
|
finalUploaded := make([]string, 0, len(uploaded))
|
|
for _, fid := range uploaded {
|
|
if fid != "" {
|
|
finalUploaded = append(finalUploaded, fid)
|
|
}
|
|
}
|
|
return finalUploaded, caption, nil
|
|
}
|
|
|
|
// respondToChat runs one assistant turn against the chat's current memory and
|
|
// streams the reply back. Both the immediate path and the debounced flush go
|
|
// through here, so a coalesced turn is byte-for-byte the same request as a
|
|
// single-message one: the messages were already written to memory at intake, and
|
|
// the model simply sees more of them.
|
|
//
|
|
// If a turn is already running for the chat, beginTextTurn decides whether this
|
|
// flush is dropped, restarts it, or runs after it; see turn.go.
|
|
func (b *Bot) respondToChat(req turnRequest) {
|
|
wantID := b.newestUserMessageIDInChat(req.chatID)
|
|
if wantID == 0 {
|
|
// Nothing of the user's is in memory, for example because /clear
|
|
// landed after this flush was collected. There is nothing to answer.
|
|
InfoLogger.Printf("[%s] turn: chat %d has no user message to answer; skipping", b.config.ID, req.chatID)
|
|
return
|
|
}
|
|
turn, turnCtx, ok := b.beginTextTurn(req, wantID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
b.runTurn(turn, turnCtx, req, func(ctx context.Context, out replyOutput) (string, error) {
|
|
contextMessages := b.snapshotForTurn(turn, b.getOrCreateChatMemory(req.chatID))
|
|
return b.getAnthropicResponse(
|
|
ctx, req.chatID, contextMessages, req.isEmojiOnly,
|
|
req.username, req.firstName, req.lastName, req.isPremium, req.languageCode, req.messageTime,
|
|
out.onSegment, out.onProgress,
|
|
)
|
|
})
|
|
}
|
|
|
|
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
|
|
isElevated := b.hasScope(userID, ScopeModelSet)
|
|
|
|
if errors.Is(err, ErrModelNotFound) && isElevated {
|
|
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
|
|
}
|
|
return fmt.Sprintf("⚠️ Anthropic call failed: %v", err)
|
|
}
|
|
|
|
return "I'm sorry, I'm having trouble processing your request right now."
|
|
}
|
|
|
|
func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.Update) {
|
|
if stopped := update.StoppedMessageGeneration; stopped != nil {
|
|
b.handleStopGeneration(stopped.Chat.ID, stopped.DraftID)
|
|
return
|
|
}
|
|
if edited := update.EditedMessage; edited != nil {
|
|
b.handleEditedMessage(ctx, edited)
|
|
return
|
|
}
|
|
if edited := update.EditedBusinessMessage; edited != nil {
|
|
b.handleEditedMessage(ctx, edited)
|
|
return
|
|
}
|
|
|
|
var message *models.Message
|
|
|
|
if update.Message != nil {
|
|
message = update.Message
|
|
} else if update.BusinessMessage != nil {
|
|
message = update.BusinessMessage
|
|
} else {
|
|
return
|
|
}
|
|
|
|
var businessConnectionID string
|
|
if update.BusinessConnection != nil {
|
|
businessConnectionID = update.BusinessConnection.ID
|
|
} else if message.BusinessConnectionID != "" {
|
|
businessConnectionID = message.BusinessConnectionID
|
|
}
|
|
|
|
if message.From == nil {
|
|
return
|
|
}
|
|
|
|
chatID := message.Chat.ID
|
|
userID := message.From.ID
|
|
username := message.From.Username
|
|
firstName := message.From.FirstName
|
|
lastName := message.From.LastName
|
|
languageCode := message.From.LanguageCode
|
|
isPremium := message.From.IsPremium
|
|
messageTime := message.Date
|
|
text := message.Text
|
|
|
|
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
|
|
}
|
|
|
|
user, err := b.getOrCreateUser(userID, username, isOwner)
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error getting or creating user: %v", err)
|
|
return
|
|
}
|
|
|
|
if user.Username != username {
|
|
user.Username = username
|
|
if err := b.db.Save(&user).Error; err != nil {
|
|
ErrorLogger.Printf("Error updating user username: %v", err)
|
|
}
|
|
}
|
|
|
|
// Media never waits on the text debounce window. Cancelling here does not
|
|
// discard the buffered text: those messages are already in chat memory, so
|
|
// the turn this media triggers answers them too.
|
|
if message.MediaGroupID != "" && len(message.Photo) > 0 {
|
|
b.cancelIntake(chatID)
|
|
b.bufferAlbumItem(ctx, message, chatID, userID, username, firstName, lastName,
|
|
isPremium, languageCode, messageTime, businessConnectionID)
|
|
return
|
|
}
|
|
if len(message.Photo) > 0 {
|
|
b.cancelIntake(chatID)
|
|
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
|
|
}
|
|
|
|
userMsg, err := b.screenIncomingMessage(message)
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error storing user message: %v", err)
|
|
return
|
|
}
|
|
|
|
if message.Entities != nil {
|
|
for _, entity := range message.Entities {
|
|
if entity.Type == "bot_command" {
|
|
command := strings.TrimSpace(message.Text[entity.Offset : entity.Offset+entity.Length])
|
|
switch command {
|
|
case "/stats":
|
|
parts := strings.Fields(message.Text)
|
|
|
|
if len(parts) == 1 {
|
|
b.sendStats(ctx, chatID, userID, 0, businessConnectionID)
|
|
return
|
|
}
|
|
|
|
if len(parts) >= 2 && parts[1] == "user" {
|
|
targetUserID := userID
|
|
|
|
if len(parts) >= 3 {
|
|
var parseErr error
|
|
targetUserID, parseErr = strconv.ParseInt(parts[2], 10, 64)
|
|
if parseErr != nil {
|
|
InfoLogger.Printf("User %d provided invalid user ID format: %s", userID, parts[2])
|
|
if err := b.sendResponse(ctx, chatID, "Invalid user ID format. Usage: /stats user [user_id]", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
b.sendStats(ctx, chatID, userID, targetUserID, businessConnectionID)
|
|
return
|
|
}
|
|
|
|
if err := b.sendResponse(ctx, chatID, "Invalid command format. Usage: /stats or /stats user [user_id]", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
case "/whoami":
|
|
b.sendWhoAmI(ctx, chatID, userID, username, businessConnectionID)
|
|
return
|
|
case "/clear":
|
|
parts := strings.Fields(message.Text)
|
|
var targetUserID, targetChatID int64
|
|
if len(parts) > 1 {
|
|
var parseErr error
|
|
targetUserID, parseErr = strconv.ParseInt(parts[1], 10, 64)
|
|
if parseErr != nil {
|
|
InfoLogger.Printf("User %d provided invalid user ID format: %s", userID, parts[1])
|
|
if err := b.sendResponse(ctx, chatID, "Invalid user ID format. Usage: /clear [user_id] [chat_id]", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if len(parts) > 2 {
|
|
var parseErr error
|
|
targetChatID, parseErr = strconv.ParseInt(parts[2], 10, 64)
|
|
if parseErr != nil {
|
|
InfoLogger.Printf("User %d provided invalid chat ID format: %s", userID, parts[2])
|
|
if err := b.sendResponse(ctx, chatID, "Invalid chat ID format. Usage: /clear [user_id] [chat_id]", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
b.clearChatHistory(ctx, chatID, userID, targetUserID, targetChatID, businessConnectionID, false)
|
|
return
|
|
case "/set_model":
|
|
if !b.hasScope(userID, ScopeModelSet) {
|
|
if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can change the model.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
parts := strings.Fields(message.Text)
|
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
|
if err := b.sendResponse(ctx, chatID, "Usage: /set_model <model-id>", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
newModel := strings.TrimSpace(parts[1])
|
|
if err := b.config.PersistModel(newModel); err != nil {
|
|
ErrorLogger.Printf("Failed to persist model change: %v", err)
|
|
if err := b.sendResponse(ctx, chatID, fmt.Sprintf("Model updated in memory to `%s`, but failed to save to config file: %v", newModel, err), businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
InfoLogger.Printf("Model changed to %s by user %d", newModel, userID)
|
|
if err := b.sendResponse(ctx, chatID, fmt.Sprintf("✅ Model updated to `%s` and saved to config.", newModel), businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
case "/clear_hard":
|
|
parts := strings.Fields(message.Text)
|
|
var targetUserID, targetChatID int64
|
|
if len(parts) > 1 {
|
|
var parseErr error
|
|
targetUserID, parseErr = strconv.ParseInt(parts[1], 10, 64)
|
|
if parseErr != nil {
|
|
InfoLogger.Printf("User %d provided invalid user ID format: %s", userID, parts[1])
|
|
if err := b.sendResponse(ctx, chatID, "Invalid user ID format. Usage: /clear_hard [user_id] [chat_id]", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if len(parts) > 2 {
|
|
var parseErr error
|
|
targetChatID, parseErr = strconv.ParseInt(parts[2], 10, 64)
|
|
if parseErr != nil {
|
|
InfoLogger.Printf("User %d provided invalid chat ID format: %s", userID, parts[2])
|
|
if err := b.sendResponse(ctx, chatID, "Invalid chat ID format. Usage: /clear_hard [user_id] [chat_id]", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
b.clearChatHistory(ctx, chatID, userID, targetUserID, targetChatID, businessConnectionID, true)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !b.checkRateLimits(userID) {
|
|
b.sendRateLimitExceededMessage(ctx, chatID, businessConnectionID)
|
|
return
|
|
}
|
|
|
|
if message.Voice != nil {
|
|
b.cancelIntake(chatID)
|
|
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID)
|
|
return
|
|
}
|
|
|
|
if message.Sticker != nil {
|
|
b.cancelIntake(chatID)
|
|
turn, turnCtx := b.beginTurn(ctx, chatID)
|
|
defer b.endTurn(turn)
|
|
contextMessages := b.snapshotForTurn(turn, b.getOrCreateChatMemory(chatID))
|
|
b.handleStickerMessage(turnCtx, chatID, userMsg, message, contextMessages, businessConnectionID)
|
|
return
|
|
}
|
|
|
|
if text == "" {
|
|
InfoLogger.Printf("Received a non-text message from user %d in chat %d", userID, chatID)
|
|
return
|
|
}
|
|
|
|
isEmojiOnly := isOnlyEmojis(text)
|
|
|
|
// Plain text is the only thing that debounces: it is what users fragment
|
|
// across several sends, and it is the only kind whose meaning survives being
|
|
// read as one turn.
|
|
if b.config.DebounceWindow() > 0 {
|
|
b.bufferIntake(ctx, chatID, userID, username, firstName, lastName,
|
|
isPremium, languageCode, messageTime, businessConnectionID, isEmojiOnly)
|
|
return
|
|
}
|
|
|
|
b.respondToChat(turnRequest{
|
|
ctx: ctx, chatID: chatID, userID: userID, isEmojiOnly: isEmojiOnly,
|
|
username: username, firstName: firstName, lastName: lastName,
|
|
isPremium: isPremium, languageCode: languageCode, messageTime: messageTime,
|
|
businessConnectionID: businessConnectionID,
|
|
})
|
|
}
|
|
|
|
// handleEditedMessage applies a Telegram edit to the stored copy of a user
|
|
// message. Mobile clients deliver autocorrect fixes as edits within a few
|
|
// seconds of the send, so the model should see the corrected text. What
|
|
// happens next depends on where the message is:
|
|
//
|
|
// - still in its quiet window: the window restarts as if it had just arrived;
|
|
// - being read by a turn that has shown nothing yet: that turn restarts;
|
|
// - already answered, or shown: only the record is corrected.
|
|
func (b *Bot) handleEditedMessage(ctx context.Context, edited *models.Message) {
|
|
text := edited.Text
|
|
if text == "" {
|
|
text = edited.Caption
|
|
}
|
|
if edited.From == nil || text == "" {
|
|
return
|
|
}
|
|
chatID := edited.Chat.ID
|
|
|
|
// Memory is the gate: only a message still in memory can influence a turn,
|
|
// and the check is cheap. It also keeps edits from chats this bot never
|
|
// stored off the database; a business connection delivers every edit on
|
|
// the whole account.
|
|
var msgID uint
|
|
answered := false
|
|
b.chatMemoriesMu.Lock()
|
|
if mem, exists := b.chatMemories[chatID]; exists {
|
|
for i := len(mem.Messages) - 1; i >= 0; i-- {
|
|
m := &mem.Messages[i]
|
|
if m.IsUser && m.TelegramMessageID == edited.ID {
|
|
m.Text = text
|
|
msgID = m.ID
|
|
break
|
|
}
|
|
if !m.IsUser {
|
|
answered = true
|
|
}
|
|
}
|
|
}
|
|
b.chatMemoriesMu.Unlock()
|
|
if msgID == 0 {
|
|
return
|
|
}
|
|
|
|
if err := b.db.Model(&Message{}).Where("id = ?", msgID).Update("text", text).Error; err != nil {
|
|
ErrorLogger.Printf("[%s] applying edit to message %d in chat %d: %v", b.config.ID, edited.ID, chatID, err)
|
|
}
|
|
if answered {
|
|
return
|
|
}
|
|
|
|
isEmojiOnly := isOnlyEmojis(text)
|
|
if b.touchIntake(chatID, isEmojiOnly) {
|
|
InfoLogger.Printf("[%s] edit to message %d re-armed the quiet window for chat %d", b.config.ID, edited.ID, chatID)
|
|
return
|
|
}
|
|
|
|
req := turnRequest{
|
|
ctx: ctx, chatID: chatID, userID: edited.From.ID,
|
|
username: edited.From.Username, firstName: edited.From.FirstName, lastName: edited.From.LastName,
|
|
languageCode: edited.From.LanguageCode, isPremium: edited.From.IsPremium, messageTime: edited.Date,
|
|
businessConnectionID: edited.BusinessConnectionID, isEmojiOnly: isEmojiOnly,
|
|
}
|
|
if b.restartForEdit(req, msgID) {
|
|
InfoLogger.Printf("[%s] edit to message %d restarted the running turn for chat %d", b.config.ID, edited.ID, chatID)
|
|
}
|
|
}
|
|
|
|
// handleStopGeneration routes Telegram's stopped_message_generation update,
|
|
// sent when the user taps Stop under a streaming draft.
|
|
func (b *Bot) handleStopGeneration(chatID int64, draftID int) {
|
|
if b.stopTurn(chatID, draftID) {
|
|
InfoLogger.Printf("[%s] user stopped generation in chat %d (draft %d)", b.config.ID, chatID, draftID)
|
|
return
|
|
}
|
|
InfoLogger.Printf("[%s] stop for chat %d ignored: draft %d is not the running turn", b.config.ID, chatID, draftID)
|
|
}
|
|
|
|
func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, businessConnectionID string) {
|
|
if err := b.sendResponse(ctx, chatID, "Rate limit exceeded. Please try again later.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending rate limit exceeded message: %v", err)
|
|
}
|
|
}
|
|
|
|
func (b *Bot) handleStickerMessage(ctx context.Context, chatID int64, userMessage Message, message *models.Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) {
|
|
|
|
response, err := b.generateStickerResponse(ctx, userMessage, contextMessages, businessConnectionID)
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error generating sticker response: %v", err)
|
|
if message.Sticker.IsAnimated {
|
|
response = "Wow, that's a cool animated sticker!"
|
|
} else if message.Sticker.IsVideo {
|
|
response = "Interesting video sticker!"
|
|
} else {
|
|
response = "That's a cool sticker!"
|
|
}
|
|
}
|
|
|
|
if err := b.sendResponse(ctx, chatID, response, businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (b *Bot) generateStickerResponse(ctx context.Context, message Message, contextMessages []anthropic.BetaMessageParam, businessConnectionID string) (string, error) {
|
|
stopTyping := b.startChatAction(ctx, message.ChatID, businessConnectionID, models.ChatActionTyping)
|
|
defer stopTyping()
|
|
|
|
if message.StickerFileID != "" {
|
|
messageTime := int(message.Timestamp.Unix())
|
|
response, err := b.getAnthropicResponse(ctx, message.ChatID, contextMessages, true, message.Username, "", "", false, "", messageTime, nil, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
return "Hmm, that's interesting!", nil
|
|
}
|
|
|
|
func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID int64, targetUserID int64, targetChatID int64, businessConnectionID string, hardDelete bool) {
|
|
if targetUserID != 0 && targetUserID != currentUserID {
|
|
requiredScope := ScopeHistoryClearAny
|
|
if hardDelete {
|
|
requiredScope = ScopeHistoryClearHardAny
|
|
}
|
|
if !b.hasScope(currentUserID, requiredScope) {
|
|
InfoLogger.Printf("User %d attempted to clear history for user %d without permission", currentUserID, targetUserID)
|
|
if err := b.sendResponse(ctx, chatID, "Permission denied. Only admins and owners can clear other users' histories.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
var targetUser User
|
|
err := b.db.Where("telegram_id = ? AND bot_id = ?", targetUserID, b.botID).First(&targetUser).Error
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error finding target user %d: %v", targetUserID, err)
|
|
if err := b.sendResponse(ctx, chatID, fmt.Sprintf("User with ID %d not found.", targetUserID), businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
} else {
|
|
targetUserID = currentUserID
|
|
}
|
|
|
|
var err error
|
|
if hardDelete {
|
|
if targetUserID == currentUserID {
|
|
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", chatID, b.botID)
|
|
InfoLogger.Printf("User %d permanently deleted their own chat history in chat %d", currentUserID, chatID)
|
|
} else {
|
|
if targetChatID != 0 {
|
|
err = b.hardDeleteScope(ctx, "chat_id = ? AND bot_id = ?", targetChatID, b.botID)
|
|
InfoLogger.Printf("Admin/owner %d permanently deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
|
|
} else {
|
|
err = b.hardDeleteScope(ctx,
|
|
"bot_id = ? AND (user_id = ? OR (chat_id = ? AND is_user = ?))",
|
|
b.botID, targetUserID, targetUserID, false)
|
|
InfoLogger.Printf("Admin/owner %d permanently deleted all chat history for user %d", currentUserID, targetUserID)
|
|
}
|
|
}
|
|
} else {
|
|
if targetUserID == currentUserID {
|
|
err = b.db.Where("chat_id = ? AND bot_id = ?", chatID, b.botID).Delete(&Message{}).Error
|
|
InfoLogger.Printf("User %d soft deleted their own chat history in chat %d", currentUserID, chatID)
|
|
} else {
|
|
if targetChatID != 0 {
|
|
err = b.db.Where("chat_id = ? AND bot_id = ?", targetChatID, b.botID).Delete(&Message{}).Error
|
|
InfoLogger.Printf("Admin/owner %d soft deleted chat history for user %d in chat %d", currentUserID, targetUserID, targetChatID)
|
|
} else {
|
|
err = b.db.Where("bot_id = ? AND user_id = ?", b.botID, targetUserID).Delete(&Message{}).Error
|
|
if err == nil {
|
|
err = b.db.Where("chat_id = ? AND bot_id = ? AND is_user = ?", targetUserID, b.botID, false).Delete(&Message{}).Error
|
|
}
|
|
InfoLogger.Printf("Admin/owner %d soft deleted all chat history for user %d", currentUserID, targetUserID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
ErrorLogger.Printf("Error clearing chat history: %v", err)
|
|
if err := b.sendResponse(ctx, chatID, "Sorry, I couldn't clear the chat history.", businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Drop any armed intake buffer for the same chat before clearing memory.
|
|
// Otherwise the debounce timer fires moments later and repopulates the chat
|
|
// with the very messages that were just deleted — the openclaw/openclaw#51046
|
|
// failure mode, but with a privacy consequence rather than a stray reply.
|
|
clearedChatID := chatID
|
|
if targetUserID != currentUserID {
|
|
clearedChatID = targetChatID
|
|
if clearedChatID == 0 {
|
|
clearedChatID = targetUserID
|
|
}
|
|
}
|
|
if discarded := b.cancelIntake(clearedChatID); discarded > 0 {
|
|
InfoLogger.Printf("[%s] discarded %d buffered message(s) for chat %d on history clear",
|
|
b.config.ID, discarded, clearedChatID)
|
|
}
|
|
// Likewise a flush collected behind a running turn: rerun against the
|
|
// emptied chat, it would call the model with no user message at all.
|
|
b.discardRerun(clearedChatID)
|
|
|
|
b.chatMemoriesMu.Lock()
|
|
if targetUserID == currentUserID {
|
|
delete(b.chatMemories, chatID)
|
|
} else if targetChatID != 0 {
|
|
delete(b.chatMemories, targetChatID)
|
|
} else {
|
|
delete(b.chatMemories, targetUserID)
|
|
}
|
|
b.chatMemoriesMu.Unlock()
|
|
|
|
var confirmationMessage string
|
|
if targetUserID == currentUserID {
|
|
confirmationMessage = "Your chat history has been cleared."
|
|
} else {
|
|
var targetUser User
|
|
err := b.db.Where("telegram_id = ? AND bot_id = ?", targetUserID, b.botID).First(&targetUser).Error
|
|
if err == nil && targetUser.Username != "" {
|
|
confirmationMessage = fmt.Sprintf("Chat history for user @%s (ID: %d) has been cleared.", targetUser.Username, targetUserID)
|
|
} else {
|
|
confirmationMessage = fmt.Sprintf("Chat history for user with ID %d has been cleared.", targetUserID)
|
|
}
|
|
}
|
|
|
|
if err := b.sendResponse(ctx, chatID, confirmationMessage, businessConnectionID); err != nil {
|
|
ErrorLogger.Printf("Error sending response: %v", err)
|
|
}
|
|
}
|