diff --git a/.env.example b/.env.example index a59443e..f19dd64 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ SUPABASE_SERVICE_ROLE_KEY=xxx DEEPSEEK_API_KEY=xxx GEMINI_API_KEY=xxx DEEPGRAM_API_KEY=xxx +# Proxy SOCKS5 optionnel pour les WS Gemini Live (ex: Cloudflare WARP sur le VPS +# prod dont l'IP est bloquée par Google). Absent = connexion directe (dev local). +GEMINI_PROXY_URL= # Stripe STRIPE_SECRET_KEY=xxx diff --git a/docs/CHANGELOG-backend.md b/docs/CHANGELOG-backend.md index e0b2e35..d1546d7 100644 --- a/docs/CHANGELOG-backend.md +++ b/docs/CHANGELOG-backend.md @@ -6,6 +6,26 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). --- +## [Unreleased] — 2026-06-30 — Proxy SOCKS5 (Cloudflare WARP) pour les WS Gemini Live + +### Added + +- `resolveGeminiProxyAgent()` (`geminiLive.ts`) — helper qui lit la variable d'environnement **optionnelle** `GEMINI_PROXY_URL` et renvoie un `SocksProxyAgent` si elle est définie, sinon `undefined` (connexion directe). Contexte : l'IP du VPS de production (datacenter) est bloquée par Google ; Cloudflare WARP tourne en mode proxy SOCKS5 sur le VPS (`socks5://127.0.0.1:40000`). Seul le trafic WS Gemini est routé via ce proxy ; Supabase, DeepSeek et les clients restent en direct. +- Dépendance `socks-proxy-agent` (`package.json`). +- `GEMINI_PROXY_URL` ajoutée à `.env.example` (vide par défaut → dev local inchangé). +- Tests `geminiLive.test.ts` — 2 tests pour `resolveGeminiProxyAgent` (absente → `undefined` ; `socks5://…` → instance `SocksProxyAgent`). + +### Changed + +- Factory WS par défaut de `openGeminiLiveSession` (T2, `geminiLive.ts`) et `openGeminiLiveT1Session` (T1, `geminiLiveT1.ts`) — passe désormais `{ agent }` au constructeur `new WebSocket(url, options)` quand un proxy est résolu. La factory injectée par les tests (`clientFactory`) n'est pas affectée. Prompt système, interruption, flush, `runT1/T2LiveCorrection` et close codes inchangés. + +### Notes + +- Tests backend : 311/311 verts. `tsc --noEmit` OK. +- Activation en prod : définir `GEMINI_PROXY_URL=socks5://127.0.0.1:40000` côté VPS/Render (config d'env, hors code). + +--- + ## [Unreleased] — 2026-06-30 — Sprint 7a-patch — T1 Live : suppression de la dépendance au questionnaire ### Changed diff --git a/package-lock.json b/package-lock.json index fde260d..88eb117 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@supabase/supabase-js": "^2.49.4", "dotenv": "^17.4.2", "hono": "^4.7.7", + "socks-proxy-agent": "^10.1.0", "stripe": "^17.7.0", "ws": "^8.20.0" }, @@ -1275,6 +1276,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1451,7 +1461,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1849,6 +1858,15 @@ "node": ">=20.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2034,7 +2052,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2366,6 +2383,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", + "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 0e64c8b..96a3897 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@supabase/supabase-js": "^2.49.4", "dotenv": "^17.4.2", "hono": "^4.7.7", + "socks-proxy-agent": "^10.1.0", "stripe": "^17.7.0", "ws": "^8.20.0" }, diff --git a/src/lib/__tests__/geminiLive.test.ts b/src/lib/__tests__/geminiLive.test.ts index 2247eb6..653d1ec 100644 --- a/src/lib/__tests__/geminiLive.test.ts +++ b/src/lib/__tests__/geminiLive.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { EventEmitter } from "node:events"; +import { SocksProxyAgent } from "socks-proxy-agent"; import { openGeminiLiveSession, buildT2SystemPrompt, + resolveGeminiProxyAgent, GEMINI_LIVE_MODEL, type WebSocketLike, } from "../geminiLive"; @@ -44,6 +46,32 @@ describe("buildT2SystemPrompt", () => { }); }); +describe("resolveGeminiProxyAgent", () => { + let originalProxy: string | undefined; + + beforeEach(() => { + originalProxy = process.env.GEMINI_PROXY_URL; + }); + + afterEach(() => { + if (originalProxy === undefined) { + delete process.env.GEMINI_PROXY_URL; + } else { + process.env.GEMINI_PROXY_URL = originalProxy; + } + }); + + it("retourne undefined quand GEMINI_PROXY_URL est absente (connexion directe)", () => { + delete process.env.GEMINI_PROXY_URL; + expect(resolveGeminiProxyAgent()).toBeUndefined(); + }); + + it("retourne un SocksProxyAgent quand GEMINI_PROXY_URL est définie", () => { + process.env.GEMINI_PROXY_URL = "socks5://127.0.0.1:40000"; + expect(resolveGeminiProxyAgent()).toBeInstanceOf(SocksProxyAgent); + }); +}); + describe("openGeminiLiveSession (raw WS)", () => { let originalKey: string | undefined; diff --git a/src/lib/geminiLive.ts b/src/lib/geminiLive.ts index 6197d4d..96bbcd0 100644 --- a/src/lib/geminiLive.ts +++ b/src/lib/geminiLive.ts @@ -15,6 +15,23 @@ */ import { WebSocket as NodeWebSocket } from "ws"; +import { SocksProxyAgent } from "socks-proxy-agent"; + +/** + * Résout l'agent proxy SOCKS5 pour les connexions WebSocket vers Gemini Live. + * + * Contexte : l'IP du VPS de production (datacenter) est bloquée par Google. + * Cloudflare WARP tourne en mode proxy sur le VPS (socks5://127.0.0.1:40000) ; + * router UNIQUEMENT le trafic Gemini via ce proxy le débloque, sans affecter + * le reste (Supabase, DeepSeek, clients). + * + * `GEMINI_PROXY_URL` est optionnelle : absente → connexion directe (dev local + * intact). Présente (ex: socks5://127.0.0.1:40000) → SocksProxyAgent. + */ +export function resolveGeminiProxyAgent(): SocksProxyAgent | undefined { + const url = process.env.GEMINI_PROXY_URL; + return url ? new SocksProxyAgent(url) : undefined; +} export const GEMINI_LIVE_URL = "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"; @@ -294,9 +311,14 @@ export function openGeminiLiveSession( }); const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; + const proxyAgent = resolveGeminiProxyAgent(); const factory = opts.clientFactory ?? - ((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); + ((u: string) => + new NodeWebSocket( + u, + proxyAgent ? { agent: proxyAgent } : undefined, + ) as unknown as WebSocketLike); console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***"); console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL); diff --git a/src/lib/geminiLiveT1.ts b/src/lib/geminiLiveT1.ts index 05136bf..3f3c57b 100644 --- a/src/lib/geminiLiveT1.ts +++ b/src/lib/geminiLiveT1.ts @@ -30,6 +30,7 @@ import { isEndSignal, parseAudioChunk, reconstructTranscript, + resolveGeminiProxyAgent, tryParseGeminiJson, type TranscriptEntry, type WebSocketLike, @@ -203,9 +204,14 @@ export function openGeminiLiveT1Session( const systemPrompt = buildT1SystemPrompt(); const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; + const proxyAgent = resolveGeminiProxyAgent(); const factory = opts.clientFactory ?? - ((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); + ((u: string) => + new NodeWebSocket( + u, + proxyAgent ? { agent: proxyAgent } : undefined, + ) as unknown as WebSocketLike); const geminiWs = factory(url);