Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
// Always enable ads in the waiting room — this is where monetization lives.
// forceStart bypasses the "wait for first user message" gate inside the hook,
// which would otherwise block ads here since no conversation exists yet.
// Uses Carbon (BuySellAds); in-chat ads still use the Gravity default.
const { ad, adData, recordImpression } = useGravityAd({
enabled: true,
forceStart: true,
provider: 'carbon',
})

useFreebuffCtrlCExit()
Expand Down
49 changes: 41 additions & 8 deletions cli/src/hooks/use-gravity-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then p
const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads
const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache

// Ad response type (matches Gravity API response, credits added after impression)
// Ad response type (normalized shape across providers; credits added after impression)
export type AdResponse = {
adText: string
title: string
Expand All @@ -30,6 +30,12 @@ export type AdResponse = {

export type AdVariant = 'banner' | 'choice'

/**
* Which upstream ad network to query. The server maps each provider onto the
* same normalized response shape, so the rest of the hook is provider-agnostic.
*/
export type AdProvider = 'gravity' | 'carbon'

export type AdData =
| { variant: 'banner'; ad: AdResponse }
| { variant: 'choice'; ads: AdResponse[] }
Expand Down Expand Up @@ -102,9 +108,12 @@ export const useGravityAd = (options?: {
/** Skip the "wait for first user message" gate. Used by the freebuff
* waiting room, which has no conversation but still needs ads. */
forceStart?: boolean
/** Which ad network to query. Defaults to Gravity. */
provider?: AdProvider
}): GravityAdState => {
const enabled = options?.enabled ?? true
const forceStart = options?.forceStart ?? false
const provider: AdProvider = options?.provider ?? 'gravity'
const [ad, setAd] = useState<AdResponse | null>(null)
const [adData, setAdData] = useState<AdData | null>(null)
const [isLoading, setIsLoading] = useState(false)
Expand Down Expand Up @@ -159,7 +168,7 @@ export const useGravityAd = (options?: {

const authToken = getAuthToken()
if (!authToken) {
logger.warn('[gravity] No auth token, skipping impression recording')
logger.warn('[ads] No auth token, skipping impression recording')
return
}

Expand All @@ -179,7 +188,7 @@ export const useGravityAd = (options?: {
if (data.creditsGranted > 0) {
logger.info(
{ creditsGranted: data.creditsGranted },
'[gravity] Ad impression credits granted',
'[ads] Ad impression credits granted',
)
setAd((cur) =>
cur?.impUrl === impUrl
Expand All @@ -205,7 +214,7 @@ export const useGravityAd = (options?: {
}
})
.catch((err) => {
logger.debug({ err }, '[gravity] Failed to record ad impression')
logger.debug({ err }, '[ads] Failed to record ad impression')
})
}

Expand Down Expand Up @@ -235,7 +244,7 @@ export const useGravityAd = (options?: {

const authToken = getAuthToken()
if (!authToken) {
logger.warn('[gravity] No auth token available')
logger.warn('[ads] No auth token available')
return null
}

Expand Down Expand Up @@ -277,16 +286,21 @@ export const useGravityAd = (options?: {
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
provider,
messages: adMessages,
sessionId: useChatStore.getState().chatSessionId,
device: getDeviceInfo(),
// Carbon requires a real browser-ish useragent for targeting/fraud
// detection. Gravity ignores it. We source one centrally so every
// provider that needs it sees the same value.
userAgent: getAdUserAgent(),
}),
})

if (!response.ok) {
logger.warn(
{ status: response.status, response: await response.json() },
'[gravity] Web API returned error',
{ provider, status: response.status, response: await response.json() },
'[ads] Web API returned error',
)
return null
}
Expand All @@ -304,7 +318,7 @@ export const useGravityAd = (options?: {

return null
} catch (err) {
logger.error({ err }, '[gravity] Failed to fetch ad')
logger.error({ err }, '[ads] Failed to fetch ad')
return null
}
}
Expand Down Expand Up @@ -465,3 +479,22 @@ function getDeviceInfo(): DeviceInfo {

return { os, timezone, locale }
}

/**
* Useragent string passed to ad providers. Carbon (BuySellAds) requires a
* plausible browser useragent for targeting and fraud screening. We send a
* stable desktop Chrome-on-{os} UA per platform so targeting is consistent
* across users on the same platform without sharing anything identifying.
*
* Chrome version needs bumping periodically — stale UAs look bot-ish to ad
* networks. Last bumped: 2026-04-21. Revisit roughly every 6 months.
*/
const AD_CHROME_VERSION = '124.0.0.0'
function getAdUserAgent(): string {
const osUA: Record<string, string> = {
darwin: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`,
win32: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`,
linux: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`,
}
return osUA[process.platform] ?? osUA.linux
}
3 changes: 3 additions & 0 deletions packages/internal/src/db/migrations/0045_mean_sleeper.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "ad_impression" ALTER COLUMN "payout" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "ad_impression" ADD COLUMN "provider" text DEFAULT 'gravity' NOT NULL;--> statement-breakpoint
ALTER TABLE "ad_impression" ADD COLUMN "extra_pixels" text[];
Loading
Loading