feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -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
|
||||
@@ -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 条而不是报错,
|
||||
所以结果为空并不能证明订单不存在。`
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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() ?? '' })),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user