Debounce and caching

This commit is contained in:
HugeFrog24
2026-07-24 23:41:28 +02:00
parent 0543283b8a
commit c36e1846f5
16 changed files with 1332 additions and 81 deletions
+93 -20
View File
@@ -28,6 +28,9 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
return
}
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)
@@ -62,6 +65,12 @@ func (b *Bot) handleVoiceMessage(ctx context.Context, message *models.Message, u
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)
@@ -111,6 +120,11 @@ func (b *Bot) handlePhotoMessage(
return
}
// Covers the Files API uploads as well as the model turn; on an album this
// is the longest wait in the bot.
stopTyping := b.startChatAction(ctx, chatID, businessConnectionID, models.ChatActionTyping)
defer stopTyping()
uploaded := make([]string, len(items))
caption := ""
g, gctx := errgroup.WithContext(ctx)
@@ -188,6 +202,47 @@ func (b *Bot) handlePhotoMessage(
}
}
// 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.
func (b *Bot) respondToChat(
ctx context.Context,
chatID, userID int64,
isEmojiOnly bool,
username, firstName, lastName string,
isPremium bool,
languageCode string,
messageTime int,
businessConnectionID string,
) {
stopTyping := b.startChatAction(ctx, chatID, businessConnectionID, models.ChatActionTyping)
defer stopTyping()
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending response: %v", sendErr)
}
return
}
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
}
}
func (b *Bot) anthropicErrorResponse(err error, userID int64) string {
isElevated := b.hasScope(userID, ScopeModelSet)
@@ -268,12 +323,17 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
}
}
// 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
@@ -422,14 +482,14 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
}
if message.Voice != nil {
b.cancelIntake(chatID)
b.handleVoiceMessage(ctx, message, userMsg, chatID, userID, username, firstName, lastName, isPremium, languageCode, messageTime, businessConnectionID)
return
}
chatMemory := b.getOrCreateChatMemory(chatID)
contextMessages := b.prepareContextMessages(chatMemory)
if message.Sticker != nil {
b.cancelIntake(chatID)
contextMessages := b.prepareContextMessages(b.getOrCreateChatMemory(chatID))
b.handleStickerMessage(ctx, chatID, userMsg, message, contextMessages, businessConnectionID)
return
}
@@ -441,24 +501,18 @@ func (b *Bot) handleUpdate(ctx context.Context, tgBot *bot.Bot, update *models.U
isEmojiOnly := isOnlyEmojis(text)
joined, err := b.getAnthropicResponse(
ctx, chatID, contextMessages, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
func(seg string) error {
return b.sendOneSegment(ctx, chatID, seg, businessConnectionID)
},
)
if err != nil {
ErrorLogger.Printf("Error getting Anthropic response: %v", err)
if sendErr := b.sendResponse(ctx, chatID, b.anthropicErrorResponse(err, userID), businessConnectionID); sendErr != nil {
ErrorLogger.Printf("Error sending response: %v", sendErr)
}
// 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
}
if _, storeErr := b.screenOutgoingMessage(chatID, joined); storeErr != nil {
ErrorLogger.Printf("Error recording assistant turn: %v", storeErr)
}
b.respondToChat(ctx, chatID, userID, isEmojiOnly,
username, firstName, lastName, isPremium, languageCode, messageTime,
businessConnectionID)
}
func (b *Bot) sendRateLimitExceededMessage(ctx context.Context, chatID int64, businessConnectionID string) {
@@ -469,7 +523,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) {
response, err := b.generateStickerResponse(ctx, userMessage, contextMessages)
response, err := b.generateStickerResponse(ctx, userMessage, contextMessages, businessConnectionID)
if err != nil {
ErrorLogger.Printf("Error generating sticker response: %v", err)
if message.Sticker.IsAnimated {
@@ -487,7 +541,10 @@ 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.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)
@@ -569,6 +626,22 @@ func (b *Bot) clearChatHistory(ctx context.Context, chatID int64, currentUserID
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)
}
b.chatMemoriesMu.Lock()
if targetUserID == currentUserID {
delete(b.chatMemories, chatID)