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>
184 lines
5.6 KiB
TypeScript
184 lines
5.6 KiB
TypeScript
import { Body, Controller, Get, HttpCode, Post, Query, RawBodyRequest, Req } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
import { LoggerService, PrismaService } from '@modules/shared';
|
|
import { ZaloOaService } from '../../infrastructure/services/zalo-oa.service';
|
|
|
|
/**
|
|
* Zalo OA event types from webhook payloads.
|
|
*
|
|
* @see https://developers.zalo.me/docs/official-account/webhook
|
|
*/
|
|
interface ZaloOaWebhookPayload {
|
|
app_id: string;
|
|
event_name: string;
|
|
timestamp: string;
|
|
sender: { id: string };
|
|
recipient: { id: string };
|
|
message?: { text?: string; msg_id?: string; attachments?: unknown[] };
|
|
follower?: { id: string };
|
|
user_id_by_app?: string;
|
|
}
|
|
|
|
const WEBHOOK_CONTEXT = 'ZaloOaWebhookController';
|
|
|
|
@Controller('webhooks/zalo-oa')
|
|
export class ZaloOaWebhookController {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly logger: LoggerService,
|
|
private readonly zaloOaService: ZaloOaService,
|
|
) {}
|
|
|
|
/**
|
|
* Webhook verification endpoint.
|
|
* Zalo OA sends a GET request with a challenge token during webhook setup.
|
|
*/
|
|
@Get()
|
|
verify(@Query('challenge') challenge: string): string {
|
|
this.logger.log(`Webhook verification: challenge=${challenge}`, WEBHOOK_CONTEXT);
|
|
return challenge ?? '';
|
|
}
|
|
|
|
/**
|
|
* Receive and process Zalo OA webhook events.
|
|
*
|
|
* Supported events:
|
|
* - `follow` — user follows the OA; records interaction + checks existing link
|
|
* - `unfollow` — user unfollows the OA
|
|
* - `user_send_text` — user sends a text message; records interaction
|
|
*/
|
|
@Post()
|
|
@HttpCode(200)
|
|
async handleEvent(
|
|
@Body() payload: ZaloOaWebhookPayload,
|
|
@Req() req: RawBodyRequest<Request>,
|
|
): Promise<{ received: true }> {
|
|
const { event_name, sender, timestamp } = payload;
|
|
|
|
this.logger.log(
|
|
`Webhook event: ${event_name} from=${sender?.id ?? 'unknown'} at=${timestamp}`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
|
|
// Accept webhooks regardless of which mode is active
|
|
if (!this.zaloOaService.isAvailable && !this.zaloOaService.isOAuthEnabled) {
|
|
this.logger.warn('Zalo OA not configured — ignoring webhook event', WEBHOOK_CONTEXT);
|
|
return { received: true };
|
|
}
|
|
|
|
try {
|
|
switch (event_name) {
|
|
case 'follow':
|
|
await this.handleFollow(payload);
|
|
break;
|
|
case 'unfollow':
|
|
await this.handleUnfollow(payload);
|
|
break;
|
|
case 'user_send_text':
|
|
await this.handleUserMessage(payload);
|
|
break;
|
|
default:
|
|
this.logger.log(`Unhandled event type: ${event_name}`, WEBHOOK_CONTEXT);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Webhook processing failed for ${event_name}: ${error instanceof Error ? error.message : error}`,
|
|
error instanceof Error ? error.stack : undefined,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
}
|
|
|
|
return { received: true };
|
|
}
|
|
|
|
/**
|
|
* Handle `follow` event — record interaction (opens 24-hour ZNS window)
|
|
* and log link status.
|
|
*/
|
|
private async handleFollow(payload: ZaloOaWebhookPayload): Promise<void> {
|
|
const zaloUid = payload.sender?.id ?? payload.follower?.id;
|
|
if (!zaloUid) return;
|
|
|
|
// Record interaction so the 24-hour window opens for ZNS sends
|
|
await this.zaloOaService.recordInteraction(zaloUid);
|
|
|
|
// Check OA account-links table first
|
|
const oaLink = await this.prisma.zaloAccountLink.findFirst({
|
|
where: { zaloUserId: zaloUid },
|
|
});
|
|
|
|
if (oaLink) {
|
|
this.logger.log(
|
|
`Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** already OA-linked to user ${oaLink.userId}`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Legacy: check OAuthAccount
|
|
const existingOAuth = await this.prisma.oAuthAccount.findFirst({
|
|
where: { provider: 'ZALO', providerUserId: zaloUid },
|
|
});
|
|
|
|
if (existingOAuth) {
|
|
this.logger.log(
|
|
`Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** linked via social OAuth to user ${existingOAuth.userId}`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.logger.log(
|
|
`Follow event: Zalo UID ${zaloUid.slice(0, 6)}*** — no existing link found. User should complete OA linking via /auth/zalo-oa/link.`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle `unfollow` event — log for analytics.
|
|
* We do NOT remove the OA link (user may re-follow and still want notifications).
|
|
*/
|
|
private async handleUnfollow(payload: ZaloOaWebhookPayload): Promise<void> {
|
|
const zaloUid = payload.sender?.id;
|
|
if (!zaloUid) return;
|
|
|
|
this.logger.log(
|
|
`Unfollow event: Zalo UID ${zaloUid.slice(0, 6)}*** unfollowed OA`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle incoming text message from a Zalo user.
|
|
* Records the interaction (refreshes the 24-hour ZNS window) and logs for routing.
|
|
*/
|
|
private async handleUserMessage(payload: ZaloOaWebhookPayload): Promise<void> {
|
|
const zaloUid = payload.sender?.id;
|
|
const text = payload.message?.text;
|
|
const msgId = payload.message?.msg_id;
|
|
|
|
if (!zaloUid || !text) return;
|
|
|
|
// Record interaction so the ZNS send window stays open
|
|
await this.zaloOaService.recordInteraction(zaloUid);
|
|
|
|
this.logger.log(
|
|
`Message from Zalo UID ${zaloUid.slice(0, 6)}***: msgId=${msgId ?? 'unknown'} length=${text.length}`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
|
|
// Find linked user via OA account-links
|
|
const oaLink = await this.prisma.zaloAccountLink.findFirst({
|
|
where: { zaloUserId: zaloUid },
|
|
select: { userId: true },
|
|
});
|
|
|
|
if (oaLink) {
|
|
this.logger.log(
|
|
`Message from OA-linked user ${oaLink.userId} via Zalo OA`,
|
|
WEBHOOK_CONTEXT,
|
|
);
|
|
}
|
|
}
|
|
}
|