diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index 0158827da..6a89e20ae 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -19,7 +19,7 @@ import { Check, Field, HintLabels, Notice, RadioCards, Select, Switch, TextInput import NameList from './NameList' import PermissionFields, { NoCapsNotice } from './PermissionFields' import { agentsApi, connectorsApi, setApiLanguage } from './api' -import { canSave, channelOnly, connectorForm, hasNoCaps, connectorPayload, emptyConnector, listEntries, replaceList, senderLine, usualCaps } from './connector' +import { DUPLICATE_CONFIRMATION, canSave, channelOnly, duplicateOther, duplicateRefusalName, isDuplicateRefusal, connectorForm, hasNoCaps, connectorPayload, emptyConnector, listEntries, replaceList, senderLine, usualCaps } from './connector' import { registerConnectorLocales } from './i18n' import './connector.css' @@ -67,6 +67,23 @@ function SingleToken({ form, setForm, t }) { ) } +// Two enabled connections that are the same bot: both answer every message. +// The warning names the other one, offers to switch this one off and opens the +// other. The host decides what is a duplicate (c.duplicate); this only shows it. +function SameBotNotice({ c, s, busy, onOff, onOpen }) { + const other = duplicateOther(c) + if (!other) return null + return ( + +

{s('dup.text', { name: other.name })}

+ + + + +
+ ) +} + const ONE_LIST = [{ key: 'allow', label: 'allow', hint: 'allowHint', testId: 'connector-allow' }] /** @@ -102,6 +119,8 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo const [confirmed, setConfirmed] = useState(false) const [busy, setBusy] = useState(false) const [toDelete, setToDelete] = useState(null) + // The question after a save that would make this connection the same bot as another: { other }. + const [dupAsk, setDupAsk] = useState(null) const [openSenders, setOpenSenders] = useState('') const [kindInfo, setKindInfo] = useState(null) const [capabilities, setCapabilities] = useState([]) @@ -162,7 +181,15 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo }, [items]) const agentName = (id) => agents.find((a) => a.id === id)?.name || id - const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote(''); setPairedList(null) } + const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote(''); setPairedList(null); setDupAsk(null) } + // The question sits by the Save button, at the end of a long form: bring it into view. + useEffect(() => { + if (dupAsk) document.querySelector('[data-testid="duplicate-confirm"]')?.scrollIntoView?.({ block: 'nearest' }) + }, [dupAsk]) + const openById = (id) => { + const c = items.find((x) => x.id === id) + if (c) open({ ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) }) + } const extra = (form) => ({ ...(messaging ? {} : { enabled: false, accept_any: false }), ...(extraBody ? extraBody(form) : {}) }) const check = async (c) => { try { @@ -179,14 +206,16 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo // The server refuses a token that the platform refuses. After a save with // a new token, the page names the bot the token belongs to. - const save = async (e) => { - e.preventDefault() + const save = (e) => { e.preventDefault(); return doSave(false) } + // confirmDuplicate answers the question of a save that makes the connection a twin of another. + const doSave = async (confirmDuplicate) => { setBusy(true) setError('') setNote('') + setDupAsk(null) try { const token = tokenOf(form).trim() - const body = connectorPayload(form, kind, token, extra(form)) + const body = connectorPayload(form, kind, token, { ...extra(form), ...(confirmDuplicate ? { confirm_duplicate: DUPLICATE_CONFIRMATION } : {}) }) const saved = form.id ? await connectorsApi.update(form.id, body) : await connectorsApi.create(body) setForm(null) await load() @@ -195,7 +224,10 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo setNote(await check(saved)) } catch (err) { setError(t('checkFailed', { reason: err?.message || '' })) } } - } catch (err) { fail(err) } finally { setBusy(false) } + } catch (err) { + if (isDuplicateRefusal(err)) setDupAsk({ other: duplicateRefusalName(err) }) + else fail(err) + } finally { setBusy(false) } } // The preset for a saved binding that has no permission on: the usual set, @@ -260,6 +292,9 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {form && (
+ {messaging && form.id && ( + { setForm(null); act(() => connectorsApi.disable(form.id)) }} onOpen={openById} /> + )} {messaging && capabilities.length > 0 && ( )} @@ -307,6 +342,16 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo )} )} + {dupAsk && ( + +

{s('dup.text', { name: dupAsk.other })}

+

{s('dup.confirmHelp')}

+ + + + +
+ )}
@@ -341,6 +386,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {c.credentials_only ? answerT(t)('credentialsOnly') : t('agentOf', { name: agentName(c.agent_id) })} } {RowInfo && } + {messaging && form?.id !== c.id && act(() => connectorsApi.disable(c.id))} onOpen={openById} />} {messaging && capabilities.length > 0 && hasNoCaps(c) && ( allowUsual(c)} /> )} diff --git a/addons/internal/connector-ui/src/connector.css b/addons/internal/connector-ui/src/connector.css index e474f5b30..b725127b9 100644 --- a/addons/internal/connector-ui/src/connector.css +++ b/addons/internal/connector-ui/src/connector.css @@ -76,6 +76,8 @@ fieldset.cn-group { .cn-link { background: none; border: 0; padding: 0 var(--dk-space-1); color: inherit; cursor: pointer; font: inherit; } .ui-callout__text .cn-link { margin-left: var(--dk-space-2); } +/* The link to the other connection of the same bot: it reads as a link, not as a dismiss mark. */ +.ui-callout__text .cn-link--text { margin-left: 0; text-align: left; text-decoration: underline; overflow-wrap: anywhere; } .ui-callout__text .cn-notice__text { margin: 0 0 var(--dk-space-2); } @media (max-width: 900px) { diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index a468c9747..1ab95f8d3 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -25,6 +25,15 @@ export function localizeAccess(view) { } } +// Two enabled connections that are the same bot answer every message twice. +// The host marks each of them with `duplicate` ({ code, message, others }); the +// page only shows it. Saving one such connection first gets a 409 of type +// duplicate_connection, and the same request with the confirmation goes through. +export const DUPLICATE_CONFIRMATION = 'answer twice' +export const duplicateOther = (c) => c?.duplicate?.others?.[0] || null +export const isDuplicateRefusal = (err) => err?.status === 409 && err.body?.error?.type === 'duplicate_connection' +export const duplicateRefusalName = (err) => (typeof err?.body?.error?.param === 'string' ? err.body.error.param : '') + export const MAX_WINDOW_MINUTES = 7 * 24 * 60 // How the triggers that the agent proposes in a chat are approved, and the diff --git a/addons/internal/connector-ui/src/i18n/connector.de.json b/addons/internal/connector-ui/src/i18n/connector.de.json index 8da5516c4..0d8411d5f 100644 --- a/addons/internal/connector-ui/src/i18n/connector.de.json +++ b/addons/internal/connector-ui/src/i18n/connector.de.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "Dieser Bot ist auch als {{name}} verbunden. Beide Verbindungen beantworten dieselbe Nachricht.", + "dup.turnOff": "Diese ausschalten", + "dup.openOther": "{{name}} öffnen", + "dup.saveAnyway": "Trotzdem speichern", + "dup.goBack": "Zurück", + "dup.confirmHelp": "Speichere nur dann trotzdem, wenn du auf jede Nachricht zwei Antworten haben willst." } diff --git a/addons/internal/connector-ui/src/i18n/connector.en.json b/addons/internal/connector-ui/src/i18n/connector.en.json index 3654df88c..8985cb917 100644 --- a/addons/internal/connector-ui/src/i18n/connector.en.json +++ b/addons/internal/connector-ui/src/i18n/connector.en.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "This bot is also connected as {{name}}. Both connections will answer the same message.", + "dup.turnOff": "Turn this one off", + "dup.openOther": "Open {{name}}", + "dup.saveAnyway": "Save anyway", + "dup.goBack": "Go back", + "dup.confirmHelp": "Save it anyway only if you want to get two answers to each message." } diff --git a/addons/internal/connector-ui/src/i18n/connector.es.json b/addons/internal/connector-ui/src/i18n/connector.es.json index 6d6214860..897c09f9c 100644 --- a/addons/internal/connector-ui/src/i18n/connector.es.json +++ b/addons/internal/connector-ui/src/i18n/connector.es.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "Este bot también está conectado como {{name}}. Ambas conexiones responderán al mismo mensaje.", + "dup.turnOff": "Desactivar esta", + "dup.openOther": "Abrir {{name}}", + "dup.saveAnyway": "Guardar de todos modos", + "dup.goBack": "Volver", + "dup.confirmHelp": "Guarda de todos modos solo si quieres recibir dos respuestas a cada mensaje." } diff --git a/addons/internal/connector-ui/src/i18n/connector.fr.json b/addons/internal/connector-ui/src/i18n/connector.fr.json index 7e4985324..c1d3fee55 100644 --- a/addons/internal/connector-ui/src/i18n/connector.fr.json +++ b/addons/internal/connector-ui/src/i18n/connector.fr.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "Ce bot est aussi connecté sous le nom {{name}}. Les deux connexions répondront au même message.", + "dup.turnOff": "Désactiver celle-ci", + "dup.openOther": "Ouvrir {{name}}", + "dup.saveAnyway": "Enregistrer quand même", + "dup.goBack": "Retour", + "dup.confirmHelp": "N'enregistrez quand même que si vous voulez recevoir deux réponses à chaque message." } diff --git a/addons/internal/connector-ui/src/i18n/connector.id.json b/addons/internal/connector-ui/src/i18n/connector.id.json index 47b02a21a..445bbf749 100644 --- a/addons/internal/connector-ui/src/i18n/connector.id.json +++ b/addons/internal/connector-ui/src/i18n/connector.id.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "Bot ini juga terhubung sebagai {{name}}. Kedua koneksi akan menjawab pesan yang sama.", + "dup.turnOff": "Matikan yang ini", + "dup.openOther": "Buka {{name}}", + "dup.saveAnyway": "Tetap simpan", + "dup.goBack": "Kembali", + "dup.confirmHelp": "Tetap simpan hanya jika Anda ingin menerima dua jawaban untuk setiap pesan." } diff --git a/addons/internal/connector-ui/src/i18n/connector.it.json b/addons/internal/connector-ui/src/i18n/connector.it.json index 5a2bf8743..d8ba7615a 100644 --- a/addons/internal/connector-ui/src/i18n/connector.it.json +++ b/addons/internal/connector-ui/src/i18n/connector.it.json @@ -297,5 +297,11 @@ "approvals.line.runs": "Per le chat del proprietario e per i mittenti abbinati al bot. Tutti gli altri vengono comunque interpellati.", "approvals.line.idle": "La modalità di scrittura di questa connessione è Sola lettura, quindi nessuno strumento di scrittura parte.", "approvals.line.other": "Solo il proprietario e un mittente abbinato saltano la scheda. Questo tipo di mittente viene interpellato.", - "access.viaNoConfirm": "eseguito senza chiedere" + "access.viaNoConfirm": "eseguito senza chiedere", + "dup.text": "Questo bot è collegato anche come {{name}}. Entrambe le connessioni risponderanno allo stesso messaggio.", + "dup.turnOff": "Disattiva questa", + "dup.openOther": "Apri {{name}}", + "dup.saveAnyway": "Salva comunque", + "dup.goBack": "Indietro", + "dup.confirmHelp": "Salva comunque solo se vuoi ricevere due risposte a ogni messaggio." } diff --git a/addons/internal/connector-ui/src/i18n/connector.ko.json b/addons/internal/connector-ui/src/i18n/connector.ko.json index 2db20934b..65f9c00d1 100644 --- a/addons/internal/connector-ui/src/i18n/connector.ko.json +++ b/addons/internal/connector-ui/src/i18n/connector.ko.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "이 봇은 {{name}}(으)로도 연결되어 있습니다. 두 연결이 같은 메시지에 모두 답합니다.", + "dup.turnOff": "이 연결 끄기", + "dup.openOther": "{{name}} 열기", + "dup.saveAnyway": "그래도 저장", + "dup.goBack": "돌아가기", + "dup.confirmHelp": "메시지마다 답을 두 번 받고 싶을 때만 그래도 저장하세요." } diff --git a/addons/internal/connector-ui/src/i18n/connector.pt-BR.json b/addons/internal/connector-ui/src/i18n/connector.pt-BR.json index 71f4e9082..a95e21772 100644 --- a/addons/internal/connector-ui/src/i18n/connector.pt-BR.json +++ b/addons/internal/connector-ui/src/i18n/connector.pt-BR.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "Este bot também está conectado como {{name}}. As duas conexões vão responder à mesma mensagem.", + "dup.turnOff": "Desativar esta", + "dup.openOther": "Abrir {{name}}", + "dup.saveAnyway": "Salvar mesmo assim", + "dup.goBack": "Voltar", + "dup.confirmHelp": "Salve mesmo assim só se você quiser receber duas respostas para cada mensagem." } diff --git a/addons/internal/connector-ui/src/i18n/connector.zh-CN.json b/addons/internal/connector-ui/src/i18n/connector.zh-CN.json index 5531d6fbf..a4263329a 100644 --- a/addons/internal/connector-ui/src/i18n/connector.zh-CN.json +++ b/addons/internal/connector-ui/src/i18n/connector.zh-CN.json @@ -297,5 +297,11 @@ "approvals.line.runs": "For the owner's own chats and for senders paired with the bot. Everyone else is still asked.", "approvals.line.idle": "The write mode of this connection is Read only, so no write tool runs.", "approvals.line.other": "Only the owner and a paired sender skip the card. This kind of sender is asked.", - "access.viaNoConfirm": "ran without asking" + "access.viaNoConfirm": "ran without asking", + "dup.text": "这个机器人也以 {{name}} 的名称连接。两个连接都会回复同一条消息。", + "dup.turnOff": "关闭这个连接", + "dup.openOther": "打开 {{name}}", + "dup.saveAnyway": "仍然保存", + "dup.goBack": "返回", + "dup.confirmHelp": "只有在你想让每条消息收到两个回复时,才仍然保存。" } diff --git a/addons/telegram/ui/test/connector.test.mjs b/addons/telegram/ui/test/connector.test.mjs index 3362abf33..a341ddfc2 100644 --- a/addons/telegram/ui/test/connector.test.mjs +++ b/addons/telegram/ui/test/connector.test.mjs @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import assert from 'node:assert/strict' -import { localizeAccess, localizeCapabilities, approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, REPLY_LANGUAGES, fileDirection, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js' +import { DUPLICATE_CONFIRMATION, duplicateOther, duplicateRefusalName, isDuplicateRefusal, localizeAccess, localizeCapabilities, approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, REPLY_LANGUAGES, fileDirection, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js' test('chat connectors: the token goes only when it was typed', () => { const create = connectorPayload({ ...emptyConnector(), name: ' Bot ', token: ' 1:abc ', agent_id: 'a1', allow: ['11', ' 11', '@ada_l', ''] }, 'telegram') @@ -231,3 +231,26 @@ test('chat connectors: the host text in the page language replaces the English o assert.equal(localizeAccess(null), null) assert.deepEqual(localizeCapabilities(undefined), []) }) + +test('chat connectors: the host names the other connection of a bot that is connected twice', () => { + assert.equal(duplicateOther({ id: 'a' }), null) + assert.equal(duplicateOther({ id: 'a', duplicate: { others: [] } }), null) + assert.deepEqual(duplicateOther({ id: 'a', duplicate: { code: 'duplicate_connection', others: [{ id: 'b', name: 'Team app' }] } }), { id: 'b', name: 'Team app' }) +}) + +test('chat connectors: only a 409 of the type duplicate_connection is the question about a twin', () => { + const refusal = (status, type, param) => Object.assign(new Error('x'), { status, body: { error: { type, param } } }) + assert.equal(isDuplicateRefusal(refusal(409, 'duplicate_connection', 'Team app')), true) + assert.equal(isDuplicateRefusal(refusal(409, 'connectors_off')), false) + assert.equal(isDuplicateRefusal(refusal(400, 'duplicate_connection')), false) + assert.equal(isDuplicateRefusal(new Error('network')), false) + assert.equal(duplicateRefusalName(refusal(409, 'duplicate_connection', 'Team app')), 'Team app') + assert.equal(duplicateRefusalName(refusal(409, 'duplicate_connection')), '') +}) + +test('chat connectors: the confirmation of a twin is the text the host expects, and goes with the save only when given', () => { + assert.equal(DUPLICATE_CONFIRMATION, 'answer twice') + const body = connectorPayload({ ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }, 'telegram', '1:x', { confirm_duplicate: DUPLICATE_CONFIRMATION }) + assert.equal(body.confirm_duplicate, 'answer twice') + assert.equal('confirm_duplicate' in connectorPayload({ ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }, 'telegram', '1:x', {}), false) +}) diff --git a/core/http/react-ui/e2e/chats-connectors.spec.js b/core/http/react-ui/e2e/chats-connectors.spec.js index bec8bced5..4191ee04c 100644 --- a/core/http/react-ui/e2e/chats-connectors.spec.js +++ b/core/http/react-ui/e2e/chats-connectors.spec.js @@ -73,6 +73,34 @@ test.describe('Chat connectors', () => { await expect.poll(() => s.calls.includes('disable')).toBe(true) }) + test('warns on a binding that is the same bot as another, links to it and turns it off', async ({ page }) => { + const twin = { ...BINDING, id: 'b2', name: 'Support bot copy' } + const pair = (x, y) => ({ ...x, duplicate: { code: 'duplicate_connection', message: '', others: [{ id: y.id, name: y.name }] } }) + const s = await api(page, { items: [pair(BINDING, twin), pair(twin, BINDING)] }) + await page.route(/\/api\/chats\/connectors\/[^/?]+\/disable$/, r => { + s.calls.push('disable') + s.items = s.items.map(x => ({ ...x, enabled: false, duplicate: undefined })) + return r.fulfill({ json: s.items[0] }) + }) + await page.goto('/app/chats/connectors') + const rows = page.getByTestId('connector-row') + await expect(rows).toHaveCount(2) + await expect(rows.first().getByTestId('connector-duplicate')).toContainText('This bot is also connected as Support bot copy. Both connections will answer the same message.') + await expect(rows.last().getByTestId('connector-duplicate')).toContainText('This bot is also connected as Support bot. Both connections will answer the same message.') + await expect(rows.first().getByTestId('connector-duplicate-open')).toHaveAttribute('href', '/app/addons/telegram/bots?binding=b2') + await expect(rows.last().getByTestId('connector-duplicate-open')).toHaveAttribute('href', '/app/addons/telegram/bots?binding=b1') + + await page.setViewportSize({ width: 360, height: 800 }) + await page.reload() + await expect(rows).toHaveCount(2) + await expect(page.getByTestId('connector-duplicate')).toHaveCount(2) + expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0) + + await rows.last().getByRole('button', { name: 'Turn this one off' }).click() + await expect.poll(() => s.calls.includes('disable')).toBe(true) + await expect(page.getByTestId('connector-duplicate')).toHaveCount(0) + }) + test('an old link to the MCP kind opens the MCP connectors page', async ({ page }) => { await api(page) await page.route('**/api/chats/mcp-connectors*', r => r.fulfill({ json: { connectors: [] } })) diff --git a/core/http/react-ui/e2e/slack-addon.spec.js b/core/http/react-ui/e2e/slack-addon.spec.js index 33fa1fab9..d65cf02c4 100644 --- a/core/http/react-ui/e2e/slack-addon.spec.js +++ b/core/http/react-ui/e2e/slack-addon.spec.js @@ -85,6 +85,97 @@ test.describe('Slack add-on', () => { expect(put.body.allow).toEqual(['U001']) }) + // The host marks two enabled bindings of one bot with `duplicate`; the page shows it on both. + const TWIN = { ...BINDING, id: 'b2', name: 'Team app copy' } + const duplicate = (other) => ({ code: 'duplicate_connection', message: `This bot is also connected as ${other.name}. Both connections will answer the same message.`, others: [other] }) + async function twins(page, a) { + const state = { items: [{ ...BINDING }, { ...TWIN }], disabled: [] } + const view = () => state.items.map(c => { + const other = state.items.find(o => o.id !== c.id) + return c.enabled && other?.enabled ? { ...c, duplicate: duplicate({ id: other.id, name: other.name }) } : c + }) + await a.install() + await page.route(/\/api\/chats\/connectors\/[^/?]+\/disable$/, r => { + const id = new URL(r.request().url()).pathname.split('/').at(-2) + state.disabled.push(id) + state.items = state.items.map(c => (c.id === id ? { ...c, enabled: false } : c)) + return r.fulfill({ json: state.items.find(c => c.id === id) }) + }) + await page.route(/\/api\/chats\/connectors(\?.*)?$/, r => { + if (r.request().method() !== 'GET') return r.fallback() + return r.fulfill({ json: { connectors: view() } }) + }) + return state + } + + test('warns on both connections of the same bot, opens the other and turns this one off', async ({ page }) => { + const a = api(page) + const state = await twins(page, a) + await page.goto(PAGE) + + const rows = page.getByTestId('connector-row') + await expect(rows).toHaveCount(2) + const warnings = page.getByTestId('duplicate-warning') + await expect(warnings).toHaveCount(2) + await expect(warnings.first()).toContainText('This bot is also connected as Team app copy. Both connections will answer the same message.') + await expect(warnings.last()).toContainText('This bot is also connected as Team app. Both connections will answer the same message.') + + // The link opens the other connection, and its own form shows the warning too. + await rows.first().getByTestId('duplicate-open').click() + const form = page.getByTestId('connector-form') + await expect(form.getByLabel('Name', { exact: true })).toHaveValue('Team app copy') + await expect(form.getByTestId('duplicate-warning')).toContainText('This bot is also connected as Team app.') + await form.getByRole('button', { name: 'Cancel' }).click() + + // The page does not scroll sideways on a phone. + await page.setViewportSize({ width: 360, height: 800 }) + await page.reload() + await expect(warnings).toHaveCount(2) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(0) + + await rows.last().getByRole('button', { name: 'Turn this one off' }).click() + await expect(page.getByTestId('duplicate-warning')).toHaveCount(0) + expect(state.disabled).toEqual(['b2']) + await expect(rows.last().getByRole('switch', { name: 'Team app copy on' })).toHaveAttribute('aria-checked', 'false') + }) + + test('asks before it saves a connection that is the same bot as another, and saves it when confirmed', async ({ page }) => { + const a = api(page, [BINDING]) + await a.install() + const bodies = [] + await page.route(/\/api\/chats\/connectors(\?.*)?$/, r => { + const req = r.request() + if (req.method() !== 'POST') return r.fallback() + const body = req.postDataJSON() + bodies.push(body) + if (body.confirm_duplicate !== 'answer twice') { + return r.fulfill({ status: 409, json: { error: { type: 'duplicate_connection', code: 409, param: 'Team app', message: 'This bot is also connected as Team app. Both connections will answer the same message.' } } }) + } + return r.fulfill({ status: 201, json: { ...TWIN, token: undefined, has_token: true } }) + }) + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Slack app' }).click() + await page.getByLabel('Name', { exact: true }).fill('Team app copy') + await page.getByLabel('Bot token').fill('xoxb-second') + await page.getByLabel('App-level token').fill('xapp-second') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByRole('button', { name: 'Save' }).click() + + const ask = page.getByTestId('duplicate-confirm') + await expect(ask).toContainText('This bot is also connected as Team app. Both connections will answer the same message.') + await expect(page.getByTestId('connector-form')).toBeVisible() + expect(bodies).toHaveLength(1) + expect(bodies[0]).not.toHaveProperty('confirm_duplicate') + + await ask.getByRole('button', { name: 'Go back' }).click() + await expect(ask).toHaveCount(0) + await page.getByRole('button', { name: 'Save' }).click() + await page.getByTestId('duplicate-save-anyway').click() + await expect(page.getByTestId('connector-form')).toHaveCount(0) + expect(bodies.at(-1)).toMatchObject({ name: 'Team app copy', confirm_duplicate: 'answer twice' }) + }) + test('shows how to make the Slack app, with its manifest', async ({ page }) => { await api(page).install() await page.goto(PAGE) diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json index 7eead0206..e74438ba6 100644 --- a/core/http/react-ui/public/locales/de/chats.json +++ b/core/http/react-ui/public/locales/de/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "Ihre Konnektoren konnten nicht geladen werden.", "waitingTitle": "Warten auf ihr Add-on", - "waitingText": "Diese Verknüpfungen bleiben erhalten, laufen aber erst, wenn ihr Add-on installiert und eingeschaltet ist." + "waitingText": "Diese Verknüpfungen bleiben erhalten, laufen aber erst, wenn ihr Add-on installiert und eingeschaltet ist.", + "duplicate": { + "text": "Dieser Bot ist auch als {{name}} verbunden. Beide Verbindungen beantworten dieselbe Nachricht.", + "turnOff": "Diese ausschalten", + "open": "{{name}} öffnen" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index d9c4f7ee3..bec570779 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -332,7 +332,12 @@ "bindingsOf": "Bindings of {{name}}", "loadFailed": "Could not load your connectors.", "waitingTitle": "Waiting for their add-on", - "waitingText": "These bindings are kept, but they do not run until their add-on is installed and turned on." + "waitingText": "These bindings are kept, but they do not run until their add-on is installed and turned on.", + "duplicate": { + "text": "This bot is also connected as {{name}}. Both connections will answer the same message.", + "turnOff": "Turn this one off", + "open": "Open {{name}}" + } }, "conversionPage": { "title": "Converted from the old chat", diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json index 18852e8c1..6c7c969f1 100644 --- a/core/http/react-ui/public/locales/es/chats.json +++ b/core/http/react-ui/public/locales/es/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "No se pudieron cargar tus conectores.", "waitingTitle": "Esperando su complemento", - "waitingText": "Estos enlaces se conservan, pero no funcionan hasta que su complemento esté instalado y activado." + "waitingText": "Estos enlaces se conservan, pero no funcionan hasta que su complemento esté instalado y activado.", + "duplicate": { + "text": "Este bot también está conectado como {{name}}. Ambas conexiones responderán al mismo mensaje.", + "turnOff": "Desactivar esta", + "open": "Abrir {{name}}" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json index 9dcfd77d8..89f854e2c 100644 --- a/core/http/react-ui/public/locales/fr/chats.json +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "Impossible de charger vos connecteurs.", "waitingTitle": "En attente de leur module", - "waitingText": "Ces liaisons sont conservées, mais elles ne fonctionnent qu'une fois leur module installé et activé." + "waitingText": "Ces liaisons sont conservées, mais elles ne fonctionnent qu'une fois leur module installé et activé.", + "duplicate": { + "text": "Ce bot est aussi connecté sous le nom {{name}}. Les deux connexions répondront au même message.", + "turnOff": "Désactiver celle-ci", + "open": "Ouvrir {{name}}" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json index 65ccf8dd6..c02fa3a4f 100644 --- a/core/http/react-ui/public/locales/id/chats.json +++ b/core/http/react-ui/public/locales/id/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "Konektor Anda tidak dapat dimuat.", "waitingTitle": "Menunggu add-on-nya", - "waitingText": "Tautan ini tetap disimpan, tetapi tidak berjalan sampai add-on-nya dipasang dan diaktifkan." + "waitingText": "Tautan ini tetap disimpan, tetapi tidak berjalan sampai add-on-nya dipasang dan diaktifkan.", + "duplicate": { + "text": "Bot ini juga terhubung sebagai {{name}}. Kedua koneksi akan menjawab pesan yang sama.", + "turnOff": "Matikan yang ini", + "open": "Buka {{name}}" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json index 42f693963..dbc40226d 100644 --- a/core/http/react-ui/public/locales/it/chats.json +++ b/core/http/react-ui/public/locales/it/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "Impossibile caricare i tuoi connettori.", "waitingTitle": "In attesa del loro add-on", - "waitingText": "Questi collegamenti restano salvati, ma non funzionano finché il loro add-on non è installato e attivo." + "waitingText": "Questi collegamenti restano salvati, ma non funzionano finché il loro add-on non è installato e attivo.", + "duplicate": { + "text": "Questo bot è collegato anche come {{name}}. Entrambe le connessioni risponderanno allo stesso messaggio.", + "turnOff": "Disattiva questa", + "open": "Apri {{name}}" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json index c5fe0e58f..711da8a7f 100644 --- a/core/http/react-ui/public/locales/ko/chats.json +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "커넥터를 불러올 수 없습니다.", "waitingTitle": "애드온 대기 중", - "waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다." + "waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다.", + "duplicate": { + "text": "이 봇은 {{name}}(으)로도 연결되어 있습니다. 두 연결이 같은 메시지에 모두 답합니다.", + "turnOff": "이 연결 끄기", + "open": "{{name}} 열기" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json index 4f402044c..d3ea3c1c2 100644 --- a/core/http/react-ui/public/locales/pt-BR/chats.json +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "Não foi possível carregar seus conectores.", "waitingTitle": "Aguardando o complemento", - "waitingText": "Estes vínculos são mantidos, mas só funcionam quando o complemento deles estiver instalado e ligado." + "waitingText": "Estes vínculos são mantidos, mas só funcionam quando o complemento deles estiver instalado e ligado.", + "duplicate": { + "text": "Este bot também está conectado como {{name}}. As duas conexões vão responder à mesma mensagem.", + "turnOff": "Desativar esta", + "open": "Abrir {{name}}" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json index 3251a1263..f0221a812 100644 --- a/core/http/react-ui/public/locales/zh-CN/chats.json +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -266,7 +266,12 @@ "connectorsPage": { "loadFailed": "无法加载你的连接器。", "waitingTitle": "等待对应的插件", - "waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。" + "waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。", + "duplicate": { + "text": "这个机器人也以 {{name}} 的名称连接。两个连接都会回复同一条消息。", + "turnOff": "关闭这个连接", + "open": "打开 {{name}}" + } }, "tools": { "commands": { diff --git a/core/http/react-ui/src/pages/ChatConnectors.jsx b/core/http/react-ui/src/pages/ChatConnectors.jsx index 9207bac5a..278dff8d8 100644 --- a/core/http/react-ui/src/pages/ChatConnectors.jsx +++ b/core/http/react-ui/src/pages/ChatConnectors.jsx @@ -5,7 +5,7 @@ import { Link, Navigate, useSearchParams } from 'react-router-dom' import Icon from '../components/Icon' import { Button, Callout, EmptyState, LoadError, Page, PageHeader, SkeletonRows } from '../components/ui' import { chatAgentsApi, chatConnectorsApi } from '../utils/api' -import { acceptsOf, groupConnectors } from '../utils/chatConnectors' +import { acceptsOf, bindingAddress, duplicateOf, groupConnectors } from '../utils/chatConnectors' import './connectors.css' // The messaging connectors of the user. A platform comes from a connector @@ -59,6 +59,8 @@ export default function ChatConnectors() { const row = (c) => { const a = acceptsOf(c) + const other = duplicateOf(c) + const otherHref = other ? bindingAddress(groups, c, other.id) : '' return (
  • @@ -68,6 +70,20 @@ export default function ChatConnectors() { {t('connectorsPage.agentOf', { name: agentName(c.agent_id) })} {t('connectorsPage.accepts', { who: a.key === 'listed' ? t('connectorsPage.modes.listed', { count: a.count }) : t(`connectorsPage.modes.${a.key}`) })} + {other && ( + + + + {t('connectorsPage.duplicate.text', { name: other.name })} + {otherHref && <> {t('connectorsPage.duplicate.open', { name: other.name })}} + + + )} + {other && ( + + + + )} {c.active === false && ( diff --git a/core/http/react-ui/src/pages/connectors.css b/core/http/react-ui/src/pages/connectors.css index fae979b91..2bbda2604 100644 --- a/core/http/react-ui/src/pages/connectors.css +++ b/core/http/react-ui/src/pages/connectors.css @@ -84,6 +84,8 @@ } @starting-style { .cn-result { opacity: 0; transform: translateY(-2px); } } .cn-result .lai-icon { flex: none; width: 14px; height: 14px; margin-top: 2px; } +.cn-row__fix { margin-top: var(--sp-1); } +.cn-result a { color: inherit; text-decoration: underline; } .cn-result--ok { color: var(--dk-ok-text); } .cn-result--error { color: var(--dk-error-text); } .cn-result--warn { color: var(--dk-warn-text); } diff --git a/core/http/react-ui/src/utils/chatConnectors.js b/core/http/react-ui/src/utils/chatConnectors.js index 88f16ea8c..aaaddf82d 100644 --- a/core/http/react-ui/src/utils/chatConnectors.js +++ b/core/http/react-ui/src/utils/chatConnectors.js @@ -27,3 +27,16 @@ export function acceptsOf(c) { const n = (c.allow || []).length return n === 0 ? { key: 'nobody' } : { key: 'listed', count: n } } + +// The other enabled binding of the same bot that the host names on a binding +// (`duplicate`), or null. Both then answer the same message. +export function duplicateOf(c) { + return c?.duplicate?.others?.[0] || null +} + +// The page of the add-on that serves a binding, opened on one binding of it. +// groups come from groupConnectors; '' when no running add-on serves the kind. +export function bindingAddress(groups, c, id) { + const href = (groups || []).find((g) => g.kind === c.kind && g.addon === c.addon)?.href + return href ? `${href}${href.includes('?') ? '&' : '?'}binding=${encodeURIComponent(id)}` : '' +} diff --git a/core/http/react-ui/tests/unit/chatconnectors.test.mjs b/core/http/react-ui/tests/unit/chatconnectors.test.mjs index cda40c66a..9cbefa97d 100644 --- a/core/http/react-ui/tests/unit/chatconnectors.test.mjs +++ b/core/http/react-ui/tests/unit/chatconnectors.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { acceptsOf, groupConnectors } from '../../src/utils/chatConnectors.js' +import { acceptsOf, bindingAddress, duplicateOf, groupConnectors } from '../../src/utils/chatConnectors.js' const kinds = [{ addon: 'telegram', kind: 'telegram', title: 'Telegram', page: 'bot' }] @@ -34,3 +34,18 @@ test('chat connectors: who a binding accepts', () => { assert.deepEqual(acceptsOf({ allow: [] }), { key: 'nobody' }) assert.deepEqual(acceptsOf({ allow: ['1', '2'] }), { key: 'listed', count: 2 }) }) + +test('chat connectors: the other binding of a bot that is connected twice', () => { + assert.equal(duplicateOf({ id: 'a' }), null) + assert.equal(duplicateOf({ id: 'a', duplicate: { code: 'duplicate_connection', others: [] } }), null) + assert.deepEqual(duplicateOf({ id: 'a', duplicate: { others: [{ id: 'b', name: 'Team app' }, { id: 'c', name: 'Third' }] } }), { id: 'b', name: 'Team app' }) +}) + +test('chat connectors: a link to a binding opens the page of its add-on on that binding', () => { + const { groups } = groupConnectors(kinds, []) + const c = { kind: 'telegram', addon: 'telegram' } + assert.equal(bindingAddress(groups, c, 'b 2'), '/app/addons/telegram/bot?binding=b%202') + const noPage = groupConnectors([{ addon: 'slack', kind: 'slack', title: 'Slack' }], []).groups + assert.equal(bindingAddress(noPage, { kind: 'slack', addon: 'slack' }, 'b2'), '/app/addons?detail=slack&binding=b2') + assert.equal(bindingAddress(groups, { kind: 'telegram', addon: 'other' }, 'b2'), '', 'no running add-on, no link') +})