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 byFeatureGuard("feature:multichat")andPermissionGuard("chat:userinfo") - REST twin:
GET /v1/chat/users/{platform}/{platform_user_id} - Platforms: Twitch (full), YouTube (partial), Kick (partial), Trovo (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
- Redis cache check -- Key:
lumio:user_profile:{account_id}:{platform}:{user_id}. If found, return immediately. - Database lookup -- Query the
platform_userstable for the user's stored profile data (message count, mod/sub/vip status, ban info, etc.). - Enrichment check -- If the profile's
enriched_attimestamp is null or older than the platform's max age, enrichment is needed. The default max age is 24 hours for Twitch / Kick / Trovo and 14 days (24 * 14hours) for YouTube, because YouTube enrichment costs Data API quota. Both are overridable viaProfileServiceConfig. - Circuit breaker check -- If the circuit is open for this account+platform, skip API calls and return DB data.
- Cold-start lock -- Before calling the platform, take a Redis
SETNXlock (PROFILE_LOCK_TTL_SECS = 5). A concurrent caller that loses the race waitsPROFILE_LOCK_WAIT_MS = 250ms 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. - Platform API call -- Fetch additional profile data from the platform API using decrypted credentials.
- Merge and persist -- Apply enrichment data to the profile, write enrichment fields back to the database, and reset the circuit-breaker failure counter.
- Cache -- Store the profile in Redis with the appropriate TTL.
Cache Keys and TTLs
| Key Pattern | TTL | Condition |
|---|---|---|
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 Pattern | Purpose |
|---|---|
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
| Parameter | Value | Description |
|---|---|---|
| Threshold | 5 failures | Number of failures before the circuit opens |
| Window | 60 seconds | Time window for counting failures |
| Cooldown | 300 seconds (5 min) | Duration the circuit stays open |
Behavior
- Each enrichment failure calls
record_failure(), which incrementslumio:circuit_count:{account_id}:{platform}. - On the first failure, the counter gets a 60-second TTL (the window).
- 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.
- A
- While the circuit is open, all enrichment attempts for that account+platform are skipped.
- After the cooldown expires (key TTL), the circuit closes automatically.
Platform Enrichment Data
Each platform provides different enrichment fields:
Twitch (Full Enrichment)
| Field | Source |
|---|---|
avatar_url | profile_image_url from Helix Users API |
description | User bio from Helix Users API |
broadcaster_type | "partner", "affiliate", or "" |
account_created_at | User creation date |
is_follower | Checked via Helix Followers API |
followed_at | Follower timestamp (if following) |
Requires moderator:read:followers scope for follower checks. If the scope is missing, the follower check fails gracefully (non-fatal).
YouTube (Avatar, Bio, Account Age)
| Field | Source |
|---|---|
avatar_url | Channel snippet thumbnails (high > medium > default) |
description | Channel snippet description |
account_created_at | Channel publishedAt date |
Kick (Avatar, Bio)
| Field | Source |
|---|---|
avatar_url | User profile_picture |
description | Channel channel_description |
Two API calls: one for the user profile (avatar), one for the channel info (description).
Trovo (Avatar, Bio, Follower Status)
| Field | Source |
|---|---|
avatar_url | Channel profile_pic |
description | Channel streamer_info |
account_created_at | Channel creation date |
is_follower | Checked via Trovo follower API |
followed_at | Follower timestamp (if following) |
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:
| Field | Source | Description |
|---|---|---|
platform | DB | Platform identifier |
platform_user_id | DB | Platform-specific user ID |
username | DB | Platform username |
display_name | DB | Display name (may differ from username) |
avatar_url | Enrichment | Profile picture URL |
color | DB | Chat color |
is_mod / is_sub / is_vip | DB | Role flags from chat messages |
badges | DB | Platform badges (JSON) |
message_count | DB | Total messages seen |
first_seen_at / last_seen_at | DB | Activity timestamps |
is_banned | DB | Ban status |
banned_at / banned_by / ban_reason / ban_type | DB | Ban details |
timeout_expires_at | DB | Timeout expiration |
user_treatment | DB | "none", "suspicious", or "monitored" |
description | Enrichment | User bio |
broadcaster_type | Enrichment | Twitch broadcaster type |
is_follower / followed_at | Enrichment | Follower status |
account_created_at | Enrichment | Account age |
enriched_at | DB | Last enrichment timestamp |
Key Files
| File | Purpose |
|---|---|
crates/lo-chat/src/profile_service.rs | ProfileService, ProfileServiceConfig, ChannelCredentials, UnifiedProfile, circuit breaker |
crates/lo-chat/src/platform_users.rs | Database queries for platform_users table |
apps/api/src/config.rs | ProfileSettings ([profile] config section) |
apps/api/src/main.rs | Builds the shared ProfileService from config at startup |
apps/api/src/graphql/chat.rs | platformUserProfile GraphQL resolver |
apps/api/src/routes/chat.rs | GET /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 |
crates/lo-trovo-api/ | Trovo Open Platform API client |