Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions backend/druks/chat/bots/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down
61 changes: 24 additions & 37 deletions backend/druks/chat/channels/whatsapp/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -74,22 +65,18 @@ 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
raise HTTPException(404, "Session not found.")


@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.
Expand Down
36 changes: 15 additions & 21 deletions backend/druks/chat/channels/whatsapp/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/chat/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 17 additions & 30 deletions backend/tests/test_whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
)
Expand All @@ -712,25 +710,31 @@ 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"})
await receive(connection, event)

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"),
],
Expand All @@ -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)
Expand Down Expand Up @@ -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
):
Expand All @@ -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"

Expand Down
28 changes: 13 additions & 15 deletions docs/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,10 @@ export const api = {
listConnections: () => getJSON<Connection[]>('/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<WahaSession[]>(
`/api/chat/services/waha/sessions${app ? `?app=${encodeURIComponent(app)}` : ''}`,
),
linkWahaSession: (app?: string) =>
// An app's linked WhatsApp numbers.
wahaSessions: (app: string) =>
getJSON<WahaSession[]>(`/api/chat/services/waha/sessions?app=${encodeURIComponent(app)}`),
linkWahaSession: (app: string) =>
postJSON<WahaSession>('/api/chat/services/waha/sessions', { app }),
wahaSessionQr: (id: string) =>
getJSON<{ mimetype: string; data: string }>(
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/components/ChatChannels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
})
4 changes: 2 additions & 2 deletions frontend/src/components/SettingsPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -75,7 +75,7 @@ const CHANNEL_PANES: Record<string, ComponentType<{ app: AppSettings }>> = {
call: CallsPane,
github: GitHubPane,
slack: SlackPane,
whatsapp: WhatsAppChannelPane,
whatsapp: WhatsAppNumbersPane,
}

function withField(
Expand Down
Loading
Loading