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

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

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
// What each provider rejects, from its own published limits — deliberately NOT
// the backend's constants (backend/app/channels/interactive_limits.py), so a
// backend that drifts past a real limit gets the same 400 it would in production.
//
// Each check returns the provider-style error message, or null when the payload
// would be accepted.
type Body = Record<string, unknown> | null
// Provider payloads are walked loosely; typing every Graph/Bot API shape buys nothing here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Loose = any
const len = (value: unknown): number => (typeof value === 'string' ? [...value].length : 0)
const bytes = (value: unknown): number => (typeof value === 'string' ? Buffer.byteLength(value) : 0)
function tooLong(field: string, value: unknown, max: number): string | null {
return len(value) > max ? `${field} is ${len(value)} characters; the limit is ${max}` : null
}
function first(...checks: Array<string | null>): string | null {
return checks.find((c) => c !== null) ?? null
}
// https://developers.facebook.com/docs/whatsapp/cloud-api/messages
function whatsapp(body: Body): string | null {
if (!body || body.messaging_product !== 'whatsapp' || !body.type) return null // receipts, typing
if (body.type === 'text') return tooLong('text.body', (body.text as Body)?.body, 4096)
if (body.type !== 'interactive') return null
const interactive = body.interactive as Record<string, Loose>
const bodyText = tooLong('interactive.body.text', interactive?.body?.text, 1024)
if (interactive?.type === 'button') {
const buttons: Loose[] = interactive.action?.buttons ?? []
if (buttons.length < 1 || buttons.length > 3) return `interactive buttons must number 1-3, got ${buttons.length}`
return first(bodyText, ...buttons.map((b) => first(
tooLong('button title', b.reply?.title, 20),
bytes(b.reply?.id) > 256 ? 'button id is over 256 bytes' : null,
)))
}
if (interactive?.type === 'list') {
const sections: Loose[] = interactive.action?.sections ?? []
const rows = sections.flatMap((s) => s.rows ?? [])
if (sections.length > 10) return `a list allows 10 sections, got ${sections.length}`
if (rows.length < 1 || rows.length > 10) return `a list allows 1-10 rows in total, got ${rows.length}`
return first(bodyText, tooLong('list button', interactive.action?.button, 20), ...rows.map((r) => first(
tooLong('row title', r.title, 24),
tooLong('row description', r.description, 72),
bytes(r.id) > 200 ? 'row id is over 200 bytes' : null,
)))
}
return bodyText
}
// https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies
function pageSend(textMax: number) {
return (body: Body): string | null => {
const message = body?.message as Record<string, Loose> | undefined
if (!message) return null // sender_action (typing)
const replies: Loose[] = message.quick_replies ?? []
if (replies.length > 13) return `quick_replies allows 13, got ${replies.length}`
return first(tooLong('message.text', message.text, textMax), ...replies.map((q) => tooLong('quick reply title', q.title, 20)))
}
}
// https://core.telegram.org/bots/api#sendmessage
function telegram(action: string, body: Body): string | null {
if (action !== 'sendMessage') return null
const keyboard: Loose[] = (body?.reply_markup as Record<string, Loose> | undefined)?.inline_keyboard?.flat() ?? []
return first(
tooLong('text', body?.text, 4096),
...keyboard.map((b) => (bytes(b.callback_data) > 64 ? 'callback_data is over 64 bytes' : null)),
)
}
// https://developers.line.biz/en/reference/messaging-api/#send-push-message
function line(action: string, body: Body): string | null {
if (action !== 'push') return null
const messages: Loose[] = (body?.messages as Loose[]) ?? []
if (messages.length < 1 || messages.length > 5) return `messages must number 1-5, got ${messages.length}`
return first(...messages.map((m) => tooLong('text', m.text, 5000)))
}
/** The provider's rejection for this call, or null. `provider` is the fake's path prefix. */
export function rejection(provider: string, action: string, method: string, body: Body): string | null {
if (method !== 'POST') return null
switch (provider) {
case 'graph': return action === 'messages' ? (body?.messaging_product ? whatsapp(body) : pageSend(2000)(body)) : null
case 'graph-instagram': return action === 'messages' ? pageSend(1000)(body) : null
case 'telegram': return telegram(action, body)
case 'line': return line(action, body)
case 'twilio': return action === 'Messages.json' ? tooLong('Body', body?.Body, 1600) : null
case 'plivo': return action === 'Message' ? tooLong('text', body?.text, 1600) : null
case 'slack': return action === 'chat.postMessage' || action === 'chat.update' ? tooLong('text', body?.text, 40000) : null
default: return null
}
}