import { Injectable, type OnModuleInit } from '@nestjs/common'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { LoggerService, PrismaService } from '@modules/shared'; // ─── DTOs ──────────────────────────────────────────────────────────────────── export interface SendZaloOaDto { /** Zalo user ID (follower UID from OA) */ toUid: string; /** ZNS template ID registered in Zalo OA Manager */ templateId: string; /** Template parameter key-value pairs */ templateData: Record; } export interface ZaloOaMessageResult { messageId: string; } export interface ZaloOaLinkResult { zaloUserId: string; linked: boolean; } // ─── Internal Zalo API shapes ───────────────────────────────────────────────── interface ZaloOaTokenResponse { access_token: string; refresh_token: string; expires_in: number; error?: number; error_description?: string; } // ─── Constants ──────────────────────────────────────────────────────────────── const MAX_RETRIES = 3; const BASE_DELAY_MS = 1_000; /** Zalo ZNS 24-hour interaction window in milliseconds */ const INTERACTION_WINDOW_MS = 24 * 60 * 60 * 1_000; /** Refresh tokens 5 minutes before expiry */ const REFRESH_BUFFER_MS = 5 * 60 * 1_000; const ZNS_URL = 'https://business.openapi.zalo.me/message/template'; const OA_TOKEN_URL = 'https://oauth.zaloapp.com/v4/oa/access_token'; // ─── Encryption helpers ─────────────────────────────────────────────────────── const AES_ALGO = 'aes-256-gcm'; function encryptToken(plaintext: string, keyHex: string): string { const key = Buffer.from(keyHex, 'hex'); const iv = randomBytes(12); const cipher = createCipheriv(AES_ALGO, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`; } function decryptToken(encoded: string, keyHex: string): string { const key = Buffer.from(keyHex, 'hex'); const parts = encoded.split('.'); if (parts.length !== 3) throw new Error('Invalid encrypted token format'); const [ivB64, tagB64, ctB64] = parts as [string, string, string]; const iv = Buffer.from(ivB64, 'base64url'); const tag = Buffer.from(tagB64, 'base64url'); const ct = Buffer.from(ctB64, 'base64url'); const decipher = createDecipheriv(AES_ALGO, key, iv); decipher.setAuthTag(tag); return decipher.update(ct) + decipher.final('utf8'); } // ─── Service ────────────────────────────────────────────────────────────────── /** * Service for Zalo Official Account (OA) API v3 integration. * * Responsibilities: * 1. ZNS template message sending (with exponential-backoff retry). * 2. OA OAuth account linking — authorize URL generation, callback handling, * and storage of per-user encrypted access/refresh tokens in `zalo_account_links`. * 3. sendTemplate — user-centric wrapper that looks up the linked Zalo UID, * checks the 24-hour interaction window, auto-refreshes expired tokens, and * calls ZNS. * * Required env vars (all mandatory for full functionality): * ZALO_OA_APP_ID — OA App ID from Zalo OA Manager * ZALO_OA_SECRET — OA App Secret * ZALO_OA_REDIRECT_URI — OAuth callback URI registered with Zalo * ZALO_OA_TOKEN_KEY — 32-byte hex key for AES-256-GCM token encryption * * Legacy ZNS-only mode (backwards-compatible): * ZALO_OA_ID — OA ID (used in ZNS requests) * ZALO_OA_ACCESS_TOKEN — Static access token (no OAuth linking) */ @Injectable() export class ZaloOaService implements OnModuleInit { // Legacy static-token mode private oaId = ''; private accessToken = ''; private initialized = false; // OAuth linking mode private oaAppId = ''; private oaSecret = ''; private oaRedirectUri = ''; private tokenEncKey = ''; private oauthEnabled = false; constructor( private readonly logger: LoggerService, private readonly prisma: PrismaService, ) {} onModuleInit(): void { // Legacy mode (backwards compat) this.oaId = process.env['ZALO_OA_ID'] ?? ''; this.accessToken = process.env['ZALO_OA_ACCESS_TOKEN'] ?? ''; if (!this.oaId || !this.accessToken) { this.logger.warn( 'ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set — Zalo OA legacy ZNS disabled', 'ZaloOaService', ); } else { this.initialized = true; this.logger.log(`Zalo OA configured for OA ID "${this.oaId}"`, 'ZaloOaService'); } // OAuth linking mode this.oaAppId = process.env['ZALO_OA_APP_ID'] ?? ''; this.oaSecret = process.env['ZALO_OA_SECRET'] ?? ''; this.oaRedirectUri = process.env['ZALO_OA_REDIRECT_URI'] ?? ''; this.tokenEncKey = process.env['ZALO_OA_TOKEN_KEY'] ?? ''; if (this.oaAppId && this.oaSecret && this.oaRedirectUri && this.tokenEncKey) { if (this.tokenEncKey.length !== 64) { this.logger.warn( 'ZALO_OA_TOKEN_KEY must be a 64-char hex string (32 bytes) — OAuth linking disabled', 'ZaloOaService', ); } else { this.oauthEnabled = true; this.logger.log('Zalo OA OAuth linking enabled', 'ZaloOaService'); } } else { this.logger.warn( 'ZALO_OA_APP_ID / ZALO_OA_SECRET / ZALO_OA_REDIRECT_URI / ZALO_OA_TOKEN_KEY not fully set — OA OAuth linking disabled', 'ZaloOaService', ); } } get isAvailable(): boolean { return this.initialized; } get isOAuthEnabled(): boolean { return this.oauthEnabled; } // ─── OAuth: Account Linking ───────────────────────────────────────────────── /** * Generate the Zalo OA OAuth authorization URL. * The `state` parameter should be a CSRF token tied to the user's session. */ getOAuthAuthorizeUrl(state: string): string { if (!this.oauthEnabled) { throw new Error('Zalo OA OAuth linking is not configured'); } const params = new URLSearchParams({ app_id: this.oaAppId, redirect_uri: this.oaRedirectUri, state, }); return `https://oauth.zaloapp.com/v4/oa/permission?${params.toString()}`; } /** * Handle OAuth callback: exchange code for OA-scoped tokens, resolve the * Zalo OA user ID, and persist encrypted tokens in `zalo_account_links`. */ async handleOAuthCallback( userId: string, code: string, ): Promise { if (!this.oauthEnabled) { throw new Error('Zalo OA OAuth linking is not configured'); } const tokenData = await this.exchangeOaCode(code); const zaloUserId = await this.resolveZaloUserId(tokenData.access_token); const expiresAt = new Date(Date.now() + tokenData.expires_in * 1_000); const encAccess = encryptToken(tokenData.access_token, this.tokenEncKey); const encRefresh = encryptToken(tokenData.refresh_token, this.tokenEncKey); await this.prisma.zaloAccountLink.upsert({ where: { userId }, create: { userId, zaloUserId, accessToken: encAccess, refreshToken: encRefresh, expiresAt, }, update: { zaloUserId, accessToken: encAccess, refreshToken: encRefresh, expiresAt, }, }); this.logger.log( `Zalo OA linked for user ${userId} → Zalo UID ${zaloUserId.slice(0, 6)}***`, 'ZaloOaService', ); return { zaloUserId, linked: true }; } /** * Unlink a user's Zalo OA account. */ async unlinkAccount(userId: string): Promise { await this.prisma.zaloAccountLink.deleteMany({ where: { userId } }); this.logger.log(`Zalo OA unlinked for user ${userId}`, 'ZaloOaService'); } // ─── sendTemplate — user-centric ZNS send ────────────────────────────────── /** * Send a ZNS template message to the Zalo OA UID linked to `userId`. * * - Resolves the linked Zalo UID. * - Checks 24-hour interaction window (required by Zalo ZNS policy). * - Auto-refreshes access token if within the refresh buffer window. * - Falls back to legacy static-token mode if no link exists (for backwards compat). * * @throws Error if user has no linked Zalo account and legacy mode is unavailable. * @throws Error if the user is outside the 24-hour interaction window. */ async sendTemplate( userId: string, templateId: string, params: Record, ): Promise { // Try per-user linked token first if (this.oauthEnabled) { const link = await this.prisma.zaloAccountLink.findUnique({ where: { userId } }); if (link) { // Check 24-hour interaction window if (!this.isWithinInteractionWindow(link.lastInteractAt)) { throw new Error( `User ${userId} is outside the 24-hour Zalo OA interaction window — cannot send ZNS template`, ); } // Refresh token if needed const resolvedLink = await this.ensureFreshToken(link); const plainAccessToken = decryptToken(resolvedLink.accessToken, this.tokenEncKey); return this.sendWithRetry({ toUid: link.zaloUserId, templateId, templateData: params, accessToken: plainAccessToken, }); } } // Legacy static-token fallback if (!this.initialized) { throw new Error( `No Zalo OA link found for user ${userId} and legacy mode is not configured`, ); } // Legacy mode: caller must supply the uid directly — log a warning this.logger.warn( `sendTemplate called for user ${userId} with no OA link — falling back to legacy static-token mode (toUid not resolved)`, 'ZaloOaService', ); throw new Error( `No Zalo OA link found for user ${userId}. Please link the account via OAuth first.`, ); } // ─── Legacy sendMessage (direct UID) ─────────────────────────────────────── /** * Send a template-based message to a Zalo user via ZNS (Zalo Notification Service). * * The user must be a follower of the Official Account, and the template must be * pre-registered and approved in the Zalo OA Manager console. * * @deprecated Prefer `sendTemplate(userId, ...)` for per-user linked tokens. */ async sendMessage(dto: SendZaloOaDto): Promise { return this.sendWithRetry({ ...dto, accessToken: this.accessToken }); } // ─── Record interaction (called from webhook handler) ──────────────────────── /** * Record that a Zalo user interacted with the OA (follow, message, etc.). * Updates `lastInteractAt` on the linked account so the 24-hour window is fresh. */ async recordInteraction(zaloUserId: string): Promise { const updated = await this.prisma.zaloAccountLink.updateMany({ where: { zaloUserId }, data: { lastInteractAt: new Date() }, }); if (updated.count > 0) { this.logger.log( `Recorded OA interaction for Zalo UID ${zaloUserId.slice(0, 6)}***`, 'ZaloOaService', ); } } // ─── Internal helpers ────────────────────────────────────────────────────── private isWithinInteractionWindow(lastInteractAt: Date | null): boolean { if (!lastInteractAt) return false; return Date.now() - lastInteractAt.getTime() < INTERACTION_WINDOW_MS; } private async ensureFreshToken( link: { id: string; accessToken: string; refreshToken: string; expiresAt: Date }, ): Promise<{ accessToken: string; refreshToken: string }> { const msUntilExpiry = link.expiresAt.getTime() - Date.now(); if (msUntilExpiry > REFRESH_BUFFER_MS) { // Token still valid return { accessToken: link.accessToken, refreshToken: link.refreshToken }; } // Refresh const plainRefresh = decryptToken(link.refreshToken, this.tokenEncKey); const newTokens = await this.refreshOaToken(plainRefresh); const newExpiresAt = new Date(Date.now() + newTokens.expires_in * 1_000); const encAccess = encryptToken(newTokens.access_token, this.tokenEncKey); const encRefresh = encryptToken(newTokens.refresh_token, this.tokenEncKey); await this.prisma.zaloAccountLink.update({ where: { id: link.id }, data: { accessToken: encAccess, refreshToken: encRefresh, expiresAt: newExpiresAt }, }); this.logger.log(`Refreshed Zalo OA token for link ${link.id}`, 'ZaloOaService'); return { accessToken: encAccess, refreshToken: encRefresh }; } private async refreshOaToken(refreshToken: string): Promise { const body = new URLSearchParams({ app_id: this.oaAppId, grant_type: 'refresh_token', refresh_token: refreshToken, }); const response = await fetch(OA_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', secret_key: this.oaSecret, }, body: body.toString(), }); const data = (await response.json()) as ZaloOaTokenResponse; if (data.error) { throw new Error( `Zalo OA token refresh failed (${data.error}): ${data.error_description ?? 'unknown'}`, ); } if (!data.access_token) { throw new Error('Zalo OA token refresh: no access_token in response'); } return data; } private async exchangeOaCode(code: string): Promise { const body = new URLSearchParams({ app_id: this.oaAppId, code, grant_type: 'authorization_code', }); const response = await fetch(OA_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', secret_key: this.oaSecret, }, body: body.toString(), }); const data = (await response.json()) as ZaloOaTokenResponse; if (data.error) { throw new Error( `Zalo OA code exchange failed (${data.error}): ${data.error_description ?? 'unknown'}`, ); } if (!data.access_token) { throw new Error('Zalo OA code exchange: no access_token in response'); } return data; } /** * Resolve the Zalo OA UID for the authenticated user by calling the OA Me endpoint. */ private async resolveZaloUserId(oaAccessToken: string): Promise { const response = await fetch('https://openapi.zalo.me/v2.0/oa/getprofile?data=%7B%7D', { headers: { access_token: oaAccessToken }, }); const data = (await response.json()) as { error?: number; message?: string; data?: { user_id_by_app?: string; user_id?: string }; }; if (data.error && data.error !== 0) { throw new Error( `Zalo OA user ID resolution failed (${data.error}): ${data.message ?? 'unknown'}`, ); } const uid = data.data?.user_id_by_app ?? data.data?.user_id; if (!uid) { throw new Error('Zalo OA user ID resolution: no UID in response'); } return uid; } private async sendWithRetry( dto: SendZaloOaDto & { accessToken: string }, ): Promise { if (!this.initialized && !this.oauthEnabled) { throw new Error('Zalo OA not initialized — ZALO_OA_ID / ZALO_OA_ACCESS_TOKEN not configured'); } let lastError: Error | undefined; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { return await this.send(dto); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); this.logger.warn( `Zalo OA attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`, 'ZaloOaService', ); await this.delay(delayMs); } } } this.logger.error( `Zalo OA message failed after ${MAX_RETRIES} attempts: ${lastError?.message}`, 'ZaloOaService', ); throw lastError; } private async send( dto: SendZaloOaDto & { accessToken: string }, ): Promise { const body = { phone: dto.toUid, template_id: dto.templateId, template_data: dto.templateData, }; const response = await fetch(ZNS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', access_token: dto.accessToken, }, body: JSON.stringify(body), }); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Zalo OA API error (${response.status}): ${errorText}`); } const data = (await response.json()) as { error?: number; message?: string; data?: { msg_id?: string }; }; // Zalo API returns error=0 on success if (data.error !== undefined && data.error !== 0) { throw new Error( `Zalo OA message rejected (code ${data.error}): ${data.message ?? 'Unknown reason'}`, ); } const messageId = data.data?.msg_id ?? `zalo-oa-${Date.now()}`; this.logger.log( `Zalo OA message sent to ${dto.toUid.slice(0, 6)}***: ${messageId}`, 'ZaloOaService', ); return { messageId }; } private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } }