feat(ui): warn when two connections are the same bot

The connection pages of the messaging add-ons and the connections list
show "This bot is also connected as <name>. Both connections will answer
the same message." on both connections, with a link to the other and a
"Turn this one off" button.

Saving a connection that would be a twin of another enabled one asks
first: the server answers 409 with the type duplicate_connection, and the
page offers "Save anyway", which sends confirm_duplicate.

The new texts are in all nine locales of the web UI and of the shared
connector pages.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 11:36:23 +00:00
1 parent c70566d4f7
commit 3f765dcdf5
28 files changed
+371 -27

No files matched your search

@@ -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 (
<Notice tone="warn" role="status" testId="duplicate-warning">
<p className="cn-notice__text">{s('dup.text', { name: other.name })}</p>
<span className="cn-row__actions">
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" data-testid="duplicate-off" disabled={busy} onClick={onOff}>{s('dup.turnOff')}</button>
<button type="button" className="cn-link cn-link--text" data-testid="duplicate-open" onClick={() => onOpen(other.id)}>{s('dup.openOther', { name: other.name })}</button>
</span>
</Notice>
)
}
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 && (
<div className="cn-formgrid">
<form className="cn-form" onSubmit={save} aria-label={form.id ? t('edit') : t('new')} data-testid="connector-form">
{messaging && form.id && (
<SameBotNotice c={savedItem} s={s} busy={busy} onOff={() => { setForm(null); act(() => connectorsApi.disable(form.id)) }} onOpen={openById} />
)}
{messaging && capabilities.length > 0 && (
<ReadinessList s={s} ctx={fixCtx} rowsInput={readyInput} />
)}
@@ -307,6 +342,16 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
<AccessView binding={form} capabilities={capabilities} s={s} version={accessVersion} />
</>)}
</>)}
{dupAsk && (
<Notice tone="warn" role="alert" testId="duplicate-confirm">
<p className="cn-notice__text">{s('dup.text', { name: dupAsk.other })}</p>
<p className="cn-notice__text">{s('dup.confirmHelp')}</p>
<span className="cn-row__actions">
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" data-testid="duplicate-save-anyway" disabled={busy} onClick={() => doSave(true)}>{s('dup.saveAnyway')}</button>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setDupAsk(null)}>{s('dup.goBack')}</button>
</span>
</Notice>
)}
<div className="cn-actions">
<button type="submit" className="dk-btn dk-btn--primary" data-testid="connector-save" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles, agentOptional) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => setForm(null)}>{t('cancel')}</button>
@@ -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) })}
</span>}
{RowInfo && <RowInfo c={c} t={t} />}
{messaging && form?.id !== c.id && <SameBotNotice c={c} s={s} busy={busy} onOff={() => act(() => connectorsApi.disable(c.id))} onOpen={openById} />}
{messaging && capabilities.length > 0 && hasNoCaps(c) && (
<NoCapsNotice capabilities={capabilities} s={s} busy={busy} name={c.name} onUsual={() => allowUsual(c)} />
)}
@@ -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) {
@@ -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
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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": "메시지마다 답을 두 번 받고 싶을 때만 그래도 저장하세요."
}
@@ -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."
}
@@ -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": "只有在你想让每条消息收到两个回复时,才仍然保存。"
}
+24 -1
View File
@@ -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)
})
@@ -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: [] } }))
@@ -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)
@@ -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": {
@@ -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",
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -266,7 +266,12 @@
"connectorsPage": {
"loadFailed": "커넥터를 불러올 수 없습니다.",
"waitingTitle": "애드온 대기 중",
"waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다."
"waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다.",
"duplicate": {
"text": "이 봇은 {{name}}(으)로도 연결되어 있습니다. 두 연결이 같은 메시지에 모두 답합니다.",
"turnOff": "이 연결 끄기",
"open": "{{name}} 열기"
}
},
"tools": {
"commands": {
@@ -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": {
@@ -266,7 +266,12 @@
"connectorsPage": {
"loadFailed": "无法加载你的连接器。",
"waitingTitle": "等待对应的插件",
"waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。"
"waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。",
"duplicate": {
"text": "这个机器人也以 {{name}} 的名称连接。两个连接都会回复同一条消息。",
"turnOff": "关闭这个连接",
"open": "打开 {{name}}"
}
},
"tools": {
"commands": {
@@ -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 (
<li key={c.id} className="cn-row" data-testid="connector-row" data-on={c.enabled ? 'true' : 'false'}>
<span className="cn-row__lead" aria-hidden="true"><Icon name="chat" /></span>
@@ -68,6 +70,20 @@ export default function ChatConnectors() {
<span>{t('connectorsPage.agentOf', { name: agentName(c.agent_id) })}</span>
<span>{t('connectorsPage.accepts', { who: a.key === 'listed' ? t('connectorsPage.modes.listed', { count: a.count }) : t(`connectorsPage.modes.${a.key}`) })}</span>
</span>
{other && (
<span className="cn-result cn-result--warn" data-testid="connector-duplicate">
<Icon name="warning" />
<span>
{t('connectorsPage.duplicate.text', { name: other.name })}
{otherHref && <> <Link to={otherHref} data-testid="connector-duplicate-open">{t('connectorsPage.duplicate.open', { name: other.name })}</Link></>}
</span>
</span>
)}
{other && (
<span className="cn-row__fix">
<Button size="sm" data-testid="connector-duplicate-off" onClick={() => act(() => chatConnectorsApi.disable(c.id))}>{t('connectorsPage.duplicate.turnOff')}</Button>
</span>
)}
{c.active === false && (
<span className="cn-result cn-result--warn">
<Icon name="warning" />
@@ -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); }
+13
View File
@@ -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)}` : ''
}
@@ -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')
})