Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
151
src/lib/whatsapp/index.ts
Normal file
151
src/lib/whatsapp/index.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* WhatsApp Provider Abstraction Layer
|
||||
*
|
||||
* Supports multiple WhatsApp providers with a common interface:
|
||||
* - Meta WhatsApp Business Cloud API
|
||||
* - Twilio WhatsApp
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { MetaWhatsAppProvider } from './meta-provider'
|
||||
import { TwilioWhatsAppProvider } from './twilio-provider'
|
||||
|
||||
export interface WhatsAppResult {
|
||||
success: boolean
|
||||
messageId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WhatsAppProvider {
|
||||
sendText(to: string, body: string): Promise<WhatsAppResult>
|
||||
sendTemplate(
|
||||
to: string,
|
||||
template: string,
|
||||
params: Record<string, string>
|
||||
): Promise<WhatsAppResult>
|
||||
testConnection(): Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
|
||||
export type WhatsAppProviderType = 'META' | 'TWILIO'
|
||||
|
||||
/**
|
||||
* Get the configured WhatsApp provider
|
||||
* Returns null if WhatsApp is not enabled or not configured
|
||||
*/
|
||||
export async function getWhatsAppProvider(): Promise<WhatsAppProvider | null> {
|
||||
try {
|
||||
// Check if WhatsApp is enabled
|
||||
const enabledSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_enabled' },
|
||||
})
|
||||
|
||||
if (enabledSetting?.value !== 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get provider type
|
||||
const providerSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_provider' },
|
||||
})
|
||||
|
||||
const providerType = (providerSetting?.value || 'META') as WhatsAppProviderType
|
||||
|
||||
if (providerType === 'META') {
|
||||
return await createMetaProvider()
|
||||
} else if (providerType === 'TWILIO') {
|
||||
return await createTwilioProvider()
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to get WhatsApp provider:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Meta WhatsApp provider from settings
|
||||
*/
|
||||
async function createMetaProvider(): Promise<WhatsAppProvider | null> {
|
||||
const [phoneNumberIdSetting, accessTokenSetting] = await Promise.all([
|
||||
prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_meta_phone_number_id' },
|
||||
}),
|
||||
prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_meta_access_token' },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!phoneNumberIdSetting?.value || !accessTokenSetting?.value) {
|
||||
console.warn('Meta WhatsApp not fully configured')
|
||||
return null
|
||||
}
|
||||
|
||||
return new MetaWhatsAppProvider(
|
||||
phoneNumberIdSetting.value,
|
||||
accessTokenSetting.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Twilio WhatsApp provider from settings
|
||||
*/
|
||||
async function createTwilioProvider(): Promise<WhatsAppProvider | null> {
|
||||
const [accountSidSetting, authTokenSetting, phoneNumberSetting] = await Promise.all([
|
||||
prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_twilio_account_sid' },
|
||||
}),
|
||||
prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_twilio_auth_token' },
|
||||
}),
|
||||
prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_twilio_phone_number' },
|
||||
}),
|
||||
])
|
||||
|
||||
if (
|
||||
!accountSidSetting?.value ||
|
||||
!authTokenSetting?.value ||
|
||||
!phoneNumberSetting?.value
|
||||
) {
|
||||
console.warn('Twilio WhatsApp not fully configured')
|
||||
return null
|
||||
}
|
||||
|
||||
return new TwilioWhatsAppProvider(
|
||||
accountSidSetting.value,
|
||||
authTokenSetting.value,
|
||||
phoneNumberSetting.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WhatsApp is configured and available
|
||||
*/
|
||||
export async function isWhatsAppEnabled(): Promise<boolean> {
|
||||
const provider = await getWhatsAppProvider()
|
||||
return provider !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current provider type
|
||||
*/
|
||||
export async function getWhatsAppProviderType(): Promise<WhatsAppProviderType | null> {
|
||||
try {
|
||||
const enabledSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_enabled' },
|
||||
})
|
||||
|
||||
if (enabledSetting?.value !== 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
const providerSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_provider' },
|
||||
})
|
||||
|
||||
return (providerSetting?.value || 'META') as WhatsAppProviderType
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
187
src/lib/whatsapp/meta-provider.ts
Normal file
187
src/lib/whatsapp/meta-provider.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Meta WhatsApp Business Cloud API Provider
|
||||
*
|
||||
* Direct integration with Meta's Graph API for WhatsApp Business.
|
||||
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api
|
||||
*/
|
||||
|
||||
import type { WhatsAppProvider, WhatsAppResult } from './index'
|
||||
|
||||
const GRAPH_API_VERSION = 'v18.0'
|
||||
const BASE_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`
|
||||
|
||||
export class MetaWhatsAppProvider implements WhatsAppProvider {
|
||||
constructor(
|
||||
private phoneNumberId: string,
|
||||
private accessToken: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a text message
|
||||
*/
|
||||
async sendText(to: string, body: string): Promise<WhatsAppResult> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/${this.phoneNumberId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to: formatPhoneNumber(to),
|
||||
type: 'text',
|
||||
text: { body },
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error?.message || `API error: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.messages?.[0]?.id,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a template message
|
||||
*
|
||||
* Templates must be pre-approved by Meta.
|
||||
* Params are passed as components to the template.
|
||||
*/
|
||||
async sendTemplate(
|
||||
to: string,
|
||||
template: string,
|
||||
params: Record<string, string>
|
||||
): Promise<WhatsAppResult> {
|
||||
try {
|
||||
// Build template components from params
|
||||
const components = buildTemplateComponents(params)
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/${this.phoneNumberId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to: formatPhoneNumber(to),
|
||||
type: 'template',
|
||||
template: {
|
||||
name: template,
|
||||
language: { code: 'en' },
|
||||
components,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error?.message || `API error: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.messages?.[0]?.id,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to Meta API
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Try to get phone number info to verify credentials
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/${this.phoneNumberId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: data.error?.message || `API error: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format phone number for WhatsApp API
|
||||
* Removes + prefix and any non-digit characters
|
||||
*/
|
||||
function formatPhoneNumber(phone: string): string {
|
||||
return phone.replace(/[^\d]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build template components from params
|
||||
* Converts { param1: "value1", param2: "value2" } to WhatsApp component format
|
||||
*/
|
||||
function buildTemplateComponents(
|
||||
params: Record<string, string>
|
||||
): Array<{
|
||||
type: 'body'
|
||||
parameters: Array<{ type: 'text'; text: string }>
|
||||
}> {
|
||||
const paramValues = Object.values(params)
|
||||
|
||||
if (paramValues.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'body',
|
||||
parameters: paramValues.map((value) => ({
|
||||
type: 'text',
|
||||
text: value,
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
155
src/lib/whatsapp/twilio-provider.ts
Normal file
155
src/lib/whatsapp/twilio-provider.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Twilio WhatsApp Provider
|
||||
*
|
||||
* Uses Twilio's WhatsApp API for sending messages.
|
||||
* Docs: https://www.twilio.com/docs/whatsapp
|
||||
*/
|
||||
|
||||
import type { WhatsAppProvider, WhatsAppResult } from './index'
|
||||
|
||||
export class TwilioWhatsAppProvider implements WhatsAppProvider {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(
|
||||
private accountSid: string,
|
||||
private authToken: string,
|
||||
private fromNumber: string
|
||||
) {
|
||||
this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message via Twilio WhatsApp
|
||||
*/
|
||||
async sendText(to: string, body: string): Promise<WhatsAppResult> {
|
||||
try {
|
||||
const formData = new URLSearchParams({
|
||||
From: `whatsapp:${formatPhoneNumber(this.fromNumber)}`,
|
||||
To: `whatsapp:${formatPhoneNumber(to)}`,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || `API error: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.sid,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a template message via Twilio WhatsApp
|
||||
*
|
||||
* Twilio uses Content Templates for WhatsApp templates.
|
||||
* The template name should be the Content SID.
|
||||
*/
|
||||
async sendTemplate(
|
||||
to: string,
|
||||
template: string,
|
||||
params: Record<string, string>
|
||||
): Promise<WhatsAppResult> {
|
||||
try {
|
||||
const formData = new URLSearchParams({
|
||||
From: `whatsapp:${formatPhoneNumber(this.fromNumber)}`,
|
||||
To: `whatsapp:${formatPhoneNumber(to)}`,
|
||||
ContentSid: template,
|
||||
})
|
||||
|
||||
// Add template variables
|
||||
if (Object.keys(params).length > 0) {
|
||||
formData.append('ContentVariables', JSON.stringify(params))
|
||||
}
|
||||
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || `API error: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.sid,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to Twilio API
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Try to fetch account info to verify credentials
|
||||
const response = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}.json`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || `API error: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format phone number for Twilio WhatsApp
|
||||
* Ensures + prefix and removes any other non-digit characters
|
||||
*/
|
||||
function formatPhoneNumber(phone: string): string {
|
||||
const digits = phone.replace(/[^\d]/g, '')
|
||||
return `+${digits}`
|
||||
}
|
||||
Reference in New Issue
Block a user