Skip to main content

ProfileService

The ProfileService is a unified profile enrichment system that combines local database records with live platform API data. It provides a single entry point for fetching user profiles across all supported chat platforms.

Architecture​

  • Crate: crates/lo-chat/src/profile_service.rs
  • GraphQL: platformUserProfile(platform, platformUserId) - guarded by FeatureGuard("feature:multichat") and PermissionGuard("chat:userinfo")
  • REST twin: GET /v1/chat/users/{platform}/{platform_user_id}
  • Platforms: Twitch (full), YouTube (partial), Kick (partial). Any other platform string returns the DB-only profile with no enrichment.

Core Struct​

pub struct ProfileService {
pub redis: lo_cache::RedisClient,
pub db: sqlx::PgPool,
pub http: reqwest::Client,
pub config: ProfileServiceConfig,
}

pub struct ProfileServiceConfig {
/// Auto-enrichment interval for YouTube profiles (days). 0 = disabled.
pub youtube_enrichment_interval_days: i64,
/// Auto-enrichment interval for other platforms (days). 0 = disabled.
pub enrichment_interval_days: i64,
}

The service is instantiated once at API startup and shared across all request handlers. It holds a Redis client for caching, a PostgreSQL connection pool for database access, a shared HTTP client for platform API calls, and the enrichment-interval config.

ProfileServiceConfig is populated in apps/api/src/main.rs from the [profile] config section:

[profile]
# YouTube enrichment costs Data API quota, so it is far less frequent.
youtube_enrichment_interval_days = 14
enrichment_interval_days = 1

Setting either to 0 disables automatic enrichment for that group - profiles then refresh only on an explicit chat:refresh_user request. ENV overrides: LUMIO__PROFILE__YOUTUBE_ENRICHMENT_INTERVAL_DAYS, LUMIO__PROFILE__ENRICHMENT_INTERVAL_DAYS.

Data Flow​

The get_profile() method follows a layered lookup strategy:

flowchart TD
A["get_profile(account_id, platform, user_id, credentials)"] --> B{Redis cache?}
B -->|Hit| C[Return cached profile]
B -->|Miss| D[Load from platform_users DB table]
D --> E{Enrichment stale?}
E -->|Fresh| F["Cache with 4h TTL, return"]
E -->|Stale| G{Credentials provided?}
G -->|No| H["Cache DB-only profile (30min TTL)"]
G -->|Yes| I{Circuit breaker open?}
I -->|Open| J["Cache DB profile (15min TTL)"]
I -->|Closed| K[Call platform API]
K -->|Success| L[Merge enrichment into profile]
L --> M[Persist to DB]
M --> N["Cache with 4h TTL"]
K -->|Failure| O[Record failure in circuit breaker]
O --> P["Cache DB profile (30min TTL)"]

style A fill:#3b82f6,color:#fff
style K fill:#f59e0b,color:#fff
style C fill:#10b981,color:#fff

Step-by-step​

  1. Redis cache check -- Key: lumio:user_profile:{account_id}:{platform}:{user_id}. If found, return immediately.
  2. Database lookup -- Query the platform_users table for the user's stored profile data (message count, mod/sub/vip status, ban info, etc.).
  3. Enrichment check -- If the profile's enriched_at timestamp is null or older than the platform's max age, enrichment is needed. The default max age is 24 hours for Twitch / Kick and 14 days (24 * 14 hours) for YouTube, because YouTube enrichment costs Data API quota. Both are overridable via ProfileServiceConfig.
  4. Circuit breaker check -- If the circuit is open for this account+platform, skip API calls and return DB data.
  5. Cold-start lock -- Before calling the platform, take a Redis SETNX lock (PROFILE_LOCK_TTL_SECS = 5). A concurrent caller that loses the race waits PROFILE_LOCK_WAIT_MS = 250 ms and re-reads the cache instead of firing a duplicate API call, so a burst of chat messages from one new user does not stampede the platform API.
  6. Platform API call -- Fetch additional profile data from the platform API using decrypted credentials.
  7. Merge and persist -- Apply enrichment data to the profile, write enrichment fields back to the database, and reset the circuit-breaker failure counter.
  8. Cache -- Store the profile in Redis with the appropriate TTL.

Cache Keys and TTLs​

Key PatternTTLCondition
lumio:user_profile:{account_id}:{platform}:{user_id}4 hours (14400s)Successful enrichment
lumio:user_profile:{account_id}:{platform}:{user_id}30 minutes (1800s)DB-only fallback (no credentials or enrichment failure)
lumio:user_profile:{account_id}:{platform}:{user_id}15 minutes (900s)Circuit breaker open

Circuit Breaker​

The circuit breaker protects against cascading failures when a platform API is unavailable or rate-limited.

Keys​

Key PatternPurpose
lumio:circuit:{account_id}:{platform}Cooldown flag -- existence means circuit is open
lumio:circuit_count:{account_id}:{platform}Failure counter within the current window

Parameters​

ParameterValueDescription
Threshold5 failuresNumber of failures before the circuit opens
Window60 secondsTime window for counting failures
Cooldown300 seconds (5 min)Duration the circuit stays open

Behavior​

  1. Each enrichment failure calls record_failure(), which increments lumio:circuit_count:{account_id}:{platform}.
  2. On the first failure, the counter gets a 60-second TTL (the window).
  3. When the counter reaches 5, the circuit opens:
    • A lumio:circuit:{account_id}:{platform} key is set with a 300-second TTL.
    • The counter is reset.
  4. While the circuit is open, all enrichment attempts for that account+platform are skipped.
  5. After the cooldown expires (key TTL), the circuit closes automatically.

Platform Enrichment Data​

Each platform provides different enrichment fields. enrich() dispatches to one boxed enricher per platform (Box::pin), so a lookup carries only the future of the platform it calls, not the largest one. Keep new platform arms boxed; enrichment_futures_stay_within_budget checks it.

Twitch (Full Enrichment)​

FieldSource
avatar_urlprofile_image_url from Helix Users API
descriptionUser bio from Helix Users API
broadcaster_type"partner", "affiliate", or ""
account_created_atUser creation date
is_followerChecked via Helix Followers API
followed_atFollower timestamp (if following)
follower_countThe user's own follower count: total of Helix GET /channels/followers?broadcaster_id=<user>&first=1 (HelixClient::get_follower_count)

Requires moderator:read:followers scope for follower checks. If the scope is missing, the follower check fails gracefully (non-fatal). The follower count needs no scope (Helix returns total to any user token) and is non-fatal as well; only the Helix Users call failing fails the Twitch enrichment.

YouTube (Avatar, Bio, Account Age)​

FieldSource
avatar_urlChannel snippet thumbnails (high > medium > default)
descriptionChannel snippet description
account_created_atChannel publishedAt date

Kick (Avatar, Bio)​

FieldSource
avatar_urlUser profile_picture
descriptionChannel channel_description

Two API calls, run concurrently and independently: one for the user profile (avatar), one for the channel info (description). merge_kick_enrichment logs each failure at warn with the HTTP status and keeps whatever the other call returned; only when both fail is the enrichment an error that counts toward the circuit breaker. Kick exposes no follower count, so follower_count stays NULL.

ChannelCredentials​

The ChannelCredentials struct decouples the ProfileService from the encryption module:

pub struct ChannelCredentials {
pub client_id: String, // From app_credentials (decrypted)
pub client_secret: String, // From app_credentials (decrypted)
pub access_token: String, // From channel_connections (decrypted)
pub refresh_token: Option<String>, // From channel_connections (decrypted)
pub platform_channel_id: Option<String>, // Broadcaster ID
}

The API layer is responsible for loading credentials from the database, decrypting them, and constructing this struct. The ProfileService never touches encryption directly.

UnifiedProfile​

The UnifiedProfile struct combines database fields with enrichment data:

FieldSourceDescription
platformDBPlatform identifier
platform_user_idDBPlatform-specific user ID
usernameDBPlatform username
display_nameDBDisplay name (may differ from username)
avatar_urlEnrichmentProfile picture URL
colorDBChat color
is_mod / is_sub / is_vipDBRole flags from chat messages
badgesDBPlatform badges (JSON)
message_countDBTotal messages seen
first_seen_at / last_seen_atDBActivity timestamps
is_bannedDBBan status
banned_at / banned_by / ban_reason / ban_typeDBBan details
timeout_expires_atDBTimeout expiration
user_treatmentDB"none", "suspicious", or "monitored"
descriptionEnrichmentUser bio
broadcaster_typeEnrichmentTwitch broadcaster type
is_follower / followed_atEnrichmentFollower status
account_created_atEnrichmentAccount age
follower_countEnrichmentOwn follower count (Twitch only; never overwritten by a failed lookup)
enriched_atDBLast enrichment timestamp

Key Files​

FilePurpose
crates/lo-chat/src/profile_service.rsProfileService, ProfileServiceConfig, ChannelCredentials, UnifiedProfile, circuit breaker
crates/lo-chat/src/platform_users.rsDatabase queries for platform_users table
apps/api/src/config.rsProfileSettings ([profile] config section)
apps/api/src/main.rsBuilds the shared ProfileService from config at startup
apps/api/src/graphql/chat.rsplatformUserProfile GraphQL resolver
apps/api/src/routes/chat.rsGET /v1/chat/users/{platform}/{platform_user_id} REST twin
crates/lo-twitch-api/Twitch Helix API client
crates/lo-youtube-api/YouTube Data API client
crates/lo-kick-api/Kick API client