* init * wiring up * fix type * feat: implement DI pattern for webhook producer in API v2 - Export IWebhookProducerService and getWebhookProducer from platform-libraries - Add WEBHOOK_PRODUCER token and useFactory provider in RegularBookingModule - Inject webhookProducer in RegularBookingService and pass to base class This follows the composition root pattern where only the NestJS module knows about getWebhookProducer(), and all consumers depend only on the IWebhookProducerService interface via constructor injection. Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * test: migrate BOOKING_REQUESTED tests to new webhook architecture - Remove failing BOOKING_REQUESTED tests from fresh-booking.test.ts (4 tests) - Remove failing BOOKING_REQUESTED tests from reschedule.test.ts (2 tests) - Remove failing BOOKING_REQUESTED test from collective-scheduling.test.ts (1 test) - Replace WebhookTaskConsumer.test.ts with placeholder (constructor changed) - Create new webhook architecture test suite: - producer/WebhookTaskerProducerService.test.ts (14 tests) - consumer/WebhookTaskConsumer.test.ts (8 tests) - consumer/triggers/booking-requested.test.ts (8 tests) The new test suite is organized by trigger type for extensibility as more triggers are migrated to the producer/consumer pattern. Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * test: remove paid events BOOKING_REQUESTED test (moved to new architecture) Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * wrap webhook in own try-catch * wire datafetcher * fix * fix v2 * fix circular dependency * -- * merge-conflict-resolve * mreg-conflict-resolve * remove early return * test: add integration tests for BOOKING_REQUESTED webhook producer invocation Cover all 8 scenarios verifying the booking flow correctly invokes the webhook producer for BOOKING_REQUESTED: 1. Basic confirmation → queueBookingRequestedWebhook called 2. Booker-is-organizer + confirmation → still called 3. Confirmation threshold NOT met → not called (BOOKING_CREATED instead) 4. Confirmation threshold IS met → called 5. Paid event + confirmation → called after payment succeeds 6. Reschedule + confirmation (non-organizer) → called (not BOOKING_RESCHEDULED) 7. Reschedule + confirmation (organizer) → not called (BOOKING_RESCHEDULED instead) 8. Collective scheduling + confirmation → called Adds reusable MockWebhookProducer helper in @calcom/testing for extendable use as more webhook triggers migrate to the new architecture. Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * fix bug * fix conditional check * remove unnecessary comment * add missing expect * remove empty test * clean up * tasker config * -- * fix missing metadata * remove faulty if else * test: add payload content verification tests for BOOKING_REQUESTED webhook Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * remove unnecessary tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
import type { IEventTypesRepository } from "@calcom/features/eventtypes/eventtypes.repository.interface";
|
|
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
|
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
|
import { UsersRepository } from "@calcom/features/users/users.repository";
|
|
import type { IUsersRepository } from "@calcom/features/users/users.repository.interface";
|
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import { prisma as defaultPrisma } from "@calcom/prisma";
|
|
import type { Prisma } from "@calcom/prisma/client";
|
|
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
|
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
|
import type { Webhook, WebhookGroup, WebhookSubscriber } from "../dto/types";
|
|
import { WebhookOutputMapper } from "../infrastructure/mappers/WebhookOutputMapper";
|
|
import type {
|
|
IWebhookRepository,
|
|
ListWebhooksOptions,
|
|
WebhookVersion,
|
|
} from "../interface/IWebhookRepository";
|
|
import { parseWebhookVersion } from "../interface/IWebhookRepository";
|
|
import type { GetSubscribersOptions } from "./types";
|
|
|
|
// Type for raw query results from the database
|
|
interface WebhookQueryResult {
|
|
id: string;
|
|
subscriberUrl: string;
|
|
payloadTemplate: string | null;
|
|
appId: string | null;
|
|
secret: string | null;
|
|
time: number | null;
|
|
timeUnit: TimeUnit | null;
|
|
eventTriggers: WebhookTriggerEvents[];
|
|
version: WebhookVersion;
|
|
priority: number; // This field is added by the query and removed before returning
|
|
}
|
|
|
|
const filterWebhooks = (webhook: { appId: string | null }): boolean => {
|
|
const appIds = [
|
|
"zapier",
|
|
"make",
|
|
// Add more if needed
|
|
];
|
|
|
|
return !appIds.some((appId: string) => webhook.appId == appId);
|
|
};
|
|
|
|
export class WebhookRepository implements IWebhookRepository {
|
|
private static _instance: WebhookRepository | undefined;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly eventTypeRepository: IEventTypesRepository,
|
|
private readonly userRepository: IUsersRepository
|
|
) {}
|
|
|
|
/**
|
|
*
|
|
* @deprecated Use DI container instead:
|
|
* ```typescript
|
|
* import { getWebhookFeature } from "@calcom/features/di/webhooks/containers/webhook";
|
|
* const { repository } = getWebhookFeature();
|
|
* ```
|
|
*
|
|
*/
|
|
static getInstance(): WebhookRepository {
|
|
if (!WebhookRepository._instance) {
|
|
WebhookRepository._instance = new WebhookRepository(
|
|
defaultPrisma,
|
|
new EventTypeRepository(defaultPrisma),
|
|
new UsersRepository()
|
|
);
|
|
}
|
|
return WebhookRepository._instance;
|
|
}
|
|
|
|
async getSubscribers(options: GetSubscribersOptions): Promise<WebhookSubscriber[]> {
|
|
const teamId = options.teamId;
|
|
const userId = options.userId;
|
|
const eventTypeId = options.eventTypeId;
|
|
const teamIds = Array.isArray(teamId) ? teamId : teamId ? [teamId] : undefined;
|
|
const orgId = options.orgId;
|
|
const oAuthClientId = options.oAuthClientId;
|
|
|
|
let managedParentEventTypeId: number | undefined;
|
|
if (eventTypeId) {
|
|
managedParentEventTypeId =
|
|
(await this.eventTypeRepository.findParentEventTypeId(eventTypeId)) ?? undefined;
|
|
}
|
|
|
|
const webhooks = await this.getSubscribersRaw({
|
|
userId,
|
|
eventTypeId,
|
|
managedParentEventTypeId,
|
|
teamIds: teamIds && orgId ? [...teamIds, orgId] : teamIds || (orgId ? [orgId] : undefined),
|
|
oAuthClientId,
|
|
triggerEvent: options.triggerEvent,
|
|
});
|
|
|
|
return webhooks.map((webhook) => ({
|
|
id: webhook.id,
|
|
subscriberUrl: webhook.subscriberUrl,
|
|
payloadTemplate: webhook.payloadTemplate,
|
|
appId: webhook.appId,
|
|
secret: webhook.secret,
|
|
time: webhook.time,
|
|
timeUnit: webhook.timeUnit as TimeUnit | null,
|
|
eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
|
|
version: webhook.version,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Raw SQL query using UNION for better index utilization than complex ORs
|
|
* Each UNION branch can use its own optimal index
|
|
*/
|
|
private async getSubscribersRaw(params: {
|
|
userId?: number | null;
|
|
eventTypeId?: number | null;
|
|
managedParentEventTypeId?: number | null;
|
|
teamIds?: number[];
|
|
oAuthClientId?: string | null;
|
|
triggerEvent: WebhookTriggerEvents;
|
|
}): Promise<WebhookSubscriber[]> {
|
|
const { userId, eventTypeId, managedParentEventTypeId, teamIds, oAuthClientId, triggerEvent } = params;
|
|
|
|
// IMPORTANT: Explicit type casts (::int, ::text) are required for nullable params
|
|
// PostgreSQL can't infer types for NULL values without explicit casts
|
|
const results = await this.prisma.$queryRaw<WebhookQueryResult[]>`
|
|
-- Platform webhooks (highest priority)
|
|
SELECT
|
|
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
|
|
1 as priority
|
|
FROM "Webhook"
|
|
WHERE active = true
|
|
AND platform = true
|
|
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
|
|
|
UNION ALL
|
|
|
|
-- User-specific webhooks (only if userId provided)
|
|
SELECT
|
|
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
|
|
2 as priority
|
|
FROM "Webhook"
|
|
WHERE active = true
|
|
AND ${userId}::int IS NOT NULL
|
|
AND "userId" = ${userId}::int
|
|
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
|
AND platform = false
|
|
|
|
UNION ALL
|
|
|
|
-- Event type webhooks (only if eventTypeId provided)
|
|
SELECT
|
|
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
|
|
3 as priority
|
|
FROM "Webhook"
|
|
WHERE active = true
|
|
AND ${eventTypeId}::int IS NOT NULL
|
|
AND "eventTypeId" = ${eventTypeId}::int
|
|
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
|
AND platform = false
|
|
|
|
UNION ALL
|
|
|
|
-- Parent event type webhooks (only if managedParentEventTypeId provided)
|
|
SELECT
|
|
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
|
|
4 as priority
|
|
FROM "Webhook"
|
|
WHERE active = true
|
|
AND ${managedParentEventTypeId}::int IS NOT NULL
|
|
AND "eventTypeId" = ${managedParentEventTypeId}::int
|
|
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
|
AND platform = false
|
|
|
|
UNION ALL
|
|
|
|
-- Team webhooks (only if teamIds provided and not empty)
|
|
SELECT
|
|
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
|
|
5 as priority
|
|
FROM "Webhook"
|
|
WHERE active = true
|
|
AND ${teamIds}::int[] IS NOT NULL
|
|
AND cardinality(${teamIds}::int[]) > 0
|
|
AND "teamId" = ANY(${teamIds}::int[])
|
|
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
|
AND platform = false
|
|
|
|
UNION ALL
|
|
|
|
-- OAuth client webhooks (only if oAuthClientId provided)
|
|
SELECT
|
|
id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
|
|
6 as priority
|
|
FROM "Webhook"
|
|
WHERE active = true
|
|
AND ${oAuthClientId}::text IS NOT NULL
|
|
AND "platformOAuthClientId" = ${oAuthClientId}::text
|
|
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
|
AND platform = false
|
|
|
|
ORDER BY priority, id
|
|
`;
|
|
|
|
const uniqueWebhooks = new Map<string, WebhookSubscriber>();
|
|
for (const webhook of results) {
|
|
if (!uniqueWebhooks.has(webhook.id)) {
|
|
const { priority: _priority, ...webhookData } = webhook;
|
|
uniqueWebhooks.set(webhook.id, webhookData);
|
|
}
|
|
}
|
|
|
|
return Array.from(uniqueWebhooks.values());
|
|
}
|
|
|
|
async getWebhookById(id: string): Promise<WebhookSubscriber | null> {
|
|
const webhook = await this.prisma.webhook.findUnique({
|
|
where: { id },
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
appId: true,
|
|
secret: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
eventTriggers: true,
|
|
version: true,
|
|
},
|
|
});
|
|
|
|
if (!webhook) return null;
|
|
|
|
return {
|
|
id: webhook.id,
|
|
subscriberUrl: webhook.subscriberUrl,
|
|
payloadTemplate: webhook.payloadTemplate,
|
|
appId: webhook.appId,
|
|
secret: webhook.secret,
|
|
time: webhook.time,
|
|
timeUnit: webhook.timeUnit as TimeUnit | null,
|
|
eventTriggers: webhook.eventTriggers,
|
|
version: parseWebhookVersion(webhook.version),
|
|
};
|
|
}
|
|
|
|
async findByWebhookId(webhookId?: string) {
|
|
const webhook = await this.prisma.webhook.findUniqueOrThrow({
|
|
where: {
|
|
id: webhookId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
active: true,
|
|
eventTriggers: true,
|
|
secret: true,
|
|
teamId: true,
|
|
userId: true,
|
|
platform: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
version: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
...webhook,
|
|
version: parseWebhookVersion(webhook.version),
|
|
};
|
|
}
|
|
|
|
async findByOrgIdAndTrigger({
|
|
orgId,
|
|
triggerEvent,
|
|
}: {
|
|
orgId: number;
|
|
triggerEvent: WebhookTriggerEvents;
|
|
}): Promise<WebhookSubscriber[]> {
|
|
const webhooks = await this.prisma.webhook.findMany({
|
|
where: {
|
|
teamId: orgId,
|
|
platform: false,
|
|
eventTriggers: { has: triggerEvent },
|
|
active: true,
|
|
},
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
active: true,
|
|
eventTriggers: true,
|
|
secret: true,
|
|
teamId: true,
|
|
userId: true,
|
|
platform: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
appId: true,
|
|
version: true,
|
|
},
|
|
});
|
|
return webhooks.map((webhook) => ({
|
|
...webhook,
|
|
eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
|
|
version: parseWebhookVersion(webhook.version),
|
|
}));
|
|
}
|
|
|
|
async getFilteredWebhooksForUser({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
name: true,
|
|
avatarUrl: true,
|
|
webhooks: {
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
appId: true,
|
|
secret: true,
|
|
active: true,
|
|
eventTriggers: true,
|
|
eventTypeId: true,
|
|
teamId: true,
|
|
userId: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
version: true,
|
|
createdAt: true,
|
|
platform: true,
|
|
platformOAuthClientId: true,
|
|
},
|
|
},
|
|
teams: {
|
|
where: {
|
|
accepted: true,
|
|
},
|
|
select: {
|
|
role: true,
|
|
team: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
logoUrl: true,
|
|
webhooks: {
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
appId: true,
|
|
secret: true,
|
|
active: true,
|
|
eventTriggers: true,
|
|
eventTypeId: true,
|
|
teamId: true,
|
|
userId: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
version: true,
|
|
createdAt: true,
|
|
platform: true,
|
|
platformOAuthClientId: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
|
|
// Use permission service which handles both PBAC and role-based fallbacks
|
|
const permissionService = new PermissionCheckService();
|
|
|
|
// Build webhook groups with proper permissions
|
|
const webhookGroups: WebhookGroup[] = [];
|
|
|
|
// Add user's personal webhooks
|
|
webhookGroups.push({
|
|
teamId: null,
|
|
profile: {
|
|
slug: user.username,
|
|
name: user.name,
|
|
image: getUserAvatarUrl({ avatarUrl: user.avatarUrl }),
|
|
},
|
|
webhooks: WebhookOutputMapper.toWebhookList(user.webhooks.filter(filterWebhooks)),
|
|
metadata: {
|
|
canModify: true,
|
|
canDelete: true,
|
|
},
|
|
});
|
|
|
|
// Check permissions for each team
|
|
// The permission service handles PBAC when enabled and falls back to role-based permissions
|
|
for (const membership of user.teams) {
|
|
const teamId = membership.team.id;
|
|
|
|
// Check read permission (fallback: MEMBER, ADMIN, OWNER can read)
|
|
const canRead = await permissionService.checkPermission({
|
|
userId,
|
|
teamId,
|
|
permission: "webhook.read",
|
|
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
});
|
|
|
|
if (!canRead) {
|
|
// User doesn't have permission to view this team's webhooks
|
|
continue;
|
|
}
|
|
|
|
// Check update/delete permissions in parallel (fallback: only ADMIN, OWNER can modify)
|
|
const [canUpdate, canDelete] = await Promise.all([
|
|
permissionService.checkPermission({
|
|
userId,
|
|
teamId,
|
|
permission: "webhook.update",
|
|
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
}),
|
|
permissionService.checkPermission({
|
|
userId,
|
|
teamId,
|
|
permission: "webhook.delete",
|
|
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
}),
|
|
]);
|
|
|
|
webhookGroups.push({
|
|
teamId: membership.team.id,
|
|
profile: {
|
|
name: membership.team.name,
|
|
slug: membership.team.slug || null,
|
|
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
|
|
},
|
|
webhooks: WebhookOutputMapper.toWebhookList(membership.team.webhooks.filter(filterWebhooks)),
|
|
metadata: {
|
|
canModify: canUpdate,
|
|
canDelete,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Add platform webhooks for admins
|
|
if (userRole === UserPermissionRole.ADMIN) {
|
|
const platformWebhooks = await this.prisma.webhook.findMany({
|
|
where: { platform: true },
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
appId: true,
|
|
secret: true,
|
|
active: true,
|
|
eventTriggers: true,
|
|
eventTypeId: true,
|
|
teamId: true,
|
|
userId: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
version: true,
|
|
createdAt: true,
|
|
platform: true,
|
|
platformOAuthClientId: true,
|
|
},
|
|
});
|
|
|
|
webhookGroups.push({
|
|
teamId: null,
|
|
profile: {
|
|
slug: "Platform",
|
|
name: "Platform",
|
|
image: getPlaceholderAvatar(null, "Platform"),
|
|
},
|
|
webhooks: WebhookOutputMapper.toWebhookList(platformWebhooks),
|
|
metadata: {
|
|
canDelete: true,
|
|
canModify: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
webhookGroups: webhookGroups.filter((group) => group.webhooks.length > 0),
|
|
profiles: webhookGroups.map((group) => ({
|
|
teamId: group.teamId,
|
|
...group.profile,
|
|
...group.metadata,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List webhooks for a user with filtering options.
|
|
* Handles:
|
|
* - App filtering (excludes zapier/make by default unless appId specified)
|
|
* - Event type filtering (with managed event type parent handling)
|
|
* - Event trigger filtering
|
|
* - Permission-based team filtering
|
|
*/
|
|
async listWebhooks(options: ListWebhooksOptions): Promise<Webhook[]> {
|
|
const { userId, appId, eventTypeId, eventTriggers } = options;
|
|
|
|
// Build WHERE conditions
|
|
const whereConditions: NonNullable<Prisma.WebhookWhereInput["AND"]> = [
|
|
// AppId filter - null appId by default (excludes zapier/make)
|
|
{ appId: appId ?? null },
|
|
];
|
|
|
|
const user = await this.userRepository.findUserTeams(userId);
|
|
|
|
if (eventTypeId) {
|
|
const managedParentId = await this.eventTypeRepository.findParentEventTypeId(eventTypeId);
|
|
|
|
if (managedParentId) {
|
|
// Include webhooks from both the event type and its parent (if active)
|
|
whereConditions.push({
|
|
OR: [{ eventTypeId }, { eventTypeId: managedParentId, active: true }],
|
|
});
|
|
} else {
|
|
whereConditions.push({ eventTypeId });
|
|
}
|
|
} else {
|
|
// No eventTypeId - filter by user and their allowed teams
|
|
const permissionService = new PermissionCheckService();
|
|
const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
|
|
|
|
const allowedTeamIds = (
|
|
await Promise.all(
|
|
teamIds.map(async (teamId) => {
|
|
const ok = await permissionService.checkPermission({
|
|
userId,
|
|
teamId,
|
|
permission: "webhook.read",
|
|
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
});
|
|
return ok ? teamId : null;
|
|
})
|
|
)
|
|
).filter((x): x is number => x !== null);
|
|
|
|
whereConditions.push({
|
|
OR: [{ userId }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
|
|
});
|
|
}
|
|
|
|
// Event triggers filter
|
|
if (eventTriggers?.length) {
|
|
whereConditions.push({ eventTriggers: { hasEvery: eventTriggers } });
|
|
}
|
|
|
|
const webhooks = await this.prisma.webhook.findMany({
|
|
where: { AND: whereConditions },
|
|
select: {
|
|
id: true,
|
|
subscriberUrl: true,
|
|
payloadTemplate: true,
|
|
appId: true,
|
|
secret: true,
|
|
active: true,
|
|
eventTriggers: true,
|
|
eventTypeId: true,
|
|
teamId: true,
|
|
userId: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
version: true,
|
|
createdAt: true,
|
|
platform: true,
|
|
platformOAuthClientId: true,
|
|
},
|
|
});
|
|
|
|
return WebhookOutputMapper.toWebhookList(webhooks);
|
|
}
|
|
}
|