diff --git a/src/background/messenger/api.ts b/src/background/messenger/api.ts index 62deb6aaf4..95a63462d7 100644 --- a/src/background/messenger/api.ts +++ b/src/background/messenger/api.ts @@ -39,6 +39,10 @@ export const waitForTargetByUrl = getMethod("WAIT_FOR_TARGET_BY_URL", bg); export const activatePartnerTheme = getMethod("ACTIVATE_PARTNER_THEME", bg); export const getPartnerPrincipals = getMethod("GET_PARTNER_PRINCIPALS", bg); export const launchAuthIntegration = getMethod("LAUNCH_AUTH_INTEGRATION", bg); +export const setPartnerCopilotData = getNotifier( + "SET_PARTNER_COPILOT_DATA", + bg, +); export const activateTab = getMethod("ACTIVATE_TAB", bg); export const reactivateEveryTab = getNotifier("REACTIVATE_EVERY_TAB", bg); diff --git a/src/background/messenger/registration.ts b/src/background/messenger/registration.ts index d2153ef7a7..e8739e8479 100644 --- a/src/background/messenger/registration.ts +++ b/src/background/messenger/registration.ts @@ -81,6 +81,7 @@ import { deleteCachedAuthData, getCachedAuthData, } from "@/background/auth/authStorage"; +import { setCopilotProcessData } from "@/background/partnerHandlers"; expectContext("background"); @@ -107,6 +108,7 @@ declare global { ACTIVATE_PARTNER_THEME: typeof initPartnerTheme; GET_PARTNER_PRINCIPALS: typeof getPartnerPrincipals; LAUNCH_AUTH_INTEGRATION: typeof launchAuthIntegration; + SET_PARTNER_COPILOT_DATA: typeof setCopilotProcessData; INSTALL_STARTER_BLUEPRINTS: typeof installStarterBlueprints; @@ -181,6 +183,7 @@ export default function registerMessenger(): void { ACTIVATE_PARTNER_THEME: initPartnerTheme, GET_PARTNER_PRINCIPALS: getPartnerPrincipals, LAUNCH_AUTH_INTEGRATION: launchAuthIntegration, + SET_PARTNER_COPILOT_DATA: setCopilotProcessData, INSTALL_STARTER_BLUEPRINTS: installStarterBlueprints, diff --git a/src/background/partnerHandlers.ts b/src/background/partnerHandlers.ts new file mode 100644 index 0000000000..122f6b85f3 --- /dev/null +++ b/src/background/partnerHandlers.ts @@ -0,0 +1,39 @@ +import { getNotifier, type MessengerMeta } from "webext-messenger"; +import { + SET_COPILOT_DATA_MESSAGE_TYPE, + type ProcessDataMap, +} from "@/contrib/automationanywhere/aaFrameProtocol"; + +type SetCopilotDataRequest = { + /** + * Mapping from process ID to data. + */ + data: ProcessDataMap; +}; + +/** + * Message the frame parent of the copilot frame to set data on the copilot form. + * @since 1.8.5 + * @see initCopilotMessenger + */ +export async function setCopilotProcessData( + this: MessengerMeta, + request: SetCopilotDataRequest, +): Promise { + const sourceTabId = this.trace[0].tab.id; + + console.debug("Sending AA Co-Pilot data to frames", { + data: request.data, + }); + + // Can't use browser.webNavigation.getAllFrames because it doesn't return extension frames + // https://github.com/pixiebrix/pixiebrix-extension/pull/7109#discussion_r1424839790 + for (const page of ["/frame.html", "/sidebar.html"]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- receiver not using webext-messenger + const notifier = getNotifier(SET_COPILOT_DATA_MESSAGE_TYPE as any, { + tabId: sourceTabId, + page, + }); + notifier(request.data); + } +} diff --git a/src/bricks/renderers/iframe.ts b/src/bricks/renderers/iframe.ts index 644cd2bc02..1c3c582c8c 100644 --- a/src/bricks/renderers/iframe.ts +++ b/src/bricks/renderers/iframe.ts @@ -38,6 +38,12 @@ export class IFrameRenderer extends RendererABC { type: "string", description: "The title of the IFrame", }, + // The name field for the iframe. Added in 1.8.5 because the AA copilot frame requires it for messaging. + // @since 1.8.5 + name: { + type: "string", + description: "The name of the IFrame", + }, width: { type: "string", description: "The width of the IFrame", @@ -60,12 +66,14 @@ export class IFrameRenderer extends RendererABC { async render({ url, title = "PixieBrix", + name = "", height = "100%", width = "100%", safeMode = false, }: BrickArgs<{ url: string; title?: string; + name?: string; height?: string; width?: string; safeMode?: boolean; @@ -74,14 +82,19 @@ export class IFrameRenderer extends RendererABC { const parsedURL = new URL(url); if (safeMode) { + const namePart = name ? ` name="${name}"` : ""; + return assumeSafe( - ``, + ``, ); } // https://transitory.technology/browser-extensions-and-csp-headers/ const frameURL = browser.runtime.getURL("frame.html"); - const source = `${frameURL}?url=${encodeURIComponent(parsedURL.href)}`; + const namePart = name ? `&name=${encodeURIComponent(name)}` : ""; + const source = `${frameURL}?url=${encodeURIComponent( + parsedURL.href, + )}${namePart}`; return assumeSafe( ``, diff --git a/src/components/fields/fieldUtils.ts b/src/components/fields/fieldUtils.ts index bbdfa06553..da0106c973 100644 --- a/src/components/fields/fieldUtils.ts +++ b/src/components/fields/fieldUtils.ts @@ -44,6 +44,8 @@ const FIELD_TITLE_ACRONYMS = new Set([ "SQL", "UI", "URL", + // Discussion: https://english.stackexchange.com/questions/101248/how-should-the-abbreviation-for-identifier-be-capitalized + "ID", ]); /** diff --git a/src/contentScript/messenger/api.ts b/src/contentScript/messenger/api.ts index 7485aa37fe..2293201f2c 100644 --- a/src/contentScript/messenger/api.ts +++ b/src/contentScript/messenger/api.ts @@ -82,6 +82,8 @@ export const runMapArgs = getMethod("RUN_MAP_ARGS"); export const getPageState = getMethod("GET_PAGE_STATE"); export const setPageState = getMethod("SET_PAGE_STATE"); +export const getCopilotHostData = getMethod("GET_COPILOT_HOST_DATA"); + export const reloadMarketplaceEnhancements = getMethod( "RELOAD_MARKETPLACE_ENHANCEMENTS", ); diff --git a/src/contentScript/messenger/registration.ts b/src/contentScript/messenger/registration.ts index f7562c73f9..2678ba474d 100644 --- a/src/contentScript/messenger/registration.ts +++ b/src/contentScript/messenger/registration.ts @@ -80,6 +80,7 @@ import { reloadActivationEnhancements } from "@/contentScript/loadActivationEnha import { getAttributeExamples } from "@/contentScript/pageEditor/elementInformation"; import { closeWalkthroughModal } from "@/contentScript/walkthroughModalProtocol"; import showWalkthroughModal from "@/components/walkthroughModal/showWalkthroughModal"; +import { getCopilotHostData } from "@/contrib/automationanywhere/SetCopilotDataEffect"; expectContext("contentScript"); @@ -144,6 +145,8 @@ declare global { GET_PAGE_STATE: typeof getPageState; SET_PAGE_STATE: typeof setPageState; + GET_COPILOT_HOST_DATA: typeof getCopilotHostData; + RELOAD_MARKETPLACE_ENHANCEMENTS: typeof reloadActivationEnhancements; } } @@ -212,6 +215,8 @@ export default function registerMessenger(): void { GET_PAGE_STATE: getPageState, SET_PAGE_STATE: setPageState, + GET_COPILOT_HOST_DATA: getCopilotHostData, + RELOAD_MARKETPLACE_ENHANCEMENTS: reloadActivationEnhancements, }); } diff --git a/src/contrib/automationanywhere/SetCopilotDataEffect.ts b/src/contrib/automationanywhere/SetCopilotDataEffect.ts new file mode 100644 index 0000000000..1cfe4df06d --- /dev/null +++ b/src/contrib/automationanywhere/SetCopilotDataEffect.ts @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2023 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { propertiesToSchema } from "@/validators/generic"; +import { validateRegistryId } from "@/types/helpers"; +import { type Schema } from "@/types/schemaTypes"; +import { type BrickArgs } from "@/types/runtimeTypes"; +import { EffectABC } from "@/types/bricks/effectTypes"; +import { type UnknownObject } from "@/types/objectTypes"; +import { setPartnerCopilotData } from "@/background/messenger/api"; +import { isLoadedInIframe } from "@/utils/iframeUtils"; +import { BusinessError } from "@/errors/businessErrors"; + +type ProcessDataMap = Record; + +// Must track host data on content script instead of the frame parent because the frame parent might not be attached +// to the page at the time this data is set. +const hostData = new Map(); + +/** + * Returns the Automation Anywhere Co-Pilot forms data for the current page. + */ +export function getCopilotHostData(): ProcessDataMap { + return Object.fromEntries(hostData.entries()); +} + +/** + * Brick to map data from the host application to Automation Anywhere Co-Pilot forms. + * https://docs.automationanywhere.com/bundle/enterprise-v2019/page/co-pilot-map-host-data.html + * @since 1.8.5 + * @see initCopilotMessenger + */ +export class SetCopilotDataEffect extends EffectABC { + static BRICK_ID = validateRegistryId( + "@pixiebrix/automation-anywhere/set-copilot-data", + ); + + constructor() { + super( + SetCopilotDataEffect.BRICK_ID, + "Map Automation Anywhere Co-Pilot Data", + "Map host data to Automation Co-Pilot forms", + ); + } + + inputSchema: Schema = propertiesToSchema( + { + processId: { + title: "Process ID", + type: ["string", "number"], + description: "The Co-Pilot process ID", + }, + data: { + title: "Form Data", + type: "object", + additionalProperties: true, + description: + "The form data. See documentation [Map host data to Automation Co-Pilot forms](https://docs.automationanywhere.com/bundle/enterprise-v2019/page/co-pilot-map-host-data.html)", + }, + }, + ["processId", "data"], + ); + + async effect({ + processId, + data, + }: BrickArgs<{ + processId: string | number; + data: UnknownObject; + }>): Promise { + if (isLoadedInIframe()) { + // Force the user to use the top-level frame because that's where the copilot protocol will check for data. + throw new BusinessError( + "This brick cannot be used in an iframe. Use target Top-Level Frame.", + ); + } + + hostData.set(String(processId), data); + + setPartnerCopilotData({ + data: Object.fromEntries(hostData.entries()), + }); + } +} + +export default SetCopilotDataEffect; diff --git a/src/contrib/automationanywhere/aaFrameProtocol.ts b/src/contrib/automationanywhere/aaFrameProtocol.ts new file mode 100644 index 0000000000..2ae20a0296 --- /dev/null +++ b/src/contrib/automationanywhere/aaFrameProtocol.ts @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2023 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { type UnknownObject } from "@/types/objectTypes"; +import { expectContext } from "@/utils/expectContext"; +import { getTopLevelFrame } from "webext-messenger"; +import { getCopilotHostData } from "@/contentScript/messenger/api"; + +/** + * Runtime event type for setting Co-Pilot data + */ +export const SET_COPILOT_DATA_MESSAGE_TYPE = "SET_COPILOT_DATA"; + +/** + * Mapping from processId to form data. + */ +export type ProcessDataMap = Record; + +/** + * `window.postMessage` data payload the Co-Pilot frame sends to the host application. + * https://docs.automationanywhere.com/bundle/enterprise-v2019/page/co-pilot-map-host-data.html + */ +type AariDataRequestData = { + aariDataRequest: "aari-data-request"; + processId: string; + // XXX: when would botId be non-null? + botId: string | null; +}; + +/** + * Runtime message to set the Co-Pilot data per process id. + * @see MessengerMessage + */ +export type SetCopilotDataMessage = { + // Follows webext-messenger message format + type: typeof SET_COPILOT_DATA_MESSAGE_TYPE; + target: { + page: string; + }; + args: [ProcessDataMap]; +}; + +/** + * Mapping from processId to form data. + */ +const hostData = new Map(); + +function setHostData(processDataMap: ProcessDataMap): void { + // Maintain the reference so that the listener can access the data + hostData.clear(); + + for (const [processId, data] of Object.entries(processDataMap)) { + hostData.set(processId, data); + } +} + +function isSetCopilotDataMessage( + message?: UnknownObject, +): message is SetCopilotDataMessage { + return message?.type === SET_COPILOT_DATA_MESSAGE_TYPE; +} + +function isMessageTarget(message: SetCopilotDataMessage): boolean { + // Mimic the page filtering of webext-messenger + return message.target.page === window.location.pathname; +} + +function isAariDataRequestData( + data?: UnknownObject, +): data is AariDataRequestData { + return data?.aariDataRequest === "aari-data-request"; +} + +/** + * Initialize the Automation Anywhere Co-Pilot window and runtime messenger. + */ +export async function initCopilotMessenger(): Promise { + expectContext( + "extension", + "should only be run in sidebar or frame tiny page", + ); + + window.addEventListener("message", (event: MessageEvent) => { + if (isAariDataRequestData(event.data)) { + const data = hostData.get(event.data.processId) ?? {}; + + console.debug("Received AARI data request", { + event, + currentProcessData: data, + }); + + // @ts-expect-error -- incorrect types https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#examples + event.source.postMessage({ data }, event.origin); + } + }); + + // Setting the runtime handler directly instead of the messenger to keep this file self-contained + browser.runtime.onMessage.addListener((message: UnknownObject) => { + // Mimic the page filtering of webext-messenger + if (isSetCopilotDataMessage(message) && isMessageTarget(message)) { + console.debug("Setting Co-Pilot data", { + location: window.location.href, + data: message.args[0], + }); + + setHostData(message.args[0]); + } + + // Always return undefined to indicate not handled. That ensures all PixieBrix frames on the page have the context + // necessary to pass to the Co-Pilot frame. + }); + + // Fetch the current data from the content script when the frame loads + const frame = await getTopLevelFrame(); + const data = await getCopilotHostData(frame); + console.debug("Setting initial Co-Pilot data", { + location: window.location.href, + data, + }); + setHostData(data); +} diff --git a/src/contrib/registerContribBlocks.ts b/src/contrib/registerContribBlocks.ts index b43ca07229..447645220b 100644 --- a/src/contrib/registerContribBlocks.ts +++ b/src/contrib/registerContribBlocks.ts @@ -36,6 +36,7 @@ import { RunLocalProcess } from "./uipath/localProcess"; import { PushZap } from "./zapier/push"; import { RunBot } from "./automationanywhere/RunBot"; import { GoogleSheetsLookup } from "@/contrib/google/sheets/bricks/lookup"; +import SetCopilotDataEffect from "@/contrib/automationanywhere/SetCopilotDataEffect"; let registered = false; @@ -80,6 +81,7 @@ function registerContribBlocks(): void { // Automation Anywhere new RunBot(), + new SetCopilotDataEffect(), ]); registered = true; diff --git a/src/development/headers.ts b/src/development/headers.ts index 33c66bd285..4446136470 100644 --- a/src/development/headers.ts +++ b/src/development/headers.ts @@ -25,7 +25,7 @@ import registerBuiltinBlocks from "@/bricks/registerBuiltinBlocks"; import registerContribBlocks from "@/contrib/registerContribBlocks"; // Maintaining this number is a simple way to ensure bricks don't accidentally get dropped -const EXPECTED_HEADER_COUNT = 125; +const EXPECTED_HEADER_COUNT = 126; registerBuiltinBlocks(); registerContribBlocks(); diff --git a/src/sidebar/sidebar.tsx b/src/sidebar/sidebar.tsx index d18a62240b..fe2efdcbcc 100644 --- a/src/sidebar/sidebar.tsx +++ b/src/sidebar/sidebar.tsx @@ -32,6 +32,7 @@ import registerBuiltinBlocks from "@/bricks/registerBuiltinBlocks"; import registerContribBlocks from "@/contrib/registerContribBlocks"; import { initToaster } from "@/utils/notify"; import { initRuntimeLogging } from "@/development/runtimeLogging"; +import { initCopilotMessenger } from "@/contrib/automationanywhere/aaFrameProtocol"; function init(): void { ReactDOM.render(, document.querySelector("#container")); @@ -44,3 +45,6 @@ registerContribBlocks(); registerBuiltinBlocks(); initToaster(); init(); + +// Handle an embedded AA business copilot frame +void initCopilotMessenger(); diff --git a/src/tinyPages/frame.ts b/src/tinyPages/frame.ts index c8a4dfb545..c8c0ddd80b 100644 --- a/src/tinyPages/frame.ts +++ b/src/tinyPages/frame.ts @@ -16,10 +16,20 @@ */ // https://transitory.technology/browser-extensions-and-csp-headers/ -// Load the passed URL into an another iframe to get around the parent page's CSP headers +// Load the passed URL into another iframe to get around the parent page's CSP headers +// Until this is resolved: https://github.com/w3c/webextensions/issues/483 -const frameUrl = new URLSearchParams(window.location.search).get("url"); +import { initCopilotMessenger } from "@/contrib/automationanywhere/aaFrameProtocol"; + +const params = new URLSearchParams(window.location.search); + +const frameUrl = params.get("url"); +const name = params.get("name"); const iframe = document.createElement("iframe"); iframe.src = frameUrl; +iframe.name = name; document.body.append(iframe); + +// Handle an embedded AA Co-Pilot frame +void initCopilotMessenger();