feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent ba91abc4b2
commit 83a16ed07d
225 files changed
+59337

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { ChannelAccount } from '@/services/channels'
/** The integration-card warning for accounts whose platform stopped accepting
* their credentials, or undefined when every account is healthy. */
export const reconnectWarning = (accounts: ChannelAccount[]): string | undefined => {
const expired = accounts.filter(a => a.needs_reconnect)
if (!expired.length) return undefined
const names = expired.map(a => a.display_name || a.external_account_id).join(', ')
return `以下账号的访问权限已过期:${names},请重新连接以恢复回复。`
}
/** The account a card's Manage opens: one that needs reconnecting first, so
* the card's warning leads to its fix, else the first connected. */
export const accountToManage = (accounts: ChannelAccount[]): ChannelAccount | null =>
accounts.find(a => a.needs_reconnect) ?? accounts[0] ?? null
+99
View File
@@ -0,0 +1,99 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Connector timeout, in seconds. Bounds the MCP startup handshake and each
* later tool call. Cold `npx` launches resolve and download the package
* first, so the default is generous.
*/
export const DEFAULT_MCP_TIMEOUT = 30
export const MIN_MCP_TIMEOUT = 1
export const MAX_MCP_TIMEOUT = 300
/**
* Keep the timeout inside the range the API accepts. A number input's
* min/max don't block submission, and the resulting 422 carries a validation
* payload rather than a message a toast can show.
*/
export function clampMCPTimeout(value: number | undefined | null): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return DEFAULT_MCP_TIMEOUT
}
return Math.min(Math.max(Math.round(value), MIN_MCP_TIMEOUT), MAX_MCP_TIMEOUT)
}
/**
* What the API sends instead of a stored environment/header value. Sending it
* back unchanged keeps the stored credential, so editing an unrelated field
* never forces the operator to mint a new one — providers typically show an
* API key exactly once. Must match SECRET_MASK in the backend schema.
*/
export const SECRET_MASK = '********'
/** Record -> "KEY=value" lines, for the textarea editors. */
export function recordToLines(record: Record<string, string> | undefined | null): string {
return Object.entries(record || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n')
}
/** "KEY=value" lines -> record. Lines without '=' are ignored. */
export function linesToRecord(lines: string): Record<string, string> {
const result: Record<string, string> = {}
for (const line of lines.split('\n')) {
const idx = line.indexOf('=')
if (idx > 0) result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
}
return result
}
/**
* Ceiling on a connector's usage guidance. Must match MAX_USAGE_GUIDANCE_CHARS
* in the backend schema — if it drifts, the operator hits a bare 422 toast
* instead of the textarea stopping them.
*/
export const MAX_GUIDANCE_CHARS = 2000
/**
* Copy for the connector guidance field. Lives here because the field appears
* in two structurally unrelated forms — the investigation connector list and
* the agent MCP tools modal — and the wording must not drift between them.
*/
export const CONNECTOR_GUIDANCE_LABEL = '如何查询此数据源'
export const CONNECTOR_GUIDANCE_HINT =
'AI 使用此连接器时会收到这段说明。请写清应查询的索引、项目或数据表,' +
'以及用于识别客户或订单的字段。选填——留空则由 AI 自行探索。'
/**
* Deliberately written as instructions, not as a description. Tested against a
* real MCP server and a real model: naming the field ("Order id is
* fields.order_ref") got the right index but a bare keyword query and a false
* "no such order". Telling it what to *do* ("query app-logs-* with
* fields.order_ref:<ref> — always name that field") found the record in one
* call. Operators copy this example, so it has to model the phrasing that
* works.
*
* The last line matters just as much: a query against a field that does not
* exist returns zero hits rather than an error, so without the warning the AI
* reads a clean "no results" and concludes the record is absent.
*/
export const CONNECTOR_GUIDANCE_EXAMPLE = `索引:app-logs-*(每次请求一条文档)、payment-logs-*(Stripe Webhook)。
要查找订单,请用 \`fields.order_ref:<订单号>\` 查询 app-logs-*——
务必显式指定该字段;只搜关键词不会命中。
时间戳字段为 @timestamp,UTC,保留 30 天。
不存在 order_id 字段;用它匹配只会返回 0 条而不是报错,
所以结果为空并不能证明订单不存在。`
+253
View File
@@ -0,0 +1,253 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const SDK_SRC = 'https://connect.facebook.net/en_US/sdk.js'
const SDK_SCRIPT_ID = 'facebook-jssdk'
/** Where each provider's OAuth dialog lives. Instagram Login is a different
* provider, not a Facebook dialog, so it has its own endpoint. */
const FACEBOOK_DIALOG_BASE = 'https://www.facebook.com'
const INSTAGRAM_AUTHORIZE_URL = 'https://www.instagram.com/oauth/authorize'
/**
* Static page Meta redirects the Login-for-Business popup to (see
* public/meta-oauth-callback.html). Its full URL — origin + this path — is the
* redirect_uri, so it must be registered as a Valid OAuth Redirect URI and is
* sent to the server to exchange the code against.
*/
export const META_OAUTH_CALLBACK_PATH = '/meta-oauth-callback.html'
/**
* Origins Embedded Signup posts its result from.
*
* Meta's own sample checks `origin.endsWith('facebook.com')`, which any
* attacker can satisfy by registering evil-facebook.com. The signup result
* names the WhatsApp number we then connect, so this is an exact allowlist.
*/
const TRUSTED_ORIGINS = [
'https://www.facebook.com',
'https://web.facebook.com',
'https://business.facebook.com',
]
/** Channels connectable through a Meta login popup. */
export type SignupChannel = 'whatsapp' | 'messenger' | 'instagram'
export interface SignupSession {
waba_id: string
phone_number_id: string
}
interface FacebookSdk {
init(options: Record<string, unknown>): void
login(
callback: (response: { authResponse?: { code?: string } }) => void,
options: Record<string, unknown>,
): void
}
declare global {
interface Window {
FB?: FacebookSdk
fbAsyncInit?: () => void
}
}
let sdkPromise: Promise<FacebookSdk> | null = null
/** Load and initialise Meta's JS SDK once per page, for a given app. */
export const loadMetaSdk = (appId: string, graphVersion: string): Promise<FacebookSdk> => {
if (sdkPromise) return sdkPromise
sdkPromise = new Promise<FacebookSdk>((resolve, reject) => {
if (window.FB) {
resolve(window.FB)
return
}
window.fbAsyncInit = () => {
window.FB?.init({
appId,
autoLogAppEvents: true,
xfbml: false,
version: graphVersion,
})
if (window.FB) resolve(window.FB)
else reject(new Error('Meta SDK 已加载但未初始化'))
}
if (document.getElementById(SDK_SCRIPT_ID)) return
const script = document.createElement('script')
script.id = SDK_SCRIPT_ID
script.src = SDK_SRC
script.async = true
script.defer = true
script.crossOrigin = 'anonymous'
script.onerror = () => {
// Let a later attempt retry rather than caching the failure forever —
// and take the dead tag with it. Left in place, the retry's `already
// present?` check above would find it and return from inside the promise
// executor without resolving or rejecting, so `await loadMetaSdk()` would
// hang forever: the Embedded Signup button simply never appears, and no
// catch ever runs to say why.
script.remove()
sdkPromise = null
reject(new Error('无法加载 Meta SDK'))
}
document.body.appendChild(script)
})
return sdkPromise
}
/**
* Read a signup result out of a postMessage, or null if it isn't one.
*
* WhatsApp Embedded Signup only: it reports the created WABA and phone number
* through postMessage while the authorization code comes back via the FB.login
* callback, so the two halves must be correlated by the caller. Facebook Login
* for Business (Messenger/Instagram) has no postMessage — its code alone is
* enough — so this is not part of that flow.
*/
export const parseSignupMessage = (event: MessageEvent): SignupSession | null => {
if (!TRUSTED_ORIGINS.includes(event.origin)) return null
if (typeof event.data !== 'string') return null
try {
const payload = JSON.parse(event.data)
if (payload?.type !== 'WA_EMBEDDED_SIGNUP' || payload.event !== 'FINISH') return null
const { waba_id: wabaId, phone_number_id: phoneNumberId } = payload.data ?? {}
if (!wabaId || !phoneNumberId) return null
return { waba_id: String(wabaId), phone_number_id: String(phoneNumberId) }
} catch {
// Facebook posts unrelated non-JSON messages on these origins too.
return null
}
}
/**
* Options for the Meta login popup that onboards a channel.
*
* `extras.setup` is WhatsApp Embedded Signup's own parameter — Facebook Login
* for Business is a plain permissions grant and takes none, so passing it would
* open the wrong flow.
*/
export const signupLoginOptions = (configId: string, channel: SignupChannel = 'whatsapp') => ({
config_id: configId,
response_type: 'code',
override_default_response_type: true,
...(channel === 'whatsapp' ? { extras: { setup: {} } } : {}),
})
export interface BusinessLoginConfig {
appId: string
configId: string
graphVersion: string
/** window.location.origin + META_OAUTH_CALLBACK_PATH */
redirectUri: string
}
export interface InstagramLoginConfig {
/** The Instagram app id — not the Meta one; Instagram Login is its own app. */
appId: string
/** window.location.origin + META_OAUTH_CALLBACK_PATH */
redirectUri: string
}
/** What Instagram Login must grant us: read the account, and handle its DMs. */
const INSTAGRAM_SCOPES = 'instagram_business_basic,instagram_business_manage_messages'
/**
* Run an OAuth authorization in a popup and resolve the code it returns.
*
* Why we drive the dialog ourselves rather than use FB.login: the JS SDK binds
* the code to an internal xd_arbiter redirect that regenerates every login, so
* it can never be a registered Valid OAuth Redirect URI — fatal once the app
* enforces Strict Mode (which it does, non-negotiably). Owning the callback URL
* means the code is bound to a value the server can reproduce at exchange time.
*
* `buildUrl` receives the CSRF state to embed; the provider echoes it back on
* the redirect and we accept only a match. Resolves with the code, rejects on
* error, popup-block, or the user closing the window ('cancelled').
*/
const runOAuthPopup = (buildUrl: (state: string) => string): Promise<string> =>
new Promise((resolve, reject) => {
const state = crypto.randomUUID()
const popup = window.open(buildUrl(state), 'meta-login', 'width=600,height=720')
if (!popup) {
reject(new Error('弹窗被拦截,请允许此站点弹出窗口后重试'))
return
}
const cleanup = () => {
window.removeEventListener('message', onMessage)
window.clearInterval(closedTimer)
}
const onMessage = (event: MessageEvent) => {
// The callback page is same-origin, so anything else is not our reply.
if (event.origin !== window.location.origin) return
const data = event.data
if (!data || data.source !== 'meta-oauth') return
cleanup()
try { popup.close() } catch { /* already closed */ }
if (data.state !== state) reject(new Error('无法验证登录'))
else if (data.error) reject(new Error(data.error))
else if (!data.code) reject(new Error('登录未返回授权码'))
else resolve(data.code)
}
const closedTimer = window.setInterval(() => {
if (popup.closed) { cleanup(); reject(new Error('cancelled')) }
}, 500)
window.addEventListener('message', onMessage)
})
/**
* Facebook Login for Business — used by Messenger, which connects a Page.
*
* The same redirectUri must go to the token exchange, so the caller passes it
* on to the server.
*/
export const runBusinessLogin = (config: BusinessLoginConfig): Promise<string> =>
runOAuthPopup((state) =>
`${FACEBOOK_DIALOG_BASE}/${config.graphVersion}/dialog/oauth?` +
new URLSearchParams({
client_id: config.appId,
config_id: config.configId,
response_type: 'code',
override_default_response_type: 'true',
redirect_uri: config.redirectUri,
state,
display: 'popup',
}).toString())
/**
* Instagram Business Login — a different provider entirely, not a Facebook
* dialog: the business signs in with Instagram, so no Facebook Page is involved
* and the resulting token is an Instagram user token.
*/
export const runInstagramLogin = (config: InstagramLoginConfig): Promise<string> =>
runOAuthPopup((state) =>
`${INSTAGRAM_AUTHORIZE_URL}?` +
new URLSearchParams({
client_id: config.appId,
redirect_uri: config.redirectUri,
response_type: 'code',
scope: INSTAGRAM_SCOPES,
state,
// Make the business pick the account deliberately rather than silently
// reusing whoever is already signed in on this browser.
force_reauth: 'true',
}).toString())
+159
View File
@@ -0,0 +1,159 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* The languages Meta accepts for a WhatsApp message template, transcribed from
* https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/supported-languages
*
* This list exists only to stop typos reaching Graph — Meta remains the
* authority on what it accepts, and the server passes the code through
* untouched. A language Meta adds after this was written is simply missing from
* the picker until we update it; it is never rejected by us, and a template
* already using one still displays (see languageLabel).
*
* Codes are not uniformly `xx` or `xx_YY`: `fil` and `prs_AF` have three-letter
* languages, `es_CRI` a three-letter region. Do not "tidy" them.
*/
export interface WhatsAppLanguage {
code: string
label: string
}
/** Meta's own names, kept verbatim so they can be cross-referenced against the
* Business Manager UI. Sorted by label here rather than at runtime — a sort on
* import hides the ordering from review and re-runs on every module load. */
export const WHATSAPP_LANGUAGES: readonly WhatsAppLanguage[] = [
{ code: 'af', label: 'Afrikaans' },
{ code: 'sq', label: 'Albanian' },
{ code: 'ar', label: 'Arabic' },
{ code: 'ar_EG', label: 'Arabic (EGY)' },
{ code: 'ar_LB', label: 'Arabic (LBN)' },
{ code: 'ar_MA', label: 'Arabic (MAR)' },
{ code: 'ar_QA', label: 'Arabic (QAT)' },
{ code: 'ar_AE', label: 'Arabic (UAE)' },
{ code: 'az', label: 'Azerbaijani' },
{ code: 'be_BY', label: 'Belarusian' },
{ code: 'bn', label: 'Bengali' },
{ code: 'bn_IN', label: 'Bengali (IND)' },
{ code: 'bg', label: 'Bulgarian' },
{ code: 'ca', label: 'Catalan' },
{ code: 'zh_CN', label: 'Chinese (CHN)' },
{ code: 'zh_HK', label: 'Chinese (HKG)' },
{ code: 'zh_TW', label: 'Chinese (TAI)' },
{ code: 'hr', label: 'Croatian' },
{ code: 'cs', label: 'Czech' },
{ code: 'da', label: 'Danish' },
{ code: 'prs_AF', label: 'Dari' },
{ code: 'nl', label: 'Dutch' },
{ code: 'nl_BE', label: 'Dutch (BEL)' },
{ code: 'en', label: 'English' },
{ code: 'en_AU', label: 'English (AUS)' },
{ code: 'en_CA', label: 'English (CAN)' },
{ code: 'en_GH', label: 'English (GHA)' },
{ code: 'en_IN', label: 'English (IND)' },
{ code: 'en_IE', label: 'English (IRL)' },
{ code: 'en_JM', label: 'English (JAM)' },
{ code: 'en_MY', label: 'English (MYS)' },
{ code: 'en_NZ', label: 'English (NZL)' },
{ code: 'en_QA', label: 'English (QAT)' },
{ code: 'en_SG', label: 'English (SGP)' },
{ code: 'en_AE', label: 'English (UAE)' },
{ code: 'en_UG', label: 'English (UGA)' },
{ code: 'en_GB', label: 'English (UK)' },
{ code: 'en_US', label: 'English (US)' },
{ code: 'en_ZA', label: 'English (ZAF)' },
{ code: 'et', label: 'Estonian' },
{ code: 'fil', label: 'Filipino' },
{ code: 'fi', label: 'Finnish' },
{ code: 'fr', label: 'French' },
{ code: 'fr_BE', label: 'French (BEL)' },
{ code: 'fr_CA', label: 'French (CAN)' },
{ code: 'fr_CH', label: 'French (CHE)' },
{ code: 'fr_CI', label: 'French (CIV)' },
{ code: 'fr_MA', label: 'French (MAR)' },
{ code: 'ka', label: 'Georgian' },
{ code: 'de', label: 'German' },
{ code: 'de_AT', label: 'German (AUT)' },
{ code: 'de_CH', label: 'German (CHE)' },
{ code: 'el', label: 'Greek' },
{ code: 'gu', label: 'Gujarati' },
{ code: 'ha', label: 'Hausa' },
{ code: 'he', label: 'Hebrew' },
{ code: 'hi', label: 'Hindi' },
{ code: 'hu', label: 'Hungarian' },
{ code: 'id', label: 'Indonesian' },
{ code: 'ga', label: 'Irish' },
{ code: 'it', label: 'Italian' },
{ code: 'ja', label: 'Japanese' },
{ code: 'kn', label: 'Kannada' },
{ code: 'kk', label: 'Kazakh' },
{ code: 'rw_RW', label: 'Kinyarwanda' },
{ code: 'ko', label: 'Korean' },
{ code: 'ky_KG', label: 'Kyrgyz (Kyrgyzstan)' },
{ code: 'lo', label: 'Lao' },
{ code: 'lv', label: 'Latvian' },
{ code: 'lt', label: 'Lithuanian' },
{ code: 'mk', label: 'Macedonian' },
{ code: 'ms', label: 'Malay' },
{ code: 'ml', label: 'Malayalam' },
{ code: 'mr', label: 'Marathi' },
{ code: 'nb', label: 'Norwegian' },
{ code: 'ps_AF', label: 'Pashto' },
{ code: 'fa', label: 'Persian' },
{ code: 'pl', label: 'Polish' },
{ code: 'pt_BR', label: 'Portuguese (BR)' },
{ code: 'pt_PT', label: 'Portuguese (POR)' },
{ code: 'pa', label: 'Punjabi' },
{ code: 'ro', label: 'Romanian' },
{ code: 'ru', label: 'Russian' },
{ code: 'sr', label: 'Serbian' },
{ code: 'si_LK', label: 'Sinhala' },
{ code: 'sk', label: 'Slovak' },
{ code: 'sl', label: 'Slovenian' },
{ code: 'es', label: 'Spanish' },
{ code: 'es_AR', label: 'Spanish (ARG)' },
{ code: 'es_CL', label: 'Spanish (CHL)' },
{ code: 'es_CO', label: 'Spanish (COL)' },
{ code: 'es_CRI', label: 'Spanish (CRI)' },
{ code: 'es_DO', label: 'Spanish (DOM)' },
{ code: 'es_EC', label: 'Spanish (ECU)' },
{ code: 'es_HN', label: 'Spanish (HND)' },
{ code: 'es_MX', label: 'Spanish (MEX)' },
{ code: 'es_PA', label: 'Spanish (PAN)' },
{ code: 'es_PE', label: 'Spanish (PER)' },
{ code: 'es_ES', label: 'Spanish (SPA)' },
{ code: 'es_UY', label: 'Spanish (URY)' },
{ code: 'sw', label: 'Swahili' },
{ code: 'sv', label: 'Swedish' },
{ code: 'ta', label: 'Tamil' },
{ code: 'te', label: 'Telugu' },
{ code: 'th', label: 'Thai' },
{ code: 'tr', label: 'Turkish' },
{ code: 'uk', label: 'Ukrainian' },
{ code: 'ur', label: 'Urdu' },
{ code: 'uz', label: 'Uzbek' },
{ code: 'vi', label: 'Vietnamese' },
{ code: 'zu', label: 'Zulu' },
] as const
export const DEFAULT_LANGUAGE = 'en_US'
const BY_CODE = new Map(WHATSAPP_LANGUAGES.map((language) => [language.code, language.label]))
/** The display name for a code, falling back to the code itself — a template
* may use a language Meta added after this list was written, and showing the
* raw code beats showing nothing. */
export const languageLabel = (code: string): string => BY_CODE.get(code) ?? code
+200
View File
@@ -0,0 +1,200 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { TemplateComponent, WhatsAppTemplate } from '@/services/channels'
const PLACEHOLDER = /\{\{(\d+)\}\}/g
/**
* Meta supplies an authentication template's verification code as the single
* body parameter. There is always exactly one, and it never appears as {{n}} in
* stored text — so it is modelled as variable 1 to reuse the same fill/validate
* path as an authored template.
*/
const AUTH_CODE_INDEX = 1
/**
* Authentication copy belongs to Meta and is localised per language — Spanish
* puts the code mid-sentence, and the button label changes with it — so it is
* described here, never reproduced. Anything written out in English would be
* wrong for most templates. The create form fetches the real rendering from
* Meta's template-previews API.
*/
export const AUTH_DESCRIPTION = 'WhatsApp 自带的验证码消息'
export const authSendSummary = (code: string): string =>
code.trim()
? `WhatsApp 将发送包含验证码 ${code.trim()} 的验证码消息。`
: 'WhatsApp 将发送自带的验证码消息。'
const placeholdersIn = (text: string): number[] => {
const found = new Set<number>()
for (const match of text.matchAll(PLACEHOLDER)) found.add(Number(match[1]))
return [...found].sort((a, b) => a - b)
}
const componentOfType = (template: WhatsAppTemplate, type: string): TemplateComponent | undefined =>
template.components?.find((c) => c.type?.toUpperCase() === type)
/** The template's message text, which is what the customer actually receives. */
export const templateBody = (template: WhatsAppTemplate): string =>
componentOfType(template, 'BODY')?.text ?? ''
/**
* What identifies one template. WhatsApp has no multilingual template: name and
* language together identify one, so `order_update` in five languages is five
* templates and Graph returns a row for each.
*
* Keying on the name alone therefore treats siblings as the same thing — the
* list renders duplicate keys, every language of a name highlights as selected
* at once, and picking a different language is a no-op that silently sends the
* one already chosen. Meta doesn't return `id` on the list edge (see
* TEMPLATE_FIELDS in meta_base.py), so the pair is the identity we have.
*/
export const templateKey = (template: WhatsAppTemplate): string =>
`${template.name}|${template.language ?? ''}`
/** Whether two rows are the same template, not merely same-named. */
export const isSameTemplate = (
a: WhatsAppTemplate | null | undefined,
b: WhatsAppTemplate | null | undefined,
): boolean => !!a && !!b && templateKey(a) === templateKey(b)
/**
* True for an authentication template — a one-time passcode.
*
* Meta owns these entirely: the body is its own fixed copy, assembled from
* add_security_recommendation / code_expiration_minutes, and the verification
* code is supplied at send time rather than written into the template. So they
* are read and sent differently from a template we author.
*/
export const isAuthentication = (template: WhatsAppTemplate): boolean =>
template.category === 'AUTHENTICATION'
/** A placeholder in a non-body text component — a header or footer. */
const hasTextPlaceholderOutsideBody = (component: TemplateComponent): boolean =>
component.type?.toUpperCase() !== 'BODY' &&
typeof component.text === 'string' &&
placeholdersIn(component.text).length > 0
/** A dynamic URL button, e.g. https://example.com/orders/{{1}}. The placeholder
* lives in the button's `url`, not in any component `text`. */
const hasUrlPlaceholder = (component: TemplateComponent): boolean =>
((component.buttons as { url?: string }[] | undefined) ?? []).some(
(button) => typeof button.url === 'string' && placeholdersIn(button.url).length > 0,
)
/** A media header takes an image/video/document parameter at send time. It has
* no `text` to scan, so it would otherwise look parameterless. */
const needsMediaParameter = (component: TemplateComponent): boolean =>
component.type?.toUpperCase() === 'HEADER' &&
typeof component.format === 'string' &&
component.format.toUpperCase() !== 'TEXT'
/**
* True when sending the template would need a parameter we cannot supply.
*
* Only body parameters are built here, and Meta rejects a send that doesn't
* cover every one the template declares — so offering such a template would
* guarantee a failed send, and it is filtered out rather than shown.
*
* Authentication templates are exempt: their one parameter is Meta's own, and
* buildAuthComponents supplies it, so the rule would wrongly hide every one.
*/
export const hasUnsupportedParameters = (template: WhatsAppTemplate): boolean =>
!isAuthentication(template) &&
(template.components ?? []).some(
(component) =>
hasTextPlaceholderOutsideBody(component) ||
hasUrlPlaceholder(component) ||
needsMediaParameter(component),
)
/** Meta only lets an APPROVED template be sent; the rest are informational. */
export const isSendable = (template: WhatsAppTemplate): boolean =>
template.status === 'APPROVED' && !hasUnsupportedParameters(template)
/**
* The {{n}} placeholders in the body, ascending and de-duplicated.
*
* Meta numbers placeholders rather than naming them, and a template may repeat
* one ({{1}} twice), so the positions are read from the body itself.
*/
export const templateVariables = (template: WhatsAppTemplate): number[] =>
isAuthentication(template) ? [AUTH_CODE_INDEX] : placeholdersIn(templateBody(template))
/** What to show for a template in a list. An authentication template stores no
* body text, and its real copy is Meta's and language-specific, so it is
* named rather than quoted. */
export const templatePreviewText = (template: WhatsAppTemplate): string =>
isAuthentication(template) ? AUTH_DESCRIPTION : templateBody(template)
/** Body text with the agent's values substituted, for previewing before sending. */
export const previewTemplate = (
template: WhatsAppTemplate,
values: Record<number, string>,
): string =>
isAuthentication(template)
? authSendSummary(values[AUTH_CODE_INDEX] ?? '')
: templateBody(template).replace(/\{\{(\d+)\}\}/g, (placeholder, index) =>
values[Number(index)]?.trim() || placeholder,
)
/** True once every placeholder has a value — Meta rejects a partial fill. */
export const isTemplateComplete = (
template: WhatsAppTemplate,
values: Record<number, string>,
): boolean => templateVariables(template).every((index) => !!values[index]?.trim())
/**
* Components for sending an authentication template.
*
* The code goes in the body *and* in the copy-code button, and Meta requires
* both: body-only produces a button that copies nothing. The button is
* addressed as sub_type 'url' at index '0' — that naming is historical, not a
* mistake; there is no 'otp' sub_type on the send side.
*/
export const buildAuthComponents = (code: string): TemplateComponent[] => {
// Built twice rather than sharing one array: two components holding the same
// mutable reference is a trap for whoever edits this next.
const parameter = () => [{ type: 'text', text: code.trim() }]
return [
{ type: 'body', parameters: parameter() },
{ type: 'button', sub_type: 'url', index: '0', parameters: parameter() },
]
}
/**
* The `components` payload for a send. Graph matches body parameters strictly by
* position, so they go in ascending placeholder order; an authored template with
* no placeholders sends none.
*/
export const buildTemplateComponents = (
template: WhatsAppTemplate,
values: Record<number, string>,
): TemplateComponent[] | undefined => {
if (isAuthentication(template)) {
return buildAuthComponents(values[AUTH_CODE_INDEX] ?? '')
}
const variables = templateVariables(template)
if (variables.length === 0) return undefined
return [
{
type: 'body',
parameters: variables.map((index) => ({ type: 'text', text: values[index]?.trim() ?? '' })),
},
]
}