Skip to content

Commit d1b670d

Browse files
bra1nDumpclaudehappy-otter
committed
feat: implement ElevenLabs voice usage gating (1hr free, then paywall)
Server gates voice at session start by querying ElevenLabs conversation history. Free users get 1 hour; after that they need a subscription. Token is minted server-side with a stable pseudonymous user ID (HMAC-SHA256 of Happy user ID) so ElevenLabs never sees the real account. - Server: derive elevenUserId, check usage, check RevenueCat server-side - Client: always go through auth flow (remove experiments bypass) - Wire: shared VoiceTokenResponse schema (discriminated union) - Config: single elevenLabsAgentId picked by APP_ENV variant Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent f3b009e commit d1b670d

10 files changed

Lines changed: 202 additions & 150 deletions

File tree

packages/happy-app/app.config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ const bundleId = {
99
preview: "com.slopus.happy.preview",
1010
production: "com.ex3ndr.happy"
1111
}[variant];
12+
const elevenLabsAgentId = {
13+
development: 'agent_7801k2c0r5hjfraa1kdbytpvs6yt',
14+
preview: 'agent_7801k2c0r5hjfraa1kdbytpvs6yt',
15+
production: 'agent_6701k211syvvegba4kt7m68nxjmw',
16+
}[variant];
1217

1318
export default {
1419
expo: {
@@ -169,7 +174,8 @@ export default {
169174
postHogKey: process.env.EXPO_PUBLIC_POSTHOG_API_KEY,
170175
revenueCatAppleKey: process.env.EXPO_PUBLIC_REVENUE_CAT_APPLE,
171176
revenueCatGoogleKey: process.env.EXPO_PUBLIC_REVENUE_CAT_GOOGLE,
172-
revenueCatStripeKey: process.env.EXPO_PUBLIC_REVENUE_CAT_STRIPE
177+
revenueCatStripeKey: process.env.EXPO_PUBLIC_REVENUE_CAT_STRIPE,
178+
elevenLabsAgentId,
173179
}
174180
},
175181
owner: "bulkacorp"

packages/happy-app/sources/realtime/RealtimeSession.ts

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import type { VoiceSession } from './types';
22
import { fetchVoiceToken } from '@/sync/apiVoice';
3-
import { storage } from '@/sync/storage';
43
import { sync } from '@/sync/sync';
54
import { Modal } from '@/modal';
65
import { TokenStorage } from '@/auth/tokenStorage';
76
import { t } from '@/text';
8-
import { config } from '@/config';
97
import { requestMicrophonePermission, showMicrophonePermissionDeniedAlert } from '@/utils/microphonePermissions';
108

119
let voiceSession: VoiceSession | null = null;
@@ -26,34 +24,13 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s
2624
return;
2725
}
2826

29-
const experimentsEnabled = storage.getState().settings.experiments;
30-
const agentId = __DEV__ ? config.elevenLabsAgentIdDev : config.elevenLabsAgentIdProd;
31-
32-
if (!agentId) {
33-
console.error('Agent ID not configured');
34-
return;
35-
}
36-
3727
try {
38-
// Simple path: No experiments = no auth needed
39-
if (!experimentsEnabled) {
40-
currentSessionId = sessionId;
41-
voiceSessionStarted = true;
42-
await voiceSession.startSession({
43-
sessionId,
44-
initialContext,
45-
agentId // Use agentId directly, no token
46-
});
47-
return;
48-
}
49-
50-
// Experiments enabled = full auth flow
5128
const credentials = await TokenStorage.getCredentials();
5229
if (!credentials) {
5330
Modal.alert(t('common.error'), t('errors.authenticationFailed'));
5431
return;
5532
}
56-
33+
5734
const response = await fetchVoiceToken(credentials, sessionId);
5835
console.log('[Voice] fetchVoiceToken response:', response);
5936

@@ -70,22 +47,13 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s
7047
currentSessionId = sessionId;
7148
voiceSessionStarted = true;
7249

73-
if (response.token) {
74-
// Use token from backend
75-
await voiceSession.startSession({
76-
sessionId,
77-
initialContext,
78-
token: response.token,
79-
agentId: response.agentId
80-
});
81-
} else {
82-
// No token (e.g. server not deployed yet) - use agentId directly
83-
await voiceSession.startSession({
84-
sessionId,
85-
initialContext,
86-
agentId
87-
});
88-
}
50+
await voiceSession.startSession({
51+
sessionId,
52+
initialContext,
53+
token: response.token,
54+
agentId: response.agentId,
55+
userId: response.elevenUserId,
56+
});
8957
} catch (error) {
9058
console.error('Failed to start realtime session:', error);
9159
currentSessionId = null;

packages/happy-app/sources/realtime/RealtimeVoiceSession.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ class RealtimeVoiceSessionImpl implements VoiceSession {
3939
language: elevenLabsLanguage
4040
}
4141
},
42-
...(config.token ? { conversationToken: config.token } : { agentId: config.agentId })
42+
...(config.token ? { conversationToken: config.token } : { agentId: config.agentId }),
43+
...(config.userId ? { userId: config.userId } : {}),
4344
};
4445

4546
await conversationInstance.startSession(sessionConfig);

packages/happy-app/sources/realtime/RealtimeVoiceSession.web.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ class RealtimeVoiceSessionImpl implements VoiceSession {
5050
language: elevenLabsLanguage
5151
}
5252
},
53-
...(config.token ? { conversationToken: config.token } : { agentId: config.agentId })
53+
...(config.token ? { conversationToken: config.token } : { agentId: config.agentId }),
54+
...(config.userId ? { userId: config.userId } : {}),
5455
};
5556

5657
const conversationId = await conversationInstance.startSession(sessionConfig);

packages/happy-app/sources/realtime/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export interface VoiceSessionConfig {
33
initialContext?: string;
44
token?: string;
55
agentId?: string;
6+
userId?: string;
67
}
78

89
export interface VoiceSession {

packages/happy-app/sources/sync/apiVoice.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
1+
import { type VoiceTokenResponse } from '@slopus/happy-wire';
12
import { AuthCredentials } from '@/auth/tokenStorage';
23
import { getServerUrl } from './serverConfig';
34
import { config } from '@/config';
4-
import { storage } from './storage';
55

6-
export interface VoiceTokenResponse {
7-
allowed: boolean;
8-
token?: string;
9-
agentId?: string;
10-
}
6+
export type { VoiceTokenResponse };
117

128
export async function fetchVoiceToken(
139
credentials: AuthCredentials,
1410
sessionId: string
1511
): Promise<VoiceTokenResponse> {
1612
const serverUrl = getServerUrl();
17-
const userId = storage.getState().profile.id;
18-
console.log(`[Voice] User ID: ${userId}`);
1913

20-
// Get agent ID from config
21-
const agentId = __DEV__
22-
? config.elevenLabsAgentIdDev
23-
: config.elevenLabsAgentIdProd;
14+
const agentId = config.elevenLabsAgentId;
2415

2516
if (!agentId) {
2617
throw new Error('Agent ID not configured');
@@ -33,18 +24,11 @@ export async function fetchVoiceToken(
3324
'Content-Type': 'application/json'
3425
},
3526
body: JSON.stringify({
36-
sessionId,
3727
agentId
3828
})
3929
});
4030

4131
if (!response.ok) {
42-
// 400 means the endpoint doesn't exist yet on this server.
43-
// Allow voice anyway to not break users on experimental/custom servers
44-
// that haven't been updated with the token endpoint yet.
45-
if (response.status === 400) {
46-
return { allowed: true };
47-
}
4832
throw new Error(`Voice token request failed: ${response.status}`);
4933
}
5034

packages/happy-app/sources/sync/appConfig.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ export interface AppConfig {
66
revenueCatAppleKey?: string;
77
revenueCatGoogleKey?: string;
88
revenueCatStripeKey?: string;
9-
elevenLabsAgentIdDev?: string;
10-
elevenLabsAgentIdProd?: string;
9+
elevenLabsAgentId?: string;
1110
serverUrl?: string;
1211
}
1312

@@ -84,14 +83,6 @@ export function loadAppConfig(): AppConfig {
8483
console.log('[loadAppConfig] Override postHogKey from EXPO_PUBLIC_POSTHOG_KEY');
8584
config.postHogKey = process.env.EXPO_PUBLIC_POSTHOG_KEY;
8685
}
87-
if (process.env.EXPO_PUBLIC_ELEVENLABS_AGENT_ID_DEV && config.elevenLabsAgentIdDev !== process.env.EXPO_PUBLIC_ELEVENLABS_AGENT_ID_DEV) {
88-
console.log('[loadAppConfig] Override elevenLabsAgentIdDev from EXPO_PUBLIC_ELEVENLABS_AGENT_ID_DEV');
89-
config.elevenLabsAgentIdDev = process.env.EXPO_PUBLIC_ELEVENLABS_AGENT_ID_DEV;
90-
}
91-
if (process.env.EXPO_PUBLIC_ELEVENLABS_AGENT_ID_PROD && config.elevenLabsAgentIdProd !== process.env.EXPO_PUBLIC_ELEVENLABS_AGENT_ID_PROD) {
92-
console.log('[loadAppConfig] Override elevenLabsAgentIdProd from EXPO_PUBLIC_ELEVENLABS_AGENT_ID_PROD');
93-
config.elevenLabsAgentIdProd = process.env.EXPO_PUBLIC_ELEVENLABS_AGENT_ID_PROD;
94-
}
9586
if (process.env.EXPO_PUBLIC_SERVER_URL && config.serverUrl !== process.env.EXPO_PUBLIC_SERVER_URL) {
9687
console.log('[loadAppConfig] Override serverUrl from EXPO_PUBLIC_SERVER_URL');
9788
config.serverUrl = process.env.EXPO_PUBLIC_SERVER_URL;

0 commit comments

Comments
 (0)