// Package typing provides a channel-agnostic typing indicator controller // with TTL safety net, keepalive support, and post-close guards. // // The controller prevents stuck typing indicators by: // - Auto-stopping after a configurable TTL (default 60s) // - Requiring both MarkRunComplete + MarkDispatchIdle for graceful cleanup // - Guarding against post-close keepalive invocations package typing import ( "log/slog" "sync" "time" ) // Options configures a typing indicator controller. type Options struct { // MaxDuration is the TTL safety net. If the indicator hasn't been // stopped after this duration, it auto-stops. 0 disables the TTL. // Default: 60s. MaxDuration time.Duration // KeepaliveInterval is how often to re-send the typing action. // Telegram typing expires after 5s, Discord after 10s. // 0 disables keepalive (single fire-and-forget). KeepaliveInterval time.Duration // StartFn sends the channel-specific typing indicator. // Called on Start() and on each keepalive tick. StartFn func() error // StopFn sends the channel-specific stop-typing signal. // Optional — some channels (Telegram) auto-stop on message send. StopFn func() error } // Controller manages the lifecycle of a typing indicator. // It is safe for concurrent use. type Controller struct { mu sync.Mutex // State flags closed bool // post-close guard: prevents stale startFn calls runComplete bool // signal 1: agent finished processing dispatchIdle bool // signal 2: message delivery finished stopSent bool // prevents duplicate stopFn calls // Configuration maxDuration time.Duration keepaliveInterval time.Duration startFn func() error stopFn func() error // Timers ttlTimer *time.Timer keepaliveDone chan struct{} } // New creates a typing controller with the given options. func New(opts Options) *Controller { maxDur := opts.MaxDuration if maxDur == 0 { maxDur = 60 * time.Second } return &Controller{ maxDuration: maxDur, keepaliveInterval: opts.KeepaliveInterval, startFn: opts.StartFn, stopFn: opts.StopFn, } } // Start begins the typing indicator, TTL timer, and keepalive loop. func (c *Controller) Start() { c.mu.Lock() defer c.mu.Unlock() if c.closed { return } // Fire initial typing action c.fireStart() // Start TTL safety net if c.maxDuration > 0 { c.ttlTimer = time.AfterFunc(c.maxDuration, func() { c.mu.Lock() if !c.closed { slog.Debug("typing: TTL exceeded, auto-stopping", "ttl", c.maxDuration) c.forceStop() } c.mu.Unlock() }) } // Start keepalive loop if c.keepaliveInterval > 0 { c.keepaliveDone = make(chan struct{}) go c.keepaliveLoop() } } // Stop forcefully stops the typing indicator and all timers. // Safe to call multiple times (idempotent). func (c *Controller) Stop() { c.mu.Lock() defer c.mu.Unlock() c.forceStop() } // MarkRunComplete signals that the agent has finished processing. // Cleanup happens only when both MarkRunComplete and MarkDispatchIdle have been called. func (c *Controller) MarkRunComplete() { c.mu.Lock() defer c.mu.Unlock() c.runComplete = true c.tryCleanup() } // MarkDispatchIdle signals that message delivery has completed. // Cleanup happens only when both MarkRunComplete and MarkDispatchIdle have been called. func (c *Controller) MarkDispatchIdle() { c.mu.Lock() defer c.mu.Unlock() c.dispatchIdle = true c.tryCleanup() } // tryCleanup runs cleanup only when both completion signals have been received. // Must be called with c.mu held. func (c *Controller) tryCleanup() { if c.runComplete && c.dispatchIdle && !c.closed { c.forceStop() } } // forceStop cancels all timers and sends stop signal. // Must be called with c.mu held. func (c *Controller) forceStop() { if c.closed { return } c.closed = true // Cancel TTL timer if c.ttlTimer != nil { c.ttlTimer.Stop() c.ttlTimer = nil } // Stop keepalive loop if c.keepaliveDone != nil { close(c.keepaliveDone) } // Send stop signal c.fireStop() } // fireStart invokes the channel-specific start function. // Must be called with c.mu held. func (c *Controller) fireStart() { if c.closed || c.startFn == nil { return } if err := c.startFn(); err != nil { slog.Debug("typing: startFn error", "error", err) } } // fireStop invokes the channel-specific stop function (once). // Must be called with c.mu held. func (c *Controller) fireStop() { if c.stopSent || c.stopFn == nil { return } c.stopSent = true if err := c.stopFn(); err != nil { slog.Debug("typing: stopFn error", "error", err) } } // keepaliveLoop periodically re-sends the typing indicator. func (c *Controller) keepaliveLoop() { ticker := time.NewTicker(c.keepaliveInterval) defer ticker.Stop() for { select { case <-c.keepaliveDone: return case <-ticker.C: c.mu.Lock() if c.closed { c.mu.Unlock() return } c.fireStart() c.mu.Unlock() } } }