wip
This commit is contained in:
+1
-3
@@ -69,8 +69,6 @@ export class AddInboundWebhookSubscription1778500000000
|
||||
`DROP INDEX "core"."IDX_INBOUND_WEBHOOK_SUBSCRIPTION_WORKSPACE_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP TABLE "core"."inboundWebhookSubscription"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "core"."inboundWebhookSubscription"`);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -19,10 +19,7 @@ import { InboundWebhookDispatcherService } from 'src/engine/core-modules/inbound
|
||||
import { InboundWebhookIdempotencyService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-idempotency.service';
|
||||
import { ProcessInboundWebhookJob } from 'src/engine/core-modules/inbound-webhook/jobs/process-inbound-webhook.job';
|
||||
import { type InboundWebhookJobData } from 'src/engine/core-modules/inbound-webhook/dtos/inbound-webhook-job-data.type';
|
||||
import {
|
||||
type InboundWebhookSource,
|
||||
isInboundWebhookSource,
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-source.type';
|
||||
import { type InboundWebhookSource } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-source.type';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
@@ -54,13 +51,6 @@ export class InboundWebhookController {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInboundWebhookSource(sourceParam)) {
|
||||
throw new InboundWebhookException(
|
||||
`Inbound webhook source ${sourceParam} is not supported`,
|
||||
InboundWebhookExceptionCode.SOURCE_NOT_SUPPORTED,
|
||||
);
|
||||
}
|
||||
|
||||
const source: InboundWebhookSource = sourceParam;
|
||||
const handler = this.dispatcher.resolve(source);
|
||||
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
|
||||
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
|
||||
import {
|
||||
type InboundWebhookEnvelope,
|
||||
type InboundWebhookRequest,
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
|
||||
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import {
|
||||
CalendarEventListFetchJob,
|
||||
type CalendarEventListFetchJobData,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarInboundWebhookHandler
|
||||
implements SubscribableInboundWebhookHandler
|
||||
{
|
||||
private readonly logger = new Logger(GoogleCalendarInboundWebhookHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly subscriptionService: InboundWebhookSubscriptionService,
|
||||
@InjectMessageQueue(MessageQueue.calendarQueue)
|
||||
private readonly calendarQueue: MessageQueueService,
|
||||
) {}
|
||||
|
||||
// Google Calendar push uses the X-Goog-Channel-Token header as a shared
|
||||
// secret declared at watch-creation time. Compare against the subscription
|
||||
// secret looked up by X-Goog-Channel-ID.
|
||||
async verify(request: InboundWebhookRequest): Promise<boolean> {
|
||||
const channelId = this.headerValue(request, 'x-goog-channel-id');
|
||||
const channelToken = this.headerValue(request, 'x-goog-channel-token');
|
||||
|
||||
if (channelId === null || channelToken === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription =
|
||||
await this.subscriptionService.findByExternalSubscriptionId({
|
||||
source: 'google-calendar',
|
||||
externalSubscriptionId: channelId,
|
||||
});
|
||||
|
||||
return subscription !== null && subscription.secret === channelToken;
|
||||
}
|
||||
|
||||
async buildEnvelope(
|
||||
request: InboundWebhookRequest,
|
||||
): Promise<InboundWebhookEnvelope> {
|
||||
const channelId = this.headerValue(request, 'x-goog-channel-id') ?? '';
|
||||
const messageNumber =
|
||||
this.headerValue(request, 'x-goog-message-number') ?? '';
|
||||
const subscription =
|
||||
await this.subscriptionService.findByExternalSubscriptionId({
|
||||
source: 'google-calendar',
|
||||
externalSubscriptionId: channelId,
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'google-calendar',
|
||||
externalEventId: `${channelId}:${messageNumber}`,
|
||||
workspaceId: subscription?.workspaceId ?? null,
|
||||
subscriptionId: subscription?.id ?? null,
|
||||
payload: {
|
||||
channelId,
|
||||
messageNumber,
|
||||
resourceState: this.headerValue(request, 'x-goog-resource-state'),
|
||||
},
|
||||
headers: request.headers as Record<string, string | string[] | undefined>,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
|
||||
if (envelope.subscriptionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionService.findById(
|
||||
envelope.subscriptionId,
|
||||
);
|
||||
|
||||
if (subscription === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.subscriptionService.markNotified(subscription.id);
|
||||
|
||||
const calendarChannelId = this.resolveCalendarChannelId(subscription);
|
||||
|
||||
if (calendarChannelId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobData: CalendarEventListFetchJobData = {
|
||||
calendarChannelId,
|
||||
workspaceId: subscription.workspaceId,
|
||||
};
|
||||
|
||||
await this.calendarQueue.add<CalendarEventListFetchJobData>(
|
||||
CalendarEventListFetchJob.name,
|
||||
jobData,
|
||||
);
|
||||
}
|
||||
|
||||
async renewSubscription(
|
||||
_subscription: InboundWebhookSubscriptionEntity,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
'Google calendar subscription renewal hook — implementation pending driver migration',
|
||||
);
|
||||
}
|
||||
|
||||
private headerValue(
|
||||
request: InboundWebhookRequest,
|
||||
name: string,
|
||||
): string | null {
|
||||
const value = request.headers[name];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveCalendarChannelId(
|
||||
subscription: InboundWebhookSubscriptionEntity,
|
||||
): string | null {
|
||||
const calendarChannelId = subscription.metadata?.['calendarChannelId'];
|
||||
|
||||
return typeof calendarChannelId === 'string' ? calendarChannelId : null;
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
|
||||
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
|
||||
import {
|
||||
type InboundWebhookEnvelope,
|
||||
type InboundWebhookRequest,
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
|
||||
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import {
|
||||
MessagingMessageListFetchJob,
|
||||
type MessagingMessageListFetchJobData,
|
||||
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||
|
||||
type PubSubPushBody = {
|
||||
message?: {
|
||||
data?: string;
|
||||
messageId?: string;
|
||||
publishTime?: string;
|
||||
};
|
||||
subscription?: string;
|
||||
};
|
||||
|
||||
type GmailHistoryNotification = {
|
||||
emailAddress?: string;
|
||||
historyId?: number | string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GoogleMessagingInboundWebhookHandler
|
||||
implements SubscribableInboundWebhookHandler
|
||||
{
|
||||
private readonly logger = new Logger(GoogleMessagingInboundWebhookHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly subscriptionService: InboundWebhookSubscriptionService,
|
||||
@InjectMessageQueue(MessageQueue.messagingQueue)
|
||||
private readonly messagingQueue: MessageQueueService,
|
||||
) {}
|
||||
|
||||
// Google Pub/Sub push delivers a JWT in the Authorization header signed by
|
||||
// Google. Production verification fetches Google's public keys + checks
|
||||
// audience. Out of scope for this PR — wired in when the driver migration
|
||||
// PR enables push for live workspaces.
|
||||
async verify(_request: InboundWebhookRequest): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async buildEnvelope(
|
||||
request: InboundWebhookRequest,
|
||||
): Promise<InboundWebhookEnvelope> {
|
||||
const body = (request.body ?? {}) as PubSubPushBody;
|
||||
const messageId = body.message?.messageId ?? '';
|
||||
const decoded = this.decodePubSubData(body.message?.data);
|
||||
const subscription = decoded.emailAddress
|
||||
? await this.findSubscriptionByEmail(decoded.emailAddress)
|
||||
: null;
|
||||
|
||||
return {
|
||||
source: 'google-messaging',
|
||||
externalEventId: messageId,
|
||||
workspaceId: subscription?.workspaceId ?? null,
|
||||
subscriptionId: subscription?.id ?? null,
|
||||
payload: { decoded, raw: body },
|
||||
headers: request.headers as Record<string, string | string[] | undefined>,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
|
||||
if (envelope.subscriptionId === null) {
|
||||
this.logger.warn(
|
||||
'Received Google messaging push for unknown subscription — dropping',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionService.findById(
|
||||
envelope.subscriptionId,
|
||||
);
|
||||
|
||||
if (
|
||||
subscription === null ||
|
||||
subscription.connectedAccountId === null ||
|
||||
subscription.workspaceId === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.subscriptionService.markNotified(subscription.id);
|
||||
|
||||
const messageChannelId = this.resolveMessageChannelId(subscription);
|
||||
|
||||
if (messageChannelId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobData: MessagingMessageListFetchJobData = {
|
||||
messageChannelId,
|
||||
workspaceId: subscription.workspaceId,
|
||||
};
|
||||
|
||||
await this.messagingQueue.add<MessagingMessageListFetchJobData>(
|
||||
MessagingMessageListFetchJob.name,
|
||||
jobData,
|
||||
);
|
||||
}
|
||||
|
||||
// Gmail watch expires after 7 days and must be re-issued. Concrete Gmail
|
||||
// API call lives in the driver layer — added in the driver migration PR.
|
||||
async renewSubscription(
|
||||
_subscription: InboundWebhookSubscriptionEntity,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
'Google messaging subscription renewal hook — implementation pending driver migration',
|
||||
);
|
||||
}
|
||||
|
||||
private decodePubSubData(data?: string): GmailHistoryNotification {
|
||||
if (data === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
Buffer.from(data, 'base64').toString('utf-8'),
|
||||
);
|
||||
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return parsed as GmailHistoryNotification;
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async findSubscriptionByEmail(
|
||||
_emailAddress: string,
|
||||
): Promise<InboundWebhookSubscriptionEntity | null> {
|
||||
// Lookup by metadata.emailAddress — populated when the driver migration
|
||||
// PR creates the subscription during message-channel onboarding.
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveMessageChannelId(
|
||||
subscription: InboundWebhookSubscriptionEntity,
|
||||
): string | null {
|
||||
const messageChannelId = subscription.metadata?.['messageChannelId'];
|
||||
|
||||
return typeof messageChannelId === 'string' ? messageChannelId : null;
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type InboundWebhookEnvelope,
|
||||
type InboundWebhookRequest,
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
|
||||
import { type InboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
|
||||
|
||||
type SesSnsMessage = {
|
||||
Type?: string;
|
||||
MessageId?: string;
|
||||
TopicArn?: string;
|
||||
Message?: string;
|
||||
Signature?: string;
|
||||
SignatureVersion?: string;
|
||||
SigningCertURL?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class InboundEmailSesInboundWebhookHandler
|
||||
implements InboundWebhookHandler
|
||||
{
|
||||
private readonly logger = new Logger(
|
||||
InboundEmailSesInboundWebhookHandler.name,
|
||||
);
|
||||
|
||||
// SES delivers via SNS. Production verification fetches the SNS signing
|
||||
// certificate and validates the signature over the canonicalized message.
|
||||
// Out of scope for this PR — wired in when inbound email aliases ship.
|
||||
async verify(_request: InboundWebhookRequest): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async buildEnvelope(
|
||||
request: InboundWebhookRequest,
|
||||
): Promise<InboundWebhookEnvelope> {
|
||||
const body = (request.body ?? {}) as SesSnsMessage;
|
||||
|
||||
return {
|
||||
source: 'inbound-email-ses',
|
||||
externalEventId: body.MessageId ?? `ses:${Date.now()}`,
|
||||
workspaceId: null,
|
||||
subscriptionId: null,
|
||||
payload: body,
|
||||
headers: request.headers as Record<string, string | string[] | undefined>,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(_envelope: InboundWebhookEnvelope): Promise<void> {
|
||||
// EML parsing + participant matching + message ingestion live here once
|
||||
// the inbound-email-alias feature is built. Body of work is heavy
|
||||
// (mailparser + S3 fetch) and runs entirely on the worker pod.
|
||||
this.logger.log(
|
||||
'SES inbound email handler — implementation pending inbound-email-alias feature',
|
||||
);
|
||||
}
|
||||
}
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
|
||||
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
|
||||
import {
|
||||
type InboundWebhookEnvelope,
|
||||
type InboundWebhookRequest,
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
|
||||
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import {
|
||||
CalendarEventListFetchJob,
|
||||
type CalendarEventListFetchJobData,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
|
||||
|
||||
type GraphChangeNotification = {
|
||||
subscriptionId?: string;
|
||||
clientState?: string;
|
||||
resourceData?: { id?: string };
|
||||
};
|
||||
|
||||
type GraphNotificationBatch = {
|
||||
value?: GraphChangeNotification[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftCalendarInboundWebhookHandler
|
||||
implements SubscribableInboundWebhookHandler
|
||||
{
|
||||
private readonly logger = new Logger(
|
||||
MicrosoftCalendarInboundWebhookHandler.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly subscriptionService: InboundWebhookSubscriptionService,
|
||||
@InjectMessageQueue(MessageQueue.calendarQueue)
|
||||
private readonly calendarQueue: MessageQueueService,
|
||||
) {}
|
||||
|
||||
async verify(request: InboundWebhookRequest): Promise<boolean> {
|
||||
const body = (request.body ?? {}) as GraphNotificationBatch;
|
||||
const notifications = body.value ?? [];
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const notification of notifications) {
|
||||
const externalSubscriptionId = notification.subscriptionId;
|
||||
const clientState = notification.clientState;
|
||||
|
||||
if (
|
||||
externalSubscriptionId === undefined ||
|
||||
clientState === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription =
|
||||
await this.subscriptionService.findByExternalSubscriptionId({
|
||||
source: 'microsoft-calendar',
|
||||
externalSubscriptionId,
|
||||
});
|
||||
|
||||
if (subscription === null || subscription.secret !== clientState) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async buildEnvelope(
|
||||
request: InboundWebhookRequest,
|
||||
): Promise<InboundWebhookEnvelope> {
|
||||
const body = (request.body ?? {}) as GraphNotificationBatch;
|
||||
const first = body.value?.[0];
|
||||
const subscription =
|
||||
first?.subscriptionId !== undefined
|
||||
? await this.subscriptionService.findByExternalSubscriptionId({
|
||||
source: 'microsoft-calendar',
|
||||
externalSubscriptionId: first.subscriptionId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
source: 'microsoft-calendar',
|
||||
externalEventId: this.computeEventId(body),
|
||||
workspaceId: subscription?.workspaceId ?? null,
|
||||
subscriptionId: subscription?.id ?? null,
|
||||
payload: body,
|
||||
headers: request.headers as Record<string, string | string[] | undefined>,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
|
||||
if (envelope.subscriptionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionService.findById(
|
||||
envelope.subscriptionId,
|
||||
);
|
||||
|
||||
if (subscription === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.subscriptionService.markNotified(subscription.id);
|
||||
|
||||
const calendarChannelId = this.resolveCalendarChannelId(subscription);
|
||||
|
||||
if (calendarChannelId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobData: CalendarEventListFetchJobData = {
|
||||
calendarChannelId,
|
||||
workspaceId: subscription.workspaceId,
|
||||
};
|
||||
|
||||
await this.calendarQueue.add<CalendarEventListFetchJobData>(
|
||||
CalendarEventListFetchJob.name,
|
||||
jobData,
|
||||
);
|
||||
}
|
||||
|
||||
async renewSubscription(
|
||||
_subscription: InboundWebhookSubscriptionEntity,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
'Microsoft calendar subscription renewal hook — implementation pending driver migration',
|
||||
);
|
||||
}
|
||||
|
||||
private computeEventId(body: GraphNotificationBatch): string {
|
||||
const ids = (body.value ?? [])
|
||||
.map((notification) => notification.resourceData?.id)
|
||||
.filter((id): id is string => typeof id === 'string');
|
||||
|
||||
return ids.length > 0 ? ids.join(':') : `graph:${Date.now()}`;
|
||||
}
|
||||
|
||||
private resolveCalendarChannelId(
|
||||
subscription: InboundWebhookSubscriptionEntity,
|
||||
): string | null {
|
||||
const calendarChannelId = subscription.metadata?.['calendarChannelId'];
|
||||
|
||||
return typeof calendarChannelId === 'string' ? calendarChannelId : null;
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { InboundWebhookSubscriptionService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-subscription.service';
|
||||
import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
|
||||
import {
|
||||
type InboundWebhookEnvelope,
|
||||
type InboundWebhookRequest,
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-context.type';
|
||||
import { type SubscribableInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import {
|
||||
MessagingMessageListFetchJob,
|
||||
type MessagingMessageListFetchJobData,
|
||||
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||
|
||||
type GraphChangeNotification = {
|
||||
subscriptionId?: string;
|
||||
clientState?: string;
|
||||
resource?: string;
|
||||
resourceData?: { id?: string };
|
||||
};
|
||||
|
||||
type GraphNotificationBatch = {
|
||||
value?: GraphChangeNotification[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftMessagingInboundWebhookHandler
|
||||
implements SubscribableInboundWebhookHandler
|
||||
{
|
||||
private readonly logger = new Logger(
|
||||
MicrosoftMessagingInboundWebhookHandler.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly subscriptionService: InboundWebhookSubscriptionService,
|
||||
@InjectMessageQueue(MessageQueue.messagingQueue)
|
||||
private readonly messagingQueue: MessageQueueService,
|
||||
) {}
|
||||
|
||||
// Microsoft Graph echoes back the clientState set when the subscription
|
||||
// was created. Looking up by subscriptionId + comparing clientState is
|
||||
// the documented verify pattern.
|
||||
async verify(request: InboundWebhookRequest): Promise<boolean> {
|
||||
const body = (request.body ?? {}) as GraphNotificationBatch;
|
||||
const notifications = body.value ?? [];
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const notification of notifications) {
|
||||
const externalSubscriptionId = notification.subscriptionId;
|
||||
const clientState = notification.clientState;
|
||||
|
||||
if (
|
||||
externalSubscriptionId === undefined ||
|
||||
clientState === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription =
|
||||
await this.subscriptionService.findByExternalSubscriptionId({
|
||||
source: 'microsoft-messaging',
|
||||
externalSubscriptionId,
|
||||
});
|
||||
|
||||
if (subscription === null || subscription.secret !== clientState) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async buildEnvelope(
|
||||
request: InboundWebhookRequest,
|
||||
): Promise<InboundWebhookEnvelope> {
|
||||
const body = (request.body ?? {}) as GraphNotificationBatch;
|
||||
const first = body.value?.[0];
|
||||
const subscription =
|
||||
first?.subscriptionId !== undefined
|
||||
? await this.subscriptionService.findByExternalSubscriptionId({
|
||||
source: 'microsoft-messaging',
|
||||
externalSubscriptionId: first.subscriptionId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
source: 'microsoft-messaging',
|
||||
externalEventId: this.computeEventId(body),
|
||||
workspaceId: subscription?.workspaceId ?? null,
|
||||
subscriptionId: subscription?.id ?? null,
|
||||
payload: body,
|
||||
headers: request.headers as Record<string, string | string[] | undefined>,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(envelope: InboundWebhookEnvelope): Promise<void> {
|
||||
if (envelope.subscriptionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionService.findById(
|
||||
envelope.subscriptionId,
|
||||
);
|
||||
|
||||
if (subscription === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.subscriptionService.markNotified(subscription.id);
|
||||
|
||||
const messageChannelId = this.resolveMessageChannelId(subscription);
|
||||
|
||||
if (messageChannelId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobData: MessagingMessageListFetchJobData = {
|
||||
messageChannelId,
|
||||
workspaceId: subscription.workspaceId,
|
||||
};
|
||||
|
||||
await this.messagingQueue.add<MessagingMessageListFetchJobData>(
|
||||
MessagingMessageListFetchJob.name,
|
||||
jobData,
|
||||
);
|
||||
}
|
||||
|
||||
async renewSubscription(
|
||||
_subscription: InboundWebhookSubscriptionEntity,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
'Microsoft messaging subscription renewal hook — implementation pending driver migration',
|
||||
);
|
||||
}
|
||||
|
||||
private computeEventId(body: GraphNotificationBatch): string {
|
||||
const ids = (body.value ?? [])
|
||||
.map((notification) => notification.resourceData?.id)
|
||||
.filter((id): id is string => typeof id === 'string');
|
||||
|
||||
return ids.length > 0 ? ids.join(':') : `graph:${Date.now()}`;
|
||||
}
|
||||
|
||||
private resolveMessageChannelId(
|
||||
subscription: InboundWebhookSubscriptionEntity,
|
||||
): string | null {
|
||||
const messageChannelId = subscription.metadata?.['messageChannelId'];
|
||||
|
||||
return typeof messageChannelId === 'string' ? messageChannelId : null;
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -1,3 +1,7 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum InboundWebhookExceptionCode {
|
||||
@@ -9,4 +13,37 @@ export enum InboundWebhookExceptionCode {
|
||||
DUPLICATE_EVENT = 'DUPLICATE_EVENT',
|
||||
}
|
||||
|
||||
export class InboundWebhookException extends CustomException<InboundWebhookExceptionCode> {}
|
||||
const getInboundWebhookExceptionUserFriendlyMessage = (
|
||||
code: InboundWebhookExceptionCode,
|
||||
): MessageDescriptor => {
|
||||
switch (code) {
|
||||
case InboundWebhookExceptionCode.SOURCE_NOT_SUPPORTED:
|
||||
return msg`Inbound webhook source is not supported.`;
|
||||
case InboundWebhookExceptionCode.INVALID_SIGNATURE:
|
||||
return msg`Inbound webhook signature is invalid.`;
|
||||
case InboundWebhookExceptionCode.MISSING_RAW_BODY:
|
||||
return msg`Inbound webhook request is missing a raw body.`;
|
||||
case InboundWebhookExceptionCode.SUBSCRIPTION_NOT_FOUND:
|
||||
return msg`Inbound webhook subscription was not found.`;
|
||||
case InboundWebhookExceptionCode.REPLAY_WINDOW_EXCEEDED:
|
||||
return msg`Inbound webhook timestamp is outside the accepted replay window.`;
|
||||
case InboundWebhookExceptionCode.DUPLICATE_EVENT:
|
||||
return msg`Inbound webhook event was already processed.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class InboundWebhookException extends CustomException<InboundWebhookExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: InboundWebhookExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getInboundWebhookExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-11
@@ -5,11 +5,6 @@ import { InboundWebhookRenewalCronCommand } from 'src/engine/core-modules/inboun
|
||||
import { InboundWebhookRenewalCronJob } from 'src/engine/core-modules/inbound-webhook/crons/jobs/inbound-webhook-renewal.cron.job';
|
||||
import { InboundWebhookController } from 'src/engine/core-modules/inbound-webhook/controllers/inbound-webhook.controller';
|
||||
import { InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/inbound-webhook/entities/inbound-webhook-subscription.entity';
|
||||
import { GoogleCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-calendar-inbound-webhook.handler';
|
||||
import { GoogleMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-messaging-inbound-webhook.handler';
|
||||
import { InboundEmailSesInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/inbound-email-ses-inbound-webhook.handler';
|
||||
import { MicrosoftCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-calendar-inbound-webhook.handler';
|
||||
import { MicrosoftMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-messaging-inbound-webhook.handler';
|
||||
import { ProcessInboundWebhookJob } from 'src/engine/core-modules/inbound-webhook/jobs/process-inbound-webhook.job';
|
||||
import { InboundWebhookDispatcherService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-dispatcher.service';
|
||||
import { InboundWebhookIdempotencyService } from 'src/engine/core-modules/inbound-webhook/services/inbound-webhook-idempotency.service';
|
||||
@@ -26,15 +21,14 @@ import { InboundWebhookVerifyService } from 'src/engine/core-modules/inbound-web
|
||||
InboundWebhookIdempotencyService,
|
||||
InboundWebhookSubscriptionService,
|
||||
InboundWebhookVerifyService,
|
||||
GoogleMessagingInboundWebhookHandler,
|
||||
GoogleCalendarInboundWebhookHandler,
|
||||
MicrosoftMessagingInboundWebhookHandler,
|
||||
MicrosoftCalendarInboundWebhookHandler,
|
||||
InboundEmailSesInboundWebhookHandler,
|
||||
ProcessInboundWebhookJob,
|
||||
InboundWebhookRenewalCronJob,
|
||||
InboundWebhookRenewalCronCommand,
|
||||
],
|
||||
exports: [InboundWebhookSubscriptionService],
|
||||
exports: [
|
||||
InboundWebhookDispatcherService,
|
||||
InboundWebhookSubscriptionService,
|
||||
InboundWebhookVerifyService,
|
||||
],
|
||||
})
|
||||
export class InboundWebhookModule {}
|
||||
|
||||
+1
-3
@@ -11,9 +11,7 @@ import { type InboundWebhookJobData } from 'src/engine/core-modules/inbound-webh
|
||||
export class ProcessInboundWebhookJob {
|
||||
private readonly logger = new Logger(ProcessInboundWebhookJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly dispatcher: InboundWebhookDispatcherService,
|
||||
) {}
|
||||
constructor(private readonly dispatcher: InboundWebhookDispatcherService) {}
|
||||
|
||||
@Process(ProcessInboundWebhookJob.name)
|
||||
async handle(data: InboundWebhookJobData): Promise<void> {
|
||||
|
||||
+7
-28
@@ -1,10 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { GoogleCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-calendar-inbound-webhook.handler';
|
||||
import { GoogleMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/google-messaging-inbound-webhook.handler';
|
||||
import { InboundEmailSesInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/inbound-email-ses-inbound-webhook.handler';
|
||||
import { MicrosoftCalendarInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-calendar-inbound-webhook.handler';
|
||||
import { MicrosoftMessagingInboundWebhookHandler } from 'src/engine/core-modules/inbound-webhook/handlers/microsoft-messaging-inbound-webhook.handler';
|
||||
import {
|
||||
InboundWebhookException,
|
||||
InboundWebhookExceptionCode,
|
||||
@@ -16,36 +11,20 @@ import {
|
||||
} from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-handler.type';
|
||||
import { type InboundWebhookSource } from 'src/engine/core-modules/inbound-webhook/types/inbound-webhook-source.type';
|
||||
|
||||
// Switch dispatcher (mirrors MessagingGetMessageListService and the Stripe
|
||||
// webhook handler in billing-webhook). Concrete sources are added by each
|
||||
// consumer PR — driver migration, inbound email, etc. — by injecting their
|
||||
// handler class and adding a case below.
|
||||
@Injectable()
|
||||
export class InboundWebhookDispatcherService {
|
||||
constructor(
|
||||
private readonly googleMessagingHandler: GoogleMessagingInboundWebhookHandler,
|
||||
private readonly googleCalendarHandler: GoogleCalendarInboundWebhookHandler,
|
||||
private readonly microsoftMessagingHandler: MicrosoftMessagingInboundWebhookHandler,
|
||||
private readonly microsoftCalendarHandler: MicrosoftCalendarInboundWebhookHandler,
|
||||
private readonly inboundEmailSesHandler: InboundEmailSesInboundWebhookHandler,
|
||||
) {}
|
||||
|
||||
resolve(source: InboundWebhookSource): InboundWebhookHandler {
|
||||
switch (source) {
|
||||
case 'google-messaging':
|
||||
return this.googleMessagingHandler;
|
||||
case 'google-calendar':
|
||||
return this.googleCalendarHandler;
|
||||
case 'microsoft-messaging':
|
||||
return this.microsoftMessagingHandler;
|
||||
case 'microsoft-calendar':
|
||||
return this.microsoftCalendarHandler;
|
||||
case 'inbound-email-ses':
|
||||
return this.inboundEmailSesHandler;
|
||||
default: {
|
||||
const _exhaustive: never = source;
|
||||
|
||||
// Per-source handlers registered in follow-up PRs.
|
||||
default:
|
||||
throw new InboundWebhookException(
|
||||
`Inbound webhook source ${_exhaustive} is not supported`,
|
||||
`Inbound webhook source ${source} is not supported`,
|
||||
InboundWebhookExceptionCode.SOURCE_NOT_SUPPORTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { InboundWebhookException, InboundWebhookExceptionCode } from 'src/engine/core-modules/inbound-webhook/inbound-webhook.exception';
|
||||
import {
|
||||
InboundWebhookException,
|
||||
InboundWebhookExceptionCode,
|
||||
} from 'src/engine/core-modules/inbound-webhook/inbound-webhook.exception';
|
||||
import { INBOUND_WEBHOOK_REPLAY_WINDOW_MS } from 'src/engine/core-modules/inbound-webhook/inbound-webhook.constants';
|
||||
import {
|
||||
type HmacAlgorithm,
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@ import { type InboundWebhookSubscriptionEntity } from 'src/engine/core-modules/i
|
||||
// belongs.
|
||||
export type InboundWebhookHandler = {
|
||||
verify(request: InboundWebhookRequest): Promise<boolean>;
|
||||
buildEnvelope(request: InboundWebhookRequest): Promise<InboundWebhookEnvelope>;
|
||||
buildEnvelope(
|
||||
request: InboundWebhookRequest,
|
||||
): Promise<InboundWebhookEnvelope>;
|
||||
handle(envelope: InboundWebhookEnvelope): Promise<void>;
|
||||
};
|
||||
|
||||
|
||||
+4
-15
@@ -1,15 +1,4 @@
|
||||
export const INBOUND_WEBHOOK_SOURCES = [
|
||||
'google-messaging',
|
||||
'google-calendar',
|
||||
'microsoft-messaging',
|
||||
'microsoft-calendar',
|
||||
'inbound-email-ses',
|
||||
] as const;
|
||||
|
||||
export type InboundWebhookSource = (typeof INBOUND_WEBHOOK_SOURCES)[number];
|
||||
|
||||
export const isInboundWebhookSource = (
|
||||
value: unknown,
|
||||
): value is InboundWebhookSource =>
|
||||
typeof value === 'string' &&
|
||||
(INBOUND_WEBHOOK_SOURCES as readonly string[]).includes(value);
|
||||
// Sources are identified by an opaque string supplied as a URL path segment.
|
||||
// Concrete values are added by each consumer PR (driver migration, inbound
|
||||
// email, etc.) when they register an InboundWebhookHandler.
|
||||
export type InboundWebhookSource = string;
|
||||
|
||||
+1
@@ -18,4 +18,5 @@ export const MESSAGE_QUEUE_PRIORITY = {
|
||||
[MessageQueue.cronQueue]: 7,
|
||||
[MessageQueue.aiQueue]: 5,
|
||||
[MessageQueue.aiStreamQueue]: 2,
|
||||
[MessageQueue.inboundWebhookQueue]: 2,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user