diff --git a/backend/druks/chat/bots/service.py b/backend/druks/chat/bots/service.py index 4696d726f..c3921c52c 100644 --- a/backend/druks/chat/bots/service.py +++ b/backend/druks/chat/bots/service.py @@ -108,15 +108,11 @@ async def route_message( ) -> Conversation | None: """The conversation that a channel's message goes to, or none when the message is for no agent. The only text Druks reads is an open admin code.""" - owner = connection.account - if owner.kind == AccountKind.OPERATOR: - # Only an operator's chat with themself reaches their agent. - if is_from_phone and is_self_chat: - return await Conversation.get_or_create_for_user(session, connection, owner.id, **user) - return bot = get_app(connection.identity["app"]).bot if bot.access == BotAccess.PAIRED: - if is_from_phone: + # The linked phone pairs through its chat with itself. Its messages to other + # people reach no agent. + if is_from_phone and not is_self_chat: return if account_id := await redeem_admin_code(connection, body): connection.identity = { @@ -142,7 +138,7 @@ async def route_message( return await Conversation.get_or_create_for_user( session, connection, admin["account_id"], **user ) - # What the phone sends to the admin person is no chat to take over. + # A message from the phone to the admin is not a chat that the admin takes over. if user["user_id"] != admin.get("user_id"): await take_over(session, connection, user, body, key) return diff --git a/backend/druks/chat/channels/whatsapp/routes.py b/backend/druks/chat/channels/whatsapp/routes.py index f2646afcd..f122c8181 100644 --- a/backend/druks/chat/channels/whatsapp/routes.py +++ b/backend/druks/chat/channels/whatsapp/routes.py @@ -13,16 +13,16 @@ from .schemas import QrResponse, SessionResponse from .services import Waha -router = APIRouter(prefix="/services/waha") +router = APIRouter(prefix="/services/waha", dependencies=[Depends(current_session_account)]) @router.get("/sessions", response_model=list[SessionResponse], response_model_by_alias=True) async def list_sessions( - session: SessionDep, app: str = "", account: Account = Depends(current_session_account) + session: SessionDep, app: str, account: Account = Depends(current_session_account) ) -> list[SessionResponse]: - """An app's numbers, or the operator's own number.""" + """The numbers of an app.""" numbers = [] - for connection in await Waha.list_sessions(session, app=app, account_id=account.id): + for connection in await Waha.list_sessions(session, app=app): number = SessionResponse.model_validate(connection) if ( connection.account.kind == AccountKind.BOT @@ -36,35 +36,26 @@ async def list_sessions( @router.post( "/sessions", status_code=201, response_model=SessionResponse, response_model_by_alias=True ) -async def link_session( - session: SessionDep, - app: Annotated[str, Body(embed=True)] = "", - account: Account = Depends(current_session_account), -) -> VaultSecret: - """Link a number for an app's Bot or for the operator.""" - owner = account - identity = {} - if app: - try: - bot = get_app(app).bot - except KeyError as exc: - raise HTTPException(404, f"Unknown app {app!r}") from exc - if not bot: - raise HTTPException(404, f"App {app!r} declares no Bot.") - owner = await Account.create_for_bot(session, AccountKind.BOT) - if bot.access == BotAccess.PAIRED: - identity = {"app": app, "operators": {}} - else: - admin = await Account.create_for_bot(session, AccountKind.BOT_ADMIN) - identity = {"app": app, "admin": {"account_id": admin.id}} +async def link_session(session: SessionDep, app: Annotated[str, Body(embed=True)]) -> VaultSecret: + """Link a number for the Bot of an app.""" + try: + bot = get_app(app).bot + except KeyError as exc: + raise HTTPException(404, f"Unknown app {app!r}") from exc + if not bot: + raise HTTPException(404, f"App {app!r} declares no Bot.") + owner = await Account.create_for_bot(session, AccountKind.BOT) + if bot.access == BotAccess.PAIRED: + identity = {"app": app, "operators": {}} + else: + admin = await Account.create_for_bot(session, AccountKind.BOT_ADMIN) + identity = {"app": app, "admin": {"account_id": admin.id}} return await Waha.link(session, owner, identity=identity) @router.get("/sessions/{session_id}/qr", response_model=QrResponse, response_model_by_alias=True) -async def get_qr( - session: SessionDep, session_id: str, account: Account = Depends(current_session_account) -) -> dict[str, str]: - connection = await Waha.get_session(session, session_id, account.id) +async def get_qr(session: SessionDep, session_id: str) -> dict[str, str]: + connection = await Waha.get_session(session, session_id) if connection and connection.is_live: client = await Waha.get_client(session, connection) return await client.get_qr() @@ -74,11 +65,9 @@ async def get_qr( @router.post( "/sessions/{session_id}/relink", response_model=SessionResponse, response_model_by_alias=True ) -async def relink_session( - session: SessionDep, session_id: str, account: Account = Depends(current_session_account) -) -> VaultSecret: +async def relink_session(session: SessionDep, session_id: str) -> VaultSecret: """Take a new scan on a number that lost its link.""" - connection = await Waha.get_session(session, session_id, account.id) + connection = await Waha.get_session(session, session_id) if connection and connection.is_live: await Waha.relink(session, connection) return connection @@ -86,10 +75,8 @@ async def relink_session( @router.delete("/sessions/{session_id}", status_code=204) -async def remove_session( - session: SessionDep, session_id: str, account: Account = Depends(current_session_account) -) -> None: - connection = await Waha.get_session(session, session_id, account.id) +async def remove_session(session: SessionDep, session_id: str) -> None: + connection = await Waha.get_session(session, session_id) if not connection: raise HTTPException(404, "Session not found.") # Removing is idempotent: a second delete finds the session removed. diff --git a/backend/druks/chat/channels/whatsapp/services.py b/backend/druks/chat/channels/whatsapp/services.py index 4db7ccb65..834bb6871 100644 --- a/backend/druks/chat/channels/whatsapp/services.py +++ b/backend/druks/chat/channels/whatsapp/services.py @@ -49,8 +49,6 @@ async def get_client( async def link(cls, session: AsyncSession, owner: Account, *, identity: dict) -> VaultSecret: """Create the number's session and its key, save the session, and then write its config: WAHA sends events only after the config names the webhook.""" - if await VaultSecret.lookup(session, SecretKind.SESSION, WAHA_AUDIENCE, owner.id): - raise WhatsAppLinkError("This account has a linked number. Remove it first.") base = load_settings().urls.webhook_base if not base: raise WhatsAppLinkError( @@ -134,33 +132,29 @@ async def relink(cls, session: AsyncSession, connection: VaultSecret) -> None: connection.identity_status = IdentityStatus.UNAVAILABLE @classmethod - async def list_sessions( - cls, session: AsyncSession, *, app: str, account_id: str - ) -> list[VaultSecret]: - """An app's sessions, or else the account's own, removed ones included.""" - query = ( - select(VaultSecret) - .where(VaultSecret.kind == SecretKind.SESSION, VaultSecret.audience == WAHA_AUDIENCE) - .order_by(VaultSecret.created_at, VaultSecret.id) + async def list_sessions(cls, session: AsyncSession, *, app: str) -> list[VaultSecret]: + """The sessions of an app. The list includes the removed sessions.""" + return list( + await session.scalars( + select(VaultSecret) + .where( + VaultSecret.kind == SecretKind.SESSION, + VaultSecret.audience == WAHA_AUDIENCE, + VaultSecret.identity["app"].astext == app, + ) + .order_by(VaultSecret.created_at, VaultSecret.id) + ) ) - if app: - query = query.where(VaultSecret.identity["app"].astext == app) - else: - query = query.where(VaultSecret.account_id == account_id) - return list(await session.scalars(query)) @classmethod - async def get_session( - cls, session: AsyncSession, session_id: str, account_id: str - ) -> VaultSecret | None: - """A session the account may manage: an app's, or its own. A removed one stays - readable.""" + async def get_session(cls, session: AsyncSession, session_id: str) -> VaultSecret | None: + """A session of an app. A removed session stays readable.""" connection = await session.get(VaultSecret, session_id) if ( connection and connection.kind == SecretKind.SESSION and connection.audience == WAHA_AUDIENCE - and (connection.account.kind == AccountKind.BOT or connection.account_id == account_id) + and connection.account.kind == AccountKind.BOT ): return connection return diff --git a/backend/druks/chat/service.py b/backend/druks/chat/service.py index 9dc418789..09f1e4b88 100644 --- a/backend/druks/chat/service.py +++ b/backend/druks/chat/service.py @@ -86,7 +86,7 @@ async def get_agent( """The conversation's agent: its Bot's id, its system prompt, and the tools its key allows. ``sign_ins`` are the facts of ``list_sign_ins``, for an operator.""" account_type = conversation.account.kind - # A web conversation and an operator's own connection belong to Chat. + # A web conversation belongs to Chat. app = "chat" if conversation.connection: app = conversation.connection.identity.get("app", app) diff --git a/backend/tests/test_whatsapp.py b/backend/tests/test_whatsapp.py index b06b26ae9..7fdfe979c 100644 --- a/backend/tests/test_whatsapp.py +++ b/backend/tests/test_whatsapp.py @@ -114,13 +114,11 @@ async def link( ): bind_ambient_session(session) await connect_service("waha", identity={"url": "http://waha.test"}, secrets={"key": "admin"}) - identity = {} - if app: - if access == BotAccess.PAIRED: - identity = {"app": app, "operators": {}} - else: - admin = await Account.create_for_bot(session, AccountKind.BOT_ADMIN) - identity = {"app": app, "admin": {"account_id": admin.id}} + if access == BotAccess.PAIRED: + identity = {"app": app, "operators": {}} + else: + admin = await Account.create_for_bot(session, AccountKind.BOT_ADMIN) + identity = {"app": app, "admin": {"account_id": admin.id}} if number: identity = {**identity, "number": number, "user_id": NUMBER} identity = {**identity, "session": session_name} @@ -701,7 +699,7 @@ async def test_disconnect_refuses_an_open_number(druks_db, druks_client, helpdes assert connection.identity == identity -async def test_a_paired_number_ignores_strangers_and_its_own_phone(druks_db, monkeypatch): +async def test_a_paired_number_ignores_strangers_and_pairs_its_own_phone(druks_db, monkeypatch): connection = await link( druks_db, await bot_account(druks_db), app="chat", access=BotAccess.PAIRED ) @@ -712,7 +710,7 @@ async def test_a_paired_number_ignores_strangers_and_its_own_phone(druks_db, mon monkeypatch.setattr(bot_service.DBOS, "start_workflow_async", delivery) monkeypatch.setattr(bot_service, "take_over", take_over) - for sender, is_from_phone in ((ANA, False), (ANA, True), (NUMBER, True), ("900@lid", True)): + for sender, is_from_phone in ((ANA, False), (ANA, True)): body = code if is_from_phone else "Hello" event = message_event(sender, body, key=f"{sender}-{is_from_phone}", from_me=is_from_phone) event["payload"].update(hasMedia=True, media={"url": "http://waha.test/media/one"}) @@ -720,17 +718,23 @@ async def test_a_paired_number_ignores_strangers_and_its_own_phone(druks_db, mon assert not await Conversation.list_for_connection(druks_db, connection.id) assert not connection.identity["operators"] - assert await bot_service.redeem_admin_code(connection, code) == operator.id save_media.assert_not_awaited() delivery.assert_not_awaited() take_over.assert_not_awaited() + await receive(connection, message_event("900@lid", code, key="self-proof", from_me=True)) + + assert connection.identity["operators"] == {NUMBER: operator.id} + [conversation] = await Conversation.list_for_connection(druks_db, connection.id) + assert (conversation.user_id, conversation.account_id) == (NUMBER, operator.id) + delivery.assert_awaited_once_with(service.deliver, conversation.id) + assert not await bot_service.redeem_admin_code(connection, code) + @pytest.mark.parametrize( "source, app", [ (ConversationSource.WEB, "chat"), - (ConversationSource.WHATSAPP, ""), (ConversationSource.WHATSAPP, "chat"), (ConversationSource.WHATSAPP, "helpdesk"), ], @@ -742,10 +746,7 @@ async def test_operator_turns_use_the_apps_prompt_and_settings_without_a_timeout operator = await Account.get_or_create(druks_db, "op@example.com") if source == ConversationSource.WHATSAPP: connection = await link( - druks_db, - await bot_account(druks_db) if app else operator, - app=app, - access=BotAccess.PAIRED, + druks_db, await bot_account(druks_db), app=app, access=BotAccess.PAIRED ) conversation = await Conversation.get_or_create_for_user( druks_db, connection, operator.id, **user(ANA) @@ -900,20 +901,6 @@ async def test_the_phones_chat_with_itself_is_an_admin_chat(druks_db, helpdesk): assert [message.body for message in chat.messages] == ["Requests today?", "And tomorrow?"] -async def test_a_personal_number_reaches_its_owner_only_from_the_self_chat(druks_db): - operator = await Account.get_or_create(druks_db, "op@example.com") - connection = await link(druks_db, operator, app="") - - await receive(connection, message_event(ANA, "Hi", key="M1")) - await receive(connection, message_event(ANA, "On my way", key="M2", from_me=True)) - await receive(connection, message_event(NUMBER, "Remind me at six", key="M3", from_me=True)) - - [conversation] = await Conversation.list_for_connection(druks_db, connection.id) - await druks_db.refresh(conversation, ["messages"]) - assert (conversation.account_id, conversation.user_id) == (operator.id, NUMBER) - assert [message.body for message in conversation.messages] == ["Remind me at six"] - - async def test_removing_a_number_holds_its_chats_and_relinking_keeps_its_history( druks_db, druks_client, helpdesk, waha, monkeypatch ): @@ -933,7 +920,7 @@ async def test_removing_a_number_holds_its_chats_and_relinking_keeps_its_history assert resume.await_args.args[1].id == chat.id await druks_db.refresh(first) assert await chat.is_held(druks_db) - sessions = await Waha.list_sessions(druks_db, app="helpdesk", account_id=owner.id) + sessions = await Waha.list_sessions(druks_db, app="helpdesk") assert [session.id for session in sessions] == [first.id, second.id] assert first.identity["number"] == "+41000000000" diff --git a/docs/chat.md b/docs/chat.md index 26fa6065b..c2612200a 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -205,26 +205,24 @@ an open source WhatsApp HTTP API. Connect WAHA first: see ### Link a number A linked number is a connection. Its owner account holds the WAHA session, the -session's key, and the webhook secret. There are three kinds: +session's key, and the webhook secret. There are two kinds: - **An app's number.** In the app's settings, open **Channels** and select **Add number**. The tab shows only when the app declares a [Bot](writing-an-app.md#answer-whatsapp-with-a-bot). Druks creates a bot account and a bot admin account for the number. These accounts never sign in. -- **The assistant's number, recommended.** Open **Chat → Channels** and select - **Add number**. Scan the QR code with a phone that holds a second WhatsApp - number. Then select **Connect my phone** and send the code from your own - WhatsApp to that number. You now talk to your agent there, under your account - and with the whole Druks toolkit. Replies arrive as normal incoming messages, - and WAHA never sees your private chats. Several operators can share the - number: each one connects their own phone. Druks ignores every sender that - has no connected phone. **Disconnect my phone** removes your phones from the - number. This number has no admin and no take-over. -- **Your own number, the fallback.** On the same tab, select **Link your - number**. This needs no second number. Only your chat with yourself reaches - your agent, and Druks ignores everyone else on that number. WhatsApp gives no - sound for a message that an account sends to itself, and WAHA receives your - private chats. +- **The assistant's number.** Open **Chat → Channels** and select **Add + number**. Scan the QR code. Then select **Connect my phone** and send the code + to that number. Send it from your own WhatsApp, or from the phone of the + number to itself. You then talk to your agent at that number, under your + account and with the whole Druks toolkit. If you use a second number, replies + arrive as normal incoming messages, and WAHA never sees your private chats. + If you use the phone of the number itself, replies arrive in your chat with + yourself. WhatsApp gives no sound for them, and WAHA receives the chats of + that phone. Several operators can share the number. Each operator connects + their own phone. Druks ignores every sender that has no connected phone. + **Disconnect my phone** removes your phones from the number. This number has + no admin and no take-over. Druks creates the WAHA session and its key, saves the connection, and then writes the session's config. Scan the QR code from **Linked devices** in diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 47b3760eb..33317ab18 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -336,12 +336,10 @@ export const api = { listConnections: () => getJSON('/api/oauth/connections'), disconnectConnection: (connectionId: string) => deleteRequest(`/api/oauth/connections/${encodeURIComponent(connectionId)}`), - // Linked WhatsApp numbers: an app's with ``app``, else the caller's own number. - wahaSessions: (app?: string) => - getJSON( - `/api/chat/services/waha/sessions${app ? `?app=${encodeURIComponent(app)}` : ''}`, - ), - linkWahaSession: (app?: string) => + // An app's linked WhatsApp numbers. + wahaSessions: (app: string) => + getJSON(`/api/chat/services/waha/sessions?app=${encodeURIComponent(app)}`), + linkWahaSession: (app: string) => postJSON('/api/chat/services/waha/sessions', { app }), wahaSessionQr: (id: string) => getJSON<{ mimetype: string; data: string }>( diff --git a/frontend/src/components/ChatChannels.test.tsx b/frontend/src/components/ChatChannels.test.tsx index f260165bc..f3995a15c 100644 --- a/frontend/src/components/ChatChannels.test.tsx +++ b/frontend/src/components/ChatChannels.test.tsx @@ -53,9 +53,8 @@ it.each(['chat', 'helpdesk'])('uses paired access for the number options in %s C mount(`/apps/${app}/settings/channels`, app) expect(await screen.findByRole('button', { name: 'Add number' })).toBeTruthy() - expect(await screen.findByRole('button', { name: 'Link your number' })).toBeTruthy() expect(screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)) - .toEqual(["Assistant's number — Recommended", 'Your own number']) + .toEqual(["Assistant's number"]) expect(screen.getByRole('link', { name: 'Bots' }).getAttribute('href')) .toBe(`/apps/${app}/settings/bots`) expect(screen.queryByRole('link', { name: 'Agents' })).toBeNull() @@ -73,7 +72,7 @@ it('keeps WhatsApp setup out of Connections Accounts', async () => { mount('/settings/connections?tab=accounts') expect(await screen.findByRole('link', { name: 'Accounts' })).toBeTruthy() - expect(screen.queryByRole('button', { name: 'Link your number' })).toBeNull() - expect(screen.queryByRole('heading', { name: 'Your own number' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Add number' })).toBeNull() + expect(screen.queryByRole('heading', { name: "Assistant's number" })).toBeNull() expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes('/waha/'))).toBe(false) }) diff --git a/frontend/src/components/SettingsPages.tsx b/frontend/src/components/SettingsPages.tsx index 6293a3eed..9b6cff13f 100644 --- a/frontend/src/components/SettingsPages.tsx +++ b/frontend/src/components/SettingsPages.tsx @@ -31,7 +31,7 @@ import { BrowserProfilesPane } from './BrowserProfilesPane' import { CallsPane } from './CallsPane' import { GitHubPane } from './GitHubPane' import { SlackPane } from './SlackPane' -import { WhatsAppChannelPane } from './WhatsAppNumbersPane' +import { WhatsAppNumbersPane } from './WhatsAppNumbersPane' import { AgentAccessPane, AgentsPane, @@ -75,7 +75,7 @@ const CHANNEL_PANES: Record> = { call: CallsPane, github: GitHubPane, slack: SlackPane, - whatsapp: WhatsAppChannelPane, + whatsapp: WhatsAppNumbersPane, } function withField( diff --git a/frontend/src/components/WhatsAppNumbersPane.test.tsx b/frontend/src/components/WhatsAppNumbersPane.test.tsx index 02de81405..62085fda6 100644 --- a/frontend/src/components/WhatsAppNumbersPane.test.tsx +++ b/frontend/src/components/WhatsAppNumbersPane.test.tsx @@ -16,11 +16,11 @@ const waiting: WahaSession = { revokedReason: '', } -function renderPane(app?: string, botAccess: AppSettings['botAccess'] = 'open') { +function renderPane(app = 'helpdesk', botAccess: AppSettings['botAccess'] = 'open') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( - + , ) return queryClient @@ -154,19 +154,4 @@ describe('WhatsAppNumbersPane', () => { expect(await screen.findByText('+41000000000')).toBeTruthy() expect(screen.queryByText('Not linked')).toBeNull() }) - - it('links the own-number fallback without an app', async () => { - const fetchMock = vi.fn(async (_url: string, request?: RequestInit) => - new Response(JSON.stringify(request?.method === 'POST' ? waiting : [])), - ) - vi.stubGlobal('fetch', fetchMock) - renderPane() - - fireEvent.click(await screen.findByRole('button', { name: 'Link your number' })) - - await waitFor(() => expect(fetchMock.mock.calls.some(([, request]) => request?.method === 'POST')).toBe(true)) - const post = fetchMock.mock.calls.find(([, request]) => request?.method === 'POST')! - expect(JSON.parse(String(post[1]?.body))).toEqual({}) - expect(screen.queryByRole('button', { name: 'Connect my phone' })).toBeNull() - }) }) diff --git a/frontend/src/components/WhatsAppNumbersPane.tsx b/frontend/src/components/WhatsAppNumbersPane.tsx index 3ca92119e..271e9ae62 100644 --- a/frontend/src/components/WhatsAppNumbersPane.tsx +++ b/frontend/src/components/WhatsAppNumbersPane.tsx @@ -28,26 +28,14 @@ function isWaiting(number: WahaSession) { return !number.revokedAt && number.identityStatus !== 'resolved' } -/** The WhatsApp channel of an app's Bot: its numbers, and under paired access the - * operator's own number. */ -export function WhatsAppChannelPane({ app }: { app: AppSettings }) { - return ( - <> - - {app.botAccess === 'paired' && } - - ) -} - -/** The WhatsApp numbers of an app's Bot, or without ``app`` the operator's own number. */ -export function WhatsAppNumbersPane({ app }: { app?: Pick }) { - const appName = app?.name - const isPaired = app?.botAccess === 'paired' +/** The WhatsApp numbers of an app's Bot. */ +export function WhatsAppNumbersPane({ app }: { app: Pick }) { + const isPaired = app.botAccess === 'paired' const queryClient = useQueryClient() const [adminCodes, setAdminCodes] = useState([]) const query = useQuery({ - queryKey: ['wahaSessions', appName], - queryFn: () => api.wahaSessions(appName), + queryKey: ['wahaSessions', app.name], + queryFn: () => api.wahaSessions(app.name), refetchInterval: (current) => current.state.data?.some(isWaiting) || adminCodes.length > 0 ? LINK_POLL_INTERVAL : false, }) @@ -61,13 +49,11 @@ export function WhatsAppNumbersPane({ app }: { app?: Pick - !number.revokedAt || - (number === newest && number.revokedReason in REFUSALS) || - (app && number.number), + !number.revokedAt || (number === newest && number.revokedReason in REFUSALS) || number.number, ) async function changeNumbers(action: () => Promise) { @@ -75,7 +61,7 @@ export function WhatsAppNumbersPane({ app }: { app?: Pick
-

- {isPaired ? "Assistant's number — Recommended" : app ? 'WhatsApp numbers' : 'Your own number'} -

+

{isPaired ? "Assistant's number" : 'WhatsApp numbers'}

{isPaired - ? 'Keep your private account outside WAHA. Replies arrive as incoming messages and can ring. This needs one more WhatsApp number.' - : app - ? `People chat with ${appLabel(app.name)} at these numbers.` - : 'Use your chat with yourself without a second number. Replies have no incoming-message sound. WAHA receives your direct chats, but Druks ignores other senders.'} + ? 'Add a number. Then connect your phone to it. If you add a second number, WAHA does not see your private account, and replies ring like incoming messages. If you add your own number, replies arrive in your chat with yourself, without a sound.' + : `People chat with ${appLabel(app.name)} at these numbers.`}

{query.isPending &&

Loading numbers…

} @@ -132,14 +114,14 @@ export function WhatsAppNumbersPane({ app }: { app?: Pick )} - {query.isSuccess && (app || numbers.every((number) => number.revokedAt)) && ( + {query.isSuccess && (
)} @@ -161,7 +143,7 @@ export function WhatsAppNumbersPane({ app }: { app?: Pick )} {number.name && {number.name}} - {app && isLinked && ( + {isLinked && ( {isPaired ? number.isPhoneConnected ? 'Your phone is connected.' : 'Connect your phone to start a conversation.' @@ -183,11 +165,11 @@ export function WhatsAppNumbersPane({ app }: { app?: Pick} {adminCode && (

- Send {adminCode.code} from your own WhatsApp to {number.number}{' '} - within {Math.round(adminCode.expiresIn / 60)} minutes.{' '} + Send {adminCode.code} to {number.number} within{' '} + {Math.round(adminCode.expiresIn / 60)} minutes.{' '} {isPaired - ? 'Druks connects that phone to your account. Keep this code private.' - : "Druks then sends this number's questions to the sender."} + ? 'Use your own WhatsApp, or send it from that phone to itself. Druks connects the phone that sent the code to your account. Keep this code private.' + : 'Use your own WhatsApp. Druks then sends the questions of this number to the sender.'}

)} {!number.revokedAt && ( @@ -210,7 +192,7 @@ export function WhatsAppNumbersPane({ app }: { app?: Pick )} - {app && isLinked && !(isPaired && number.isPhoneConnected) && ( + {isLinked && !(isPaired && number.isPhoneConnected) && (