mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-08-28 22:11:38 +00:00
Compare commits
1
Commits
564f96c97a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36e1846f5 |
@@ -76,9 +76,37 @@ Each bot is one JSON file in `config/` (see `config/default.json` for the templa
|
||||
| `max_tokens` | number | `1000` | Maximum output tokens per reply. **Thinking tokens count toward this limit** — raise it (e.g. `4000`+) whenever `thinking` is `"adaptive"`, or a turn can spend the whole budget on reasoning and produce no text. |
|
||||
| `thinking` | string | *(omitted)* | Reasoning mode: `"adaptive"` (the model decides when and how much to think) or `"disabled"`. Omit the key entirely to use the model's own API default. If the configured model doesn't support the chosen mode, the API rejects the request with a 400 — owners/admins see the raw error, regular users get the generic fallback. Check [Anthropic's model docs](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking) for per-model support. |
|
||||
| `thinking_display` | string | *(omitted)* | `"summarized"` or `"omitted"`. Only valid together with `"thinking": "adaptive"`. Controls whether the API returns a readable summary of the reasoning (logged, never sent to chat). Thinking is billed the same either way; when omitted, the API's per-model default applies. |
|
||||
| `debounce_ms` | number | *(omitted)* | Quiet window, in milliseconds, for coalescing rapid follow-up messages into a single turn. Omit or set `0` to disable. See [Coalescing rapid messages](#coalescing-rapid-messages) below. |
|
||||
| `cache_history` | bool | `true` | Places a prompt-cache breakpoint on the trailing conversation block, so each turn reads the prior history from cache instead of reprocessing it at full price. Set `false` to cache only the system prompt. |
|
||||
| `web_search` | object | *(omitted)* | Enables Anthropic's server-side web search (and, optionally, web fetch), sandboxed to a domain allowlist. Omit the key entirely to leave both tools off — an absent block sends byte-identical requests to before. See [Web search / fetch](#web-search--fetch) below. |
|
||||
|
||||
Every reply logs one accounting line — `[usage] model=... in=... out=... thinking=... stop=...` — so thinking spend (billed even when its text is omitted) stays visible in `journalctl`/`docker compose logs`. A `stop=max_tokens` line is accompanied by an error-level warning that the reply was truncated.
|
||||
Every reply logs one accounting line — `[usage] model=... in=... out=... thinking=... cache_read=... cache_write=... stop=...` — so thinking spend (billed even when its text is omitted) stays visible in `journalctl`/`docker compose logs`. A `stop=max_tokens` line is accompanied by an error-level warning that the reply was truncated.
|
||||
|
||||
> [!TIP]
|
||||
> Watch `cache_read`/`cache_write` after changing prompts or models. A cache breakpoint below the model's minimum cacheable prefix fails **silently** — no error, just `cache_write=0` forever. The minimum is model-specific and not monotonic across generations (Haiku 4.5 needs 4096 tokens; Sonnet 4.6 needs 1024), so a short system prompt that caches fine on one model may never cache on another. Note also that chat memory is a sliding window: once it is full, each turn evicts the oldest message and changes the prefix, so `cache_read` on long-running chats will be lower than on fresh ones.
|
||||
|
||||
### Coalescing rapid messages
|
||||
|
||||
A user who sends "how do I do X", then "sorry typo", then "lmao" in five seconds would otherwise get three separate replies — the bot starts a full turn per message, because each Telegram update independently drives one. `debounce_ms` holds text messages in a per-chat buffer and resets the window on every new message, dispatching a single turn once the user stops typing.
|
||||
|
||||
```json
|
||||
"debounce_ms": 2500
|
||||
```
|
||||
|
||||
Reasonable windows are 1500–3000ms for ordinary chat and up to 8000ms for Telegram Business, where a person writing to a business account tends to send longer bursts. The maximum is 30000ms.
|
||||
|
||||
Nothing is discarded while buffering. Each message is still persisted and added to chat memory the moment it arrives, so the single coalesced turn sees all of them — the buffer only decides *when* to answer, never *what* the model reads. Reply metadata (language, premium status, business connection) follows the most recent message in the batch.
|
||||
|
||||
What deliberately does **not** wait:
|
||||
|
||||
- **Commands** (`/stats`, `/clear`, …) dispatch immediately.
|
||||
- **Photos, albums, voice, and stickers** dispatch immediately and cancel any pending text window. The buffered text is not lost — it is already in memory, so the media turn answers it too.
|
||||
- **`/clear` and `/clear_hard`** cancel the buffer outright. Without this, the window would fire seconds after the wipe and replay the just-deleted messages back into memory.
|
||||
|
||||
While a turn is running the bot shows Telegram's "typing…" indicator (or "recording audio" while synthesising a voice reply), refreshed every 4 seconds because Telegram expires the status after 5. Without it, a debounce window reads as the bot ignoring you — which is what prompts users to send more messages in the first place.
|
||||
|
||||
> [!NOTE]
|
||||
> Debouncing is the cheap fix and the reason there is no "cancel the in-flight request" mode. Anthropic bills input tokens plus any output already generated when a turn stops partway, and any web searches it already ran are billed and re-billed on the retry. A message that never dispatched costs nothing.
|
||||
|
||||
### Web search / fetch
|
||||
|
||||
|
||||
+7
-2
@@ -414,9 +414,14 @@ func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNe
|
||||
|
||||
stopReason := string(message.StopReason)
|
||||
if stopReason != "" || message.Usage.OutputTokens > 0 {
|
||||
InfoLogger.Printf("[usage] model=%s in=%d out=%d thinking=%d stop=%s",
|
||||
// cache_read/cache_write make the caching configuration falsifiable: a
|
||||
// breakpoint below the model's minimum cacheable prefix fails silently,
|
||||
// reporting cache_write=0 rather than raising an error.
|
||||
InfoLogger.Printf("[usage] model=%s in=%d out=%d thinking=%d cache_read=%d cache_write=%d stop=%s",
|
||||
params.Model, message.Usage.InputTokens, message.Usage.OutputTokens,
|
||||
message.Usage.OutputTokensDetails.ThinkingTokens, stopReason)
|
||||
message.Usage.OutputTokensDetails.ThinkingTokens,
|
||||
message.Usage.CacheReadInputTokens, message.Usage.CacheCreationInputTokens,
|
||||
stopReason)
|
||||
if message.StopReason == anthropic.BetaStopReasonMaxTokens {
|
||||
ErrorLogger.Printf("[usage] response truncated at max_tokens=%d - raise max_tokens (thinking counts toward it)",
|
||||
params.MaxTokens)
|
||||
|
||||
@@ -29,6 +29,9 @@ type Bot struct {
|
||||
botID uint
|
||||
albumBuffers map[string]*pendingAlbum
|
||||
albumBuffersMu sync.Mutex
|
||||
intakeBuffers map[int64]*pendingIntake
|
||||
intakeBuffersMu sync.Mutex
|
||||
intakeSeq uint64
|
||||
}
|
||||
|
||||
func messageType(msg *models.Message) string {
|
||||
@@ -90,6 +93,7 @@ func NewBot(db *gorm.DB, config BotConfig, clock Clock, tgClient TelegramClient)
|
||||
botID: botEntry.ID,
|
||||
tgBot: tgClient,
|
||||
albumBuffers: make(map[string]*pendingAlbum),
|
||||
intakeBuffers: make(map[int64]*pendingIntake),
|
||||
}
|
||||
|
||||
if tgClient == nil {
|
||||
@@ -298,9 +302,39 @@ func (b *Bot) prepareContextMessages(chatMemory *ChatMemory) []anthropic.BetaMes
|
||||
}
|
||||
contextMessages = append(contextMessages, param)
|
||||
}
|
||||
|
||||
if b.config.CacheHistoryEnabled() {
|
||||
markTrailingCacheBreakpoint(contextMessages)
|
||||
}
|
||||
return contextMessages
|
||||
}
|
||||
|
||||
// markTrailingCacheBreakpoint puts a cache_control breakpoint on the final
|
||||
// content block of the conversation, so the next turn reads the whole prefix
|
||||
// from cache instead of reprocessing it. The system prompt keeps its own
|
||||
// breakpoint; tools and system render ahead of messages, so the two compose.
|
||||
//
|
||||
// Caveat worth knowing when reading [usage] lines: chat memory is a sliding
|
||||
// window. Once it is full, each new turn evicts the oldest message, which
|
||||
// changes the prefix and forces a miss. Until then, and for any chat shorter
|
||||
// than the window, this converts a full-price reread into a cache read.
|
||||
func markTrailingCacheBreakpoint(messages []anthropic.BetaMessageParam) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
blocks := messages[len(messages)-1].Content
|
||||
if len(blocks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch last := &blocks[len(blocks)-1]; {
|
||||
case last.OfText != nil:
|
||||
last.OfText.CacheControl = anthropic.NewBetaCacheControlEphemeralParam()
|
||||
case last.OfImage != nil:
|
||||
last.OfImage.CacheControl = anthropic.NewBetaCacheControlEphemeralParam()
|
||||
}
|
||||
}
|
||||
|
||||
func contentBlocksForMessage(msg Message) []anthropic.BetaContentBlockParamUnion {
|
||||
var blocks []anthropic.BetaContentBlockParamUnion
|
||||
if msg.IsUser && len(msg.ImageFileIDs) > 0 {
|
||||
@@ -706,16 +740,18 @@ func (b *Bot) screenOutgoingMessage(chatID int64, response string) (Message, err
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
// Mark every outstanding user message in the chat, not just the newest one.
|
||||
// A coalesced turn answers the whole batch, so a single-row update would
|
||||
// leave the earlier messages permanently unanswered. This also drops an
|
||||
// UPDATE ... ORDER BY ... LIMIT, which stock SQLite builds do not support.
|
||||
now := time.Now()
|
||||
err := b.db.Model(&Message{}).
|
||||
Where("chat_id = ? AND bot_id = ? AND is_user = ? AND answered_on IS NULL",
|
||||
chatID, b.botID, true).
|
||||
Order("timestamp DESC").
|
||||
Limit(1).
|
||||
Update("answered_on", now).Error
|
||||
|
||||
if err != nil {
|
||||
ErrorLogger.Printf("Error marking user message as answered: %v", err)
|
||||
ErrorLogger.Printf("Error marking user messages as answered: %v", err)
|
||||
}
|
||||
|
||||
chatMemory := b.getOrCreateChatMemory(chatID)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MCPServer struct {
|
||||
@@ -31,6 +32,28 @@ const (
|
||||
ThinkingDisplayOmitted = "omitted"
|
||||
)
|
||||
|
||||
// maxDebounceMs bounds debounce_ms. Beyond this the bot reads as unresponsive
|
||||
// rather than deliberate, and the coalesced turn drifts far enough from the
|
||||
// user's last message that the reply feels stale.
|
||||
const maxDebounceMs = 30000
|
||||
|
||||
// DebounceWindow is the quiet period an intake buffer waits before dispatching a
|
||||
// coalesced turn. Zero disables debouncing entirely (the default), matching the
|
||||
// opt-in behavior of comparable gateways.
|
||||
func (c *BotConfig) DebounceWindow() time.Duration {
|
||||
if c.DebounceMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(c.DebounceMs) * time.Millisecond
|
||||
}
|
||||
|
||||
// CacheHistoryEnabled reports whether a cache_control breakpoint should be placed
|
||||
// on the trailing conversation block in addition to the system prompt. Defaults
|
||||
// to true; set "cache_history": false to opt out.
|
||||
func (c *BotConfig) CacheHistoryEnabled() bool {
|
||||
return c.CacheHistory == nil || *c.CacheHistory
|
||||
}
|
||||
|
||||
type BotConfig struct {
|
||||
ID string `json:"id"`
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
@@ -43,6 +66,8 @@ type BotConfig struct {
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
ThinkingDisplay string `json:"thinking_display,omitempty"`
|
||||
DebounceMs int `json:"debounce_ms,omitempty"`
|
||||
CacheHistory *bool `json:"cache_history,omitempty"`
|
||||
SystemPrompts map[string]string `json:"system_prompts"`
|
||||
Active bool `json:"active"`
|
||||
OwnerTelegramID int64 `json:"owner_telegram_id"`
|
||||
@@ -117,11 +142,36 @@ func loadAllConfigs(dir string) ([]BotConfig, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
logConfigAdvisories(&config)
|
||||
|
||||
config.ConfigFilePath = validPath
|
||||
configs = append(configs, config)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("no valid configs found")
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// logConfigAdvisories emits non-fatal boot-time notes about settings that are
|
||||
// valid but likely to surprise: silently ineffective, or costlier than intended.
|
||||
func logConfigAdvisories(config *BotConfig) {
|
||||
if config.Thinking == ThinkingModeAdaptive && config.MaxTokens > 0 && config.MaxTokens < 4000 {
|
||||
InfoLogger.Printf("[%s] thinking=adaptive with max_tokens=%d: thinking tokens count toward max_tokens; consider >= 4000",
|
||||
config.ID, config.MaxTokens)
|
||||
}
|
||||
|
||||
if config.DebounceMs > 0 {
|
||||
InfoLogger.Printf("[%s] intake debounce enabled: coalescing rapid text messages over a %dms quiet window",
|
||||
config.ID, config.DebounceMs)
|
||||
} else {
|
||||
InfoLogger.Printf("[%s] intake debounce disabled: every message dispatches its own turn (set debounce_ms to coalesce rapid follow-ups)",
|
||||
config.ID)
|
||||
}
|
||||
|
||||
if ws := config.WebSearch; ws != nil && len(ws.AllowedDomains) == 0 && len(ws.BlockedDomains) == 0 {
|
||||
InfoLogger.Printf("[%s] web_search enabled with no allowed_domains/blocked_domains: the model may search the open web",
|
||||
config.ID)
|
||||
@@ -139,17 +189,6 @@ func loadAllConfigs(dir string) ([]BotConfig, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.ConfigFilePath = validPath
|
||||
configs = append(configs, config)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("no valid configs found")
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func validateConfig(config *BotConfig, ids, tokens map[string]bool) error {
|
||||
@@ -196,6 +235,14 @@ func validateConfig(config *BotConfig, ids, tokens map[string]bool) error {
|
||||
return fmt.Errorf("'max_tokens' must be greater than 0 when set")
|
||||
}
|
||||
|
||||
if config.DebounceMs < 0 {
|
||||
return fmt.Errorf("'debounce_ms' must be greater than 0 when set")
|
||||
}
|
||||
if config.DebounceMs > maxDebounceMs {
|
||||
return fmt.Errorf("'debounce_ms' of %d exceeds the maximum of %d (Telegram drops long-idle updates and users read silence as failure)",
|
||||
config.DebounceMs, maxDebounceMs)
|
||||
}
|
||||
|
||||
if ws := config.WebSearch; ws != nil {
|
||||
if len(ws.AllowedDomains) > 0 && len(ws.BlockedDomains) > 0 {
|
||||
return fmt.Errorf("'web_search' cannot set both allowed_domains and blocked_domains (the API rejects that)")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"model": "claude-haiku-4-5",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1000,
|
||||
"debounce_ms": 2500,
|
||||
"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 [email protected].\n- If asked about your capabilities, be honest about what you can and cannot do.\nAlways maintain a friendly and professional tone.",
|
||||
|
||||
Binary file not shown.
+93
-20
@@ -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)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pendingIntake holds a chat's coalescing window. The buffered message bodies
|
||||
// are deliberately absent: screenIncomingMessage has already persisted each
|
||||
// message to the database and to chat memory by the time it is buffered, so the
|
||||
// flushed turn picks them all up from memory. What is kept here is the metadata
|
||||
// the turn needs, always refreshed to the most recent message in the batch.
|
||||
type pendingIntake struct {
|
||||
chatID, userID int64
|
||||
username, firstName, lastName, languageCode string
|
||||
isPremium bool
|
||||
messageTime int
|
||||
businessConnectionID string
|
||||
allEmojiOnly bool
|
||||
count int
|
||||
seq uint64
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// bufferIntake holds a text message for the configured quiet window instead of
|
||||
// dispatching a turn immediately, resetting the window on each new message.
|
||||
// Rapid follow-ups therefore produce one reply rather than one per message.
|
||||
func (b *Bot) bufferIntake(
|
||||
ctx context.Context,
|
||||
chatID, userID int64,
|
||||
username, firstName, lastName string,
|
||||
isPremium bool,
|
||||
languageCode string,
|
||||
messageTime int,
|
||||
businessConnectionID string,
|
||||
isEmojiOnly bool,
|
||||
) {
|
||||
window := b.config.DebounceWindow()
|
||||
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
|
||||
pending, exists := b.intakeBuffers[chatID]
|
||||
if !exists {
|
||||
b.intakeSeq++
|
||||
pending = &pendingIntake{seq: b.intakeSeq, allEmojiOnly: true}
|
||||
b.intakeBuffers[chatID] = pending
|
||||
}
|
||||
|
||||
// Reply metadata tracks the most recent message in the batch.
|
||||
pending.chatID = chatID
|
||||
pending.userID = userID
|
||||
pending.username = username
|
||||
pending.firstName = firstName
|
||||
pending.lastName = lastName
|
||||
pending.isPremium = isPremium
|
||||
pending.languageCode = languageCode
|
||||
pending.messageTime = messageTime
|
||||
pending.businessConnectionID = businessConnectionID
|
||||
pending.allEmojiOnly = pending.allEmojiOnly && isEmojiOnly
|
||||
pending.count++
|
||||
|
||||
if pending.timer != nil {
|
||||
pending.timer.Stop()
|
||||
}
|
||||
seq := pending.seq
|
||||
pending.timer = time.AfterFunc(window, func() {
|
||||
b.flushIntake(ctx, chatID, seq)
|
||||
})
|
||||
}
|
||||
|
||||
// flushIntake dispatches the coalesced turn for a chat. seq guards against a
|
||||
// timer that had already fired before its Stop call landed: a stale goroutine
|
||||
// would otherwise flush a buffer belonging to a later batch.
|
||||
func (b *Bot) flushIntake(ctx context.Context, chatID int64, seq uint64) {
|
||||
b.intakeBuffersMu.Lock()
|
||||
pending, exists := b.intakeBuffers[chatID]
|
||||
if !exists || pending.seq != seq {
|
||||
b.intakeBuffersMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(b.intakeBuffers, chatID)
|
||||
captured := *pending
|
||||
b.intakeBuffersMu.Unlock()
|
||||
|
||||
if captured.count > 1 {
|
||||
InfoLogger.Printf("[%s] intake flush: coalesced %d messages into one turn for chat %d",
|
||||
b.config.ID, captured.count, chatID)
|
||||
}
|
||||
|
||||
b.respondToChat(
|
||||
ctx, chatID, captured.userID, captured.allEmojiOnly,
|
||||
captured.username, captured.firstName, captured.lastName,
|
||||
captured.isPremium, captured.languageCode, captured.messageTime,
|
||||
captured.businessConnectionID,
|
||||
)
|
||||
}
|
||||
|
||||
// cancelIntake drops a chat's pending buffer without dispatching, returning how
|
||||
// many messages were discarded.
|
||||
//
|
||||
// This is the fix for the class of bug in openclaw/openclaw#51046, where a stop
|
||||
// command aborted the running turn but left the debounce buffer armed, so the
|
||||
// timer fired afterwards and started the very turn the user had just cancelled.
|
||||
// Here the stakes are higher than a stray turn: /clear and /clear_hard delete
|
||||
// chat memory, and a surviving buffer would repopulate it moments later with
|
||||
// content the user asked to have removed.
|
||||
func (b *Bot) cancelIntake(chatID int64) int {
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
|
||||
pending, exists := b.intakeBuffers[chatID]
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
if pending.timer != nil {
|
||||
pending.timer.Stop()
|
||||
}
|
||||
delete(b.intakeBuffers, chatID)
|
||||
return pending.count
|
||||
}
|
||||
|
||||
// hasPendingIntake reports whether a chat currently holds a buffered batch.
|
||||
func (b *Bot) hasPendingIntake(chatID int64) bool {
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
_, exists := b.intakeBuffers[chatID]
|
||||
return exists
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// bufferOnly parks a message in the intake buffer without letting the flush run,
|
||||
// by using a window long enough that no test waits it out.
|
||||
const bufferOnly = 10 * time.Second
|
||||
|
||||
func bufferText(b *Bot, chatID int64, isEmojiOnly bool) {
|
||||
b.bufferIntake(context.Background(), chatID, 555,
|
||||
"tester", "Test", "User", false, "en", int(time.Now().Unix()), "", isEmojiOnly)
|
||||
}
|
||||
|
||||
func TestBufferIntake_CoalescesIntoSingleTurn(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
|
||||
// The burst this whole feature exists for: one real question, then filler.
|
||||
for i := 0; i < 5; i++ {
|
||||
bufferText(b, 900, false)
|
||||
}
|
||||
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
assert.Len(t, b.intakeBuffers, 1, "rapid messages must share one buffer entry")
|
||||
assert.Equal(t, 5, b.intakeBuffers[900].count, "all five messages land in the same batch")
|
||||
}
|
||||
|
||||
// Separate chats must not share a window; one user's burst cannot delay another's.
|
||||
func TestBufferIntake_IsolatesChats(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
|
||||
bufferText(b, 910, false)
|
||||
bufferText(b, 911, false)
|
||||
bufferText(b, 911, false)
|
||||
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
assert.Equal(t, 1, b.intakeBuffers[910].count)
|
||||
assert.Equal(t, 2, b.intakeBuffers[911].count)
|
||||
}
|
||||
|
||||
func TestBufferIntake_ResetsWindowAndKeepsLatestMetadata(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
|
||||
b.bufferIntake(context.Background(), 901, 1, "first", "First", "", false, "en", 1000, "", true)
|
||||
b.bufferIntake(context.Background(), 901, 2, "second", "Second", "", true, "de", 2000, "biz-42", false)
|
||||
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
pending := b.intakeBuffers[901]
|
||||
require.NotNil(t, pending)
|
||||
|
||||
assert.Equal(t, 2, pending.count)
|
||||
// OpenClaw semantics: reply metadata follows the most recent message.
|
||||
assert.Equal(t, "second", pending.username)
|
||||
assert.Equal(t, int64(2), pending.userID)
|
||||
assert.Equal(t, "de", pending.languageCode)
|
||||
assert.Equal(t, 2000, pending.messageTime)
|
||||
assert.Equal(t, "biz-42", pending.businessConnectionID)
|
||||
assert.True(t, pending.isPremium)
|
||||
// One non-emoji message makes the whole coalesced turn non-emoji.
|
||||
assert.False(t, pending.allEmojiOnly)
|
||||
}
|
||||
|
||||
func TestBufferIntake_AllEmojiOnlySurvivesWhenEveryMessageIsEmoji(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
|
||||
bufferText(b, 902, true)
|
||||
bufferText(b, 902, true)
|
||||
|
||||
b.intakeBuffersMu.Lock()
|
||||
defer b.intakeBuffersMu.Unlock()
|
||||
assert.True(t, b.intakeBuffers[902].allEmojiOnly)
|
||||
}
|
||||
|
||||
func TestCancelIntake_DiscardsPendingBatch(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
|
||||
bufferText(b, 903, false)
|
||||
bufferText(b, 903, false)
|
||||
require.True(t, b.hasPendingIntake(903))
|
||||
|
||||
assert.Equal(t, 2, b.cancelIntake(903), "cancel reports how many messages it discarded")
|
||||
assert.False(t, b.hasPendingIntake(903))
|
||||
assert.Equal(t, 0, b.cancelIntake(903), "cancelling an empty chat is a no-op")
|
||||
}
|
||||
|
||||
// A cancelled buffer must never dispatch afterwards. This is the openclaw#51046
|
||||
// shape: the timer had already been armed when the cancel landed.
|
||||
func TestCancelIntake_TimerDoesNotFireAfterCancel(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = 20
|
||||
|
||||
bufferText(b, 904, false)
|
||||
b.cancelIntake(904)
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
assert.False(t, b.hasPendingIntake(904), "cancelled buffer must not resurrect")
|
||||
}
|
||||
|
||||
// A stale timer from a cancelled batch must not flush a newer batch early.
|
||||
func TestFlushIntake_IgnoresSupersededSequence(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
|
||||
bufferText(b, 905, false)
|
||||
b.intakeBuffersMu.Lock()
|
||||
staleSeq := b.intakeBuffers[905].seq
|
||||
b.intakeBuffersMu.Unlock()
|
||||
|
||||
b.cancelIntake(905)
|
||||
bufferText(b, 905, false) // new batch, new sequence
|
||||
|
||||
// The old timer firing late must not consume the new batch.
|
||||
b.flushIntake(context.Background(), 905, staleSeq)
|
||||
assert.True(t, b.hasPendingIntake(905), "superseded flush must leave the newer batch armed")
|
||||
}
|
||||
|
||||
func TestClearChatHistory_CancelsPendingIntake(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
b.config.DebounceMs = int(bufferOnly / time.Millisecond)
|
||||
mockTg.SendMessageFunc = func(_ context.Context, _ *bot.SendMessageParams) (*models.Message, error) {
|
||||
return &models.Message{}, nil
|
||||
}
|
||||
|
||||
const chatID int64 = 906
|
||||
bufferText(b, chatID, false)
|
||||
require.True(t, b.hasPendingIntake(chatID))
|
||||
|
||||
b.clearChatHistory(context.Background(), chatID, 123, 0, 0, "", false)
|
||||
|
||||
assert.False(t, b.hasPendingIntake(chatID),
|
||||
"clearing history must disarm the buffer, or deleted messages get replayed into memory")
|
||||
}
|
||||
|
||||
func TestDebounceWindow(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ms int
|
||||
want time.Duration
|
||||
}{
|
||||
{"unset disables debouncing", 0, 0},
|
||||
{"negative disables debouncing", -1, 0},
|
||||
{"positive converts to duration", 2500, 2500 * time.Millisecond},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := BotConfig{DebounceMs: tc.ms}
|
||||
assert.Equal(t, tc.want, c.DebounceWindow())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheHistoryEnabled_DefaultsOn(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
assert.True(t, (&BotConfig{}).CacheHistoryEnabled(), "cache_history defaults to enabled")
|
||||
|
||||
off := false
|
||||
assert.False(t, (&BotConfig{CacheHistory: &off}).CacheHistoryEnabled())
|
||||
|
||||
on := true
|
||||
assert.True(t, (&BotConfig{CacheHistory: &on}).CacheHistoryEnabled())
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/packages/param"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMarkTrailingCacheBreakpoint(t *testing.T) {
|
||||
t.Run("marks the final text block of the final turn", func(t *testing.T) {
|
||||
msgs := []anthropic.BetaMessageParam{
|
||||
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("older")),
|
||||
anthropic.NewBetaUserMessage(
|
||||
anthropic.NewBetaTextBlock("first"),
|
||||
anthropic.NewBetaTextBlock("last"),
|
||||
),
|
||||
}
|
||||
|
||||
markTrailingCacheBreakpoint(msgs)
|
||||
|
||||
last := msgs[1].Content[1].OfText
|
||||
require.NotNil(t, last)
|
||||
assert.False(t, param.IsOmitted(last.CacheControl),
|
||||
"the trailing block carries the breakpoint")
|
||||
|
||||
// Everything earlier stays unmarked: one breakpoint, not one per block.
|
||||
assert.True(t, param.IsOmitted(msgs[1].Content[0].OfText.CacheControl))
|
||||
assert.True(t, param.IsOmitted(msgs[0].Content[0].OfText.CacheControl))
|
||||
})
|
||||
|
||||
t.Run("marks a trailing image block", func(t *testing.T) {
|
||||
msgs := []anthropic.BetaMessageParam{
|
||||
anthropic.NewBetaUserMessage(
|
||||
anthropic.NewBetaImageBlock(anthropic.BetaFileImageSourceParam{FileID: "file_1"}),
|
||||
),
|
||||
}
|
||||
|
||||
markTrailingCacheBreakpoint(msgs)
|
||||
|
||||
require.NotNil(t, msgs[0].Content[0].OfImage)
|
||||
assert.False(t, param.IsOmitted(msgs[0].Content[0].OfImage.CacheControl))
|
||||
})
|
||||
|
||||
t.Run("tolerates empty input", func(t *testing.T) {
|
||||
assert.NotPanics(t, func() { markTrailingCacheBreakpoint(nil) })
|
||||
assert.NotPanics(t, func() {
|
||||
markTrailingCacheBreakpoint([]anthropic.BetaMessageParam{{}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareContextMessages_CacheHistoryToggle(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, _ := setupBotForTest(t, 123)
|
||||
memory := &ChatMemory{
|
||||
Messages: []Message{
|
||||
{IsUser: true, Text: "hello"},
|
||||
{IsUser: false, Text: "hi there"},
|
||||
},
|
||||
Size: 10,
|
||||
}
|
||||
|
||||
t.Run("enabled by default", func(t *testing.T) {
|
||||
msgs := b.prepareContextMessages(memory)
|
||||
require.Len(t, msgs, 2)
|
||||
assert.False(t, param.IsOmitted(msgs[1].Content[0].OfText.CacheControl))
|
||||
})
|
||||
|
||||
t.Run("opt-out leaves history unmarked", func(t *testing.T) {
|
||||
off := false
|
||||
b.config.CacheHistory = &off
|
||||
defer func() { b.config.CacheHistory = nil }()
|
||||
|
||||
msgs := b.prepareContextMessages(memory)
|
||||
require.Len(t, msgs, 2)
|
||||
assert.True(t, param.IsOmitted(msgs[1].Content[0].OfText.CacheControl))
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfig_DebounceMs(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
base := func(ms int) *BotConfig {
|
||||
return &BotConfig{
|
||||
ID: "b",
|
||||
TelegramToken: "t",
|
||||
Model: "claude-sonnet-4-6",
|
||||
MessagePerHour: 1,
|
||||
MessagePerDay: 1,
|
||||
DebounceMs: ms,
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ms int
|
||||
wantErr bool
|
||||
}{
|
||||
{"omitted is valid", 0, false},
|
||||
{"typical chat window is valid", 2500, false},
|
||||
{"at the ceiling is valid", maxDebounceMs, false},
|
||||
{"negative is rejected", -1, true},
|
||||
{"above the ceiling is rejected", maxDebounceMs + 1, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateConfig(base(tc.ms), map[string]bool{}, map[string]bool{})
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
type TelegramClient interface {
|
||||
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
||||
SendAudio(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
|
||||
SendChatAction(ctx context.Context, params *bot.SendChatActionParams) (bool, error)
|
||||
SetMyCommands(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
||||
GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
|
||||
FileDownloadLink(f *models.File) string
|
||||
|
||||
@@ -12,6 +12,7 @@ type MockTelegramClient struct {
|
||||
mock.Mock
|
||||
SendMessageFunc func(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
||||
SendAudioFunc func(ctx context.Context, params *bot.SendAudioParams) (*models.Message, error)
|
||||
SendChatActionFunc func(ctx context.Context, params *bot.SendChatActionParams) (bool, error)
|
||||
SetMyCommandsFunc func(ctx context.Context, params *bot.SetMyCommandsParams) (bool, error)
|
||||
GetFileFunc func(ctx context.Context, params *bot.GetFileParams) (*models.File, error)
|
||||
FileDownloadLinkFunc func(f *models.File) string
|
||||
@@ -43,6 +44,13 @@ func (m *MockTelegramClient) SendAudio(ctx context.Context, params *bot.SendAudi
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTelegramClient) SendChatAction(ctx context.Context, params *bot.SendChatActionParams) (bool, error) {
|
||||
if m.SendChatActionFunc != nil {
|
||||
return m.SendChatActionFunc(ctx, params)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MockTelegramClient) GetFile(ctx context.Context, params *bot.GetFileParams) (*models.File, error) {
|
||||
if m.GetFileFunc != nil {
|
||||
return m.GetFileFunc(ctx, params)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// typingRefreshInterval re-arms the chat action before Telegram expires it.
|
||||
// The Bot API sets the status "for 5 seconds or less", so anything at or above
|
||||
// 5s leaves visible gaps. Telegram also clears the status as soon as the bot
|
||||
// sends a message, so streamed segments naturally interrupt it until the next
|
||||
// tick; there is no API call to clear it early.
|
||||
const typingRefreshInterval = 4 * time.Second
|
||||
|
||||
// startChatAction shows a chat action (typing, uploading a photo, recording a
|
||||
// voice note) and keeps it alive until the returned stop function runs.
|
||||
//
|
||||
// The returned function is idempotent and MUST be deferred by the caller. A
|
||||
// keepalive loop that can outlive its turn is the failure mode behind
|
||||
// openclaw/openclaw#27177, where the indicator stuck on until the process was
|
||||
// restarted, so the loop here owns a derived context and exits on the first of:
|
||||
// stop being called, or the parent context ending.
|
||||
func (b *Bot) startChatAction(
|
||||
ctx context.Context,
|
||||
chatID int64,
|
||||
businessConnectionID string,
|
||||
action models.ChatAction,
|
||||
) (stop func()) {
|
||||
actionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
send := func() {
|
||||
params := &bot.SendChatActionParams{
|
||||
ChatID: chatID,
|
||||
Action: action,
|
||||
}
|
||||
if businessConnectionID != "" {
|
||||
params.BusinessConnectionID = businessConnectionID
|
||||
}
|
||||
if _, err := b.tgBot.SendChatAction(actionCtx, params); err != nil {
|
||||
// Cosmetic only: a failed indicator must never abort the turn.
|
||||
InfoLogger.Printf("[%s] chat action %q failed for chat %d: %v",
|
||||
b.config.ID, action, chatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
send()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(typingRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-actionCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
send()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return cancel
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStartChatAction_SendsImmediately(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []*bot.SendChatActionParams
|
||||
mockTg.SendChatActionFunc = func(_ context.Context, p *bot.SendChatActionParams) (bool, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got = append(got, p)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
stop := b.startChatAction(context.Background(), 42, "biz-7", "typing")
|
||||
stop()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Len(t, got, 1, "the indicator must show before the slow work starts, not after")
|
||||
assert.Equal(t, int64(42), got[0].ChatID)
|
||||
assert.EqualValues(t, "typing", got[0].Action)
|
||||
assert.Equal(t, "biz-7", got[0].BusinessConnectionID,
|
||||
"business chats need the connection id or the indicator never renders")
|
||||
}
|
||||
|
||||
func TestStartChatAction_OmitsEmptyBusinessConnectionID(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
|
||||
var mu sync.Mutex
|
||||
var captured *bot.SendChatActionParams
|
||||
mockTg.SendChatActionFunc = func(_ context.Context, p *bot.SendChatActionParams) (bool, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
captured = p
|
||||
return true, nil
|
||||
}
|
||||
|
||||
stop := b.startChatAction(context.Background(), 42, "", "typing")
|
||||
stop()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.NotNil(t, captured)
|
||||
assert.Empty(t, captured.BusinessConnectionID)
|
||||
}
|
||||
|
||||
// The openclaw#27177 guard: once the turn ends, the keepalive must stop. A loop
|
||||
// that can re-arm after completion left the indicator stuck on until restart.
|
||||
func TestStartChatAction_StopHaltsKeepalive(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
|
||||
var calls atomic.Int32
|
||||
mockTg.SendChatActionFunc = func(_ context.Context, _ *bot.SendChatActionParams) (bool, error) {
|
||||
calls.Add(1)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
stop := b.startChatAction(context.Background(), 42, "", "typing")
|
||||
stop()
|
||||
after := calls.Load()
|
||||
|
||||
// Well past a refresh tick had the loop survived.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.Equal(t, after, calls.Load(), "no chat action may be sent after stop")
|
||||
}
|
||||
|
||||
// Cancelling the parent context must also tear the loop down, so a turn aborted
|
||||
// upstream cannot leak a goroutine that keeps calling Telegram.
|
||||
func TestStartChatAction_ParentCancelHaltsKeepalive(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
|
||||
var calls atomic.Int32
|
||||
mockTg.SendChatActionFunc = func(_ context.Context, _ *bot.SendChatActionParams) (bool, error) {
|
||||
calls.Add(1)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stop := b.startChatAction(ctx, 42, "", "typing")
|
||||
defer stop()
|
||||
|
||||
cancel()
|
||||
after := calls.Load()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.Equal(t, after, calls.Load(), "parent cancellation must stop the keepalive")
|
||||
}
|
||||
|
||||
func TestStartChatAction_StopIsIdempotent(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
mockTg.SendChatActionFunc = func(_ context.Context, _ *bot.SendChatActionParams) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
stop := b.startChatAction(context.Background(), 42, "", "typing")
|
||||
// The voice path calls stop early and again via defer.
|
||||
assert.NotPanics(t, func() {
|
||||
stop()
|
||||
stop()
|
||||
})
|
||||
}
|
||||
|
||||
// A failed indicator is cosmetic and must never surface as a turn failure.
|
||||
func TestStartChatAction_SendErrorIsNonFatal(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
b, mockTg := setupBotForTest(t, 123)
|
||||
mockTg.SendChatActionFunc = func(_ context.Context, _ *bot.SendChatActionParams) (bool, error) {
|
||||
return false, assert.AnError
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
stop := b.startChatAction(context.Background(), 42, "", "typing")
|
||||
stop()
|
||||
})
|
||||
}
|
||||
|
||||
// Telegram clears the status after "5 seconds or less", so the refresh must be
|
||||
// strictly under that or the indicator visibly drops out mid-turn.
|
||||
func TestTypingRefreshInterval_UnderTelegramExpiry(t *testing.T) { //NOSONAR go:S100 -- underscore separation is idiomatic in Go test names
|
||||
assert.Less(t, typingRefreshInterval, 5*time.Second,
|
||||
"Telegram expires a chat action after at most 5s")
|
||||
}
|
||||
Reference in New Issue
Block a user