// Package ws provides typing indicator management via Redis. // Mirrors Chatwoot's typing indicator pattern: short-lived events // published to Redis Pub/Sub with TTL-based auto-expiry. package ws import ( "context" "encoding/json" "fmt" "time" "github.com/redis/go-redis/v9" "github.com/gochat/gochat/pkg/logger" ) // TypingTracker manages typing indicators for conversations. // Uses Redis for cross-instance visibility and TTL for auto-expiry. // // Reference: Chatwoot publishes typing_on/typing_off events via // ActionCable, which are broadcast to all agents viewing that conversation. // The Go implementation uses Redis keys with TTL so typing indicators // automatically expire after TypingTTLSec seconds. type TypingTracker struct { rdb *redis.Client relay *BroadcastRelay } // NewTypingTracker creates a new typing tracker with Redis client and broadcast relay. func NewTypingTracker(rdb *redis.Client, relay *BroadcastRelay) *TypingTracker { return &TypingTracker{ rdb: rdb, relay: relay, } } // SetTypingOn marks a user/contact as typing in a conversation and broadcasts // the typing_on event to all clients viewing that conversation. // // Reference: Chatwoot ConversationTypingOnJob — sets a Redis key with TTL // and broadcasts the event to the account and conversation rooms. func (t *TypingTracker) SetTypingOn(ctx context.Context, accountID, conversationID uint, performer *Performer) error { key := fmt.Sprintf(RedisKeyTyping, accountID, conversationID) // Store typing state in Redis with TTL (auto-expiry) typingData := &typingState{ AccountID: accountID, ConversationID: conversationID, Performer: performer, StartedAt: time.Now().Unix(), } data, err := json.Marshal(typingData) if err != nil { return fmt.Errorf("failed to marshal typing state: %w", err) } ttl := time.Duration(TypingTTLSec) * time.Second if err := t.rdb.Set(ctx, key, data, ttl).Err(); err != nil { return fmt.Errorf("failed to set typing key %s: %w", key, err) } // Broadcast typing_on event to the account and conversation rooms msg := &WSMessage{ Event: EventConversationTypingOn, Data: map[string]any{ "conversation_id": conversationID, }, AccountID: accountID, Performer: performer, } // Publish to Redis for cross-instance relay if err := t.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws typing: failed to broadcast typing_on: %v", err) } logger.L().Debugf("ws typing: typing_on set for account=%d conversation=%d user=%d", accountID, conversationID, performer.ID) return nil } // SetTypingOff removes a typing indicator and broadcasts the typing_off event. // // Reference: Chatwoot ConversationTypingOffJob — removes Redis key // and broadcasts typing_off to the account and conversation rooms. func (t *TypingTracker) SetTypingOff(ctx context.Context, accountID, conversationID uint, performer *Performer) error { key := fmt.Sprintf(RedisKeyTyping, accountID, conversationID) // Remove typing state from Redis if err := t.rdb.Del(ctx, key).Err(); err != nil { return fmt.Errorf("failed to delete typing key %s: %w", key, err) } // Broadcast typing_off event msg := &WSMessage{ Event: EventConversationTypingOff, Data: map[string]any{ "conversation_id": conversationID, }, AccountID: accountID, Performer: performer, } if err := t.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws typing: failed to broadcast typing_off: %v", err) } logger.L().Debugf("ws typing: typing_off set for account=%d conversation=%d user=%d", accountID, conversationID, performer.ID) return nil } // IsTyping checks whether a typing indicator is currently active for a conversation. func (t *TypingTracker) IsTyping(ctx context.Context, accountID, conversationID uint) (bool, error) { key := fmt.Sprintf(RedisKeyTyping, accountID, conversationID) result, err := t.rdb.Exists(ctx, key).Result() if err != nil { return false, fmt.Errorf("failed to check typing key %s: %w", key, err) } return result > 0, nil } // GetTypingState retrieves the full typing state for a conversation. // Returns nil if no typing indicator is active. func (t *TypingTracker) GetTypingState(ctx context.Context, accountID, conversationID uint) (*typingState, error) { key := fmt.Sprintf(RedisKeyTyping, accountID, conversationID) data, err := t.rdb.Get(ctx, key).Bytes() if err != nil { if err == redis.Nil { return nil, nil // no active typing } return nil, fmt.Errorf("failed to get typing state for key %s: %w", key, err) } var state typingState if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("failed to unmarshal typing state: %w", err) } return &state, nil } // typingState represents the persisted typing indicator data stored in Redis. type typingState struct { AccountID uint `json:"account_id"` ConversationID uint `json:"conversation_id"` Performer *Performer `json:"performer"` StartedAt int64 `json:"started_at"` }