Explorar el Código

Merge branch 'master' into una/modules-updates-for-15.0.0-rc3

Anton hace 17 horas
padre
commit
4b16d82139

+ 3 - 2
inc/classes/BxDolAIToolChatButtons.php

@@ -33,7 +33,7 @@ class BxDolAIToolChatButtons extends BxDolAITool
         return [
             new ArrayProperty(
                 name: 'buttons',
-                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration); form = opens a site form in a popup over the chat, form_url comes from a tool result only (never made up), after the form is saved the "message" text is sent as the visitor next message. Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
+                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration), with check_url and message from a tool result the chat waits for it and sends message by itself; form = opens a site form in a popup over the chat, form_url comes from a tool result only (never made up), after the form is saved the "message" text is sent as the visitor next message. Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
                 required: true,
                 items: new ObjectProperty(
                     name: 'button',
@@ -42,7 +42,8 @@ class BxDolAIToolChatButtons extends BxDolAITool
                         new ToolProperty('label', PropertyType::STRING, 'Button text shown to the visitor', true),
                         new ToolProperty('url', PropertyType::STRING, 'Required for link. https URL.', false),
                         new ToolProperty('form_url', PropertyType::STRING, 'Required for form. Exactly the form_url from a tool result, like "module/method/&params[]=1".', false),
-                        new ToolProperty('message', PropertyType::STRING, 'For form. Sent as the visitor message after the form is saved, in the site language, e.g. "DigitalOcean is connected".', false),
+                        new ToolProperty('check_url', PropertyType::STRING, 'Optional for link, together with message. Exactly the check_url from a tool result, like "module/method/&params[]=1": the chat polls it after the link is opened and sends message once it is done.', false),
+                        new ToolProperty('message', PropertyType::STRING, 'For form, and for link with check_url. Sent as the visitor message after the form is saved or the check_url reports done, in the site language, e.g. "DigitalOcean is connected".', false),
                     ]
                 ),
                 minItems: 1,

+ 30 - 5
inc/classes/BxDolAiChatUi.php

@@ -337,16 +337,24 @@ class BxDolAiChatUi
                 $sUrl = trim((string)($aItem['url'] ?? ''));
                 if (!$this->isAllowedChatActionUrl($sUrl))
                     continue;
-                $aOut[] = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+                $aLink = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+                // a link the chat waits for (e.g. OAuth): it polls check_url and sends message when done
+                $sCheckUrl = trim((string)($aItem['check_url'] ?? ''));
+                $sMessage = $this->sanitizeChatActionMessage($aItem['message'] ?? '');
+                if ($sMessage !== '' && $this->isAllowedChatActionServiceUrl($sCheckUrl)) {
+                    $aLink['check_url'] = $sCheckUrl;
+                    $aLink['message'] = $sMessage;
+                }
+                $aOut[] = $aLink;
             } elseif ($sType === 'form') {
-                // form opened in a popup over the chat: 'module/method/&params[]=..' API service call
+                // form opened in a popup over the chat
                 $sFormUrl = trim((string)($aItem['form_url'] ?? ''));
-                if (!preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', $sFormUrl))
+                if (!$this->isAllowedChatActionServiceUrl($sFormUrl))
                     continue;
                 $aForm = ['type' => 'form', 'label' => $sLabel, 'form_url' => $sFormUrl];
-                $sMessage = trim(strip_tags((string)($aItem['message'] ?? '')));
+                $sMessage = $this->sanitizeChatActionMessage($aItem['message'] ?? '');
                 if ($sMessage !== '')
-                    $aForm['message'] = function_exists('mb_substr') ? mb_substr($sMessage, 0, 200) : substr($sMessage, 0, 200);
+                    $aForm['message'] = $sMessage;
                 $aOut[] = $aForm;
             }
 
@@ -512,6 +520,23 @@ class BxDolAiChatUi
         return strtolower((string)$mixed);
     }
 
+    /**
+     * API service call of a chat action: 'module/method/&params[]=..', nothing else.
+     */
+    protected function isAllowedChatActionServiceUrl($sUrl)
+    {
+        return (bool)preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', (string)$sUrl);
+    }
+
+    /**
+     * Text a chat action sends as the visitor answer: plain, one line, max 200 chars.
+     */
+    protected function sanitizeChatActionMessage($mixed)
+    {
+        $s = trim(strip_tags((string)$mixed));
+        return get_mb_substr($s, 0, 200);
+    }
+
     protected function isAllowedChatActionUrl($sUrl)
     {
         $a = parse_url((string)$sUrl);

+ 1 - 1
inc/version.inc.php

@@ -7,6 +7,6 @@
  * @{
  */
 
-define('BX_DOL_VERSION', '15.0.0-RC2'); ///< system files version, for DB version @see bx_get_ver, DB and files version must match to
+define('BX_DOL_VERSION', '15.0.0-RC3'); ///< system files version, for DB version @see bx_get_ver, DB and files version must match to
 
 /** @} */

+ 1 - 1
install/index.php

@@ -10,7 +10,7 @@
 define ('BX_DOL_INSTALL',  true);
 define ('BX_SKIP_INSTALL_CHECK', true);
 
-define ('BX_DOL_VER', '15.0.0-RC2');
+define ('BX_DOL_VER', '15.0.0-RC3');
 
 define ('BX_INSTALL_DEFAULT_LANGUAGE', 'en');
 

+ 2 - 2
modules/boonex/messenger/install/config.php

@@ -17,12 +17,12 @@ $aConfig = array(
 	'name' => 'bx_messenger',
 	'title' => 'Messenger',
 	'note' => 'Messenger, instant messages, chat',
-	'version' => '14.0.0.DEV',
+	'version' => '15.0.0.DEV',
 	'vendor' => 'BoonEx',
 	'help_url' => 'http://feed.una.io/?section={module_name}',
 
 	'compatible_with' => array(
-		'14.0.0'
+		'15.0.x'
 	),
 
 	/**

+ 103 - 19
neo/packages/app/components/elements/entity-cover.js

@@ -1,35 +1,119 @@
-import { View } from 'app/design/view';
-import Image from 'app/ui/atoms/image';
-import { Text } from 'app/design/typography';
-import { CoverMenuMeta, CoverMenu } from 'app/components/elements/covers/menu-cover';
-import { stripTags } from 'app/lib/util'
+import { View } from 'app/design/view'
+import Image from 'app/ui/atoms/image'
+import { Text } from 'app/design/typography'
+import { CoverMenuMeta, CoverMenu } from 'app/components/elements/covers/menu-cover'
+import { appSetting, stripTags, LAYOUT_BREAKPOINTS } from 'app/lib/util'
 import { BlockWrapper } from 'app/components/block-wrapper'
+import { NeoButton } from 'app/design/controls/neo-button/neo-button'
+import { useProfileImageUpload } from 'app/lib/image/use-profile-image-upload'
+import { getCoverAspectClass } from 'app/lib/image/upload-settings'
+import Loading from 'app/ui/atoms/loading'
+import { useCurrentUser } from 'app/context/user'
+
+/**
+ * entity_cover with the page-header cover's upload: the same crop, camera
+ * button and `useProfileImageUpload` flow. The header hides that button when
+ * `cover.view_by_module` is `min` (projects), so the block is where the cover
+ * is changed.
+ *
+ * UNA puts `allow_edit` on `cover_block`, not on this block. The edit-profile
+ * action is the same check (`checkAllowedEdit`: owner account or moderator).
+ */
+function canChangeCover(data, currentUser) {
+    if (!appSetting('cover', 'allow_edit')) return false
+    if (data?.allow_edit) return true
+    if (menuHasEditProfile(data?.actions_menu)) return true
+    if (!currentUser) return false
+    const profileId = data?.profile?.id
+    if (profileId != null && String(currentUser.id) === String(profileId)) return true
+    return !!(currentUser.moderator || currentUser.operator)
+}
+
+function menuHasEditProfile(menu) {
+    const items = Array.isArray(menu?.items) ? menu.items : []
+    for (const item of items) {
+        const name = String(item?.name || '')
+        const link = String(item?.link || item?.url || '')
+        if (/^edit-.+-profile$/.test(name) || /edit-.+-profile/.test(link)) return true
+        if (item?.list && menuHasEditProfile({ items: item.list })) return true
+    }
+    return false
+}
+
+export default function EntityCover({ data, block, blockWrapperProps, uri }) {
+    const { currentUser } = useCurrentUser()
+    const moduleName = data?.profile?.module || block?.module || ''
+    const profile = data?.profile
+    const allowEdit = canChangeCover(data, currentUser)
+    const { imageUrl, isUploading, pick, modal } = useProfileImageUpload({
+        kind: 'cover',
+        profileId: data?.id || profile?.info?.id || 0,
+        moduleName,
+        storage: data?.cover?.storage || '',
+        initialUrl: data?.cover?.src || '',
+        isProfile: true,
+    })
+
+    const showPicture = moduleName === 'bx_persons'
+        || (!!moduleName && !!appSetting('cover', 'show_pic_by_module', moduleName))
+    const profileTitle = profile?.display_name || profile?.title || ''
+    const showCover = !!imageUrl || allowEdit
 
-export default function ({ data, blockWrapperProps, uri }) {
     return (
         <BlockWrapper {...blockWrapperProps}>
-            {!!data.cover && <View className="w-full h-28 ">
-                <Image alt={data.fullname} view="cover" src={data.cover.src} priority />
-            </View>}
+            {showCover ? (
+                <View className={`relative w-full overflow-hidden rounded-t-2xl bg-accent/50 ${getCoverAspectClass(moduleName, true)}`}>
+                    {imageUrl ? (
+                        <Image
+                            alt={profileTitle}
+                            view="cover"
+                            sizes={LAYOUT_BREAKPOINTS.xl}
+                            className="u-cover"
+                            src={imageUrl}
+                            priority
+                        />
+                    ) : null}
+                    {imageUrl?.includes('data:') || isUploading ? (
+                        <View className="absolute inset-0 h-full w-full opacity-60 bg-card justify-center items-center">
+                            <Loading />
+                        </View>
+                    ) : null}
+                    {allowEdit ? (
+                        <View className="absolute top-3 right-3">
+                            <NeoButton
+                                image="Camera"
+                                style="glass"
+                                controlSize="regular"
+                                borderShape="circle"
+                                disabled={isUploading}
+                                onPress={pick}
+                            />
+                        </View>
+                    ) : null}
+                </View>
+            ) : null}
             <View className="p-4 w-full mx-auto bg-card h-min overflow-hidden rounded-2xl shadow-sm">
-                {!!data.image && <View className="w-28 h-28 overflow-hidden bg-background   rounded-full  "><Image alt={data.fullname} className="rounded-full" view="cover" src={data.image.src} priority={!data.cover} /></View>}
-                <View className="flex-row flex-wrap ">
-                    <View className=" flex-auto">
-                        <Text className="text-2xl pt-4 font-bold text-secondary-foreground ">
-                            {data.fullname || data.space_name || data.name}
+                {showPicture && !!data.image?.src ? (
+                    <View className="w-28 h-28 overflow-hidden bg-background rounded-full">
+                        <Image alt={profileTitle} className="rounded-full" view="cover" src={data.image.src} priority={!imageUrl} />
+                    </View>
+                ) : null}
+                <View className="flex-row flex-wrap">
+                    <View className="flex-auto">
+                        <Text className="text-2xl pt-4 font-bold text-secondary-foreground">
+                            {profileTitle}
                         </Text>
-
                     </View>
                 </View>
                 <View className="text-center flex-row py-3 gap-3 items-center">
                     <CoverMenuMeta {...data.meta_menu} />
                 </View>
                 <View className="w-full">
-                    <CoverMenu  isSplitMenu={false} size='sm' {...data.actions_menu} uri={uri} />
-                  
+                    <CoverMenu isSplitMenu={false} size="sm" {...data.actions_menu} uri={uri} />
                 </View>
-                <Text numberOfLines={3} className='mt-2 text-base text-muted-foreground '>{stripTags(data.description || data.space_desc)}</Text>
+                <Text numberOfLines={3} className="mt-2 text-base text-muted-foreground">{stripTags(data.description || data.space_desc)}</Text>
             </View>
+            {modal}
         </BlockWrapper>
-    );
+    )
 }

+ 0 - 128
neo/packages/app/ui/molecules/ai-agent/chat-actions.js

@@ -1,128 +0,0 @@
-'use client';
-
-import { memo, useCallback, useEffect, useState } from 'react';
-import { View } from 'app/design/view';
-import { NeoButton, NeoButtonLink, Modal } from 'app/design/controls';
-import { fetcher } from 'app/lib/fetcher';
-
-/**
- * The quick-reply / link / form buttons an assistant message can carry.
- *
- * Selection has one authority: `chosenReply`, derived by the parent from the user
- * message that actually followed this bubble. `pending` is only an optimistic bridge
- * covering the round-trip, and it is cleared as soon as the transcript confirms the
- * choice or the send reports failure — so a failed send can no longer leave a button
- * highlighted as if it had been answered.
- *
- * A form button opens the site form (`/api.php?r={formUrl}`) in a popup over the chat,
- * like the deploy block does; once the form is saved the popup closes and the action
- * `message` is sent as the user's answer, so the agent goes on by itself.
- */
-function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' }) {
-    const [pending, setPending] = useState('');
-    const [form, setForm] = useState(null);
-    const [opening, setOpening] = useState(false);
-    const selected = chosenReply || pending;
-
-    useEffect(() => {
-        // The transcript now records the answer; the optimistic copy has no job left.
-        if (chosenReply) setPending('');
-    }, [chosenReply]);
-
-    const press = useCallback(async (message) => {
-        setPending(message);
-        // onReply resolves false when the message never made it into the transcript.
-        const sent = await onReply?.(message);
-        if (!sent) setPending('');
-    }, [onReply]);
-
-    const openForm = useCallback(async (action) => {
-        setOpening(true);
-        try {
-            // Loaded on demand: the block renderer imports the component registry,
-            // which in turn imports this chat.
-            const [{ BlockByDataInt }, response] = await Promise.all([
-                import('app/components/block'),
-                fetcher(`/api.php?r=${action.formUrl}`),
-            ]);
-            setForm({
-                Block: BlockByDataInt,
-                block: { content: response?.data, designbox_id: 0, title: action.label },
-                message: action.message,
-            });
-        } finally {
-            setOpening(false);
-        }
-    }, []);
-
-    const closeForm = useCallback(() => setForm(null), []);
-
-    // The form calls onFormEmpty once the server answered the submit with "done";
-    // closing the popup with its X does not, so only a saved form sends the answer.
-    const completeForm = useCallback(() => {
-        const message = form?.message;
-        setForm(null);
-        if (message && repliesEnabled) void press(message);
-    }, [form, press, repliesEnabled]);
-
-    if (!actions.length) return null;
-
-    return (
-        <View className="flex-row flex-wrap gap-2 mt-2 max-w-full">
-            {actions.map((action, index) => {
-                const isLink = action.type === 'link';
-                const isForm = action.type === 'form';
-                const isSelected = !isLink && !!action.message && action.message === selected;
-                // Index is part of the key because an agent may legitimately repeat a
-                // label within one message ("Other", "Other").
-                const key = `${action.type}:${action.label}:${index}`;
-                const buttonProps = {
-                    label: action.label,
-                    style: isSelected ? 'glassProminent' : 'bordered',
-                    controlSize: 'small',
-                    borderShape: 'rounded',
-                };
-                if (isLink) {
-                    // Target vetting happens in messageActions(); NeoButtonLink routes
-                    // internal hrefs through the SPA router and external ones to the
-                    // in-app browser.
-                    return <NeoButtonLink key={key} {...buttonProps} href={action.url} target="_blank" />;
-                }
-                if (isForm) {
-                    // Like a link, a form stays usable after the turn is over; only the
-                    // answer it sends on save needs the turn to be open.
-                    return (
-                        <NeoButton
-                            key={key}
-                            {...buttonProps}
-                            selected={isSelected}
-                            disabled={opening}
-                            onPress={() => void openForm(action)}
-                        />
-                    );
-                }
-                return (
-                    <NeoButton
-                        key={key}
-                        {...buttonProps}
-                        selected={isSelected}
-                        // The answered option stays enabled so it keeps reading as the
-                        // choice that was made; the alternatives lock once the turn is over.
-                        disabled={!isSelected && !repliesEnabled}
-                        onPress={() => {
-                            if (!repliesEnabled || isSelected) return;
-                            void press(action.message);
-                        }}
-                    />
-                );
-            })}
-            {form && (
-                <Modal scrollable={true} title={form.block.title} onVisible={true} onClose={closeForm}>
-                    <form.Block block={form.block} onFormEmpty={completeForm} />
-                </Modal>
-            )}
-        </View>
-    );
-}
-
-export const ChatActions = memo(ChatActionsImpl);

+ 216 - 0
neo/packages/app/ui/molecules/ai-agent/chat-actions.tsx

@@ -0,0 +1,216 @@
+'use client';
+
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { View } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { NeoButton, NeoButtonLink, Modal } from 'app/design/controls';
+import { fetcher } from 'app/lib/fetcher';
+import { useTranslation } from 'react-i18next';
+
+// A link the chat waits for (e.g. OAuth in another tab): poll every 3 s, give up after 10 min.
+const WATCH_INTERVAL = 3000;
+const WATCH_TIMEOUT = 10 * 60 * 1000;
+
+export type ChatAction = {
+    type: 'reply' | 'link' | 'form';
+    label: string;
+    message?: string;
+    url?: string;
+    checkUrl?: string;
+    formUrl?: string;
+};
+
+type ChatActionsProps = {
+    actions: ChatAction[];
+    onReply?: (message: string) => Promise<boolean> | boolean;
+    repliesEnabled: boolean;
+    chosenReply?: string;
+};
+
+type OpenForm = {
+    Block: any;
+    block: { content: any; designbox_id: number; title: string };
+    message?: string;
+};
+
+type Watch = { checkUrl: string; message?: string; until: number };
+
+/**
+ * The quick-reply / link / form buttons an assistant message can carry.
+ *
+ * Selection has one authority: `chosenReply`, derived by the parent from the user
+ * message that actually followed this bubble. `pending` is only an optimistic bridge
+ * covering the round-trip, cleared once the transcript confirms the choice or the
+ * send reports failure.
+ *
+ * A form button opens the site form (`/api.php?r={formUrl}`) in a popup over the chat;
+ * once the form is saved the popup closes and the action `message` is sent.
+ *
+ * A link with `checkUrl` opens as usual, then the chat polls `/api.php?r={checkUrl}`
+ * and sends the link `message` itself once it answers `done`.
+ */
+function ChatActionsImpl({ actions, onReply, repliesEnabled, chosenReply = '' }: Readonly<ChatActionsProps>) {
+    const { t } = useTranslation();
+    const [pending, setPending] = useState('');
+    const [form, setForm] = useState<OpenForm | null>(null);
+    const [opening, setOpening] = useState(false);
+    const [formError, setFormError] = useState('');
+    // Several buttons may send the same answer: only the pressed one reads as chosen.
+    const [pressedIndex, setPressedIndex] = useState<number | null>(null);
+    const selected = chosenReply || pending;
+
+    useEffect(() => {
+        if (chosenReply) setPending('');
+    }, [chosenReply]);
+
+    const press = useCallback(async (message: string) => {
+        setPending(message);
+        const sent = await onReply?.(message);
+        if (!sent) setPending('');
+    }, [onReply]);
+
+    const openForm = useCallback(async (action: ChatAction) => {
+        setOpening(true);
+        setFormError('');
+        try {
+            // Loaded on demand: the block renderer imports the component registry,
+            // which in turn imports this chat.
+            const [{ BlockByDataInt }, response] = await Promise.all([
+                import('app/components/block'),
+                fetcher(`/api.php?r=${action.formUrl}`),
+            ]);
+            if (!response?.data) throw new Error('empty form');
+            setForm({
+                Block: BlockByDataInt,
+                block: { content: response.data, designbox_id: 0, title: action.label },
+                message: action.message,
+            });
+        } catch {
+            // The button stays enabled, so pressing it again retries.
+            setFormError(t('Something went wrong'));
+        } finally {
+            setOpening(false);
+        }
+    }, [t]);
+
+    const closeForm = useCallback(() => setForm(null), []);
+
+    // Latest press / repliesEnabled for the polling loop, which outlives renders.
+    const pressRef = useRef(press);
+    const repliesEnabledRef = useRef(repliesEnabled);
+    useEffect(() => {
+        pressRef.current = press;
+        repliesEnabledRef.current = repliesEnabled;
+    }, [press, repliesEnabled]);
+
+    const [watch, setWatch] = useState<Watch | null>(null);
+
+    useEffect(() => {
+        if (!watch) return undefined;
+
+        // Chained timeouts instead of an interval: the next check starts only after
+        // the previous one answered, so slow responses never overlap.
+        let stopped = false;
+        let timer: ReturnType<typeof setTimeout> | undefined;
+        const check = async () => {
+            if (stopped) return;
+            if (Date.now() > watch.until) {
+                setWatch(null);
+                return;
+            }
+            try {
+                const response = await fetcher(`/api.php?r=${watch.checkUrl}`);
+                if (stopped) return;
+                if (response?.data?.done) {
+                    stopped = true;
+                    setWatch(null);
+                    if (watch.message && repliesEnabledRef.current) void pressRef.current(watch.message);
+                    return;
+                }
+            } catch {
+                // Network hiccup: keep waiting until the timeout.
+            }
+            if (!stopped) timer = setTimeout(check, WATCH_INTERVAL);
+        };
+        timer = setTimeout(check, WATCH_INTERVAL);
+
+        return () => {
+            stopped = true;
+            if (timer) clearTimeout(timer);
+        };
+    }, [watch]);
+
+    // The form calls onFormEmpty once the server answered the submit with "done";
+    // closing the popup with its X does not, so only a saved form sends the answer.
+    const completeForm = useCallback(() => {
+        const message = form?.message;
+        setForm(null);
+        if (message && repliesEnabled) void press(message);
+    }, [form, press, repliesEnabled]);
+
+    if (!actions.length) return null;
+
+    const firstSelectedIndex = selected ? actions.findIndex((action) => action.message === selected) : -1;
+
+    return (
+        <View className="flex-row flex-wrap gap-2 mt-2 max-w-full">
+            {actions.map((action, index) => {
+                const isSelected = !!action.message && action.message === selected
+                    && (pressedIndex !== null && actions[pressedIndex]?.message === selected ? pressedIndex === index : firstSelectedIndex === index);
+                // Index is part of the key: an agent may repeat a label ("Other", "Other").
+                const key = `${action.type}:${action.label}:${index}`;
+                const buttonProps = {
+                    label: action.label,
+                    style: isSelected ? 'glassProminent' : 'bordered',
+                    controlSize: 'small',
+                    borderShape: 'rounded',
+                };
+                if (action.type === 'link') {
+                    const checkUrl = action.checkUrl;
+                    const startWatch = checkUrl
+                        ? () => {
+                            setPressedIndex(index);
+                            setWatch({ checkUrl, message: action.message, until: Date.now() + WATCH_TIMEOUT });
+                        }
+                        : undefined;
+                    return <NeoButtonLink key={key} {...buttonProps} href={action.url} target="_blank" onPress={startWatch} />;
+                }
+                if (action.type === 'form') {
+                    return (
+                        <NeoButton
+                            key={key}
+                            {...buttonProps}
+                            selected={isSelected}
+                            disabled={opening}
+                            onPress={() => {
+                                setPressedIndex(index);
+                                void openForm(action);
+                            }}
+                        />
+                    );
+                }
+                return (
+                    <NeoButton
+                        key={key}
+                        {...buttonProps}
+                        selected={isSelected}
+                        disabled={!isSelected && !repliesEnabled}
+                        onPress={() => {
+                            if (!repliesEnabled || isSelected || !action.message) return;
+                            setPressedIndex(index);
+                            void press(action.message);
+                        }}
+                    />
+                );
+            })}
+            {!!formError && <Text className="w-full text-destructive text-sm">{formError}</Text>}
+            {form && (
+                <Modal scrollable={true} title={form.block.title} onVisible={true} onClose={closeForm}>
+                    <form.Block block={form.block} onFormEmpty={completeForm} />
+                </Modal>
+            )}
+        </View>
+    );
+}
+
+export const ChatActions = memo(ChatActionsImpl);

+ 12 - 2
neo/packages/app/ui/molecules/ai-agent/message-content.js

@@ -231,7 +231,8 @@ function safeActionUrl(raw) {
 }
 
 /**
- * Normalize a message's actions into `{ type, label, url }` / `{ type, label, message }` /
+ * Normalize a message's actions into `{ type, label, url }` (plus `checkUrl`, `message`
+ * for a link the chat waits for) / `{ type, label, message }` /
  * `{ type: 'form', label, formUrl, message }`.
  * `streamedActions` (from the live CUSTOM chunk) wins over whatever is on the
  * message, because the message copy may not have been persisted yet.
@@ -259,7 +260,16 @@ export function messageActions(message, streamedActions) {
             // but whose target we rejected must be dropped, never silently downgraded
             // to a reply that would push the label into the chat as if the user typed it.
             const isLink = action.type === 'link' || (!action.type && !!rawUrl);
-            if (isLink) return url ? { type: 'link', label, url } : null;
+            if (isLink) {
+                if (!url) return null;
+                // Optional "wait for it" link (e.g. OAuth): after the press the chat polls
+                // `check_url` and sends `message` itself once the server reports it done.
+                const checkUrl = String(action.check_url || '').trim();
+                const linkMessage = String(action.message || '').trim();
+                return SAFE_FORM_URL.test(checkUrl) && linkMessage
+                    ? { type: 'link', label, url, checkUrl, message: linkMessage }
+                    : { type: 'link', label, url };
+            }
             return { type: 'reply', label, message: String(action.message || label).trim() || label };
         })
         .filter(Boolean);

+ 4 - 12
neo/packages/app/ui/molecules/sections/conductor/helpers.js

@@ -737,12 +737,6 @@ function mapLayoutBlocks(items = [], { browseAsBlocks = false } = {}) {
     return { content, endpoint: payload && designbox ? { ...payload, designbox } : payload }
 }
 
-/** Block whose content is an entity cover (`entity_cover` element). */
-function isEntityCoverBlock(item) {
-    return Array.isArray(item?.content)
-        && item.content.some((entry) => entry?.type === 'entity_cover')
-}
-
 /**
  * Build content/endpoint/sidebar/leftbar from layout_parsed page data.
  *
@@ -751,17 +745,15 @@ function isEntityCoverBlock(item) {
  * the list (marked `afterList`, see buildConductorListRows). The list is the
  * center's browse block; the top's or the bottom's only when the center has
  * none (the person profile keeps its timeline in the top area).
+ *
+ * An `entity_cover` block stays in its cell. The page header cover is a
+ * separate payload, `cover_block`, and does not replace this block.
  */
 function processParsedUrl(data) {
     const elements = data?.elements || {}
-    // A profile page's cover comes from `cover_block` (the Conductor header);
-    // an entity cover block in any cell would draw it a second time.
     const cell = (name, options) => {
         const items = Array.isArray(elements[name]) ? elements[name] : []
-        return mapLayoutBlocks(
-            data?.cover_block?.profile ? items.filter((item) => !isEntityCoverBlock(item)) : items,
-            options
-        )
+        return mapLayoutBlocks(items, options)
     }
     const cellTop = cell('cell_top')
     const cellCenter = cell('cell_center')

+ 99 - 0
tests/units/inc/classes/BxDolAiChatUiTest.php

@@ -0,0 +1,99 @@
+<?php
+
+/**
+ * Chat action buttons the agent asks for: only safe types, URLs and texts reach the chat.
+ */
+class BxDolAiChatUiTest extends \PHPUnit\Framework\TestCase
+{
+    private function sanitize($aActions)
+    {
+        return BxDolAiChatUi::getInstance()->sanitizeChatActions($aActions);
+    }
+
+    public function testRejectsBadInput()
+    {
+        $this->assertSame([], $this->sanitize('reply'));
+        $this->assertSame([], $this->sanitize([
+            'not an array',
+            ['type' => 'reply', 'label' => '  '],
+            ['type' => 'script', 'label' => 'X'],
+        ]));
+    }
+
+    public function testReplyAndLink()
+    {
+        $this->assertSame([
+            ['type' => 'reply', 'label' => 'Yes'],
+            ['type' => 'link', 'label' => 'Help', 'url' => 'https://una.io/help'],
+        ], $this->sanitize([
+            ['type' => 'REPLY', 'label' => ' Yes '],
+            ['type' => 'link', 'label' => 'Help', 'url' => 'https://una.io/help'],
+            ['type' => 'link', 'label' => 'Http', 'url' => 'http://una.io/'],
+            ['type' => 'link', 'label' => 'Js', 'url' => 'javascript:alert(1)'],
+            ['type' => 'link', 'label' => 'Local', 'url' => 'https://localhost/'],
+            ['type' => 'link', 'label' => 'Private', 'url' => 'https://10.0.0.5/'],
+        ]));
+    }
+
+    public function testLinkCheckUrlNeedsMessageAndServiceUrl()
+    {
+        $aOut = $this->sanitize([
+            ['type' => 'link', 'label' => 'A', 'url' => 'https://a.test/', 'check_url' => 'bx_projects/get_provider_connected/&params[]=aws&params[]=1', 'message' => 'AWS is connected'],
+            ['type' => 'link', 'label' => 'B', 'url' => 'https://a.test/', 'check_url' => 'bx_projects/get_provider_connected'],
+            ['type' => 'link', 'label' => 'C', 'url' => 'https://a.test/', 'check_url' => 'https://evil.test/x', 'message' => 'm'],
+            ['type' => 'link', 'label' => 'D', 'url' => 'https://a.test/', 'check_url' => 'a/b/&params[]=<x>', 'message' => 'm'],
+        ]);
+
+        $this->assertSame('bx_projects/get_provider_connected/&params[]=aws&params[]=1', $aOut[0]['check_url']);
+        $this->assertSame('AWS is connected', $aOut[0]['message']);
+        foreach ([1, 2, 3] as $i) {
+            $this->assertArrayNotHasKey('check_url', $aOut[$i]);
+            $this->assertArrayNotHasKey('message', $aOut[$i]);
+        }
+    }
+
+    public function testForm()
+    {
+        $this->assertSame([
+            ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=2', 'message' => 'AWS is connected'],
+            ['type' => 'form', 'label' => 'No message', 'form_url' => 'bx_projects/get_block_sr_provider_add'],
+        ], $this->sanitize([
+            ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=2', 'message' => '<b>AWS</b> is connected'],
+            ['type' => 'form', 'label' => 'No message', 'form_url' => 'bx_projects/get_block_sr_provider_add', 'message' => ' '],
+            ['type' => 'form', 'label' => 'Bad', 'form_url' => '../../admin'],
+            ['type' => 'form', 'label' => 'Bad', 'form_url' => 'https://evil.test/a/b'],
+            ['type' => 'form', 'label' => 'Missing'],
+        ]));
+    }
+
+    public function testMergeFormActions()
+    {
+        $oChat = BxDolAiChatUi::getInstance();
+        $aKeys = ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=2'];
+        $aRole = ['type' => 'form', 'label' => 'Add keys', 'form_url' => 'bx_projects/get_block_sr_provider_add/&params[]=3'];
+        $aBad = ['type' => 'form', 'label' => 'Bad', 'form_url' => 'https://evil.test/a/b'];
+
+        // same label and form_url is one button, another form_url is another one
+        $this->assertSame([$aKeys, $aRole], $oChat->mergeChatActions([$aKeys, $aBad], [$aKeys, $aRole]));
+
+        // a form never merges with a link of the same label
+        $aLink = ['type' => 'link', 'label' => 'Add keys', 'url' => 'https://a.test/'];
+        $this->assertCount(2, $oChat->mergeChatActions([$aKeys], [$aLink]));
+
+        $this->assertCount(8, $oChat->mergeChatActions(
+            array_map(fn($i) => ['type' => 'form', 'label' => 'F' . $i, 'form_url' => 'a/b'], range(1, 6)),
+            array_map(fn($i) => ['type' => 'reply', 'label' => 'R' . $i], range(1, 6))
+        ));
+    }
+
+    public function testLimits()
+    {
+        $aOut = $this->sanitize([
+            ['type' => 'form', 'label' => str_repeat('я', 100), 'form_url' => 'a/b', 'message' => str_repeat('я', 300)],
+        ]);
+        $this->assertSame(80, mb_strlen($aOut[0]['label']));
+        $this->assertSame(200, mb_strlen($aOut[0]['message']));
+
+        $this->assertCount(8, $this->sanitize(array_fill(0, 12, ['type' => 'reply', 'label' => 'x'])));
+    }
+}

+ 63 - 0
tests/units/install/ModuleUpdatePacksTest.php

@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * Module update packs (modules/<vendor>/<module>/updates/<from>_<to>/) must name the module they update the way the
+ * installed module is recorded. BxDolStudioUpdater finds the module by `module_dir` and `module_uri`, and builds the
+ * updater class name from `class_prefix`, so a pack that differs from the module's own config fails on every site.
+ */
+class ModuleUpdatePacksTest extends \PHPUnit\Framework\TestCase
+{
+    private static function modulesPath(): string
+    {
+        return __DIR__ . '/../../../modules/';
+    }
+
+    private static function readConfig(string $sPath): array
+    {
+        $aConfig = null;
+        include($sPath);
+        if (!is_array($aConfig))
+            self::fail(substr($sPath, strlen(self::modulesPath())) . ' does not define $aConfig.');
+
+        return $aConfig;
+    }
+
+    public function testPacksMatchTheirModule(): void
+    {
+        $sModules = self::modulesPath();
+        $aPacks = glob($sModules . '*/*/updates/*/install/config.php');
+        $this->assertNotEmpty($aPacks, 'The repository has module update packs.');
+
+        $aModules = [];
+        $aErrors = [];
+        foreach ($aPacks as $sPath) {
+            $sPack = substr(dirname($sPath, 2), strlen($sModules)) . '/';
+            $aPack = self::readConfig($sPath);
+
+            $sModuleDir = $aPack['module_dir'] ?? '';
+            if (strpos($sPack, $sModuleDir . 'updates/') !== 0) {
+                $aErrors[] = "$sPack: module_dir '$sModuleDir' is not the module the pack is in";
+                continue;
+            }
+
+            if (!isset($aModules[$sModuleDir]))
+                $aModules[$sModuleDir] = self::readConfig($sModules . $sModuleDir . 'install/config.php');
+            $aModule = $aModules[$sModuleDir];
+
+            if (($aPack['module_uri'] ?? '') !== $aModule['home_uri'])
+                $aErrors[] = "$sPack: module_uri '" . ($aPack['module_uri'] ?? '') . "' is not the module's home_uri '{$aModule['home_uri']}'";
+
+            if (($aPack['class_prefix'] ?? '') !== $aModule['class_prefix'])
+                $aErrors[] = "$sPack: class_prefix '" . ($aPack['class_prefix'] ?? '') . "' is not the module's '{$aModule['class_prefix']}'";
+
+            $sVersions = ($aPack['version_from'] ?? '') . '_' . ($aPack['version_to'] ?? '');
+            if (basename($sPack) !== $sVersions)
+                $aErrors[] = "$sPack: the folder is not named after version_from and version_to ($sVersions)";
+
+            if (($aPack['home_dir'] ?? '') !== $sModuleDir . 'updates/update_' . $sVersions . '/')
+                $aErrors[] = "$sPack: home_dir '" . ($aPack['home_dir'] ?? '') . "' is not '{$sModuleDir}updates/update_{$sVersions}/'";
+        }
+
+        $this->assertSame([], $aErrors, "Update packs that would not apply:\n" . implode("\n", $aErrors));
+    }
+}