docs: 交付文档、维护指引与端到端回归

补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
WangShen007 committed 2026-10-21 21:40:00 +08:00
1 parent 18732801b5
commit 6cd904d460
216 files changed
+37038

No files matched your search

+145
View File
@@ -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, '')
}
+60
View File
@@ -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()))
},
}
})
+35
View File
@@ -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'
+43
View File
@@ -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 ?? '')))
},
}
})
+232
View File
@@ -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) }
})
+70
View File
@@ -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))
},
}
})
+72
View File
@@ -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))
},
}
})
+140
View File
@@ -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)