codex@macbookpro
·
2026-03-31
delivery-adapters.js
1(function initBaaDeliveryAdapters(globalScope) {
2 const DEFAULT_TIMEOUT_MS = 30_000;
3 const DEFAULT_POLL_INTERVAL_MS = 150;
4 const DEFAULT_RETRY_ATTEMPTS = 2;
5 const DEFAULT_RETRY_DELAY_MS = 250;
6 const PLATFORM_ADAPTERS = {
7 claude: {
8 label: "Claude",
9 pageHosts: ["claude.ai"],
10 readinessSelectors: [
11 "main",
12 "form",
13 "[data-testid*='composer' i]",
14 "div.ProseMirror[contenteditable='true']",
15 "[role='textbox']"
16 ],
17 composerSelectors: [
18 "div[contenteditable='true'][data-testid*='composer']",
19 "div[contenteditable='true'][aria-label*='message' i]",
20 "div[contenteditable='true'][role='textbox']",
21 "div.ProseMirror[contenteditable='true']",
22 "textarea"
23 ],
24 sendButtonSelectors: [
25 "button[data-testid*='send' i]",
26 "button[aria-label*='send' i]",
27 "form button[type='submit']"
28 ],
29 sendingSelectors: [
30 "button[data-testid*='stop' i]",
31 "button[aria-label*='stop' i]",
32 "button[aria-label*='cancel' i]"
33 ]
34 },
35 chatgpt: {
36 label: "ChatGPT",
37 pageHosts: ["chatgpt.com", "chat.openai.com"],
38 readinessSelectors: [
39 "main",
40 "form",
41 "#prompt-textarea",
42 "[data-testid='prompt-textarea']",
43 "[role='textbox']"
44 ],
45 composerSelectors: [
46 "#prompt-textarea",
47 "textarea[data-testid='prompt-textarea']",
48 "div[contenteditable='true'][data-testid='prompt-textarea']",
49 "div[contenteditable='true'][role='textbox']",
50 "textarea"
51 ],
52 sendButtonSelectors: [
53 "button[data-testid='send-button']",
54 "button[aria-label*='send prompt' i]",
55 "button[aria-label*='send message' i]",
56 "form button[type='submit']"
57 ],
58 sendingSelectors: [
59 "button[data-testid='stop-button']",
60 "button[aria-label*='stop generating' i]",
61 "button[aria-label*='stop' i]"
62 ]
63 },
64 gemini: {
65 label: "Gemini",
66 pageHosts: ["gemini.google.com"],
67 readinessSelectors: [
68 ".conversation-container",
69 "chat-window",
70 "rich-text-field",
71 "rich-textarea",
72 ".ql-editor[contenteditable='true']",
73 "[role='textbox']"
74 ],
75 composerSelectors: [
76 "rich-text-field .ql-editor[contenteditable='true']",
77 "rich-textarea .ql-editor[contenteditable='true']",
78 "rich-text-field div[contenteditable='true']",
79 "rich-textarea div[contenteditable='true']",
80 "div[contenteditable='true'][aria-label*='prompt' i]",
81 "div[contenteditable='true'][aria-label*='message' i]",
82 "div[contenteditable='true'][role='textbox']",
83 "textarea[aria-label*='prompt' i]",
84 "textarea"
85 ],
86 sendButtonSelectors: [
87 "button[aria-label*='Send message' i]",
88 "button[aria-label*='send' i]",
89 "button[mattooltip*='send' i]",
90 ".send-button",
91 ".input-area-container button:not([aria-label*='microphone' i])"
92 ],
93 sendingSelectors: [
94 "button[aria-label*='Stop' i]",
95 "button[aria-label*='Cancel' i]",
96 "mat-icon[data-mat-icon-name='stop_circle']"
97 ]
98 }
99 };
100
101 class DeliveryError extends Error {
102 constructor(code, message, details = {}) {
103 super(message);
104 this.name = "DeliveryError";
105 this.code = trimToNull(code) || "command_failed";
106 this.details = isRecord(details) ? details : {};
107 }
108 }
109
110 function isRecord(value) {
111 return value !== null && typeof value === "object" && !Array.isArray(value);
112 }
113
114 function trimToNull(value) {
115 if (typeof value !== "string") {
116 return null;
117 }
118
119 const normalized = value.trim();
120 return normalized === "" ? null : normalized;
121 }
122
123 function toPositiveInteger(value, fallback) {
124 const numeric = Number(value);
125 if (!Number.isFinite(numeric) || numeric <= 0) {
126 return fallback;
127 }
128
129 return Math.max(1, Math.floor(numeric));
130 }
131
132 function normalizeText(value) {
133 return String(value || "")
134 .replace(/\s+/gu, " ")
135 .trim()
136 .toLowerCase();
137 }
138
139 function normalizeError(error, fallbackMessage, details = {}) {
140 if (error instanceof DeliveryError) {
141 return error;
142 }
143
144 const message = error instanceof Error ? error.message : trimToNull(String(error)) || fallbackMessage;
145 return new DeliveryError("command_failed", message, details);
146 }
147
148 function formatFailureReason(error) {
149 const normalized = normalizeDeliveryError(error, {});
150 return `delivery.${normalized.code}: ${normalized.message}`;
151 }
152
153 function normalizeDeliveryError(error, details = {}) {
154 const normalized = normalizeError(error, "delivery command failed", details);
155
156 if (!normalized.details.attempt && details.attempt) {
157 normalized.details.attempt = details.attempt;
158 }
159 if (!normalized.details.attempts && details.attempts) {
160 normalized.details.attempts = details.attempts;
161 }
162
163 return normalized;
164 }
165
166 function isElementLike(value) {
167 return value != null && typeof value === "object" && typeof value.getBoundingClientRect === "function";
168 }
169
170 function isButtonLike(value) {
171 return isElementLike(value) && String(value.tagName || "").toUpperCase() === "BUTTON";
172 }
173
174 function isInputLike(value) {
175 const tagName = String(value?.tagName || "").toUpperCase();
176 return isElementLike(value) && (tagName === "INPUT" || tagName === "TEXTAREA");
177 }
178
179 function isContentEditableLike(value) {
180 return isElementLike(value) && value.isContentEditable === true;
181 }
182
183 function createBrowserEnv(overrides = {}) {
184 const documentRef = overrides.document || globalScope.document || null;
185 const locationRef = overrides.location || globalScope.location || null;
186
187 return {
188 createChangeEvent: overrides.createChangeEvent || (() => new globalScope.Event("change", {
189 bubbles: true
190 })),
191 createInputEvent: overrides.createInputEvent || ((data) =>
192 new globalScope.InputEvent("input", {
193 bubbles: true,
194 cancelable: true,
195 data,
196 inputType: "insertText"
197 })
198 ),
199 document: documentRef,
200 getComputedStyle: overrides.getComputedStyle || ((element) =>
201 typeof globalScope.getComputedStyle === "function" ? globalScope.getComputedStyle(element) : null
202 ),
203 getLocationHref: overrides.getLocationHref || (() => locationRef?.href || ""),
204 now: overrides.now || (() => Date.now()),
205 sleep: overrides.sleep || ((ms) =>
206 new Promise((resolve) => globalScope.setTimeout(resolve, Math.max(0, Number(ms) || 0)))
207 )
208 };
209 }
210
211 function getPlatformAdapter(platform) {
212 const normalized = trimToNull(platform);
213
214 if (!normalized) {
215 return null;
216 }
217
218 const adapter = PLATFORM_ADAPTERS[normalized];
219 return adapter ? {
220 ...adapter,
221 platform: normalized
222 } : null;
223 }
224
225 function listPlatformAdapters() {
226 return Object.keys(PLATFORM_ADAPTERS).sort().map((platform) => getPlatformAdapter(platform));
227 }
228
229 function elementDisabled(element) {
230 if (!isElementLike(element)) {
231 return true;
232 }
233
234 if (typeof element.disabled === "boolean") {
235 return element.disabled;
236 }
237
238 return element.getAttribute?.("aria-disabled") === "true";
239 }
240
241 function isElementVisible(env, element, options = {}) {
242 if (!isElementLike(element)) {
243 return false;
244 }
245
246 if (options.allowHidden === true) {
247 return true;
248 }
249
250 const style = env.getComputedStyle(element);
251 if (style) {
252 if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") === 0) {
253 return false;
254 }
255 }
256
257 const rect = element.getBoundingClientRect();
258 return rect.width > 0 && rect.height > 0;
259 }
260
261 function queryAll(env, selector) {
262 if (!env.document || typeof selector !== "string" || !selector) {
263 return [];
264 }
265
266 try {
267 const matches = env.document.querySelectorAll(selector);
268 return Array.isArray(matches) ? matches : Array.from(matches || []);
269 } catch (_) {
270 return [];
271 }
272 }
273
274 function queryFirst(env, selectors, options = {}) {
275 for (const selector of selectors) {
276 for (const element of queryAll(env, selector)) {
277 if (isElementVisible(env, element, options)) {
278 return element;
279 }
280 }
281 }
282
283 return null;
284 }
285
286 function readComposerText(element) {
287 if (isInputLike(element)) {
288 return typeof element.value === "string" ? element.value : "";
289 }
290
291 if (isContentEditableLike(element)) {
292 return typeof element.textContent === "string" ? element.textContent : "";
293 }
294
295 return "";
296 }
297
298 function dispatchInputEvents(env, element) {
299 if (!isElementLike(element) || typeof element.dispatchEvent !== "function") {
300 return;
301 }
302
303 element.dispatchEvent(env.createInputEvent(readComposerText(element)));
304 element.dispatchEvent(env.createChangeEvent());
305 }
306
307 function setNativeValue(element, value) {
308 if (!isInputLike(element)) {
309 return;
310 }
311
312 const prototype = String(element.tagName || "").toUpperCase() === "TEXTAREA"
313 ? globalScope.HTMLTextAreaElement?.prototype
314 : globalScope.HTMLInputElement?.prototype;
315 const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
316
317 if (descriptor?.set) {
318 descriptor.set.call(element, value);
319 return;
320 }
321
322 element.value = value;
323 }
324
325 function setComposerText(env, element, text) {
326 if (isInputLike(element)) {
327 element.focus?.();
328 setNativeValue(element, text);
329 dispatchInputEvents(env, element);
330 return;
331 }
332
333 if (!isContentEditableLike(element)) {
334 throw new Error("page composer is not editable");
335 }
336
337 element.focus?.();
338 try {
339 if (typeof env.document?.execCommand === "function") {
340 env.document.execCommand("selectAll", false);
341 env.document.execCommand("insertText", false, text);
342 } else {
343 element.textContent = text;
344 }
345 } catch (_) {
346 element.textContent = text;
347 }
348 dispatchInputEvents(env, element);
349 }
350
351 async function waitForValue(env, resolveValue, options = {}) {
352 const timeoutMs = toPositiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS);
353 const intervalMs = toPositiveInteger(options.intervalMs, DEFAULT_POLL_INTERVAL_MS);
354 const startedAt = env.now();
355 let lastError = null;
356
357 while (env.now() - startedAt <= timeoutMs) {
358 try {
359 const value = await resolveValue();
360
361 if (value) {
362 return value;
363 }
364 } catch (error) {
365 lastError = error;
366 }
367
368 if (env.now() - startedAt >= timeoutMs) {
369 break;
370 }
371
372 await env.sleep(intervalMs);
373 }
374
375 if (typeof options.buildError === "function") {
376 throw options.buildError(lastError);
377 }
378
379 throw normalizeDeliveryError(lastError, {
380 timeout_ms: timeoutMs
381 });
382 }
383
384 function matchesExpectedHost(expectedHosts, currentHref) {
385 const href = trimToNull(currentHref);
386 if (!href || expectedHosts.length === 0) {
387 return true;
388 }
389
390 try {
391 const parsed = new URL(href, "https://delivery.invalid/");
392 return expectedHosts.includes(parsed.hostname);
393 } catch (_) {
394 return true;
395 }
396 }
397
398 function buildAttemptContext(env, adapter, command, metadata = {}) {
399 return {
400 command,
401 platform: adapter.platform,
402 ready_state: trimToNull(env.document?.readyState) || null,
403 url: trimToNull(env.getLocationHref()) || null,
404 ...metadata
405 };
406 }
407
408 function resolveAttemptSettings(command) {
409 const totalTimeoutMs = toPositiveInteger(command?.timeoutMs, DEFAULT_TIMEOUT_MS);
410 const attempts = Math.min(3, Math.max(1, toPositiveInteger(command?.retryAttempts, DEFAULT_RETRY_ATTEMPTS)));
411 const intervalMs = Math.max(10, toPositiveInteger(command?.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS));
412 const attemptBudgetMs = Math.max(150, Math.floor(totalTimeoutMs / attempts));
413
414 return {
415 attemptBudgetMs,
416 attempts,
417 confirmTimeoutMs: Math.max(50, Math.min(6_000, Math.floor(attemptBudgetMs * 0.3))),
418 intervalMs,
419 pageReadyTimeoutMs: Math.max(50, Math.min(12_000, Math.floor(attemptBudgetMs * 0.35))),
420 retryDelayMs: Math.max(25, toPositiveInteger(command?.retryDelayMs, DEFAULT_RETRY_DELAY_MS)),
421 selectorTimeoutMs: Math.max(50, Math.min(10_000, Math.floor(attemptBudgetMs * 0.35)))
422 };
423 }
424
425 function findComposer(env, adapter) {
426 return queryFirst(env, adapter.composerSelectors);
427 }
428
429 function findSendButton(env, adapter, options = {}) {
430 const candidate = queryFirst(env, adapter.sendButtonSelectors, {
431 allowHidden: options.allowHidden === true
432 });
433
434 if (candidate == null) {
435 return null;
436 }
437
438 if (options.includeDisabled === true || !elementDisabled(candidate)) {
439 return candidate;
440 }
441
442 return null;
443 }
444
445 function findSendingIndicator(env, adapter) {
446 return queryFirst(env, adapter.sendingSelectors, {
447 allowHidden: true
448 });
449 }
450
451 async function ensurePageReady(env, adapter, command, settings, attempt, attempts) {
452 const expectedHosts = Array.isArray(adapter.pageHosts) ? adapter.pageHosts : [];
453 if (!matchesExpectedHost(expectedHosts, env.getLocationHref())) {
454 throw new DeliveryError(
455 "page_context_mismatch",
456 `${adapter.label} page host does not match delivery target`,
457 buildAttemptContext(env, adapter, command, {
458 attempt,
459 attempts,
460 expected_hosts: expectedHosts
461 })
462 );
463 }
464
465 return await waitForValue(
466 env,
467 () => {
468 if (!env.document?.body) {
469 return null;
470 }
471
472 const readyState = String(env.document.readyState || "").toLowerCase();
473 if (readyState === "loading") {
474 return null;
475 }
476
477 const marker = queryFirst(env, adapter.readinessSelectors, {
478 allowHidden: true
479 });
480 if (!marker) {
481 return null;
482 }
483
484 return {
485 readyState
486 };
487 },
488 {
489 buildError: () => new DeliveryError(
490 "page_not_ready",
491 `${adapter.label} page is not ready for ${command}`,
492 buildAttemptContext(env, adapter, command, {
493 attempt,
494 attempts,
495 readiness_selectors: adapter.readinessSelectors
496 })
497 ),
498 intervalMs: settings.intervalMs,
499 timeoutMs: settings.pageReadyTimeoutMs
500 }
501 );
502 }
503
504 async function resolveSelector(env, adapter, command, selectorKind, selectors, settings, attempt, attempts, options = {}) {
505 return await waitForValue(
506 env,
507 () => queryFirst(env, selectors, options),
508 {
509 buildError: () => new DeliveryError(
510 "selector_missing",
511 `${adapter.label} ${selectorKind} selector was not found`,
512 buildAttemptContext(env, adapter, command, {
513 attempt,
514 attempts,
515 selector_kind: selectorKind,
516 selectors
517 })
518 ),
519 intervalMs: settings.intervalMs,
520 timeoutMs: settings.selectorTimeoutMs
521 }
522 );
523 }
524
525 async function confirmInjection(env, adapter, command, text, settings, attempt, attempts) {
526 const expectedText = normalizeText(text);
527
528 return await waitForValue(
529 env,
530 () => {
531 const composer = findComposer(env, adapter);
532 if (!composer) {
533 return null;
534 }
535
536 const currentText = normalizeText(readComposerText(composer));
537 if (!currentText || !currentText.includes(expectedText)) {
538 return null;
539 }
540
541 return {
542 confirmation: "composer_text_match"
543 };
544 },
545 {
546 buildError: () => new DeliveryError(
547 "inject_not_confirmed",
548 `${adapter.label} composer did not retain injected text`,
549 buildAttemptContext(env, adapter, command, {
550 attempt,
551 attempts,
552 text_length: text.length
553 })
554 ),
555 intervalMs: settings.intervalMs,
556 timeoutMs: settings.confirmTimeoutMs
557 }
558 );
559 }
560
561 async function confirmSend(env, adapter, command, beforeText, settings, attempt, attempts) {
562 return await waitForValue(
563 env,
564 () => {
565 if (findSendingIndicator(env, adapter)) {
566 return {
567 confirmation: "sending_indicator"
568 };
569 }
570
571 const button = findSendButton(env, adapter, {
572 allowHidden: true,
573 includeDisabled: true
574 });
575 if (button && elementDisabled(button)) {
576 return {
577 confirmation: "send_button_disabled"
578 };
579 }
580
581 const composer = findComposer(env, adapter);
582 if (composer) {
583 const afterText = normalizeText(readComposerText(composer));
584 if (beforeText && afterText !== beforeText) {
585 return {
586 confirmation: afterText ? "composer_changed" : "composer_cleared"
587 };
588 }
589 }
590
591 return null;
592 },
593 {
594 buildError: () => new DeliveryError(
595 "send_not_confirmed",
596 `${adapter.label} send click did not produce a confirmed state transition`,
597 buildAttemptContext(env, adapter, command, {
598 attempt,
599 attempts
600 })
601 ),
602 intervalMs: settings.intervalMs,
603 timeoutMs: settings.confirmTimeoutMs
604 }
605 );
606 }
607
608 async function executeInject(env, adapter, command, settings, attempt, attempts) {
609 const text = trimToNull(command?.text);
610
611 if (!text) {
612 throw new DeliveryError("invalid_payload", "message text is required");
613 }
614
615 await ensurePageReady(env, adapter, "inject_message", settings, attempt, attempts);
616 const composer = await resolveSelector(
617 env,
618 adapter,
619 "inject_message",
620 "composer",
621 adapter.composerSelectors,
622 settings,
623 attempt,
624 attempts
625 );
626 setComposerText(env, composer, text);
627 const confirmation = await confirmInjection(env, adapter, "inject_message", text, settings, attempt, attempts);
628
629 return {
630 ok: true,
631 details: {
632 attempt,
633 attempts,
634 confirmed_by: confirmation.confirmation
635 }
636 };
637 }
638
639 async function executeSend(env, adapter, command, settings, attempt, attempts) {
640 await ensurePageReady(env, adapter, "send_message", settings, attempt, attempts);
641 const composer = await resolveSelector(
642 env,
643 adapter,
644 "send_message",
645 "composer",
646 adapter.composerSelectors,
647 settings,
648 attempt,
649 attempts
650 );
651 const button = await resolveSelector(
652 env,
653 adapter,
654 "send_message",
655 "send_button",
656 adapter.sendButtonSelectors,
657 settings,
658 attempt,
659 attempts
660 );
661
662 if (elementDisabled(button)) {
663 throw new DeliveryError(
664 "send_unavailable",
665 `${adapter.label} send button is disabled before click`,
666 buildAttemptContext(env, adapter, "send_message", {
667 attempt,
668 attempts
669 })
670 );
671 }
672
673 const beforeText = normalizeText(readComposerText(composer));
674 button.click?.();
675 const confirmation = await confirmSend(env, adapter, "send_message", beforeText, settings, attempt, attempts);
676
677 return {
678 ok: true,
679 details: {
680 attempt,
681 attempts,
682 confirmed_by: confirmation.confirmation
683 }
684 };
685 }
686
687 async function executeCommandAttempt(env, adapter, command, settings, attempt, attempts) {
688 switch (command.command) {
689 case "inject_message":
690 return await executeInject(env, adapter, command, settings, attempt, attempts);
691 case "send_message":
692 return await executeSend(env, adapter, command, settings, attempt, attempts);
693 default:
694 throw new DeliveryError("invalid_command", `unsupported delivery command: ${command.command}`);
695 }
696 }
697
698 function shouldRetry(error) {
699 const code = trimToNull(error?.code) || "";
700 return !["invalid_command", "invalid_payload", "page_context_mismatch"].includes(code);
701 }
702
703 function buildSuccessResult(adapter, commandName, result) {
704 return {
705 ok: true,
706 platform: adapter.platform,
707 command: commandName,
708 remoteHandle: trimToNull(result?.remoteHandle) || null,
709 details: {
710 adapter: adapter.label,
711 ...(isRecord(result?.details) ? result.details : {})
712 }
713 };
714 }
715
716 function buildFailureResult(adapter, commandName, error) {
717 const normalized = normalizeDeliveryError(error, {
718 command: commandName,
719 platform: adapter?.platform || null
720 });
721
722 return {
723 ok: false,
724 platform: adapter?.platform || null,
725 command: commandName,
726 code: normalized.code,
727 reason: formatFailureReason(normalized),
728 details: normalized.details
729 };
730 }
731
732 function createDeliveryRuntime(options = {}) {
733 const env = createBrowserEnv(options.env || {});
734
735 return {
736 async handleCommand(command = {}) {
737 const commandName = trimToNull(command?.command);
738 if (!commandName) {
739 return {
740 ok: false,
741 command: null,
742 platform: trimToNull(command?.platform),
743 code: "invalid_command",
744 reason: "delivery.invalid_command: delivery command is required",
745 details: {}
746 };
747 }
748
749 const adapter = getPlatformAdapter(command?.platform);
750 if (!adapter) {
751 return {
752 ok: false,
753 command: commandName,
754 platform: trimToNull(command?.platform),
755 code: "unsupported_platform",
756 reason: `delivery.unsupported_platform: unsupported delivery platform: ${trimToNull(command?.platform) || "-"}`,
757 details: {}
758 };
759 }
760
761 const settings = resolveAttemptSettings(command);
762 let lastError = null;
763
764 for (let attempt = 1; attempt <= settings.attempts; attempt += 1) {
765 try {
766 const result = await executeCommandAttempt(env, adapter, command, settings, attempt, settings.attempts);
767 return buildSuccessResult(adapter, commandName, result);
768 } catch (error) {
769 lastError = normalizeDeliveryError(error, {
770 attempt,
771 attempts: settings.attempts,
772 command: commandName,
773 platform: adapter.platform
774 });
775
776 if (attempt < settings.attempts && shouldRetry(lastError)) {
777 await env.sleep(settings.retryDelayMs);
778 continue;
779 }
780 }
781 }
782
783 return buildFailureResult(adapter, commandName, lastError);
784 }
785 };
786 }
787
788 const api = {
789 createDeliveryRuntime,
790 getPlatformAdapter,
791 listPlatformAdapters
792 };
793
794 if (typeof module !== "undefined" && module.exports) {
795 module.exports = api;
796 }
797
798 globalScope.BAADeliveryAdapters = api;
799})(typeof globalThis !== "undefined" ? globalThis : this);