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,145 @@
|
||||
import { createHmac, randomInt } from 'node:crypto'
|
||||
import type { APIRequestContext, APIResponse } from '@playwright/test'
|
||||
import type { ApiClient } from '../api/client'
|
||||
import type { RecordedCall } from '../fakes/externals'
|
||||
import { env } from '../env'
|
||||
import { step } from '../support/step'
|
||||
|
||||
/** One customer's conversation with the business on a messaging channel. */
|
||||
export interface ChannelChat {
|
||||
/** Delivers a message from the customer to the backend, as the provider would. */
|
||||
send(text: string): Promise<void>
|
||||
/** Every text the business sent into this chat, oldest first. */
|
||||
replies(): Promise<string[]>
|
||||
}
|
||||
|
||||
/** A chat on a channel that can show reply buttons (WhatsApp, Messenger, Instagram, Telegram). */
|
||||
export interface ButtonChat extends ChannelChat {
|
||||
/** Titles of the buttons under the business's latest message that has any. */
|
||||
buttons(): Promise<string[]>
|
||||
/** Taps one of those buttons, reported the way the provider reports a tap. */
|
||||
tap(title: string): Promise<void>
|
||||
}
|
||||
|
||||
/** A channel account connected to the test org and routed to the test agent. */
|
||||
export interface ConnectedChannel<C extends ChannelChat = ChannelChat> {
|
||||
chatWith(customerName: string): C
|
||||
}
|
||||
|
||||
export interface ConnectContext {
|
||||
api: ApiClient
|
||||
request: APIRequestContext
|
||||
agentId: string
|
||||
}
|
||||
|
||||
/** Connects a fresh account of one channel; every call gets its own account. */
|
||||
export type ChannelConnector<C extends ChannelChat = ChannelChat> = (ctx: ConnectContext) => Promise<ConnectedChannel<C>>
|
||||
|
||||
/**
|
||||
* A ChannelChat from the two things that differ per channel: how a customer
|
||||
* message is delivered and where the business's replies are recorded.
|
||||
*/
|
||||
export class Chat implements ChannelChat {
|
||||
protected readonly channel: string
|
||||
protected readonly customerName: string
|
||||
private readonly deliver: (text: string) => Promise<void>
|
||||
private readonly read: () => Promise<string[]>
|
||||
|
||||
constructor(channel: string, customerName: string, deliver: (text: string) => Promise<void>, read: () => Promise<string[]>) {
|
||||
this.channel = channel
|
||||
this.customerName = customerName
|
||||
this.deliver = deliver
|
||||
this.read = read
|
||||
}
|
||||
|
||||
send(text: string): Promise<void> {
|
||||
return step(`${this.customerName} sends "${text}" on ${this.channel}`, () => this.deliver(text))
|
||||
}
|
||||
|
||||
replies(): Promise<string[]> {
|
||||
return this.read()
|
||||
}
|
||||
}
|
||||
|
||||
export interface Button {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/** A Chat whose business messages can carry buttons the customer taps. `B` is whatever a tap needs to know about a button. */
|
||||
export class ChatWithButtons<B extends Button = Button> extends Chat implements ButtonChat {
|
||||
private readonly latestButtons: () => Promise<B[]>
|
||||
private readonly deliverTap: (button: B) => Promise<void>
|
||||
|
||||
constructor(
|
||||
channel: string,
|
||||
customerName: string,
|
||||
io: {
|
||||
deliver: (text: string) => Promise<void>
|
||||
read: () => Promise<string[]>
|
||||
latestButtons: () => Promise<B[]>
|
||||
deliverTap: (button: B) => Promise<void>
|
||||
},
|
||||
) {
|
||||
super(channel, customerName, io.deliver, io.read)
|
||||
this.latestButtons = io.latestButtons
|
||||
this.deliverTap = io.deliverTap
|
||||
}
|
||||
|
||||
async buttons(): Promise<string[]> {
|
||||
return (await this.latestButtons()).map((b) => b.title)
|
||||
}
|
||||
|
||||
tap(title: string): Promise<void> {
|
||||
return step(`${this.customerName} taps "${title}" on ${this.channel}`, async () => {
|
||||
const button = (await this.latestButtons()).find((b) => b.title === title)
|
||||
if (!button) throw new Error(`no "${title}" button under the latest ${this.channel} message`)
|
||||
await this.deliverTap(button)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type WebhookPayload = { json: string } | { form: Record<string, string> }
|
||||
|
||||
/**
|
||||
* POSTs a provider webhook to the backend and hands back the response, whatever
|
||||
* its status — for tests of what the backend refuses. JSON goes as the exact
|
||||
* string that was signed: re-serialising would change the bytes the signature covers.
|
||||
*/
|
||||
export function sendWebhook(request: APIRequestContext, path: string, payload: WebhookPayload, headers: Record<string, string> = {}): Promise<APIResponse> {
|
||||
return 'json' in payload
|
||||
? request.post(`${env.apiUrl}${path}`, { data: payload.json, headers: { 'content-type': 'application/json', ...headers } })
|
||||
: request.post(`${env.apiUrl}${path}`, { form: payload.form, headers })
|
||||
}
|
||||
|
||||
/** sendWebhook for a delivery that must be accepted. */
|
||||
export async function postWebhook(request: APIRequestContext, path: string, payload: WebhookPayload, headers: Record<string, string> = {}): Promise<void> {
|
||||
const response = await sendWebhook(request, path, payload, headers)
|
||||
if (!response.ok()) throw new Error(`POST ${path} → ${response.status()}: ${(await response.text()).slice(0, 300)}`)
|
||||
}
|
||||
|
||||
export function hmac(algorithm: 'sha1' | 'sha256', secret: string, data: string): Buffer {
|
||||
return createHmac(algorithm, secret).update(data).digest()
|
||||
}
|
||||
|
||||
/** `n` random digits, never starting with 0 — ids and phone numbers no other test uses. */
|
||||
export function digits(n: number): string {
|
||||
let out = String(randomInt(1, 10))
|
||||
while (out.length < n) out += String(randomInt(0, 10))
|
||||
return out
|
||||
}
|
||||
|
||||
/** A UK mobile number in E.164 (what the backend stores as the customer's phone). */
|
||||
export function phoneNumber(): string {
|
||||
return `+447${digits(9)}`
|
||||
}
|
||||
|
||||
/** True when the backend authenticated `call` with `token` — i.e. it was sent as that account. */
|
||||
export function sentWith(call: RecordedCall, token: string): boolean {
|
||||
return call.headers.authorization === `Bearer ${token}`
|
||||
}
|
||||
|
||||
/** Phone numbers compared the way providers differ on them: digits only. */
|
||||
export function samePhone(a: unknown, b: string): boolean {
|
||||
return String(a).replace(/\D/g, '') === b.replace(/\D/g, '')
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { connectEmail, routeToAgent } from '../api/channels'
|
||||
import { env } from '../env'
|
||||
import { Mailbox } from '../fakes/mailbox'
|
||||
import { unique } from '../support/ids'
|
||||
import { step } from '../support/step'
|
||||
import { Chat, postWebhook, type ChannelChat, type ChannelConnector } from './chat'
|
||||
|
||||
/** An email chat, plus what only email has: the subject line every message carries. */
|
||||
export interface EmailChat extends ChannelChat {
|
||||
readonly address: string
|
||||
readonly subject: string
|
||||
/** Message-ID of the customer's latest email, which a threaded reply points back at. */
|
||||
readonly lastMessageId: string | undefined
|
||||
}
|
||||
|
||||
class EmailConversation extends Chat implements EmailChat {
|
||||
readonly address: string
|
||||
readonly subject: string
|
||||
private readonly sent: { messageId?: string }
|
||||
|
||||
constructor(customerName: string, address: string, subject: string, sent: { messageId?: string }, deliver: (text: string) => Promise<void>, read: () => Promise<string[]>) {
|
||||
super('email', customerName, deliver, read)
|
||||
this.address = address
|
||||
this.subject = subject
|
||||
this.sent = sent
|
||||
}
|
||||
|
||||
get lastMessageId(): string | undefined {
|
||||
return this.sent.messageId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A support inbox fed by an inbound-parse webhook (the per-account URL carries a
|
||||
* secret token, there is no signature). Replies go out over SMTP into the stack's
|
||||
* Mailpit, where the customer's address collects them.
|
||||
*/
|
||||
export const email: ChannelConnector<EmailChat> = ({ api, request, agentId }) =>
|
||||
step('Connect a support inbox', async () => {
|
||||
const inbox = `${unique('support')}@e2e.example.com`
|
||||
const account = await connectEmail(api, inbox)
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const token = new URL(account.webhook_url ?? '', env.backendUrl).searchParams.get('token')
|
||||
if (!token) throw new Error(`no token in the email webhook URL ${account.webhook_url}`)
|
||||
const mailbox = new Mailbox(request)
|
||||
|
||||
return {
|
||||
chatWith(customerName) {
|
||||
const address = `${unique('customer').toLowerCase()}@customer.example.com`
|
||||
const subject = `Question from ${customerName}`
|
||||
const sent: { messageId?: string } = {}
|
||||
return new EmailConversation(customerName, address, subject, sent, async (text) => {
|
||||
sent.messageId = `<${unique('msg')}@customer.example.com>`
|
||||
await postWebhook(request, `/webhooks/email/${account.id}?token=${token}`, {
|
||||
json: JSON.stringify({ from: `${customerName} <${address}>`, to: inbox, subject, text, message_id: sent.messageId }),
|
||||
})
|
||||
}, async () => (await mailbox.messagesTo(address)).filter((m) => m.from === inbox).map((m) => m.text.trim()))
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ButtonChat, ChannelConnector } from './chat'
|
||||
import { email } from './email'
|
||||
import { line } from './line'
|
||||
import { instagram, messenger, whatsapp } from './meta'
|
||||
import { plivo, twilio } from './sms'
|
||||
import { slack } from './slack'
|
||||
import { telegram } from './telegram'
|
||||
|
||||
/**
|
||||
* Every messaging channel the suite can drive end to end, by display name. A
|
||||
* journey that should hold on all channels loops over this; adding a channel is
|
||||
* one driver file and one line here.
|
||||
*/
|
||||
export const channels = {
|
||||
Telegram: telegram,
|
||||
WhatsApp: whatsapp,
|
||||
Messenger: messenger,
|
||||
Instagram: instagram,
|
||||
Slack: slack,
|
||||
LINE: line,
|
||||
'SMS (Twilio)': twilio,
|
||||
'SMS (Plivo)': plivo,
|
||||
Email: email,
|
||||
} satisfies Record<string, ChannelConnector>
|
||||
|
||||
/** The channels that show the agent's suggested replies as tappable buttons. */
|
||||
export const channelsWithButtons = {
|
||||
Telegram: telegram,
|
||||
WhatsApp: whatsapp,
|
||||
Messenger: messenger,
|
||||
Instagram: instagram,
|
||||
} satisfies Record<string, ChannelConnector<ButtonChat>>
|
||||
|
||||
export type { EmailChat } from './email'
|
||||
export type { ButtonChat, ChannelChat, ChannelConnector, ConnectedChannel } from './chat'
|
||||
@@ -0,0 +1,43 @@
|
||||
import { connectLine, routeToAgent } from '../api/channels'
|
||||
import { FakeExternals } from '../fakes/externals'
|
||||
import { unique } from '../support/ids'
|
||||
import { step } from '../support/step'
|
||||
import { Chat, digits, hmac, postWebhook, sentWith, type ChannelConnector } from './chat'
|
||||
|
||||
interface LinePush { to: string; messages: Array<{ type: string; text?: string }> }
|
||||
|
||||
/** A LINE Official Account; the webhook is per account and signed with its channel secret. */
|
||||
export const line: ChannelConnector = ({ api, request, agentId }) =>
|
||||
step('Connect a LINE Official Account', async () => {
|
||||
const channelSecret = unique('line-secret')
|
||||
const accessToken = unique('line-token')
|
||||
const account = await connectLine(api, channelSecret, accessToken)
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
|
||||
return {
|
||||
chatWith(customerName) {
|
||||
const userId = `U${digits(32)}`
|
||||
return new Chat('LINE', customerName, async (text) => {
|
||||
const json = JSON.stringify({
|
||||
destination: account.external_account_id,
|
||||
events: [{
|
||||
type: 'message',
|
||||
mode: 'active',
|
||||
timestamp: Date.now(),
|
||||
source: { type: 'user', userId },
|
||||
webhookEventId: unique('ev'),
|
||||
message: { type: 'text', id: digits(14), text },
|
||||
}],
|
||||
})
|
||||
await postWebhook(request, `/webhooks/line/${account.id}`, { json }, {
|
||||
'x-line-signature': hmac('sha256', channelSecret, json).toString('base64'),
|
||||
})
|
||||
}, async () => (await externals.calls('line', 'push', { contains: userId }))
|
||||
.filter((c) => c.status === 200 && sentWith(c, accessToken))
|
||||
.map((c) => c.body as LinePush)
|
||||
.filter((b) => b.to === userId)
|
||||
.flatMap((b) => b.messages.map((m) => m.text ?? '')))
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { connectInstagram, connectMessenger, connectWhatsApp, routeToAgent, type ChannelAccount } from '../api/channels'
|
||||
import type { ApiClient } from '../api/client'
|
||||
import { env } from '../env'
|
||||
import { FakeExternals, type RecordedCall } from '../fakes/externals'
|
||||
import { unique } from '../support/ids'
|
||||
import { step } from '../support/step'
|
||||
import { ChatWithButtons, digits, hmac, phoneNumber, postWebhook, samePhone, sentWith, type Button, type ButtonChat, type ChannelConnector } from './chat'
|
||||
|
||||
// WhatsApp, Messenger and Instagram share one webhook (/webhooks/meta), signed
|
||||
// with the Meta app secret; `object` in the body says which product sent it.
|
||||
// A number or Page from the customer's OWN Meta app is signed with that app's
|
||||
// secret instead, and reaches its account's own address (/webhooks/meta/<id>).
|
||||
|
||||
/** Where Meta posts a webhook, and the app secret it signs it with. */
|
||||
export interface MetaWebhook {
|
||||
path: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
const SHARED_META_WEBHOOK: MetaWebhook = { path: '/webhooks/meta', secret: env.channelSecrets.meta }
|
||||
|
||||
/** The X-Hub-Signature-256 header Meta puts on `json`. */
|
||||
export function metaSignature(json: string, secret: string = env.channelSecrets.meta): Record<string, string> {
|
||||
return { 'x-hub-signature-256': `sha256=${hmac('sha256', secret, json).toString('hex')}` }
|
||||
}
|
||||
|
||||
/** A Meta webhook POST, signed like Meta signs it. */
|
||||
export async function deliverMeta(request: APIRequestContext, payload: Record<string, unknown>, to = SHARED_META_WEBHOOK): Promise<void> {
|
||||
const json = JSON.stringify(payload)
|
||||
await postWebhook(request, to.path, { json }, metaSignature(json, to.secret))
|
||||
}
|
||||
|
||||
/** A Meta access token that owns `nodes` in the fake Graph (see fake-externals). */
|
||||
export function metaToken(...nodes: string[]): string {
|
||||
return `owns:${nodes.join(',')}:${unique('token')}`
|
||||
}
|
||||
|
||||
/** A customer's own Meta app (e.g. the one holding Meta's free test number):
|
||||
* its tokens name it in the fake Graph, and Meta signs its webhooks with its secret. */
|
||||
export interface OwnMetaApp {
|
||||
id: string
|
||||
secret: string
|
||||
token(...nodes: string[]): string
|
||||
}
|
||||
|
||||
export function ownMetaApp(): OwnMetaApp {
|
||||
const id = digits(15)
|
||||
const secret = unique('appsecret')
|
||||
return { id, secret, token: (...nodes) => `owns:${nodes.join(',')}:app=${id}.${secret}:${unique('token')}` }
|
||||
}
|
||||
|
||||
/** The backend path of an absolute webhook URL it handed out. */
|
||||
function webhookPath(url: string): string {
|
||||
return new URL(url).pathname.replace(/^\/api\/v1/, '')
|
||||
}
|
||||
|
||||
/** Where Meta sends a WhatsApp number's webhooks: the per-number callback the
|
||||
* backend set (and the fake accepted), else the app's own callback — which
|
||||
* is the customer's server, so here the shared address, refusing the app's secret. */
|
||||
export async function whereMetaSends(externals: FakeExternals, phoneNumberId: string, app: OwnMetaApp): Promise<MetaWebhook> {
|
||||
const overrides = (await externals.calls('graph', phoneNumberId))
|
||||
.filter((c) => c.method === 'POST' && c.status === 200)
|
||||
.map((c) => (c.body as { webhook_configuration?: { override_callback_uri?: string } }).webhook_configuration)
|
||||
.filter((config) => config !== undefined)
|
||||
const url = overrides.at(-1)?.override_callback_uri
|
||||
return { path: url ? webhookPath(url) : SHARED_META_WEBHOOK.path, secret: app.secret }
|
||||
}
|
||||
|
||||
/** What Meta does when the customer pastes a callback URL and verify token into
|
||||
* their app and saves: a GET that must echo the challenge. */
|
||||
export async function metaVerifiesCallback(request: APIRequestContext, url: string, verifyToken: string): Promise<boolean> {
|
||||
const challenge = unique('challenge')
|
||||
const response = await request.get(url, { params: { 'hub.mode': 'subscribe', 'hub.verify_token': verifyToken, 'hub.challenge': challenge } })
|
||||
return response.ok() && (await response.text()) === challenge
|
||||
}
|
||||
|
||||
/** A pasted callback URL as the webhook Meta posts to, signed with the app's secret. */
|
||||
export function pastedCallback(url: string, app: OwnMetaApp): MetaWebhook {
|
||||
return { path: webhookPath(url), secret: app.secret }
|
||||
}
|
||||
|
||||
// ---------- WhatsApp ----------
|
||||
|
||||
interface WhatsAppSend {
|
||||
to: string
|
||||
type: string
|
||||
text?: { body: string }
|
||||
interactive?: {
|
||||
body: { text: string }
|
||||
action: { buttons?: Array<{ reply: Button }>; sections?: Array<{ rows: Button[] }> }
|
||||
}
|
||||
}
|
||||
|
||||
type WhatsAppChoice = Button & { reply: 'button_reply' | 'list_reply' }
|
||||
|
||||
export interface WhatsAppNumber {
|
||||
account: ChannelAccount
|
||||
phoneNumberId: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/** Connects a WhatsApp Cloud API number by hand (phone number id + system-user token),
|
||||
* from the customer's own Meta app when `app` is given (its token, with its secret). */
|
||||
export async function connectWhatsAppNumber(api: ApiClient, phoneNumberId = digits(15), app?: OwnMetaApp): Promise<WhatsAppNumber> {
|
||||
const token = app ? app.token(phoneNumberId) : metaToken(phoneNumberId)
|
||||
const account = await connectWhatsApp(api, phoneNumberId, token, app?.secret)
|
||||
return { account, phoneNumberId, token }
|
||||
}
|
||||
|
||||
/** The webhook body WhatsApp sends for one inbound message. */
|
||||
export function whatsappPayload(phoneNumberId: string, waId: string, name: string, message: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{ id: digits(15), changes: [{ field: 'messages', value: {
|
||||
messaging_product: 'whatsapp',
|
||||
metadata: { phone_number_id: phoneNumberId },
|
||||
contacts: [{ wa_id: waId, profile: { name } }],
|
||||
messages: [{ from: waId, id: `wamid.${unique('in')}`, timestamp: String(Math.floor(Date.now() / 1000)), ...message }],
|
||||
} }] }],
|
||||
}
|
||||
}
|
||||
|
||||
/** One WhatsApp user's chat with a connected number. */
|
||||
export class WhatsAppChat extends ChatWithButtons<WhatsAppChoice> {
|
||||
readonly waId: string
|
||||
readonly number: WhatsAppNumber
|
||||
|
||||
/** `to` says where Meta sends this number's webhooks (the shared address by default). */
|
||||
constructor(request: APIRequestContext, externals: FakeExternals, number: WhatsAppNumber, customerName: string,
|
||||
to: () => Promise<MetaWebhook> = async () => SHARED_META_WEBHOOK) {
|
||||
const waId = phoneNumber().slice(1) // WhatsApp ids are the number without the +
|
||||
const inbound = async (message: Record<string, unknown>) =>
|
||||
deliverMeta(request, whatsappPayload(number.phoneNumberId, waId, customerName, message), await to())
|
||||
// Only what this number sent: its path, its token. The same endpoint carries
|
||||
// read receipts and typing indicators, which have no `type`.
|
||||
const sends = async () => (await externals.calls('graph', 'messages', { contains: waId }))
|
||||
.filter((c) => c.status === 200 && c.path.includes(`/${number.phoneNumberId}/messages`) && sentWith(c, number.token))
|
||||
.map((c) => c.body as WhatsAppSend)
|
||||
.filter((b) => (b.type === 'text' || b.type === 'interactive') && samePhone(b.to, waId))
|
||||
const latestChoices = async (): Promise<WhatsAppChoice[]> => {
|
||||
const action = (await sends()).filter((b) => b.interactive).at(-1)?.interactive?.action
|
||||
if (action?.sections) return action.sections.flatMap((s) => s.rows).map((r) => ({ ...r, reply: 'list_reply' as const }))
|
||||
return (action?.buttons ?? []).map((b) => ({ ...b.reply, reply: 'button_reply' as const }))
|
||||
}
|
||||
|
||||
super('WhatsApp', customerName, {
|
||||
deliver: (text) => inbound({ type: 'text', text: { body: text } }),
|
||||
read: async () => (await sends()).map((b) => (b.type === 'text' ? b.text!.body : b.interactive!.body.text)),
|
||||
latestButtons: latestChoices,
|
||||
// A pick from a list arrives as list_reply, a button tap as button_reply.
|
||||
deliverTap: (button) => inbound({ type: 'interactive', interactive: { type: button.reply, [button.reply]: { id: button.id, title: button.title } } }),
|
||||
})
|
||||
this.waId = waId
|
||||
this.number = number
|
||||
}
|
||||
}
|
||||
|
||||
export const whatsapp: ChannelConnector<WhatsAppChat> = ({ api, request, agentId }) =>
|
||||
step('Connect a WhatsApp number', async () => {
|
||||
const number = await connectWhatsAppNumber(api)
|
||||
await routeToAgent(api, number.account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
return { chatWith: (customerName) => new WhatsAppChat(request, externals, number, customerName) }
|
||||
})
|
||||
|
||||
// ---------- Messenger and Instagram (the Send API on a page) ----------
|
||||
|
||||
interface PageSend {
|
||||
recipient: { id: string }
|
||||
message?: { text?: string; quick_replies?: Array<{ title: string; payload: string }> }
|
||||
}
|
||||
|
||||
export function pageChat(
|
||||
label: string,
|
||||
request: APIRequestContext,
|
||||
externals: FakeExternals,
|
||||
object: 'page' | 'instagram',
|
||||
pageId: string,
|
||||
token: string,
|
||||
customerName: string,
|
||||
to = SHARED_META_WEBHOOK,
|
||||
): ChatWithButtons {
|
||||
const senderId = digits(16)
|
||||
const provider = object === 'page' ? 'graph' : 'graph-instagram'
|
||||
const inbound = (message: Record<string, unknown>) => deliverMeta(request, {
|
||||
object,
|
||||
entry: [{ id: pageId, time: Date.now(), messaging: [{
|
||||
sender: { id: senderId },
|
||||
recipient: { id: pageId },
|
||||
timestamp: Date.now(),
|
||||
message: { mid: `m_${unique('in')}`, ...message },
|
||||
}] }],
|
||||
}, to)
|
||||
// `/me/messages` names no page: the token is the only proof of which page sent it.
|
||||
const sends = async () => (await externals.calls(provider, 'messages', { contains: senderId }))
|
||||
.filter((c: RecordedCall) => c.status === 200 && sentWith(c, token))
|
||||
.map((c) => c.body as PageSend)
|
||||
.filter((b) => b.recipient?.id === senderId && b.message?.text)
|
||||
|
||||
return new ChatWithButtons(label, customerName, {
|
||||
deliver: (text) => inbound({ text }),
|
||||
read: async () => (await sends()).map((b) => b.message!.text!),
|
||||
latestButtons: async () => ((await sends())
|
||||
.filter((b) => b.message?.quick_replies?.length).at(-1)?.message?.quick_replies ?? [])
|
||||
.map((q) => ({ id: q.payload, title: q.title })),
|
||||
// A quick-reply tap is a message whose text is the title, with the payload attached.
|
||||
deliverTap: (button) => inbound({ text: button.title, quick_reply: { payload: button.id } }),
|
||||
})
|
||||
}
|
||||
|
||||
/** A Facebook Page connected by hand (page id + page access token). */
|
||||
export const messenger: ChannelConnector<ButtonChat> = ({ api, request, agentId }) =>
|
||||
step('Connect a Facebook Page', async () => {
|
||||
const pageId = digits(15)
|
||||
const token = metaToken(pageId)
|
||||
const account = await connectMessenger(api, pageId, token)
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
return { chatWith: (customerName) => pageChat('Messenger', request, externals, 'page', pageId, token, customerName) }
|
||||
})
|
||||
|
||||
/** An Instagram professional account connected by hand (IG user id + page token). */
|
||||
export const instagram: ChannelConnector<ButtonChat> = ({ api, request, agentId }) =>
|
||||
step('Connect an Instagram account', async () => {
|
||||
const igId = digits(17)
|
||||
const token = metaToken(igId)
|
||||
const account = await connectInstagram(api, igId, token)
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
return { chatWith: (customerName) => pageChat('Instagram', request, externals, 'instagram', igId, token, customerName) }
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { installSlack, listChannelAccounts, routeToAgent, type ChannelAccount } from '../api/channels'
|
||||
import type { ApiClient } from '../api/client'
|
||||
import { env } from '../env'
|
||||
import { FakeExternals } from '../fakes/externals'
|
||||
import { unique } from '../support/ids'
|
||||
import { step } from '../support/step'
|
||||
import { Chat, digits, hmac, postWebhook, sentWith, type ChannelConnector } from './chat'
|
||||
|
||||
interface OAuthAnswer { team: { id: string }; access_token: string }
|
||||
interface SlackPost { channel: string; text: string }
|
||||
|
||||
/** The "typing…" placeholder the backend posts and then edits into the reply. */
|
||||
const TYPING_PLACEHOLDER = '_typing…_'
|
||||
|
||||
/** Slack's v0 request signature over `v0:<timestamp>:<body>`. */
|
||||
export function slackSignature(json: string, timestamp: string): string {
|
||||
return `v0=${hmac('sha256', env.channelSecrets.slack, `v0:${timestamp}:${json}`).toString('hex')}`
|
||||
}
|
||||
|
||||
export interface SlackWorkspace {
|
||||
account: ChannelAccount
|
||||
teamId: string
|
||||
botToken: string
|
||||
}
|
||||
|
||||
/** Installs the Slack app in a fresh workspace through the OAuth flow. */
|
||||
export async function installSlackWorkspace(api: ApiClient, request: APIRequestContext): Promise<SlackWorkspace> {
|
||||
const externals = new FakeExternals(request)
|
||||
const code = unique('slack-code')
|
||||
await installSlack(api, code)
|
||||
// The workspace is whatever Slack answered the code exchange with.
|
||||
const exchange = (await externals.calls('slack', 'oauth.v2.access', { contains: code }))[0]
|
||||
const answer = exchange?.response as OAuthAnswer | undefined
|
||||
const account = (await listChannelAccounts(api)).find((a) => a.channel_type === 'slack' && a.external_account_id === answer?.team.id)
|
||||
if (!answer || !account) throw new Error(`no Slack account after the OAuth callback for code ${code}`)
|
||||
return { account, teamId: answer.team.id, botToken: answer.access_token }
|
||||
}
|
||||
|
||||
/** A Slack workspace installed through the OAuth flow; customers DM the bot. */
|
||||
export const slack: ChannelConnector = ({ api, request, agentId }) =>
|
||||
step('Install the Slack app in a workspace', async () => {
|
||||
const { account, teamId, botToken } = await installSlackWorkspace(api, request)
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
|
||||
return {
|
||||
chatWith(customerName) {
|
||||
const user = `U${digits(10)}`
|
||||
const channel = `D${digits(10)}`
|
||||
return new Chat('Slack', customerName, async (text) => {
|
||||
const ts = `${Math.floor(Date.now() / 1000)}.${digits(6)}`
|
||||
const json = JSON.stringify({
|
||||
type: 'event_callback',
|
||||
team_id: teamId,
|
||||
event_id: `Ev${digits(10)}`,
|
||||
event: { type: 'message', channel_type: 'im', channel, user, text, ts },
|
||||
})
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
await postWebhook(request, '/webhooks/slack', { json }, { 'x-slack-request-timestamp': timestamp, 'x-slack-signature': slackSignature(json, timestamp) })
|
||||
}, async () => (await externals.calls('slack', undefined, { contains: channel }))
|
||||
// A reply is the placeholder edited by chat.update, or a fresh chat.postMessage,
|
||||
// sent with this workspace's bot token.
|
||||
.filter((c) => (c.action === 'chat.update' || c.action === 'chat.postMessage') && c.status === 200 && sentWith(c, botToken))
|
||||
.map((c) => c.body as SlackPost)
|
||||
.filter((b) => b.channel === channel && b.text !== TYPING_PLACEHOLDER)
|
||||
.map((b) => b.text))
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { connectSms, routeToAgent } from '../api/channels'
|
||||
import { env } from '../env'
|
||||
import { FakeExternals } from '../fakes/externals'
|
||||
import { unique } from '../support/ids'
|
||||
import { step } from '../support/step'
|
||||
import { Chat, hmac, phoneNumber, postWebhook, samePhone, type ChannelConnector } from './chat'
|
||||
|
||||
// SMS webhooks are per account and signed over the public webhook URL, which the
|
||||
// backend rebuilds from BACKEND_URL — the same URL the test posts to.
|
||||
|
||||
interface TwilioSend { From: string; To: string; Body: string }
|
||||
interface PlivoSend { src: string; dst: string; text: string }
|
||||
|
||||
/** Twilio: X-Twilio-Signature = base64(HMAC-SHA1(auth token, url + sorted key/value pairs)). */
|
||||
export const twilio: ChannelConnector = ({ api, request, agentId }) =>
|
||||
step('Connect a Twilio number', async () => {
|
||||
const authToken = unique('twilio-token')
|
||||
const businessNumber = phoneNumber()
|
||||
const accountSid = unique('AC')
|
||||
const account = await connectSms(api, 'twilio', businessNumber, { account_sid: accountSid, auth_token: authToken })
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
const path = `/webhooks/sms/twilio/${account.id}`
|
||||
|
||||
return {
|
||||
chatWith(customerName) {
|
||||
const customerNumber = phoneNumber()
|
||||
return new Chat('SMS (Twilio)', customerName, async (text) => {
|
||||
const form = { From: customerNumber, To: businessNumber, Body: text, MessageSid: unique('SM') }
|
||||
const signed = `${env.apiUrl}${path}` + Object.keys(form).sort().map((k) => k + form[k as keyof typeof form]).join('')
|
||||
await postWebhook(request, path, { form }, { 'x-twilio-signature': hmac('sha1', authToken, signed).toString('base64') })
|
||||
}, async () => (await externals.calls('twilio', 'Messages.json', { contains: accountSid }))
|
||||
// Sent through this account (its SID in the path) from this number.
|
||||
.filter((c) => c.status === 200 && c.path.includes(`/Accounts/${accountSid}/`))
|
||||
.map((c) => c.body as TwilioSend)
|
||||
.filter((b) => samePhone(b.To, customerNumber) && samePhone(b.From, businessNumber))
|
||||
.map((b) => b.Body))
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
/** Plivo: X-Plivo-Signature-V3 = base64(HMAC-SHA256(auth token, url + nonce)). */
|
||||
export const plivo: ChannelConnector = ({ api, request, agentId }) =>
|
||||
step('Connect a Plivo number', async () => {
|
||||
const authToken = unique('plivo-token')
|
||||
const businessNumber = phoneNumber()
|
||||
const authId = unique('MA')
|
||||
const account = await connectSms(api, 'plivo', businessNumber, { auth_id: authId, auth_token: authToken })
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
const externals = new FakeExternals(request)
|
||||
const path = `/webhooks/sms/plivo/${account.id}`
|
||||
|
||||
return {
|
||||
chatWith(customerName) {
|
||||
// Plivo sends numbers without the leading +.
|
||||
const customerNumber = phoneNumber().slice(1)
|
||||
return new Chat('SMS (Plivo)', customerName, async (text) => {
|
||||
const nonce = unique('nonce')
|
||||
await postWebhook(request, path, {
|
||||
form: { From: customerNumber, To: businessNumber.slice(1), Text: text, MessageUUID: unique('uuid') },
|
||||
}, {
|
||||
'x-plivo-signature-v3': hmac('sha256', authToken, `${env.apiUrl}${path}${nonce}`).toString('base64'),
|
||||
'x-plivo-signature-v3-nonce': nonce,
|
||||
})
|
||||
}, async () => (await externals.calls('plivo', 'Message', { contains: authId }))
|
||||
.filter((c) => c.status === 200 && c.path.includes(`/Account/${authId}/`))
|
||||
.map((c) => c.body as PlivoSend)
|
||||
.filter((b) => samePhone(b.dst, customerNumber) && samePhone(b.src, businessNumber))
|
||||
.map((b) => b.text))
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { randomInt } from 'node:crypto'
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { connectTelegram, routeToAgent, type ChannelAccount } from '../api/channels'
|
||||
import type { ApiClient } from '../api/client'
|
||||
import { env } from '../env'
|
||||
import { FakeExternals } from '../fakes/externals'
|
||||
import { unique } from '../support/ids'
|
||||
import { step } from '../support/step'
|
||||
import type { ChannelConnector } from './chat'
|
||||
|
||||
interface SetWebhookBody {
|
||||
url: string
|
||||
secret_token: string
|
||||
}
|
||||
|
||||
interface SendMessageBody {
|
||||
chat_id: number | string
|
||||
text: string
|
||||
reply_markup?: { inline_keyboard?: Array<Array<{ text: string; callback_data: string }>> }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays Telegram for one connected bot: delivers user messages to the backend's
|
||||
* webhook exactly as Telegram would (secret token header included) and reads the
|
||||
* bot's replies from what the backend sent to the fake Bot API.
|
||||
*/
|
||||
export class TelegramBot {
|
||||
readonly account: ChannelAccount
|
||||
private readonly request: APIRequestContext
|
||||
private readonly externals: FakeExternals
|
||||
private readonly secret: string
|
||||
private readonly token: string
|
||||
private updateId = 0
|
||||
|
||||
private constructor(account: ChannelAccount, request: APIRequestContext, externals: FakeExternals, secret: string, token: string) {
|
||||
this.account = account
|
||||
this.request = request
|
||||
this.externals = externals
|
||||
this.secret = secret
|
||||
this.token = token
|
||||
}
|
||||
|
||||
/** Connects a fresh bot to the org and routes it to `agentId`. */
|
||||
static async connect(api: ApiClient, request: APIRequestContext, agentId: string): Promise<TelegramBot> {
|
||||
return step('Connect a Telegram bot', async () => {
|
||||
const externals = new FakeExternals(request)
|
||||
const token = `${randomInt(1e9)}:${unique('E2E')}`
|
||||
const account = await connectTelegram(api, token)
|
||||
await routeToAgent(api, account.id, agentId)
|
||||
// The secret Telegram would echo back — the backend sent it in setWebhook.
|
||||
const webhook = await externals.waitForCall<SetWebhookBody>('telegram', 'setWebhook', (body) => body.url.includes(account.id))
|
||||
return new TelegramBot(account, request, externals, webhook.secret_token, token)
|
||||
})
|
||||
}
|
||||
|
||||
/** A private chat with one Telegram user. */
|
||||
chatWith(firstName: string): TelegramChat {
|
||||
return new TelegramChat(this, randomInt(1e9), firstName)
|
||||
}
|
||||
|
||||
async deliver(update: Record<string, unknown>): Promise<void> {
|
||||
this.updateId += 1
|
||||
const response = await this.request.post(`${env.apiUrl}/webhooks/telegram/${this.account.id}`, {
|
||||
headers: { 'x-telegram-bot-api-secret-token': this.secret },
|
||||
data: { update_id: this.updateId, ...update },
|
||||
})
|
||||
if (!response.ok()) throw new Error(`Telegram webhook → ${response.status()}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
/** What the bot sent into `chatId`, with the message_id Telegram assigned each. */
|
||||
async messagesTo(chatId: number): Promise<Array<SendMessageBody & { message_id: number }>> {
|
||||
// Only what this bot sent: the Bot API puts the token in the path.
|
||||
const calls = await this.externals.calls('telegram', 'sendMessage', { contains: `/bot${this.token}/` })
|
||||
return calls
|
||||
.filter((c) => c.status === 200 && String((c.body as SendMessageBody).chat_id) === String(chatId))
|
||||
.map((c) => ({ ...(c.body as SendMessageBody), message_id: (c.response as { result: { message_id: number } }).result.message_id }))
|
||||
}
|
||||
}
|
||||
|
||||
export class TelegramChat {
|
||||
private readonly bot: TelegramBot
|
||||
readonly chatId: number
|
||||
readonly firstName: string
|
||||
private messageId = 0
|
||||
|
||||
constructor(bot: TelegramBot, chatId: number, firstName: string) {
|
||||
this.bot = bot
|
||||
this.chatId = chatId
|
||||
this.firstName = firstName
|
||||
}
|
||||
|
||||
async send(text: string): Promise<void> {
|
||||
await step(`${this.firstName} sends "${text}" on Telegram`, async () => {
|
||||
this.messageId += 1
|
||||
const user = { id: this.chatId, is_bot: false, first_name: this.firstName }
|
||||
await this.bot.deliver({
|
||||
message: { message_id: this.messageId, date: Math.floor(Date.now() / 1000), chat: { ...user, type: 'private' }, from: user, text },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Every message the bot has sent into this chat, oldest first. */
|
||||
async replies(): Promise<string[]> {
|
||||
return (await this.bot.messagesTo(this.chatId)).map((m) => m.text)
|
||||
}
|
||||
|
||||
/** The buttons under the bot's latest message that has any. */
|
||||
async buttons(): Promise<string[]> {
|
||||
const withKeyboard = (await this.bot.messagesTo(this.chatId)).filter((m) => m.reply_markup?.inline_keyboard?.length)
|
||||
return (withKeyboard.at(-1)?.reply_markup?.inline_keyboard ?? []).flat().map((b) => b.text)
|
||||
}
|
||||
|
||||
/** Taps a button under the bot's latest keyboard, as Telegram reports it (callback_query). */
|
||||
async tap(title: string): Promise<void> {
|
||||
await step(`${this.firstName} taps "${title}" on Telegram`, async () => {
|
||||
const keyboardMessage = (await this.bot.messagesTo(this.chatId)).filter((m) => m.reply_markup?.inline_keyboard?.length).at(-1)
|
||||
const button = (keyboardMessage?.reply_markup?.inline_keyboard ?? []).flat().find((b) => b.text === title)
|
||||
if (!keyboardMessage || !button) throw new Error(`no "${title}" button in the latest Telegram keyboard`)
|
||||
const user = { id: this.chatId, is_bot: false, first_name: this.firstName }
|
||||
await this.bot.deliver({
|
||||
callback_query: {
|
||||
id: `cb-${Date.now()}`,
|
||||
from: user,
|
||||
// The message the keyboard is attached to — the bot's, not the user's —
|
||||
// with its keyboard, which is where the backend reads the button title.
|
||||
message: {
|
||||
message_id: keyboardMessage.message_id,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: { ...user, type: 'private' },
|
||||
reply_markup: keyboardMessage.reply_markup,
|
||||
},
|
||||
data: button.callback_data,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Telegram as a ChannelConnector, for journeys that loop over every channel. */
|
||||
export const telegram: ChannelConnector<TelegramChat> = ({ api, request, agentId }) => TelegramBot.connect(api, request, agentId)
|
||||
Reference in new issue
Block a user