Include files missed from previous commit: - ZaloOaLinkController (GET /auth/zalo-oa/link, GET /auth/zalo-oa/callback, DELETE) - prisma/schema.prisma — ZaloAccountLink model + User.zaloAccountLink relation - prisma/migrations/20260421010000_add_zalo_account_links/migration.sql - Updated ZaloOaService, webhook controller, notifications module, and specs Co-Authored-By: Paperclip <noreply@paperclip.ing>
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
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<string, string>;
|
|
}
|
|
|
|
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<ZaloOaLinkResult> {
|
|
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<void> {
|
|
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<string, string>,
|
|
): Promise<ZaloOaMessageResult> {
|
|
// 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<ZaloOaMessageResult> {
|
|
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<void> {
|
|
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<ZaloOaTokenResponse> {
|
|
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<ZaloOaTokenResponse> {
|
|
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<string> {
|
|
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<ZaloOaMessageResult> {
|
|
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<ZaloOaMessageResult> {
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
}
|