@uranotools/urano-social 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +212 -2
  4. package/dist/client/createUranoChat.d.ts +16 -0
  5. package/dist/client/createUranoChat.js +223 -0
  6. package/dist/client/index.d.ts +6 -0
  7. package/dist/client/index.js +4 -0
  8. package/dist/client/recaptcha.d.ts +14 -0
  9. package/dist/client/recaptcha.js +36 -0
  10. package/dist/client/sse.d.ts +3 -0
  11. package/dist/client/sse.js +104 -0
  12. package/dist/client/submitContactForm.d.ts +61 -0
  13. package/dist/client/submitContactForm.js +133 -0
  14. package/dist/crypto.d.ts +5 -0
  15. package/dist/crypto.js +35 -0
  16. package/dist/flavors/UranoSocialChatElement.d.ts +13 -0
  17. package/dist/flavors/UranoSocialChatElement.js +173 -0
  18. package/dist/flavors/UranoSocialContactFormElement.d.ts +9 -0
  19. package/dist/flavors/UranoSocialContactFormElement.js +103 -0
  20. package/dist/flavors/styles.css +257 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +10 -0
  23. package/dist/react/UranoSocialChat.d.ts +38 -0
  24. package/dist/react/UranoSocialChat.js +81 -0
  25. package/dist/react/UranoSocialContactForm.d.ts +19 -0
  26. package/dist/react/UranoSocialContactForm.js +78 -0
  27. package/dist/react/index.d.ts +4 -0
  28. package/dist/react/index.js +2 -0
  29. package/dist/server/antiSpam.d.ts +76 -0
  30. package/dist/server/antiSpam.js +204 -0
  31. package/dist/server/index.d.ts +4 -0
  32. package/dist/server/index.js +4 -0
  33. package/dist/server/proxy.d.ts +16 -0
  34. package/dist/server/proxy.js +253 -0
  35. package/dist/server/visitorCookie.d.ts +11 -0
  36. package/dist/server/visitorCookie.js +38 -0
  37. package/dist/server/websocket.d.ts +21 -0
  38. package/dist/server/websocket.js +126 -0
  39. package/dist/types.d.ts +94 -0
  40. package/dist/types.js +3 -0
  41. package/package.json +92 -4
package/.env.example ADDED
@@ -0,0 +1,22 @@
1
+ # Server-only — never use NEXT_PUBLIC_ / PUBLIC_ prefixes.
2
+ URANO_SOCIAL_WEBHOOK_URL=http://127.0.0.1:6274/v1/webhook/mcp_SalesAgentConnector/default
3
+ URANO_SOCIAL_WEBHOOK_SECRET=replace-with-the-same-WEBHOOK_SECRET-from-the-plugin-vault
4
+
5
+ # Optional: separate secret for signing the visitor cookie (defaults to WEBHOOK_SECRET).
6
+ # URANO_SOCIAL_COOKIE_SECRET=
7
+
8
+ # --- Contact anti-spam (on by default) ---
9
+ # Ticket + honeypot + rate limit always run for type=contact.
10
+ # URANO_SOCIAL_ANTISPAM=true
11
+ # URANO_SOCIAL_CONTACT_MAX_PER_HOUR=5
12
+ # URANO_SOCIAL_CONTACT_MIN_AGE_MS=2500
13
+
14
+ # Optional Google reCAPTCHA v3 — paste secret here; site key goes in the frontend.
15
+ # https://www.google.com/recaptcha/admin
16
+ # URANO_SOCIAL_RECAPTCHA_SECRET=
17
+ # URANO_SOCIAL_RECAPTCHA_MIN_SCORE=0.5
18
+ # URANO_SOCIAL_RECAPTCHA_ACTION=contact
19
+ #
20
+ # Frontend (public):
21
+ # NEXT_PUBLIC_URANO_SOCIAL_RECAPTCHA_SITE_KEY=
22
+ # VITE_URANO_SOCIAL_RECAPTCHA_SITE_KEY=
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Urano Open Source
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,213 @@
1
- # Temporary Holding Version
1
+ # @uranotools/urano-social
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Chat de soporte y formularios de contacto** para tu sitio, conectados a un agente Urano (Desktop o Cloud) — sin exponer secretos al navegador.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@uranotools/urano-social.svg)](https://www.npmjs.com/package/@uranotools/urano-social)
6
+ [![license](https://img.shields.io/npm/l/@uranotools/urano-social.svg)](./LICENSE)
7
+
8
+ Tu visitante habla con un widget o envía un formulario. Tu servidor firma la petición. El agente responde en tiempo real (chat) o por correo (contacto).
9
+
10
+ ---
11
+
12
+ ## Por qué usarlo
13
+
14
+ | Ventaja | Qué significa |
15
+ |---------|----------------|
16
+ | **Secreto fuera del browser** | El `WEBHOOK_SECRET` vive solo en tu servidor. El front solo llama a `/api/urano-social`. |
17
+ | **Tu agente, tu marca** | Conecta el SalesAgentConnector de Urano; el tono y las herramientas los defines tú. |
18
+ | **Chat + contacto** | Un mismo proxy sirve burbuja de chat (SSE / wait / WebSocket) y formularios SMTP. |
19
+ | **Anti-spam de verdad** | Honeypot, ticket firmado y rate limit incluidos. reCAPTCHA v3 opcional — pegas las keys y listo. |
20
+ | **UI a tu medida** | Flavors listos (`floating`, `sidebar`) o headless / slots para tu diseño. |
21
+ | **Next, Astro, Vite…** | Proxy Web-standard; funciona en App Router, edge/Node y Cloudflare Pages Functions. |
22
+
23
+ Sin esto, un formulario “a pelo” apuntando a un agente es un blanco fácil para quemar tokens de IA. El proxy corta bots **antes** de llamar al webhook.
24
+
25
+ ---
26
+
27
+ ## Instalar
28
+
29
+ ```bash
30
+ npm install @uranotools/urano-social
31
+ ```
32
+
33
+ Requisitos:
34
+
35
+ 1. **Urano Desktop o Cloud** con el plugin **SalesAgentConnector** (1.4.0+).
36
+ 2. En el vault del plugin: `WEBHOOK_SECRET`, `TARGET_AGENT` (y SMTP + `ENABLE_EMAIL_CONTACT=true` si usas formularios).
37
+ 3. Un endpoint en tu sitio que use el proxy de este paquete.
38
+
39
+ ---
40
+
41
+ ## Cómo encaja
42
+
43
+ ```
44
+ Visitante → tu sitio (/api/urano-social) → firma HMAC → webhook Urano → agente
45
+ ↑
46
+ cookie HttpOnly
47
+ anti-spam (contacto)
48
+ ```
49
+
50
+ El browser **nunca** recibe el secreto. Tú configuras dos variables de servidor:
51
+
52
+ ```env
53
+ URANO_SOCIAL_WEBHOOK_URL=https://tu-host/v1/webhook/mcp_SalesAgentConnector/default
54
+ URANO_SOCIAL_WEBHOOK_SECRET=el-mismo-del-vault
55
+ ```
56
+
57
+ Local (Desktop):
58
+
59
+ ```env
60
+ URANO_SOCIAL_WEBHOOK_URL=http://127.0.0.1:6274/v1/webhook/mcp_SalesAgentConnector/default
61
+ URANO_SOCIAL_WEBHOOK_SECRET=…
62
+ ```
63
+
64
+ No uses prefijos `NEXT_PUBLIC_` / `VITE_` / `PUBLIC_` para el secreto.
65
+
66
+ ---
67
+
68
+ ## Arranque en 3 pasos
69
+
70
+ ### 1. Proxy en tu app
71
+
72
+ **Next.js (App Router)**
73
+
74
+ ```ts
75
+ // app/api/urano-social/route.ts
76
+ import { socialProxyRoute } from '@uranotools/urano-social/server';
77
+
78
+ export const { GET, POST, OPTIONS } = socialProxyRoute();
79
+ ```
80
+
81
+ **Astro / Node / edge**
82
+
83
+ ```ts
84
+ import { createSocialProxy } from '@uranotools/urano-social/server';
85
+
86
+ const proxy = createSocialProxy();
87
+ // return proxy.handle(request)
88
+ ```
89
+
90
+ ### 2. Widget de chat
91
+
92
+ **HTML / Astro (custom element)**
93
+
94
+ ```html
95
+ <script type="module">
96
+ import { registerUranoSocialChat } from '@uranotools/urano-social';
97
+ registerUranoSocialChat();
98
+ </script>
99
+ <urano-social-chat flavor="floating" title="Soporte"></urano-social-chat>
100
+ ```
101
+
102
+ **React**
103
+
104
+ ```tsx
105
+ import { UranoSocialChat } from '@uranotools/urano-social/react';
106
+ import '@uranotools/urano-social/flavors.css';
107
+
108
+ export function SupportWidget() {
109
+ return <UranoSocialChat flavor="floating" title="Soporte" />;
110
+ }
111
+ ```
112
+
113
+ **Headless** (tu propia UI)
114
+
115
+ ```ts
116
+ import { createUranoChat } from '@uranotools/urano-social/client';
117
+
118
+ const chat = createUranoChat({ endpoint: '/api/urano-social' });
119
+ await chat.send('Hola, necesito ayuda');
120
+ ```
121
+
122
+ ### 3. Formulario de contacto (opcional)
123
+
124
+ El agente recibe el mensaje y puede responder por SMTP.
125
+
126
+ ```tsx
127
+ import { UranoSocialContactForm } from '@uranotools/urano-social/react';
128
+
129
+ <UranoSocialContactForm
130
+ title="Escríbenos"
131
+ recaptchaSiteKey={import.meta.env.VITE_URANO_SOCIAL_RECAPTCHA_SITE_KEY}
132
+ />
133
+ ```
134
+
135
+ O solo la API:
136
+
137
+ ```ts
138
+ import { submitContactForm } from '@uranotools/urano-social/client';
139
+
140
+ await submitContactForm({
141
+ email: 'ana@empresa.com',
142
+ name: 'Ana',
143
+ message: 'Quiero una demo',
144
+ });
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Flavors de chat
150
+
151
+ | Flavor | Ideal para |
152
+ |--------|------------|
153
+ | `floating` | Burbuja fija en la esquina (soporte clásico) |
154
+ | `sidebar` | Panel lateral en product / docs |
155
+ | `headless` | Diseño 100 % tuyo; solo el controlador |
156
+
157
+ Slots del custom element: `header`, `messages`, `composer`, `launcher`, `empty`.
158
+
159
+ ---
160
+
161
+ ## Anti-spam en formularios
162
+
163
+ Incluido de fábrica:
164
+
165
+ - **Honeypot** — si un bot llena el campo oculto, responde 200 falso y **no** llama a la IA
166
+ - **Ticket firmado** — edad mínima / máxima entre abrir el form y enviar
167
+ - **Rate limit** — por defecto 5 envíos / hora por visitante + IP
168
+
169
+ Opcional — **reCAPTCHA v3** (solo pegar keys):
170
+
171
+ ```env
172
+ # Servidor
173
+ URANO_SOCIAL_RECAPTCHA_SECRET=tu_secret_de_google
174
+
175
+ # Front (público)
176
+ VITE_URANO_SOCIAL_RECAPTCHA_SITE_KEY=tu_site_key
177
+ # o NEXT_PUBLIC_URANO_SOCIAL_RECAPTCHA_SITE_KEY=
178
+ ```
179
+
180
+ Con el secret configurado, el proxy exige y verifica el token **antes** del webhook.
181
+
182
+ ---
183
+
184
+ ## Seguridad (resumen)
185
+
186
+ - Cookie **HttpOnly** firmada: el visitante no elige su `visitorId` CRM
187
+ - Firma **HMAC** (`x-urano-signature`) solo en el proxy
188
+ - Controles de turno: `status` / `events` / `cancel` + `Idempotency-Key`
189
+ - Contacto filtrado antes de gastar tokens del agente
190
+
191
+ ---
192
+
193
+ ## Ejemplos
194
+
195
+ - [`examples/next`](examples/next) — formulario de contacto + proxy
196
+ - [`examples/astro`](examples/astro) — misma idea en Astro
197
+
198
+ ```bash
199
+ cd examples/next
200
+ cp .env.example .env # pon URL + secreto del plugin
201
+ npm install
202
+ npm run dev # http://localhost:3011
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Enlaces
208
+
209
+ - npm: [npmjs.com/package/@uranotools/urano-social](https://www.npmjs.com/package/@uranotools/urano-social)
210
+ - UranoAI: [uranoai.com](https://uranoai.com)
211
+ - Org: [github.com/uranotools](https://github.com/uranotools)
212
+
213
+ MIT © Urano Open Source
@@ -0,0 +1,16 @@
1
+ import type { CreateUranoChatOptions, SocialChatEvent, SocialClientMessage, SocialControlOptions, SocialSendOptions } from '../types';
2
+ export interface UranoChat {
3
+ readonly sessionId: string | null;
4
+ readonly messages: SocialClientMessage[];
5
+ readonly streaming: boolean;
6
+ send(options: SocialSendOptions | string): Promise<SocialClientMessage>;
7
+ control(options: SocialControlOptions): Promise<Response | AsyncGenerator<SocialChatEvent>>;
8
+ cancel(turnId: string): Promise<void>;
9
+ setDisplayName(name: string): void;
10
+ destroy(): void;
11
+ }
12
+ /**
13
+ * Headless chat controller. Talks only to the site proxy (`/api/urano-social`).
14
+ * Never receives or sends the webhook secret.
15
+ */
16
+ export declare function createUranoChat(options?: CreateUranoChatOptions): UranoChat;
@@ -0,0 +1,223 @@
1
+ import { readSseStream } from './sse';
2
+ function newMessageId(prefix) {
3
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
4
+ }
5
+ /**
6
+ * Headless chat controller. Talks only to the site proxy (`/api/urano-social`).
7
+ * Never receives or sends the webhook secret.
8
+ */
9
+ export function createUranoChat(options = {}) {
10
+ const endpoint = (options.endpoint || '/api/urano-social').replace(/\/$/, '');
11
+ let sessionId = options.sessionId ?? null;
12
+ let displayName = options.displayName || '';
13
+ let streaming = false;
14
+ let destroyed = false;
15
+ const messages = [];
16
+ let abort = null;
17
+ const emitMessages = () => options.onMessages?.(messages.slice());
18
+ const emitStatus = () => options.onStatus?.({
19
+ connected: !destroyed,
20
+ streaming,
21
+ sessionId,
22
+ });
23
+ function applyEvent(evt, assistant) {
24
+ options.onEvent?.(evt);
25
+ if (evt.sessionId)
26
+ sessionId = String(evt.sessionId);
27
+ if (evt.type === 'session' && evt.sessionId)
28
+ sessionId = String(evt.sessionId);
29
+ if (evt.type === 'TokenStreamChunk' && typeof evt.chunk === 'string') {
30
+ assistant.content += evt.chunk;
31
+ assistant.streaming = true;
32
+ emitMessages();
33
+ }
34
+ if (evt.type === 'done') {
35
+ if (typeof evt.response === 'string' && evt.response && !assistant.content) {
36
+ assistant.content = evt.response;
37
+ }
38
+ assistant.streaming = false;
39
+ if (evt.turnId)
40
+ assistant.turnId = String(evt.turnId);
41
+ emitMessages();
42
+ }
43
+ if (evt.type === 'error') {
44
+ assistant.streaming = false;
45
+ if (!assistant.content)
46
+ assistant.content = String(evt.error || 'error');
47
+ options.onError?.(new Error(String(evt.error || 'stream_error')));
48
+ emitMessages();
49
+ }
50
+ }
51
+ async function sendViaSse(text, idempotencyKey, signal) {
52
+ const assistant = {
53
+ id: newMessageId('a'),
54
+ role: 'assistant',
55
+ content: '',
56
+ streaming: true,
57
+ createdAt: Date.now(),
58
+ };
59
+ messages.push(assistant);
60
+ emitMessages();
61
+ const body = JSON.stringify({
62
+ text,
63
+ sessionId: sessionId || undefined,
64
+ displayName: displayName || undefined,
65
+ });
66
+ const headers = {
67
+ 'Content-Type': 'application/json',
68
+ Accept: 'text/event-stream',
69
+ };
70
+ if (idempotencyKey)
71
+ headers['Idempotency-Key'] = idempotencyKey;
72
+ const response = await fetch(`${endpoint}?mode=sse`, {
73
+ method: 'POST',
74
+ headers,
75
+ body,
76
+ credentials: 'include',
77
+ signal,
78
+ });
79
+ if (!response.ok) {
80
+ const errText = await response.text().catch(() => '');
81
+ throw new Error(errText || `http_${response.status}`);
82
+ }
83
+ const contentType = response.headers.get('Content-Type') || '';
84
+ if (contentType.includes('text/event-stream')) {
85
+ for await (const evt of readSseStream(response, signal)) {
86
+ applyEvent(evt, assistant);
87
+ }
88
+ }
89
+ else {
90
+ const json = await response.json();
91
+ if (json.sessionId)
92
+ sessionId = String(json.sessionId);
93
+ assistant.content = String(json.text || json.response || '');
94
+ assistant.streaming = false;
95
+ assistant.turnId = json.turnId ? String(json.turnId) : undefined;
96
+ emitMessages();
97
+ }
98
+ assistant.streaming = false;
99
+ emitMessages();
100
+ }
101
+ async function sendViaWait(text, idempotencyKey, signal) {
102
+ const assistant = {
103
+ id: newMessageId('a'),
104
+ role: 'assistant',
105
+ content: '',
106
+ streaming: true,
107
+ createdAt: Date.now(),
108
+ };
109
+ messages.push(assistant);
110
+ emitMessages();
111
+ const headers = { 'Content-Type': 'application/json' };
112
+ if (idempotencyKey)
113
+ headers['Idempotency-Key'] = idempotencyKey;
114
+ const response = await fetch(`${endpoint}?mode=wait`, {
115
+ method: 'POST',
116
+ headers,
117
+ credentials: 'include',
118
+ signal,
119
+ body: JSON.stringify({
120
+ text,
121
+ sessionId: sessionId || undefined,
122
+ displayName: displayName || undefined,
123
+ }),
124
+ });
125
+ const json = await response.json().catch(() => ({}));
126
+ if (!response.ok) {
127
+ throw new Error(json.error || `http_${response.status}`);
128
+ }
129
+ if (json.sessionId)
130
+ sessionId = String(json.sessionId);
131
+ assistant.content = String(json.text || json.response || '');
132
+ assistant.streaming = false;
133
+ assistant.turnId = json.turnId ? String(json.turnId) : undefined;
134
+ options.onEvent?.({ type: 'done', ...json });
135
+ emitMessages();
136
+ }
137
+ const api = {
138
+ get sessionId() { return sessionId; },
139
+ get messages() { return messages.slice(); },
140
+ get streaming() { return streaming; },
141
+ async send(input) {
142
+ if (destroyed)
143
+ throw new Error('destroyed');
144
+ const opts = typeof input === 'string' ? { text: input } : input;
145
+ const text = String(opts.text || '').trim();
146
+ if (!text)
147
+ throw new Error('empty_text');
148
+ messages.push({
149
+ id: newMessageId('u'),
150
+ role: 'user',
151
+ content: text,
152
+ createdAt: Date.now(),
153
+ });
154
+ emitMessages();
155
+ abort?.abort();
156
+ abort = new AbortController();
157
+ const signal = opts.signal || abort.signal;
158
+ streaming = true;
159
+ emitStatus();
160
+ try {
161
+ const mode = opts.mode
162
+ || (options.preferredTransport === 'wait' ? 'wait' : 'sse');
163
+ if (mode === 'wait') {
164
+ await sendViaWait(text, opts.idempotencyKey, signal);
165
+ }
166
+ else {
167
+ await sendViaSse(text, opts.idempotencyKey, signal);
168
+ }
169
+ return messages[messages.length - 1];
170
+ }
171
+ catch (error) {
172
+ options.onError?.(error instanceof Error ? error : new Error(String(error)));
173
+ throw error;
174
+ }
175
+ finally {
176
+ streaming = false;
177
+ emitStatus();
178
+ }
179
+ },
180
+ async control(controlOpts) {
181
+ const body = {
182
+ webhookAction: controlOpts.action,
183
+ turnId: controlOpts.turnId,
184
+ sessionId: sessionId || undefined,
185
+ after: controlOpts.after,
186
+ };
187
+ if (controlOpts.action === 'events') {
188
+ const response = await fetch(`${endpoint}?mode=sse`, {
189
+ method: 'POST',
190
+ headers: {
191
+ 'Content-Type': 'application/json',
192
+ Accept: 'text/event-stream',
193
+ ...(controlOpts.after ? { 'Last-Event-ID': controlOpts.after } : {}),
194
+ },
195
+ credentials: 'include',
196
+ body: JSON.stringify(body),
197
+ signal: controlOpts.signal,
198
+ });
199
+ return readSseStream(response, controlOpts.signal);
200
+ }
201
+ return fetch(`${endpoint}?mode=wait`, {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ credentials: 'include',
205
+ body: JSON.stringify(body),
206
+ signal: controlOpts.signal,
207
+ });
208
+ },
209
+ async cancel(turnId) {
210
+ await api.control({ action: 'cancel', turnId });
211
+ },
212
+ setDisplayName(name) {
213
+ displayName = String(name || '');
214
+ },
215
+ destroy() {
216
+ destroyed = true;
217
+ abort?.abort();
218
+ emitStatus();
219
+ },
220
+ };
221
+ emitStatus();
222
+ return api;
223
+ }
@@ -0,0 +1,6 @@
1
+ export { createUranoChat } from './createUranoChat';
2
+ export type { UranoChat } from './createUranoChat';
3
+ export { readSseStream, parseSseChunk } from './sse';
4
+ export { submitContactForm, fetchContactTicket } from './submitContactForm';
5
+ export type { ContactFormPayload, ContactFormResult, ContactTicketResponse, SubmitContactFormOptions, } from './submitContactForm';
6
+ export { loadRecaptchaV3, executeRecaptchaV3 } from './recaptcha';
@@ -0,0 +1,4 @@
1
+ export { createUranoChat } from './createUranoChat';
2
+ export { readSseStream, parseSseChunk } from './sse';
3
+ export { submitContactForm, fetchContactTicket } from './submitContactForm';
4
+ export { loadRecaptchaV3, executeRecaptchaV3 } from './recaptcha';
@@ -0,0 +1,14 @@
1
+ declare global {
2
+ interface Window {
3
+ grecaptcha?: {
4
+ ready: (cb: () => void) => void;
5
+ execute: (siteKey: string, opts: {
6
+ action: string;
7
+ }) => Promise<string>;
8
+ };
9
+ }
10
+ }
11
+ /** Loads the reCAPTCHA v3 script once (browser only). */
12
+ export declare function loadRecaptchaV3(siteKey: string): Promise<void>;
13
+ /** Executes reCAPTCHA v3 and returns a short-lived token for the proxy. */
14
+ export declare function executeRecaptchaV3(siteKey: string, action?: string): Promise<string>;
@@ -0,0 +1,36 @@
1
+ const SCRIPT_ID = 'uranotools-recaptcha-v3';
2
+ /** Loads the reCAPTCHA v3 script once (browser only). */
3
+ export function loadRecaptchaV3(siteKey) {
4
+ if (typeof window === 'undefined') {
5
+ return Promise.reject(new Error('recaptcha_browser_only'));
6
+ }
7
+ if (!siteKey)
8
+ return Promise.reject(new Error('recaptcha_site_key_required'));
9
+ if (window.grecaptcha) {
10
+ return new Promise((resolve) => window.grecaptcha.ready(() => resolve()));
11
+ }
12
+ const existing = document.getElementById(SCRIPT_ID);
13
+ if (existing) {
14
+ return new Promise((resolve, reject) => {
15
+ existing.addEventListener('load', () => window.grecaptcha.ready(() => resolve()));
16
+ existing.addEventListener('error', () => reject(new Error('recaptcha_script_failed')));
17
+ });
18
+ }
19
+ return new Promise((resolve, reject) => {
20
+ const script = document.createElement('script');
21
+ script.id = SCRIPT_ID;
22
+ script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
23
+ script.async = true;
24
+ script.onload = () => window.grecaptcha.ready(() => resolve());
25
+ script.onerror = () => reject(new Error('recaptcha_script_failed'));
26
+ document.head.appendChild(script);
27
+ });
28
+ }
29
+ /** Executes reCAPTCHA v3 and returns a short-lived token for the proxy. */
30
+ export async function executeRecaptchaV3(siteKey, action = 'contact') {
31
+ await loadRecaptchaV3(siteKey);
32
+ const token = await window.grecaptcha.execute(siteKey, { action });
33
+ if (!token)
34
+ throw new Error('recaptcha_empty_token');
35
+ return token;
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { SocialChatEvent } from '../types';
2
+ export declare function readSseStream(response: Response, signal?: AbortSignal): AsyncGenerator<SocialChatEvent>;
3
+ export declare function parseSseChunk(chunk: string): SocialChatEvent[];
@@ -0,0 +1,104 @@
1
+ export async function* readSseStream(response, signal) {
2
+ if (!response.body) {
3
+ throw new Error('missing_response_body');
4
+ }
5
+ const reader = response.body.getReader();
6
+ const decoder = new TextDecoder();
7
+ let buffer = '';
8
+ let eventType = 'message';
9
+ let dataLines = [];
10
+ const flush = () => {
11
+ if (!dataLines.length) {
12
+ eventType = 'message';
13
+ return null;
14
+ }
15
+ const raw = dataLines.join('\n');
16
+ dataLines = [];
17
+ const type = eventType;
18
+ eventType = 'message';
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ if (parsed && typeof parsed === 'object') {
22
+ return { type: parsed.type || type, ...parsed };
23
+ }
24
+ }
25
+ catch {
26
+ return { type, data: raw };
27
+ }
28
+ return { type, data: raw };
29
+ };
30
+ try {
31
+ while (true) {
32
+ if (signal?.aborted)
33
+ break;
34
+ const { done, value } = await reader.read();
35
+ if (done)
36
+ break;
37
+ buffer += decoder.decode(value, { stream: true });
38
+ let nl;
39
+ while ((nl = buffer.indexOf('\n')) >= 0) {
40
+ let line = buffer.slice(0, nl);
41
+ buffer = buffer.slice(nl + 1);
42
+ if (line.endsWith('\r'))
43
+ line = line.slice(0, -1);
44
+ if (line === '') {
45
+ const evt = flush();
46
+ if (evt)
47
+ yield evt;
48
+ continue;
49
+ }
50
+ if (line.startsWith(':'))
51
+ continue;
52
+ if (line.startsWith('event:')) {
53
+ eventType = line.slice(6).trim();
54
+ continue;
55
+ }
56
+ if (line.startsWith('data:')) {
57
+ dataLines.push(line.slice(5).trimStart());
58
+ }
59
+ }
60
+ }
61
+ const trailing = flush();
62
+ if (trailing)
63
+ yield trailing;
64
+ }
65
+ finally {
66
+ try {
67
+ await reader.cancel();
68
+ }
69
+ catch { /* ignore */ }
70
+ }
71
+ }
72
+ export function parseSseChunk(chunk) {
73
+ const events = [];
74
+ let eventType = 'message';
75
+ let dataLines = [];
76
+ for (const rawLine of chunk.split(/\r?\n/)) {
77
+ const line = rawLine;
78
+ if (line === '') {
79
+ if (dataLines.length) {
80
+ const raw = dataLines.join('\n');
81
+ dataLines = [];
82
+ const type = eventType;
83
+ eventType = 'message';
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ events.push({ type: parsed.type || type, ...parsed });
87
+ }
88
+ catch {
89
+ events.push({ type, data: raw });
90
+ }
91
+ }
92
+ continue;
93
+ }
94
+ if (line.startsWith(':'))
95
+ continue;
96
+ if (line.startsWith('event:')) {
97
+ eventType = line.slice(6).trim();
98
+ continue;
99
+ }
100
+ if (line.startsWith('data:'))
101
+ dataLines.push(line.slice(5).trimStart());
102
+ }
103
+ return events;
104
+ }