docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
1 parent
18732801b5
commit
6cd904d460
216 files changed
+37038
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { test, expect, channels, FakeExternals, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('when a channel refuses a message', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true } })
|
||||
|
||||
test('the teammate is told it was not delivered, and the next one goes through @channels @handover', async ({ agent, llm, adminApi, request, inbox }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().handOver('Let me connect you with a teammate.'))
|
||||
const customer = unique('Wanda')
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(customer)
|
||||
await chat.send('I need a person')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1)
|
||||
|
||||
await inbox.goto()
|
||||
await inbox.openConversationWith(customer)
|
||||
await inbox.takeOver()
|
||||
|
||||
// WhatsApp refuses free-form messages outside the 24-hour window with error 131047.
|
||||
const restore = await new FakeExternals(request).fail({
|
||||
provider: 'graph',
|
||||
action: 'messages',
|
||||
contains: chat.waId,
|
||||
status: 400,
|
||||
body: { error: { message: 'Re-engagement message', type: 'OAuthException', code: 131047, fbtrace_id: 'e2e' } },
|
||||
times: 1,
|
||||
})
|
||||
await inbox.reply('Are you still there?')
|
||||
await expect(inbox.agentMessage('Are you still there?')).toContainText('Not delivered')
|
||||
await restore()
|
||||
|
||||
await inbox.reply('Here is your tracking link.')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toContain('Here is your tracking link.')
|
||||
await expect(inbox.agentMessage('Here is your tracking link.')).not.toContainText('Not delivered')
|
||||
expect(await chat.replies()).not.toContain('Are you still there?')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('choices that do not fit a channel\'s buttons', () => {
|
||||
test.use({ agentSettings: { channelQuickReplies: true } })
|
||||
|
||||
test('five options become a WhatsApp list within its limits, and a pick still gets its answer @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
const options = ['Track my order', 'Returns', 'Change my delivery address', 'Billing question', 'Talk to someone']
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/change my delivery address/i).reply('Sure — what is the new address?')
|
||||
.otherwise().reply('What can I help you with?', { quick_replies: options }))
|
||||
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hi')
|
||||
// More than three buttons is a list; the fake rejects anything past WhatsApp's real limits.
|
||||
await expect.poll(() => chat.buttons(), AI_REPLY).toHaveLength(5)
|
||||
const titles = await chat.buttons()
|
||||
for (const title of titles) expect([...title].length).toBeLessThanOrEqual(24)
|
||||
|
||||
await chat.tap(titles[2])
|
||||
await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toBe('Sure — what is the new address?')
|
||||
})
|
||||
|
||||
test('a button title longer than WhatsApp allows is shortened, and the tap still works @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
const long = 'Change my delivery address please'
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/change my delivery address/i).reply('Sure — what is the new address?')
|
||||
.otherwise().reply('What can I help you with?', { quick_replies: [long, 'Returns'] }))
|
||||
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hi')
|
||||
await expect.poll(() => chat.buttons(), AI_REPLY).toHaveLength(2)
|
||||
const [shortened] = await chat.buttons()
|
||||
expect([...shortened].length).toBeLessThanOrEqual(24)
|
||||
|
||||
await chat.tap(shortened)
|
||||
await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toBe('Sure — what is the new address?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, channels, expectUnchanged, Mailbox, AI_REPLY } from '@e2e'
|
||||
|
||||
test('an email reply stays in the customer\'s thread @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help with that.'))
|
||||
|
||||
const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Can I change my delivery address?')
|
||||
|
||||
const mailbox = new Mailbox(request)
|
||||
await expect.poll(async () => (await mailbox.messagesTo(chat.address)).length, AI_REPLY).toBe(1)
|
||||
// One question, one model call, one email — a duplicate would arrive later.
|
||||
expect(await llm.requests(agent)).toHaveLength(1)
|
||||
await expectUnchanged('emails to the customer', async () => (await mailbox.messagesTo(chat.address)).length)
|
||||
const [reply] = await mailbox.messagesTo(chat.address)
|
||||
expect(reply.subject).toBe(`Re: ${chat.subject}`)
|
||||
// In-Reply-To is what makes mail clients file the answer under the question.
|
||||
expect((await mailbox.headers(reply.id))['In-Reply-To']).toEqual([chat.lastMessageId])
|
||||
})
|
||||
|
||||
test('a customer\'s follow-up email continues the same conversation @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/tuesday/i).reply('Tuesday works — I have noted it.')
|
||||
.otherwise().reply('Happy to help with that. Which day suits you?'))
|
||||
|
||||
const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Can I change my delivery day?')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1)
|
||||
await chat.send('Tuesday please')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual([
|
||||
'Happy to help with that. Which day suits you?',
|
||||
'Tuesday works — I have noted it.',
|
||||
])
|
||||
|
||||
// One conversation for this sender, holding both exchanges — not two.
|
||||
const conversations = await adminApi.get<Array<{ message_count: number }>>('/chats/recent', { params: { customer_email: chat.address } })
|
||||
expect(conversations).toHaveLength(1)
|
||||
expect(conversations[0].message_count).toBe(4)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
test, expect, connectEmail, connectLine, connectMessenger, connectSms, connectWhatsAppNumber, digits,
|
||||
expectUnchanged, hmac, installSlackWorkspace, metaSignature, metaToken, phoneNumber, routeToAgent, sendWebhook,
|
||||
slackSignature, unique, whatsappPayload, TelegramBot,
|
||||
} from '@e2e'
|
||||
|
||||
// Anyone can POST to a webhook URL. These are the requests a provider would never
|
||||
// send: unsigned, wrongly signed, replayed — and events that are signed but must
|
||||
// never start an AI turn (our own echoes, delivery receipts, other bots).
|
||||
|
||||
test.describe('forged webhooks are refused', () => {
|
||||
test('Meta (WhatsApp, Messenger, Instagram): a wrong or missing signature @channels @security', async ({ adminApi, request }) => {
|
||||
const { phoneNumberId } = await connectWhatsAppNumber(adminApi)
|
||||
const json = JSON.stringify(whatsappPayload(phoneNumberId, phoneNumber().slice(1), 'Mallory', { type: 'text', text: { body: 'hi' } }))
|
||||
|
||||
const wrongKey = `sha256=${hmac('sha256', 'not-the-app-secret', json).toString('hex')}`
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json }, { 'x-hub-signature-256': wrongKey })).status()).toBe(403)
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json })).status()).toBe(403)
|
||||
})
|
||||
|
||||
test('Slack: a wrong signature, or a correctly signed request replayed later @channels @security', async ({ request }) => {
|
||||
const json = JSON.stringify({ type: 'event_callback', team_id: 'T0FORGED', event: { type: 'message', channel: 'D1', user: 'U1', text: 'hi', ts: '1.1' } })
|
||||
const now = String(Math.floor(Date.now() / 1000))
|
||||
const tenMinutesAgo = String(Math.floor(Date.now() / 1000) - 600)
|
||||
|
||||
const wrong = { 'x-slack-request-timestamp': now, 'x-slack-signature': 'v0=00' }
|
||||
expect((await sendWebhook(request, '/webhooks/slack', { json }, wrong)).status()).toBe(403)
|
||||
const replayed = { 'x-slack-request-timestamp': tenMinutesAgo, 'x-slack-signature': slackSignature(json, tenMinutesAgo) }
|
||||
expect((await sendWebhook(request, '/webhooks/slack', { json }, replayed)).status()).toBe(403)
|
||||
})
|
||||
|
||||
test('Telegram: a wrong webhook secret @channels @security', async ({ adminApi, request, agent }) => {
|
||||
const { account } = await TelegramBot.connect(adminApi, request, agent.id)
|
||||
const response = await sendWebhook(request, `/webhooks/telegram/${account.id}`, {
|
||||
json: JSON.stringify({ update_id: 1, message: { message_id: 1, date: 0, chat: { id: 1, type: 'private' }, from: { id: 1, is_bot: false, first_name: 'M' }, text: 'hi' } }),
|
||||
}, { 'x-telegram-bot-api-secret-token': 'guessed' })
|
||||
expect(response.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('LINE: a signature made with the wrong channel secret @channels @security', async ({ adminApi, request }) => {
|
||||
const account = await connectLine(adminApi, unique('line-secret'), unique('line-token'))
|
||||
const json = JSON.stringify({ destination: account.external_account_id, events: [{ type: 'message', source: { type: 'user', userId: `U${digits(32)}` }, message: { type: 'text', id: '1', text: 'hi' } }] })
|
||||
const response = await sendWebhook(request, `/webhooks/line/${account.id}`, { json }, {
|
||||
'x-line-signature': hmac('sha256', 'not-the-channel-secret', json).toString('base64'),
|
||||
})
|
||||
expect(response.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('SMS: Twilio and Plivo signatures made with the wrong auth token @channels @security', async ({ adminApi, request }) => {
|
||||
const number = phoneNumber()
|
||||
const twilio = await connectSms(adminApi, 'twilio', number, { account_sid: unique('AC'), auth_token: unique('token') })
|
||||
const form = { From: phoneNumber(), To: number, Body: 'hi', MessageSid: unique('SM') }
|
||||
const forgedTwilio = await sendWebhook(request, `/webhooks/sms/twilio/${twilio.id}`, { form }, { 'x-twilio-signature': hmac('sha1', 'guessed', 'x').toString('base64') })
|
||||
expect(forgedTwilio.status()).toBe(403)
|
||||
|
||||
const plivo = await connectSms(adminApi, 'plivo', phoneNumber(), { auth_id: unique('MA'), auth_token: unique('token') })
|
||||
const forgedPlivo = await sendWebhook(request, `/webhooks/sms/plivo/${plivo.id}`, { form: { From: '447700900000', To: '447700900001', Text: 'hi', MessageUUID: unique('u') } }, {
|
||||
'x-plivo-signature-v3': hmac('sha256', 'guessed', 'x').toString('base64'), 'x-plivo-signature-v3-nonce': 'n',
|
||||
})
|
||||
expect(forgedPlivo.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('email: an inbound-parse post without the inbox\'s token @channels @security', async ({ adminApi, request }) => {
|
||||
const inbox = await connectEmail(adminApi, `${unique('support')}@e2e.example.com`)
|
||||
const json = JSON.stringify({ from: 'mallory@example.com', to: inbox.external_account_id, subject: 'hi', text: 'hi' })
|
||||
expect((await sendWebhook(request, `/webhooks/email/${inbox.id}?token=guessed`, { json })).status()).toBe(403)
|
||||
expect((await sendWebhook(request, `/webhooks/email/${inbox.id}`, { json })).status()).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('signed events that must not start an AI turn', () => {
|
||||
test('a Messenger echo of our own message, a WhatsApp delivery receipt and another bot\'s Slack message @channels', async ({ adminApi, request, agent, llm }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.'))
|
||||
|
||||
// Messenger: Meta echoes the page's own sends back with is_echo.
|
||||
const pageId = digits(15)
|
||||
const page = await connectMessenger(adminApi, pageId, metaToken(pageId))
|
||||
await routeToAgent(adminApi, page.id, agent.id)
|
||||
const echo = { object: 'page', entry: [{ id: pageId, messaging: [{ sender: { id: pageId }, recipient: { id: digits(16) }, timestamp: Date.now(), message: { mid: unique('m'), text: 'Hello from us', is_echo: true } }] }] }
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(echo) }, metaSignature(JSON.stringify(echo)))).ok()).toBe(true)
|
||||
|
||||
// WhatsApp: delivery/read receipts arrive as `statuses`, with no messages.
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const receipt = { object: 'whatsapp_business_account', entry: [{ id: '1', changes: [{ field: 'messages', value: { messaging_product: 'whatsapp', metadata: { phone_number_id: number.phoneNumberId }, statuses: [{ id: 'wamid.x', status: 'read', timestamp: '1', recipient_id: '447700900000' }] } }] }] }
|
||||
expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(receipt) }, metaSignature(JSON.stringify(receipt)))).ok()).toBe(true)
|
||||
|
||||
// Slack: messages posted by a bot (including ours) carry bot_id.
|
||||
const workspace = await installSlackWorkspace(adminApi, request)
|
||||
await routeToAgent(adminApi, workspace.account.id, agent.id)
|
||||
const teamId = workspace.teamId
|
||||
const now = String(Math.floor(Date.now() / 1000))
|
||||
const botMessage = JSON.stringify({ type: 'event_callback', team_id: teamId, event_id: unique('Ev'), event: { type: 'message', channel_type: 'im', channel: 'D1', bot_id: 'B1', text: 'beep', ts: `${now}.000100` } })
|
||||
expect((await sendWebhook(request, '/webhooks/slack', { json: botMessage }, { 'x-slack-request-timestamp': now, 'x-slack-signature': slackSignature(botMessage, now) })).ok()).toBe(true)
|
||||
|
||||
// Each was accepted (a provider would retry a refusal) and none reached the model.
|
||||
await expectUnchanged('model calls', async () => (await llm.requests(agent)).length, 5_000)
|
||||
expect(await llm.requests(agent)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect, channels, expectUnchanged, unique, AI_REPLY } from '@e2e'
|
||||
|
||||
// The inbox side is the same for every channel, and a human's reply leaves through
|
||||
// the same adapter call as the AI's (covered per channel in replies.spec), so one
|
||||
// channel is enough here.
|
||||
test.describe('human handover on a channel', () => {
|
||||
test.use({ agentSettings: { transferToHuman: true } })
|
||||
|
||||
test('a WhatsApp customer asks for a person, the teammate\'s reply reaches them on WhatsApp, and the AI stays out of it @channels @handover', async ({ agent, llm, adminApi, request, inbox }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/human|person/i).handOver('Let me connect you with a teammate.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
const customer = unique('Wanda')
|
||||
const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(customer)
|
||||
await chat.send('I want to talk to a person')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Let me connect you with a teammate.'])
|
||||
|
||||
await inbox.goto()
|
||||
await inbox.openConversationWith(customer)
|
||||
await expect(inbox.lastCustomerMessage()).toContainText('I want to talk to a person')
|
||||
await inbox.takeOver()
|
||||
await inbox.reply('Hi, this is Sam. How can I help?')
|
||||
// The takeover itself tells the customer a person has joined.
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual([
|
||||
'Let me connect you with a teammate.',
|
||||
'You\'re now connected with a member of our team.',
|
||||
'Hi, this is Sam. How can I help?',
|
||||
])
|
||||
|
||||
const modelCalls = (await llm.requests(agent)).length
|
||||
await chat.send('My parcel never arrived')
|
||||
await expect(inbox.lastCustomerMessage()).toContainText('My parcel never arrived')
|
||||
await expectUnchanged('WhatsApp replies after takeover', () => chat.replies())
|
||||
expect((await llm.requests(agent))).toHaveLength(modelCalls)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
test, expect, connectWhatsAppNumber, expectUnchanged, listChannelAccounts, routeToAgent,
|
||||
ApiClient, FakeExternals, IntegrationsPage, WhatsAppChat, AI_REPLY,
|
||||
} from '@e2e'
|
||||
|
||||
// When Meta stops accepting a number's access token (an expired temporary
|
||||
// token, a revoked system user), every reply fails until someone reconnects
|
||||
// the number. The account is flagged so the Integrations card can say so,
|
||||
// rather than replies failing with nothing on screen.
|
||||
//
|
||||
// Other tests share the org and its WhatsApp card, so the card is checked for
|
||||
// this test's number by name, never for the absence of any warning at all.
|
||||
|
||||
const TOKEN_REFUSED = { error: { message: 'Error validating access token: Session has expired', type: 'OAuthException', code: 190, fbtrace_id: 'e2e' } }
|
||||
// Each read lists every account in the shared org; once a second is plenty
|
||||
const FLAG_POLL = { ...AI_REPLY, intervals: [1_000] }
|
||||
const WINDOW_CLOSED = { error: { message: 'Re-engagement message', type: 'OAuthException', code: 131047, fbtrace_id: 'e2e' } }
|
||||
|
||||
async function needsReconnect(api: ApiClient, accountId: string): Promise<boolean> {
|
||||
const account = (await listChannelAccounts(api)).find((a) => a.id === accountId)
|
||||
return account?.needs_reconnect ?? false
|
||||
}
|
||||
|
||||
test.describe('a WhatsApp number whose token Meta refuses', () => {
|
||||
test('is flagged, the Integrations card names it, and reconnecting clears it @channels', async ({ agent, llm, adminApi, request, page }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess')
|
||||
|
||||
const restore = await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 401, body: TOKEN_REFUSED })
|
||||
await chat.send('Hello?')
|
||||
await expect.poll(() => needsReconnect(adminApi, number.account.id), FLAG_POLL).toBe(true)
|
||||
expect(await chat.replies()).toEqual([])
|
||||
|
||||
const integrations = new IntegrationsPage(page)
|
||||
const warning = integrations.expiredAccessFor(number.account.display_name ?? '')
|
||||
await integrations.goto()
|
||||
await expect(integrations.card('WhatsApp')).toContainText(warning)
|
||||
// Other numbers are connected too; Manage still leads to the fix, not the first number
|
||||
await integrations.manage('WhatsApp')
|
||||
await expect(integrations.reconnectPrompt()).toBeVisible()
|
||||
|
||||
// The customer swaps in a working token: same number, connected again
|
||||
await restore()
|
||||
const reconnected = await connectWhatsAppNumber(adminApi, number.phoneNumberId)
|
||||
expect(reconnected.account.id).toBe(number.account.id)
|
||||
expect(reconnected.account.needs_reconnect).toBe(false)
|
||||
|
||||
await integrations.goto()
|
||||
await expect(integrations.card('WhatsApp')).not.toContainText(warning)
|
||||
// Replies now go out with the new token
|
||||
const afterReconnect = new WhatsAppChat(request, externals, reconnected, 'Tess')
|
||||
await afterReconnect.send('Are you there now?')
|
||||
await expect.poll(() => afterReconnect.replies(), AI_REPLY).toEqual(['Happy to help!'])
|
||||
})
|
||||
|
||||
test('is healthy again as soon as a reply goes through @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess')
|
||||
|
||||
// Meta refuses the token once, then accepts it (it was refreshed on Meta's side)
|
||||
await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 401, body: TOKEN_REFUSED, times: 1 })
|
||||
await chat.send('Hello?')
|
||||
await expect.poll(() => needsReconnect(adminApi, number.account.id), FLAG_POLL).toBe(true)
|
||||
|
||||
await chat.send('Hello again?')
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Happy to help!'])
|
||||
expect(await needsReconnect(adminApi, number.account.id)).toBe(false)
|
||||
})
|
||||
|
||||
test('is not flagged when Meta refuses one message for its own reason @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Happy to help!'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess')
|
||||
|
||||
// A closed 24-hour window refuses the message, not the token
|
||||
const restore = await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 400, body: WINDOW_CLOSED })
|
||||
await chat.send('Hello?')
|
||||
const refused = async () => (await externals.calls('graph', 'messages', { contains: chat.waId }))
|
||||
.filter((c) => c.status === 400).length
|
||||
await expect.poll(refused, AI_REPLY).toBeGreaterThan(0)
|
||||
|
||||
await expectUnchanged('the number\'s reconnect flag', () => needsReconnect(adminApi, number.account.id))
|
||||
expect(await needsReconnect(adminApi, number.account.id)).toBe(false)
|
||||
await restore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
test, expect, connectMessenger, connectWhatsAppNumber, digits, expectUnchanged, metaVerifiesCallback, ownMetaApp,
|
||||
pageChat, pastedCallback, routeToAgent, whereMetaSends, FakeExternals, WhatsAppChat, AI_REPLY,
|
||||
} from '@e2e'
|
||||
|
||||
// A WhatsApp number or Facebook Page connected with credentials from the
|
||||
// customer's OWN Meta app — the only way to try Meta's free test number. Meta
|
||||
// signs that app's webhooks with its own secret, so each such account has its
|
||||
// own webhook address, checked with that secret only (issue #353).
|
||||
//
|
||||
// Connected through the API: the org is shared, and once any test has a number
|
||||
// the Integrations card offers Manage, not Connect. The card itself is covered
|
||||
// by the dashboard's unit tests.
|
||||
|
||||
test.describe('a number or Page from the customer\'s own Meta app', () => {
|
||||
test('a WhatsApp number receives messages: its webhooks are pointed at ReplyPier on connect @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
const app = ownMetaApp()
|
||||
const number = await connectWhatsAppNumber(adminApi, digits(15), app)
|
||||
// Meta took the per-number callback, after checking it answers: nothing to paste
|
||||
expect(number.account.webhook_auto_configured).toBe(true)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
|
||||
const externals = new FakeExternals(request)
|
||||
const chat = new WhatsAppChat(request, externals, number, 'Tess', () => whereMetaSends(externals, number.phoneNumberId, app))
|
||||
await chat.send('What are your opening hours?')
|
||||
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.'])
|
||||
})
|
||||
|
||||
test('a Messenger Page receives messages once its callback URL and verify token are pasted into the app @channels', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Hi! How can we help?'))
|
||||
const app = ownMetaApp()
|
||||
const pageId = digits(15)
|
||||
const token = app.token(pageId)
|
||||
const account = await connectMessenger(adminApi, pageId, token, app.secret)
|
||||
await routeToAgent(adminApi, account.id, agent.id)
|
||||
const url = account.webhook_url ?? ''
|
||||
const verifyToken = account.webhook_verify_token ?? ''
|
||||
|
||||
// The customer pastes both into their app; Meta checks the address before saving
|
||||
expect(await metaVerifiesCallback(request, url, verifyToken)).toBe(true)
|
||||
expect(await metaVerifiesCallback(request, url, 'a-guessed-token')).toBe(false)
|
||||
|
||||
const chat = pageChat('Messenger', request, new FakeExternals(request), 'page', pageId, token, 'Remi', pastedCallback(url, app))
|
||||
await chat.send('Hello there')
|
||||
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Hi! How can we help?'])
|
||||
})
|
||||
|
||||
test('a wrong app secret is refused when connecting @channels', async ({ adminApi }) => {
|
||||
const app = ownMetaApp()
|
||||
await expect(connectWhatsAppNumber(adminApi, digits(15), { ...app, secret: 'not-the-app-secret' }))
|
||||
.rejects.toThrow(/400: .*That app secret is not the one for the Meta app this token comes from/)
|
||||
})
|
||||
|
||||
test('its address takes only its own app\'s accounts, never another app\'s number @channels @security', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.'))
|
||||
const mine = ownMetaApp()
|
||||
const ourNumber = await connectWhatsAppNumber(adminApi, digits(15), mine)
|
||||
const theirNumber = await connectWhatsAppNumber(adminApi, digits(15), ownMetaApp())
|
||||
await routeToAgent(adminApi, theirNumber.account.id, agent.id)
|
||||
const externals = new FakeExternals(request)
|
||||
|
||||
// Signed with our app's secret and sent to our address, but naming their number
|
||||
const forged = new WhatsAppChat(request, externals, theirNumber, 'Mallory', () => whereMetaSends(externals, ourNumber.phoneNumberId, mine))
|
||||
await forged.send('Hi, I am not who I say I am')
|
||||
|
||||
await expectUnchanged('replies on the other app\'s number', () => forged.replies())
|
||||
})
|
||||
|
||||
test('without the app secret a Page has no address of its own @channels', async ({ adminApi }) => {
|
||||
const pageId = digits(15)
|
||||
const account = await connectMessenger(adminApi, pageId, ownMetaApp().token(pageId))
|
||||
expect(account.webhook_url).toBeFalsy()
|
||||
expect(account.webhook_verify_token).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect, channels, expectUnchanged, AI_REPLY } from '@e2e'
|
||||
|
||||
// The core promise of every channel: a customer writes in, the agent's answer goes
|
||||
// back out on the same channel, to that customer.
|
||||
for (const [name, connect] of Object.entries(channels)) {
|
||||
test(`a customer on ${name} gets the agent's answer back there @channels`, async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.')
|
||||
.otherwise().reply('Happy to help!'))
|
||||
|
||||
const channel = await connect({ api: adminApi, request, agentId: agent.id })
|
||||
const chat = channel.chatWith('Tess')
|
||||
await chat.send('What are your opening hours?')
|
||||
|
||||
// Exactly one answer, the scripted one, from this account — no duplicate, no default.
|
||||
await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.'])
|
||||
await expectUnchanged(`${name} replies`, () => chat.replies())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect, channelsWithButtons, AI_REPLY } from '@e2e'
|
||||
|
||||
test.describe('reply buttons', () => {
|
||||
test.use({ agentSettings: { channelQuickReplies: true } })
|
||||
|
||||
for (const [name, connect] of Object.entries(channelsWithButtons)) {
|
||||
test(`the AI offers buttons on ${name} and a tap gets the next answer @channels`, async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s
|
||||
.when(/track my order/i).reply('Your order ships tomorrow.')
|
||||
.otherwise().reply('What can I help you with?', { quick_replies: ['Track my order', 'Returns'] }))
|
||||
|
||||
const chat = (await connect({ api: adminApi, request, agentId: agent.id })).chatWith('Tess')
|
||||
await chat.send('Hi')
|
||||
await expect.poll(() => chat.buttons(), AI_REPLY).toEqual(['Track my order', 'Returns'])
|
||||
|
||||
await chat.tap('Track my order')
|
||||
await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toContain('Your order ships tomorrow.')
|
||||
expect(await chat.replies()).toHaveLength(2)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test, expect, connectWhatsAppNumber, deliverMeta, expectUnchanged, phoneNumber, routeToAgent, whatsappPayload, FakeExternals, AI_REPLY } from '@e2e'
|
||||
|
||||
// Providers retry a webhook they think failed. Dedupe lives in Redis, which only
|
||||
// the nightly Redis run turns on (scripts/stack.sh E2E_REDIS_URL); playwright.config.ts
|
||||
// leaves @redis tests out otherwise.
|
||||
|
||||
test('a provider delivering the same message twice gets one answer @channels @redis', async ({ agent, llm, adminApi, request }) => {
|
||||
await llm.script(agent, (s) => s.otherwise().reply('Answered once.'))
|
||||
const number = await connectWhatsAppNumber(adminApi)
|
||||
await routeToAgent(adminApi, number.account.id, agent.id)
|
||||
const waId = phoneNumber().slice(1)
|
||||
|
||||
// The exact same delivery, message id included, twice.
|
||||
const payload = whatsappPayload(number.phoneNumberId, waId, 'Rita', { type: 'text', text: { body: 'Hello?' } })
|
||||
await deliverMeta(request, payload)
|
||||
await deliverMeta(request, payload)
|
||||
|
||||
const sent = async () => (await new FakeExternals(request).calls('graph', 'messages', { contains: waId }))
|
||||
.filter((c) => (c.body as { type?: string }).type === 'text').length
|
||||
await expect.poll(sent, AI_REPLY).toBe(1)
|
||||
await expectUnchanged('replies to the retried message', sent)
|
||||
expect(await llm.requests(agent)).toHaveLength(1)
|
||||
})
|
||||
Reference in new issue
Block a user