@vicacha-devs/strapi-plugin-broadcasts 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/admin/BroadcastsHomePage-CM_N_spo.mjs +1210 -0
- package/dist/admin/BroadcastsHomePage-DQueM7nj.js +1228 -0
- package/dist/admin/SettingsPage-TawvAAiv.js +1187 -0
- package/dist/admin/SettingsPage-ikcb0cfh.mjs +1169 -0
- package/dist/admin/ar-vwekVqfp.js +232 -0
- package/dist/admin/ar-wZTCd3Za.mjs +232 -0
- package/dist/admin/de-IZicxuvv.mjs +232 -0
- package/dist/admin/de-Qa2SQQfY.js +232 -0
- package/dist/admin/en-B-HnZo8U.mjs +232 -0
- package/dist/admin/en-D1byrK0s.js +232 -0
- package/dist/admin/es-Co7PmAj8.mjs +232 -0
- package/dist/admin/es-Dgylqagv.js +232 -0
- package/dist/admin/fr-B9zZn3z1.mjs +232 -0
- package/dist/admin/fr-C7-MIWnw.js +232 -0
- package/dist/admin/index-CApLw9Uu.mjs +1036 -0
- package/dist/admin/index-Dw6J7U1v.js +1053 -0
- package/dist/admin/index.js +6 -0
- package/dist/admin/index.mjs +6 -0
- package/dist/admin/it-DJU9iBqT.mjs +232 -0
- package/dist/admin/it-D_mulZtx.js +232 -0
- package/dist/admin/pt-B9swwjPu.js +232 -0
- package/dist/admin/pt-Dr8u5we0.mjs +232 -0
- package/dist/admin/ru-B0NNiMNA.js +232 -0
- package/dist/admin/ru-Bd4cEKDV.mjs +232 -0
- package/dist/admin/selectOptions-BlvNrhvc.mjs +150 -0
- package/dist/admin/selectOptions-DuHQ0KK3.js +149 -0
- package/dist/admin/src/components/AutomationAndLogsTabs.d.ts +16 -0
- package/dist/admin/src/components/BridgesSandboxTab.d.ts +8 -0
- package/dist/admin/src/components/CampaignComposerForm.d.ts +26 -0
- package/dist/admin/src/components/CampaignsTableSection.d.ts +27 -0
- package/dist/admin/src/components/DeliveryLedgerSection.d.ts +17 -0
- package/dist/admin/src/components/Initializer.d.ts +6 -0
- package/dist/admin/src/components/OrganizationEmailTab.d.ts +10 -0
- package/dist/admin/src/components/PluginIcon.d.ts +3 -0
- package/dist/admin/src/components/WhatsAppTransportTab.d.ts +11 -0
- package/dist/admin/src/index.d.ts +24 -0
- package/dist/admin/src/pages/BroadcastsHomePage.d.ts +2 -0
- package/dist/admin/src/pages/SettingsPage.d.ts +3 -0
- package/dist/admin/src/permissions.d.ts +30 -0
- package/dist/admin/src/pluginId.d.ts +2 -0
- package/dist/admin/src/translations/cmLabels.d.ts +1 -0
- package/dist/admin/src/translations/cmLabelsExtra.d.ts +1 -0
- package/dist/admin/src/translations/cmPlaceholders.d.ts +1 -0
- package/dist/admin/src/translations/cmPlaceholdersExtra.d.ts +1 -0
- package/dist/admin/src/utils/extractApiError.d.ts +1 -0
- package/dist/admin/src/utils/selectOptions.d.ts +21 -0
- package/dist/admin/tr-Cc-ItZUP.js +232 -0
- package/dist/admin/tr-DlyHa2Vk.mjs +232 -0
- package/dist/admin/zh-CLzJ7x3y.mjs +232 -0
- package/dist/admin/zh-CaqwZk-p.js +232 -0
- package/dist/server/index.js +3399 -0
- package/dist/server/index.mjs +3399 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +5 -0
- package/dist/server/src/content-types/automation-rule/index.d.ts +90 -0
- package/dist/server/src/content-types/automation-rule/schema.json.d.ts +90 -0
- package/dist/server/src/content-types/campaign/index.d.ts +118 -0
- package/dist/server/src/content-types/campaign/schema.json.d.ts +118 -0
- package/dist/server/src/content-types/delivery-log/index.d.ts +71 -0
- package/dist/server/src/content-types/delivery-log/schema.json.d.ts +71 -0
- package/dist/server/src/content-types/index.d.ts +279 -0
- package/dist/server/src/controllers/broadcastController.d.ts +32 -0
- package/dist/server/src/controllers/index.d.ts +22 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/eventHubRegistry.d.ts +5 -0
- package/dist/server/src/index.d.ts +463 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin.d.ts +17 -0
- package/dist/server/src/routes/content-api.d.ts +13 -0
- package/dist/server/src/routes/index.d.ts +31 -0
- package/dist/server/src/services/automationService.d.ts +33 -0
- package/dist/server/src/services/campaignChannelDispatch.d.ts +20 -0
- package/dist/server/src/services/campaignHelpers.d.ts +20 -0
- package/dist/server/src/services/campaignLifecycle.d.ts +23 -0
- package/dist/server/src/services/campaignService.d.ts +54 -0
- package/dist/server/src/services/deliveryLedger.d.ts +18 -0
- package/dist/server/src/services/emailDesignerBridge.d.ts +30 -0
- package/dist/server/src/services/idempotency.d.ts +5 -0
- package/dist/server/src/services/index.d.ts +119 -0
- package/dist/server/src/services/settings.d.ts +12 -0
- package/dist/server/src/services/transportService.d.ts +70 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +825 -0
- package/dist/shared/index.mjs +823 -0
- package/package.json +109 -3
|
@@ -0,0 +1,3399 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const shared = require("@vicacha-devs/strapi-plugin-calendar/shared");
|
|
4
|
+
const node_crypto = require("node:crypto");
|
|
5
|
+
function isSupportedCalendar(cal) {
|
|
6
|
+
if (typeof cal !== "string" || !cal.trim()) return false;
|
|
7
|
+
return shared.SUPPORTED_INTL_CALENDARS.some((item) => item.id === cal.trim());
|
|
8
|
+
}
|
|
9
|
+
function formatSecondaryDate(isoDateString, calendar = "islamic-umalqura", locale = "es") {
|
|
10
|
+
if (!isoDateString) {
|
|
11
|
+
return { calendar, formatted: "" };
|
|
12
|
+
}
|
|
13
|
+
const date = new Date(isoDateString);
|
|
14
|
+
if (Number.isNaN(date.getTime())) {
|
|
15
|
+
return { calendar, formatted: isoDateString };
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const formatter = new Intl.DateTimeFormat(`${locale}-u-ca-${calendar}`, {
|
|
19
|
+
year: "numeric",
|
|
20
|
+
month: "short",
|
|
21
|
+
day: "numeric"
|
|
22
|
+
});
|
|
23
|
+
const parts = formatter.formatToParts(date);
|
|
24
|
+
const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
|
|
25
|
+
const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
|
|
26
|
+
const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
|
|
27
|
+
return {
|
|
28
|
+
calendar,
|
|
29
|
+
formatted: formatter.format(date),
|
|
30
|
+
year,
|
|
31
|
+
month,
|
|
32
|
+
day
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return {
|
|
36
|
+
calendar,
|
|
37
|
+
formatted: date.toISOString().slice(0, 10)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const PLUGIN_ID = "broadcasts";
|
|
42
|
+
const CAMPAIGN_UID = "plugin::broadcasts.campaign";
|
|
43
|
+
const AUTOMATION_RULE_UID = "plugin::broadcasts.automation-rule";
|
|
44
|
+
const DELIVERY_LOG_UID = "plugin::broadcasts.delivery-log";
|
|
45
|
+
const SUPPORTED_CHANNELS = [
|
|
46
|
+
"email",
|
|
47
|
+
"telegram",
|
|
48
|
+
"whatsapp"
|
|
49
|
+
];
|
|
50
|
+
const SUPPORTED_WHATSAPP_DRIVERS = [
|
|
51
|
+
"meta-cloud",
|
|
52
|
+
"openwa",
|
|
53
|
+
"evolution"
|
|
54
|
+
];
|
|
55
|
+
const DEFAULT_BROADCAST_TOPICS = [
|
|
56
|
+
"announcements",
|
|
57
|
+
"events",
|
|
58
|
+
"courses",
|
|
59
|
+
"ramadan_eid",
|
|
60
|
+
"urgent_janazah",
|
|
61
|
+
"newsletter"
|
|
62
|
+
];
|
|
63
|
+
const DEFAULT_BROADCAST_SETTINGS = {
|
|
64
|
+
primaryCalendar: "gregory",
|
|
65
|
+
secondaryCalendars: ["islamic-umalqura"],
|
|
66
|
+
defaultLocale: "es",
|
|
67
|
+
supportedLocales: ["es", "en", "ar", "tr"],
|
|
68
|
+
availableTopics: [...DEFAULT_BROADCAST_TOPICS],
|
|
69
|
+
dryRun: true,
|
|
70
|
+
enableInboxSubscriberBridge: true,
|
|
71
|
+
enableEventHubAutomations: true,
|
|
72
|
+
enableEmailDesignerBridge: true,
|
|
73
|
+
defaultEmailTemplateRef: "",
|
|
74
|
+
siteBaseUrl: "https://mezquitabogota.org",
|
|
75
|
+
organizationName: "Mezquita Central Abu Bakr As-Siddiq — Bogotá",
|
|
76
|
+
emailFromAddress: "no-reply@mezquitabogota.org",
|
|
77
|
+
emailReplyToAddress: "info@mezquitabogota.org",
|
|
78
|
+
telegramEnabled: true,
|
|
79
|
+
telegramBotToken: "",
|
|
80
|
+
telegramDefaultChatId: "@MezquitaCentralBogota",
|
|
81
|
+
telegramTopicChatMap: {},
|
|
82
|
+
telegramTopicThreadMap: {},
|
|
83
|
+
whatsappEnabled: true,
|
|
84
|
+
whatsappDriver: "meta-cloud",
|
|
85
|
+
whatsappPhoneNumberId: "",
|
|
86
|
+
whatsappAccessToken: "",
|
|
87
|
+
whatsappGatewayUrl: "",
|
|
88
|
+
whatsappGatewaySession: "default",
|
|
89
|
+
whatsappGatewayApiKey: "",
|
|
90
|
+
whatsappDefaultRecipients: [],
|
|
91
|
+
whatsappTopicGroupMap: {},
|
|
92
|
+
allowLocalhostGateways: false,
|
|
93
|
+
publicChannelLinks: [
|
|
94
|
+
{
|
|
95
|
+
id: "telegram-main",
|
|
96
|
+
channel: "telegram",
|
|
97
|
+
label: "Canal Oficial de Telegram",
|
|
98
|
+
handle: "@MezquitaCentralBogota",
|
|
99
|
+
inviteUrl: "https://t.me/MezquitaCentralBogota",
|
|
100
|
+
topic: "announcements",
|
|
101
|
+
localizedLabels: {
|
|
102
|
+
es: "Canal Oficial de Telegram",
|
|
103
|
+
en: "Official Telegram Channel",
|
|
104
|
+
ar: "القناة الرسمية على تيليجرام",
|
|
105
|
+
tr: "Resmi Telegram Kanalı"
|
|
106
|
+
},
|
|
107
|
+
localizedDescriptions: {
|
|
108
|
+
es: "Horarios de Jumu’ah, avistamiento lunar de Ramadán, cursos y avisos urgentes.",
|
|
109
|
+
en: "Jumu’ah schedules, Ramadan moon sighting, courses, and urgent community notices.",
|
|
110
|
+
ar: "مواعيد صلاة الجمعة، تحري هلال رمضان، الدورات والإعلانات العاجلة.",
|
|
111
|
+
tr: "Cuma vakitleri, Ramazan hilal gözlemi, kurslar ve acil duyurular."
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "whatsapp-community",
|
|
116
|
+
channel: "whatsapp",
|
|
117
|
+
label: "Comunidad Oficial de WhatsApp",
|
|
118
|
+
handle: "+57 310 000 0000",
|
|
119
|
+
inviteUrl: "https://whatsapp.com/channel/0029VaMezquitaBogota",
|
|
120
|
+
topic: "announcements",
|
|
121
|
+
localizedLabels: {
|
|
122
|
+
es: "Comunidad Oficial de WhatsApp",
|
|
123
|
+
en: "Official WhatsApp Community",
|
|
124
|
+
ar: "مجتمع واتساب الرسمي",
|
|
125
|
+
tr: "Resmi WhatsApp Topluluğu"
|
|
126
|
+
},
|
|
127
|
+
localizedDescriptions: {
|
|
128
|
+
es: "Alertas directas de oración funeraria (Janazah), eventos comunitarios y boletín semanal.",
|
|
129
|
+
en: "Direct Janazah funeral prayer alerts, community events, and weekly newsletter.",
|
|
130
|
+
ar: "تنبيهات صلاة الجنازة العاجلة، الفعاليات المجتمعية والنشرة الأسبوعية.",
|
|
131
|
+
tr: "Acil cenaze namazı duyuruları, topluluk etkinlikleri ve haftalık bülten."
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
};
|
|
136
|
+
const STARTER_CAMPAIGN_TEMPLATES = [
|
|
137
|
+
{
|
|
138
|
+
id: "jumuah-weekly-reminder",
|
|
139
|
+
slug: "jumuah-weekly-reminder",
|
|
140
|
+
title: "Recordatorio Semanal de Khutbah y Oración del Viernes (Jumu’ah)",
|
|
141
|
+
topic: "announcements",
|
|
142
|
+
channels: ["email", "telegram", "whatsapp"],
|
|
143
|
+
priority: "normal",
|
|
144
|
+
subject: "🕌 Jumu’ah en {{organizationName}} ({{secondaryDate}})",
|
|
145
|
+
bodyText: "As-salamu alaykum {{recipientName}}. Te esperamos este viernes para la Khutbah y oración de Jumu’ah en {{organizationName}}. Fecha Hijri: {{secondaryDate}}.",
|
|
146
|
+
bodyHtml: "<p>As-salamu alaykum <strong>{{recipientName}}</strong>,</p><p>Te esperamos este viernes para la Khutbah y oración congregacional de <strong>Jumu’ah</strong> en {{organizationName}}.</p><p>Calendario Hijri: <em>{{secondaryDate}}</em></p>",
|
|
147
|
+
ctaLabel: "Ver Horarios de Oración",
|
|
148
|
+
ctaUrl: "https://mezquitabogota.org/es/horarios",
|
|
149
|
+
translations: {
|
|
150
|
+
es: {
|
|
151
|
+
subject: "🕌 Jumu’ah en {{organizationName}} ({{secondaryDate}})",
|
|
152
|
+
bodyText: "As-salamu alaykum {{recipientName}}. Te esperamos este viernes para la Khutbah y oración de Jumu’ah en {{organizationName}}. Fecha Hijri: {{secondaryDate}}.",
|
|
153
|
+
bodyHtml: "<p>As-salamu alaykum <strong>{{recipientName}}</strong>,</p><p>Te esperamos este viernes para la Khutbah y oración congregacional de <strong>Jumu’ah</strong> en {{organizationName}}.</p><p>Calendario Hijri: <em>{{secondaryDate}}</em></p>",
|
|
154
|
+
ctaLabel: "Ver Horarios de Oración",
|
|
155
|
+
ctaUrl: "https://mezquitabogota.org/es/horarios"
|
|
156
|
+
},
|
|
157
|
+
en: {
|
|
158
|
+
subject: "🕌 Friday Jumu’ah Reminder — {{organizationName}} ({{secondaryDate}})",
|
|
159
|
+
bodyText: "As-salamu alaykum {{recipientName}}. Join us this Friday for the Jumu’ah Khutbah and congregational prayer at {{organizationName}}. Hijri date: {{secondaryDate}}.",
|
|
160
|
+
bodyHtml: "<p>As-salamu alaykum <strong>{{recipientName}}</strong>,</p><p>Join us this Friday for the <strong>Jumu’ah Khutbah</strong> and congregational prayer at {{organizationName}}.</p><p>Hijri date: <em>{{secondaryDate}}</em></p>",
|
|
161
|
+
ctaLabel: "View Prayer Schedule",
|
|
162
|
+
ctaUrl: "https://mezquitabogota.org/en/prayer-times"
|
|
163
|
+
},
|
|
164
|
+
ar: {
|
|
165
|
+
subject: "🕌 تذكير صلاة وخطبة الجمعة — {{organizationName}} ({{secondaryDate}})",
|
|
166
|
+
bodyText: "السلام عليكم {{recipientName}}، ندعوكم لحضور خطبة وصلاة الجمعة في {{organizationName}}. التاريخ الهجري: {{secondaryDate}}.",
|
|
167
|
+
bodyHtml: "<p>السلام عليكم <strong>{{recipientName}}</strong>،</p><p>ندعوكم لحضور خطبة وصلاة <strong>الجمعة</strong> في {{organizationName}}.</p><p>التاريخ الهجري: <em>{{secondaryDate}}</em></p>",
|
|
168
|
+
ctaLabel: "عرض مواقيت الصلاة",
|
|
169
|
+
ctaUrl: "https://mezquitabogota.org/ar/prayer-times"
|
|
170
|
+
},
|
|
171
|
+
tr: {
|
|
172
|
+
subject: "🕌 Cuma Hutbesi ve Namazı Hatırlatması — {{organizationName}} ({{secondaryDate}})",
|
|
173
|
+
bodyText: "Es-selamu aleyküm {{recipientName}}. Bu Cuma {{organizationName}} bünyesinde Cuma hutbesi ve namazına davetlisiniz. Hicri Tarih: {{secondaryDate}}.",
|
|
174
|
+
bodyHtml: "<p>Es-selamu aleyküm <strong>{{recipientName}}</strong>,</p><p>Bu Cuma {{organizationName}} bünyesinde <strong>Cuma Hutbesi ve Namazına</strong> davetlisiniz.</p><p>Hicri Tarih: <em>{{secondaryDate}}</em></p>",
|
|
175
|
+
ctaLabel: "Namaz Vakitlerini Gör",
|
|
176
|
+
ctaUrl: "https://mezquitabogota.org/tr/prayer-times"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "urgent-janazah-alert",
|
|
182
|
+
slug: "urgent-janazah-alert",
|
|
183
|
+
title: "Aviso Urgente de Oración Funeraria (Salat al-Janazah)",
|
|
184
|
+
topic: "urgent_janazah",
|
|
185
|
+
channels: ["telegram", "whatsapp", "email"],
|
|
186
|
+
priority: "urgent",
|
|
187
|
+
subject: "🕯️ Aviso Urgente: Salat al-Janazah en {{organizationName}}",
|
|
188
|
+
bodyText: "Inna lillahi wa inna ilayhi raji’un. Informamos a la comunidad que se realizará la oración funeraria (Salat al-Janazah) en {{organizationName}} después de la oración de Dhuhr ({{secondaryDate}}).",
|
|
189
|
+
bodyHtml: "<p><strong>Inna lillahi wa inna ilayhi raji’un</strong></p><p>Informamos a la comunidad que se realizará la oración funeraria (<strong>Salat al-Janazah</strong>) en {{organizationName}} después de la oración de Dhuhr ({{secondaryDate}}).</p>",
|
|
190
|
+
ctaLabel: "Detalles y Ubicación",
|
|
191
|
+
ctaUrl: "https://mezquitabogota.org/es/avisos",
|
|
192
|
+
translations: {
|
|
193
|
+
es: {
|
|
194
|
+
subject: "🕯️ Aviso Urgente: Salat al-Janazah en {{organizationName}}",
|
|
195
|
+
bodyText: "Inna lillahi wa inna ilayhi raji’un. Informamos a la comunidad que se realizará la oración funeraria (Salat al-Janazah) en {{organizationName}} después de la oración de Dhuhr ({{secondaryDate}}).",
|
|
196
|
+
ctaLabel: "Detalles y Ubicación",
|
|
197
|
+
ctaUrl: "https://mezquitabogota.org/es/avisos"
|
|
198
|
+
},
|
|
199
|
+
en: {
|
|
200
|
+
subject: "🕯️ Urgent Community Notice: Salat al-Janazah at {{organizationName}}",
|
|
201
|
+
bodyText: "Inna lillahi wa inna ilayhi raji’un. Funeral prayer (Salat al-Janazah) will be held at {{organizationName}} following Dhuhr prayer ({{secondaryDate}}).",
|
|
202
|
+
ctaLabel: "Details & Location",
|
|
203
|
+
ctaUrl: "https://mezquitabogota.org/en/announcements"
|
|
204
|
+
},
|
|
205
|
+
ar: {
|
|
206
|
+
subject: "🕯️ إعلان عاجل: صلاة الجنازة في {{organizationName}}",
|
|
207
|
+
bodyText: "إنا لله وإنا إليه راجعون. تُقام صلاة الجنازة في {{organizationName}} عقب صلاة الظهر ({{secondaryDate}}).",
|
|
208
|
+
ctaLabel: "التفاصيل والموقع",
|
|
209
|
+
ctaUrl: "https://mezquitabogota.org/ar/announcements"
|
|
210
|
+
},
|
|
211
|
+
tr: {
|
|
212
|
+
subject: "🕯️ Acil Duyuru: {{organizationName}} Cenaze Namazı",
|
|
213
|
+
bodyText: "İnna lillahi ve inna ileyhi raciun. Öğle namazını müteakip {{organizationName}} camiimizde cenaze namazı kılınacaktır ({{secondaryDate}}).",
|
|
214
|
+
ctaLabel: "Detaylar ve Konum",
|
|
215
|
+
ctaUrl: "https://mezquitabogota.org/tr/announcements"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
const STARTER_AUTOMATION_RULES = [
|
|
221
|
+
{
|
|
222
|
+
slug: "auto-alert-banner-broadcast",
|
|
223
|
+
name: "Auto-Broadcast Urgent Site Alert Banners",
|
|
224
|
+
eventName: "inbox.alert.published",
|
|
225
|
+
topic: "announcements",
|
|
226
|
+
channels: ["telegram", "whatsapp"],
|
|
227
|
+
active: true,
|
|
228
|
+
priority: "urgent",
|
|
229
|
+
subjectTemplate: "🚨 {{title}} — {{organizationName}}",
|
|
230
|
+
bodyTextTemplate: "🚨 {{title}}: {{message}} ({{secondaryDate}})",
|
|
231
|
+
bodyHtmlTemplate: "<p><strong>🚨 {{title}}</strong></p><p>{{message}}</p><p><em>{{secondaryDate}}</em></p>",
|
|
232
|
+
ctaLabelTemplate: "Ver Aviso Oficial",
|
|
233
|
+
ctaUrlTemplate: "{{siteBaseUrl}}"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
slug: "auto-event-published-broadcast",
|
|
237
|
+
name: "Auto-Broadcast Newly Published Community Events",
|
|
238
|
+
eventName: "event.published",
|
|
239
|
+
targetContentType: "api::event.event",
|
|
240
|
+
topic: "events",
|
|
241
|
+
channels: ["telegram", "email"],
|
|
242
|
+
active: true,
|
|
243
|
+
priority: "normal",
|
|
244
|
+
subjectTemplate: "📅 Nuevo Evento: {{title}} ({{secondaryDate}})",
|
|
245
|
+
bodyTextTemplate: "Nuevo evento comunitario en {{organizationName}}: {{title}}. {{summary}} ({{secondaryDate}}).",
|
|
246
|
+
bodyHtmlTemplate: "<p>Nuevo evento comunitario en <strong>{{organizationName}}</strong>:</p><h3>{{title}}</h3><p>{{summary}}</p><p><em>{{secondaryDate}}</em></p>",
|
|
247
|
+
ctaLabelTemplate: "Inscribirse al Evento",
|
|
248
|
+
ctaUrlTemplate: "{{siteBaseUrl}}/events"
|
|
249
|
+
}
|
|
250
|
+
];
|
|
251
|
+
const HTML_ESCAPE_MAP = {
|
|
252
|
+
"&": "&",
|
|
253
|
+
"<": "<",
|
|
254
|
+
">": ">",
|
|
255
|
+
'"': """,
|
|
256
|
+
"'": "'"
|
|
257
|
+
};
|
|
258
|
+
const TELEGRAM_INLINE_TAGS = /* @__PURE__ */ new Set([
|
|
259
|
+
"b",
|
|
260
|
+
"strong",
|
|
261
|
+
"i",
|
|
262
|
+
"em",
|
|
263
|
+
"u",
|
|
264
|
+
"ins",
|
|
265
|
+
"s",
|
|
266
|
+
"strike",
|
|
267
|
+
"del",
|
|
268
|
+
"code",
|
|
269
|
+
"pre",
|
|
270
|
+
"tg-spoiler"
|
|
271
|
+
]);
|
|
272
|
+
function escapeHtml(raw) {
|
|
273
|
+
return String(raw).replace(
|
|
274
|
+
/[&<>"']/g,
|
|
275
|
+
(char) => HTML_ESCAPE_MAP[char] || char
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
function stripHeaderControlChars(raw) {
|
|
279
|
+
return String(raw || "").replace(/[\r\n<>]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
280
|
+
}
|
|
281
|
+
function extractSafeAnchorHref(attrs) {
|
|
282
|
+
const match = attrs.match(
|
|
283
|
+
/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
284
|
+
);
|
|
285
|
+
const rawHref = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").replace(/&/gi, "&").trim();
|
|
286
|
+
if (!rawHref || /[\u0000-\u001f\u007f]/.test(rawHref)) return null;
|
|
287
|
+
try {
|
|
288
|
+
const parsed = new URL(rawHref);
|
|
289
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return parsed.toString();
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function sanitizeTelegramHtml(rawHtml) {
|
|
298
|
+
if (!rawHtml) return "";
|
|
299
|
+
let text = String(rawHtml).replace(/[\u0001\u0002]/g, "").replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "").replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<h[1-6](?:\s[^>]*)?>/gi, "<b>").replace(/<\/h[1-6]\s*>/gi, "</b>\n").replace(/<p(?:\s[^>]*)?>/gi, "").replace(/<\/p\s*>/gi, "\n\n").replace(/<div(?:\s[^>]*)?>/gi, "").replace(/<\/div\s*>/gi, "\n").replace(/<li(?:\s[^>]*)?>/gi, "• ").replace(/<\/li\s*>/gi, "\n");
|
|
300
|
+
let openAnchors = 0;
|
|
301
|
+
text = text.replace(
|
|
302
|
+
/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?(\/?)>/g,
|
|
303
|
+
(_full, slash, rawName, attrs = "", selfClose = "") => {
|
|
304
|
+
const tag = rawName.toLowerCase();
|
|
305
|
+
if (selfClose === "/") return "";
|
|
306
|
+
if (slash === "/") {
|
|
307
|
+
if (TELEGRAM_INLINE_TAGS.has(tag)) return `/${tag}`;
|
|
308
|
+
if (tag === "a" && openAnchors > 0) {
|
|
309
|
+
openAnchors -= 1;
|
|
310
|
+
return "/a";
|
|
311
|
+
}
|
|
312
|
+
return "";
|
|
313
|
+
}
|
|
314
|
+
if (TELEGRAM_INLINE_TAGS.has(tag)) return `${tag}`;
|
|
315
|
+
if (tag === "a") {
|
|
316
|
+
const safeHref = extractSafeAnchorHref(attrs);
|
|
317
|
+
if (!safeHref) return "";
|
|
318
|
+
openAnchors += 1;
|
|
319
|
+
return `a href="${escapeHtml(safeHref)}"`;
|
|
320
|
+
}
|
|
321
|
+
return "";
|
|
322
|
+
}
|
|
323
|
+
);
|
|
324
|
+
while (openAnchors > 0) {
|
|
325
|
+
text += "/a";
|
|
326
|
+
openAnchors -= 1;
|
|
327
|
+
}
|
|
328
|
+
text = text.replace(/</g, "<").replace(/>/g, ">").replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/gi, "&").replace(/\u0001([^\u0002]+)\u0002/g, "<$1>").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
329
|
+
return text;
|
|
330
|
+
}
|
|
331
|
+
function interpolateBroadcastTemplate(template, variables, options2 = {}) {
|
|
332
|
+
if (!template) return "";
|
|
333
|
+
const shouldEscape = Boolean(options2.escapeHtmlValues);
|
|
334
|
+
return template.replace(
|
|
335
|
+
/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g,
|
|
336
|
+
(_match, key) => {
|
|
337
|
+
const rawValue = variables[key];
|
|
338
|
+
if (rawValue === void 0 || rawValue === null) {
|
|
339
|
+
return "";
|
|
340
|
+
}
|
|
341
|
+
const strValue = String(rawValue);
|
|
342
|
+
return shouldEscape ? escapeHtml(strValue) : strValue;
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
function maskEmail(email) {
|
|
347
|
+
const trimmed = String(email || "").trim().toLowerCase();
|
|
348
|
+
const atIndex = trimmed.indexOf("@");
|
|
349
|
+
if (atIndex <= 0) {
|
|
350
|
+
return "***@invalid";
|
|
351
|
+
}
|
|
352
|
+
const local = trimmed.slice(0, atIndex);
|
|
353
|
+
const domain = trimmed.slice(atIndex + 1);
|
|
354
|
+
if (!domain) {
|
|
355
|
+
return "***@invalid";
|
|
356
|
+
}
|
|
357
|
+
const visiblePrefix = local.slice(0, 1);
|
|
358
|
+
return `${visiblePrefix}***@${domain}`;
|
|
359
|
+
}
|
|
360
|
+
function maskPhone(phone) {
|
|
361
|
+
const normalized = normalizeE164Phone(phone);
|
|
362
|
+
if (normalized.length <= 4) {
|
|
363
|
+
return "***";
|
|
364
|
+
}
|
|
365
|
+
const prefix = normalized.startsWith("+") ? normalized.slice(0, 3) : normalized.slice(0, 2);
|
|
366
|
+
const suffix = normalized.slice(-4);
|
|
367
|
+
return `${prefix}***${suffix}`;
|
|
368
|
+
}
|
|
369
|
+
function isWhatsAppGroupOrChannelJid(raw) {
|
|
370
|
+
const trimmed = String(raw || "").trim().toLowerCase();
|
|
371
|
+
return /^[\d-]+@g\.us$/.test(trimmed) || /^[\d-]+@newsletter$/.test(trimmed);
|
|
372
|
+
}
|
|
373
|
+
function normalizeWhatsAppTarget(raw, defaultCountryCode = "57") {
|
|
374
|
+
const trimmed = String(raw || "").trim();
|
|
375
|
+
if (!trimmed) {
|
|
376
|
+
return {
|
|
377
|
+
normalized: "",
|
|
378
|
+
isGroupOrChannel: false,
|
|
379
|
+
chatId: "",
|
|
380
|
+
evolutionNumber: ""
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const lower = trimmed.toLowerCase();
|
|
384
|
+
if (isWhatsAppGroupOrChannelJid(lower)) {
|
|
385
|
+
return {
|
|
386
|
+
normalized: lower,
|
|
387
|
+
isGroupOrChannel: true,
|
|
388
|
+
chatId: lower,
|
|
389
|
+
evolutionNumber: lower
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (lower.endsWith("@c.us") || lower.endsWith("@s.whatsapp.net")) {
|
|
393
|
+
const userPart = lower.split("@")[0];
|
|
394
|
+
const e1642 = normalizeE164Phone(userPart, defaultCountryCode);
|
|
395
|
+
const digits2 = e1642.replace(/^\+/, "");
|
|
396
|
+
return {
|
|
397
|
+
normalized: e1642,
|
|
398
|
+
isGroupOrChannel: false,
|
|
399
|
+
chatId: digits2 ? `${digits2}@c.us` : "",
|
|
400
|
+
evolutionNumber: digits2
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const e164 = normalizeE164Phone(trimmed, defaultCountryCode);
|
|
404
|
+
const digits = e164.replace(/^\+/, "");
|
|
405
|
+
return {
|
|
406
|
+
normalized: e164,
|
|
407
|
+
isGroupOrChannel: false,
|
|
408
|
+
chatId: digits ? `${digits}@c.us` : "",
|
|
409
|
+
evolutionNumber: digits
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function maskRecipientIdentifier(identifier) {
|
|
413
|
+
const trimmed = String(identifier || "").trim();
|
|
414
|
+
if (!trimmed) return "***";
|
|
415
|
+
const lower = trimmed.toLowerCase();
|
|
416
|
+
if (isWhatsAppGroupOrChannelJid(lower)) {
|
|
417
|
+
return lower;
|
|
418
|
+
}
|
|
419
|
+
if (trimmed.includes("@") && !trimmed.startsWith("@")) {
|
|
420
|
+
return maskEmail(trimmed);
|
|
421
|
+
}
|
|
422
|
+
if (trimmed.startsWith("@") || /^-\d+$/.test(trimmed)) {
|
|
423
|
+
return trimmed;
|
|
424
|
+
}
|
|
425
|
+
return maskPhone(trimmed);
|
|
426
|
+
}
|
|
427
|
+
function normalizeE164Phone(phone, defaultCountryCode = "57") {
|
|
428
|
+
const raw = String(phone || "").trim();
|
|
429
|
+
if (!raw) return "";
|
|
430
|
+
if (isWhatsAppGroupOrChannelJid(raw)) {
|
|
431
|
+
return raw.toLowerCase();
|
|
432
|
+
}
|
|
433
|
+
const hasPlus = raw.startsWith("+");
|
|
434
|
+
const digitsOnly = raw.replace(/\D+/g, "");
|
|
435
|
+
if (!digitsOnly) return "";
|
|
436
|
+
if (hasPlus) {
|
|
437
|
+
return `+${digitsOnly}`;
|
|
438
|
+
}
|
|
439
|
+
if (digitsOnly.startsWith("00") && digitsOnly.length > 4) {
|
|
440
|
+
return `+${digitsOnly.slice(2)}`;
|
|
441
|
+
}
|
|
442
|
+
if (digitsOnly.length === 10 && defaultCountryCode) {
|
|
443
|
+
return `+${defaultCountryCode.replace(/\D+/g, "")}${digitsOnly}`;
|
|
444
|
+
}
|
|
445
|
+
return `+${digitsOnly}`;
|
|
446
|
+
}
|
|
447
|
+
function resolveLocalizedCampaignCopy(campaign2, locale, defaultLocale = "es") {
|
|
448
|
+
const translations = campaign2.translations || {};
|
|
449
|
+
const match = translations[locale] || translations[defaultLocale];
|
|
450
|
+
return {
|
|
451
|
+
subject: match?.subject || campaign2.subject,
|
|
452
|
+
bodyText: match?.bodyText || campaign2.bodyText,
|
|
453
|
+
bodyHtml: match?.bodyHtml || campaign2.bodyHtml,
|
|
454
|
+
ctaLabel: match?.ctaLabel || campaign2.ctaLabel,
|
|
455
|
+
ctaUrl: match?.ctaUrl || campaign2.ctaUrl
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
459
|
+
const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
460
|
+
"metadata.google.internal",
|
|
461
|
+
"metadata",
|
|
462
|
+
"100.100.100.200"
|
|
463
|
+
]);
|
|
464
|
+
function parseIpv4Octets(host) {
|
|
465
|
+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
466
|
+
if (!match) return null;
|
|
467
|
+
const octets = [
|
|
468
|
+
Number(match[1]),
|
|
469
|
+
Number(match[2]),
|
|
470
|
+
Number(match[3]),
|
|
471
|
+
Number(match[4])
|
|
472
|
+
];
|
|
473
|
+
if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
return octets;
|
|
477
|
+
}
|
|
478
|
+
function isBlockedIpv4([a, b, c, d], allowLoopback) {
|
|
479
|
+
if (a === 127) {
|
|
480
|
+
return !(allowLoopback && b === 0 && c === 0 && d === 1);
|
|
481
|
+
}
|
|
482
|
+
if (a === 0 || a === 10 || a >= 224) return true;
|
|
483
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
484
|
+
if (a === 169 && b === 254) return true;
|
|
485
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
486
|
+
if (a === 192 && b === 168) return true;
|
|
487
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
function expandIpv6Groups(rawHost) {
|
|
491
|
+
if (!rawHost.includes(":") || rawHost.includes("%")) return null;
|
|
492
|
+
let candidate = rawHost;
|
|
493
|
+
const lastColon = candidate.lastIndexOf(":");
|
|
494
|
+
const tail = candidate.slice(lastColon + 1);
|
|
495
|
+
if (tail.includes(".")) {
|
|
496
|
+
const v4 = parseIpv4Octets(tail);
|
|
497
|
+
if (!v4) return null;
|
|
498
|
+
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
499
|
+
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
500
|
+
candidate = `${candidate.slice(0, lastColon)}:${hi}:${lo}`;
|
|
501
|
+
}
|
|
502
|
+
const dblParts = candidate.split("::");
|
|
503
|
+
if (dblParts.length > 2) return null;
|
|
504
|
+
const parsePart = (part) => {
|
|
505
|
+
if (!part) return [];
|
|
506
|
+
const tokens = part.split(":");
|
|
507
|
+
const nums = [];
|
|
508
|
+
for (const tok of tokens) {
|
|
509
|
+
if (!/^[0-9a-f]{1,4}$/i.test(tok)) return null;
|
|
510
|
+
nums.push(Number.parseInt(tok, 16));
|
|
511
|
+
}
|
|
512
|
+
return nums;
|
|
513
|
+
};
|
|
514
|
+
const left = parsePart(dblParts[0]);
|
|
515
|
+
if (!left) return null;
|
|
516
|
+
if (dblParts.length === 1) {
|
|
517
|
+
return left.length === 8 ? left : null;
|
|
518
|
+
}
|
|
519
|
+
const right = parsePart(dblParts[1]);
|
|
520
|
+
if (!right) return null;
|
|
521
|
+
const missing = 8 - (left.length + right.length);
|
|
522
|
+
if (missing < 1) return null;
|
|
523
|
+
return [...left, ...Array.from({ length: missing }, () => 0), ...right];
|
|
524
|
+
}
|
|
525
|
+
function isBlockedIpv6(groups, allowLoopback) {
|
|
526
|
+
const allZeroPrefix = groups.slice(0, 7).every((g) => g === 0);
|
|
527
|
+
if (allZeroPrefix && groups[7] === 0) return true;
|
|
528
|
+
if (allZeroPrefix && groups[7] === 1) return !allowLoopback;
|
|
529
|
+
const first = groups[0];
|
|
530
|
+
if ((first & 65024) === 64512) return true;
|
|
531
|
+
if ((first & 65472) === 65152) return true;
|
|
532
|
+
if ((first & 65280) === 65280) return true;
|
|
533
|
+
if (first === 8194) {
|
|
534
|
+
const embedded6to4 = [
|
|
535
|
+
groups[1] >> 8 & 255,
|
|
536
|
+
groups[1] & 255,
|
|
537
|
+
groups[2] >> 8 & 255,
|
|
538
|
+
groups[2] & 255
|
|
539
|
+
];
|
|
540
|
+
return isBlockedIpv4(embedded6to4, false);
|
|
541
|
+
}
|
|
542
|
+
const isMappedV4 = groups.slice(0, 5).every((g) => g === 0) && (groups[5] === 65535 || groups[5] === 0);
|
|
543
|
+
const isNat64 = groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0);
|
|
544
|
+
if (isMappedV4 || isNat64) {
|
|
545
|
+
const mappedQuad = [
|
|
546
|
+
groups[6] >> 8 & 255,
|
|
547
|
+
groups[6] & 255,
|
|
548
|
+
groups[7] >> 8 & 255,
|
|
549
|
+
groups[7] & 255
|
|
550
|
+
];
|
|
551
|
+
return isBlockedIpv4(mappedQuad, false);
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
function normalizeUrlHostname(rawHostname) {
|
|
556
|
+
const lower = rawHostname.toLowerCase();
|
|
557
|
+
let unbracketed = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
558
|
+
while (unbracketed.endsWith(".")) {
|
|
559
|
+
unbracketed = unbracketed.slice(0, -1);
|
|
560
|
+
}
|
|
561
|
+
return unbracketed;
|
|
562
|
+
}
|
|
563
|
+
function evaluateGatewayHost(host, allowLocalhost) {
|
|
564
|
+
if (!host) {
|
|
565
|
+
return { isExactLoopback: false, blockedError: "Invalid gateway hostname" };
|
|
566
|
+
}
|
|
567
|
+
const isExactLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
568
|
+
if (BLOCKED_HOSTNAMES.has(host) || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
|
|
569
|
+
return {
|
|
570
|
+
isExactLoopback: false,
|
|
571
|
+
blockedError: "Private, loopback, and metadata hosts are forbidden (SSRF protection)"
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (host === "localhost") {
|
|
575
|
+
return {
|
|
576
|
+
isExactLoopback: true,
|
|
577
|
+
blockedError: allowLocalhost ? null : "Localhost gateway URLs are forbidden unless explicitly allowed"
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const v4 = parseIpv4Octets(host);
|
|
581
|
+
if (v4) {
|
|
582
|
+
return {
|
|
583
|
+
isExactLoopback,
|
|
584
|
+
blockedError: isBlockedIpv4(v4, allowLocalhost) ? "Private, loopback, CGNAT, and link-local IPv4 hosts are forbidden" : null
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (host.includes(":")) {
|
|
588
|
+
const v6 = expandIpv6Groups(host);
|
|
589
|
+
if (!v6 || isBlockedIpv6(v6, allowLocalhost)) {
|
|
590
|
+
return {
|
|
591
|
+
isExactLoopback,
|
|
592
|
+
blockedError: "Private, loopback, link-local, and mapped IPv6 hosts are forbidden"
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { isExactLoopback, blockedError: null };
|
|
597
|
+
}
|
|
598
|
+
function validateGatewayUrl(rawUrl, options2 = {}) {
|
|
599
|
+
const errors = [];
|
|
600
|
+
const trimmed = String(rawUrl || "").trim();
|
|
601
|
+
if (!trimmed) {
|
|
602
|
+
return { valid: false, errors: ["Gateway URL is required"] };
|
|
603
|
+
}
|
|
604
|
+
if (trimmed.includes("\\") || /[\u0000-\u001f\u007f]/.test(trimmed)) {
|
|
605
|
+
return {
|
|
606
|
+
valid: false,
|
|
607
|
+
errors: ["Gateway URL must not contain backslashes or control chars"]
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
let parsed;
|
|
611
|
+
try {
|
|
612
|
+
parsed = new URL(trimmed);
|
|
613
|
+
} catch {
|
|
614
|
+
return { valid: false, errors: ["Invalid URL format"] };
|
|
615
|
+
}
|
|
616
|
+
if (parsed.username || parsed.password) {
|
|
617
|
+
errors.push("Gateway URL must not contain embedded credentials");
|
|
618
|
+
}
|
|
619
|
+
if (parsed.search || parsed.hash) {
|
|
620
|
+
errors.push("Gateway URL must not contain query parameters or fragments");
|
|
621
|
+
}
|
|
622
|
+
const allowLocalhost = Boolean(options2.allowLocalhost);
|
|
623
|
+
const host = normalizeUrlHostname(parsed.hostname);
|
|
624
|
+
const { isExactLoopback, blockedError } = evaluateGatewayHost(
|
|
625
|
+
host,
|
|
626
|
+
allowLocalhost
|
|
627
|
+
);
|
|
628
|
+
if (blockedError) errors.push(blockedError);
|
|
629
|
+
if (parsed.protocol === "http:") {
|
|
630
|
+
if (!allowLocalhost || !isExactLoopback) {
|
|
631
|
+
errors.push("HTTPS protocol is required for external gateway URLs");
|
|
632
|
+
}
|
|
633
|
+
} else if (parsed.protocol !== "https:") {
|
|
634
|
+
errors.push(
|
|
635
|
+
"Only HTTPS (or explicitly allowed localhost HTTP) protocol is permitted"
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
return { valid: errors.length === 0, errors };
|
|
639
|
+
}
|
|
640
|
+
function isSafeHttpUrl(rawUrl) {
|
|
641
|
+
if (typeof rawUrl !== "string") return false;
|
|
642
|
+
const trimmed = rawUrl.trim();
|
|
643
|
+
if (!trimmed || /[\u0000-\u001f\u007f<>\\]/.test(trimmed)) return false;
|
|
644
|
+
try {
|
|
645
|
+
const parsed = new URL(trimmed);
|
|
646
|
+
if (parsed.username || parsed.password) return false;
|
|
647
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function validatePublicChannelLinks(links) {
|
|
653
|
+
const errors = [];
|
|
654
|
+
if (!Array.isArray(links)) {
|
|
655
|
+
return { valid: false, errors: ["publicChannelLinks must be an array"] };
|
|
656
|
+
}
|
|
657
|
+
for (let i = 0; i < links.length; i += 1) {
|
|
658
|
+
const item = links[i];
|
|
659
|
+
if (!item || typeof item !== "object") {
|
|
660
|
+
errors.push(`publicChannelLinks[${i}] must be an object`);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (!isSafeHttpUrl(item.inviteUrl)) {
|
|
664
|
+
errors.push(
|
|
665
|
+
`publicChannelLinks[${i}].inviteUrl must be a valid http(s) URL`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { valid: errors.length === 0, errors };
|
|
670
|
+
}
|
|
671
|
+
function normalizeChannels(rawChannels, fallback = ["email"]) {
|
|
672
|
+
if (!Array.isArray(rawChannels)) {
|
|
673
|
+
return [...fallback];
|
|
674
|
+
}
|
|
675
|
+
const filtered = rawChannels.map((c) => String(c).trim().toLowerCase()).filter(
|
|
676
|
+
(c) => SUPPORTED_CHANNELS.includes(c)
|
|
677
|
+
);
|
|
678
|
+
return filtered.length > 0 ? Array.from(new Set(filtered)) : [...fallback];
|
|
679
|
+
}
|
|
680
|
+
function isSupportedWhatsAppDriver(driver) {
|
|
681
|
+
return SUPPORTED_WHATSAPP_DRIVERS.includes(
|
|
682
|
+
String(driver || "").trim()
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const GATEWAY_SESSION_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
686
|
+
function isValidGatewaySession(session) {
|
|
687
|
+
return typeof session === "string" && GATEWAY_SESSION_RE.test(session.trim());
|
|
688
|
+
}
|
|
689
|
+
function validateCampaignChannelsList(rawChannels, errors) {
|
|
690
|
+
const channels = Array.isArray(rawChannels) ? rawChannels : [];
|
|
691
|
+
if (channels.length === 0) {
|
|
692
|
+
errors.push("At least one broadcast channel is required");
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
for (const ch of channels) {
|
|
696
|
+
const normalized = String(ch).trim().toLowerCase();
|
|
697
|
+
if (!SUPPORTED_CHANNELS.includes(normalized)) {
|
|
698
|
+
errors.push(`Unsupported broadcast channel: ${String(ch)}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function validateOptionalHttpUrl(urlValue, fieldName, errors) {
|
|
703
|
+
if (!urlValue) return;
|
|
704
|
+
if (!isSafeHttpUrl(urlValue)) {
|
|
705
|
+
errors.push(`${fieldName} must use http or https protocol`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function validateCampaignInput(input) {
|
|
709
|
+
const errors = [];
|
|
710
|
+
const slug = String(input.slug || "").trim();
|
|
711
|
+
const title = String(input.title || "").trim();
|
|
712
|
+
const subject = String(input.subject || "").trim();
|
|
713
|
+
const bodyText = String(input.bodyText || "").trim();
|
|
714
|
+
if (!slug || !SLUG_REGEX.test(slug)) {
|
|
715
|
+
errors.push("Campaign slug must be lowercase kebab-case");
|
|
716
|
+
}
|
|
717
|
+
if (title.length < 3) {
|
|
718
|
+
errors.push("Campaign title must be at least 3 characters");
|
|
719
|
+
}
|
|
720
|
+
if (subject.length < 3) {
|
|
721
|
+
errors.push("Campaign subject must be at least 3 characters");
|
|
722
|
+
}
|
|
723
|
+
if (bodyText.length < 5) {
|
|
724
|
+
errors.push("Campaign bodyText must be at least 5 characters");
|
|
725
|
+
}
|
|
726
|
+
validateCampaignChannelsList(input.channels, errors);
|
|
727
|
+
validateOptionalHttpUrl(input.ctaUrl, "ctaUrl", errors);
|
|
728
|
+
validateOptionalHttpUrl(input.mediaUrl, "mediaUrl", errors);
|
|
729
|
+
if (input.translations && typeof input.translations === "object") {
|
|
730
|
+
for (const [locale, tr] of Object.entries(input.translations)) {
|
|
731
|
+
if (tr && typeof tr === "object" && tr.ctaUrl) {
|
|
732
|
+
validateOptionalHttpUrl(
|
|
733
|
+
tr.ctaUrl,
|
|
734
|
+
`translations.${locale}.ctaUrl`,
|
|
735
|
+
errors
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return { valid: errors.length === 0, errors };
|
|
741
|
+
}
|
|
742
|
+
function requireNonEmptyFields(fields, errors) {
|
|
743
|
+
for (const [val, errMessage] of fields) {
|
|
744
|
+
if (!String(val || "").trim()) {
|
|
745
|
+
errors.push(errMessage);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function validateCtaUrlTemplate(rawTemplate, errors) {
|
|
750
|
+
if (!rawTemplate) return;
|
|
751
|
+
const trimmed = String(rawTemplate).trim();
|
|
752
|
+
if (!trimmed) return;
|
|
753
|
+
const resolvedProbe = trimmed.replace(/^\{\{\s*siteBaseUrl\s*\}\}/, "https://example.org").replace(/\{\{\s*[a-zA-Z0-9_.-]+\s*\}\}/g, "segment");
|
|
754
|
+
if (!isSafeHttpUrl(resolvedProbe)) {
|
|
755
|
+
errors.push("ctaUrlTemplate must resolve to an http or https URL");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function validateAutomationRuleInput(input) {
|
|
759
|
+
const errors = [];
|
|
760
|
+
const slug = String(input.slug || "").trim();
|
|
761
|
+
const name = String(input.name || "").trim();
|
|
762
|
+
const eventName = String(input.eventName || "").trim();
|
|
763
|
+
const targetUid = String(input.targetContentType || "").trim();
|
|
764
|
+
if (!slug || !SLUG_REGEX.test(slug)) {
|
|
765
|
+
errors.push("Automation rule slug must be lowercase kebab-case");
|
|
766
|
+
}
|
|
767
|
+
if (name.length < 3) {
|
|
768
|
+
errors.push("Automation rule name must be at least 3 characters");
|
|
769
|
+
}
|
|
770
|
+
requireNonEmptyFields(
|
|
771
|
+
[
|
|
772
|
+
[input.eventName, "eventName is required"],
|
|
773
|
+
[input.subjectTemplate, "subjectTemplate is required"],
|
|
774
|
+
[input.bodyTextTemplate, "bodyTextTemplate is required"]
|
|
775
|
+
],
|
|
776
|
+
errors
|
|
777
|
+
);
|
|
778
|
+
if (eventName.toLowerCase().startsWith("broadcasts.")) {
|
|
779
|
+
errors.push('eventName cannot start with "broadcasts." (loop protection)');
|
|
780
|
+
}
|
|
781
|
+
if (targetUid.toLowerCase().startsWith("plugin::broadcasts.")) {
|
|
782
|
+
errors.push(
|
|
783
|
+
'targetContentType cannot target "plugin::broadcasts.*" (loop protection)'
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (input.channels !== void 0) {
|
|
787
|
+
validateCampaignChannelsList(input.channels, errors);
|
|
788
|
+
}
|
|
789
|
+
validateCtaUrlTemplate(input.ctaUrlTemplate, errors);
|
|
790
|
+
return { valid: errors.length === 0, errors };
|
|
791
|
+
}
|
|
792
|
+
const MONITORED_EVENTS = [
|
|
793
|
+
"inbox.alert.published",
|
|
794
|
+
"inbox.subscriber.confirmed",
|
|
795
|
+
"event.published",
|
|
796
|
+
"media-feeds.episode.synced"
|
|
797
|
+
];
|
|
798
|
+
const hubRegistry = /* @__PURE__ */ new WeakMap();
|
|
799
|
+
function getOrCreateEntry(strapi) {
|
|
800
|
+
let entry = hubRegistry.get(strapi);
|
|
801
|
+
if (!entry) {
|
|
802
|
+
entry = { unsubs: [], boundEvents: /* @__PURE__ */ new Set(), usesCatchAll: false };
|
|
803
|
+
hubRegistry.set(strapi, entry);
|
|
804
|
+
}
|
|
805
|
+
return entry;
|
|
806
|
+
}
|
|
807
|
+
async function dispatchEventToAutomation(strapi, eventName, payload) {
|
|
808
|
+
if (!eventName || eventName.toLowerCase().startsWith("broadcasts.")) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
const automationService = strapi.plugin(PLUGIN_ID).service("automationService");
|
|
813
|
+
const safePayload = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
814
|
+
await automationService.handleEventTrigger(eventName, safePayload);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
strapi.log.warn(
|
|
817
|
+
`[@vicacha-devs/strapi-plugin-broadcasts] EventHub automation warning for ${eventName}: ${err.message}`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function ensureEventHubListener(strapi, rawEventName) {
|
|
822
|
+
const evt = String(rawEventName || "").trim();
|
|
823
|
+
if (!evt || evt.toLowerCase().startsWith("broadcasts.")) return;
|
|
824
|
+
const entry = getOrCreateEntry(strapi);
|
|
825
|
+
if (entry.usesCatchAll || entry.boundEvents.has(evt)) return;
|
|
826
|
+
if (typeof strapi.eventHub?.on !== "function") return;
|
|
827
|
+
const listener = async (payload) => {
|
|
828
|
+
await dispatchEventToAutomation(strapi, evt, payload);
|
|
829
|
+
};
|
|
830
|
+
strapi.eventHub.on(evt, listener);
|
|
831
|
+
entry.boundEvents.add(evt);
|
|
832
|
+
entry.unsubs.push(() => {
|
|
833
|
+
if (typeof strapi.eventHub?.off === "function") {
|
|
834
|
+
strapi.eventHub.off(evt, listener);
|
|
835
|
+
} else if (typeof strapi.eventHub?.removeListener === "function") {
|
|
836
|
+
strapi.eventHub.removeListener(evt, listener);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
async function registerEventHubListeners(strapi) {
|
|
841
|
+
cleanupEventHubListeners(strapi);
|
|
842
|
+
if (!strapi.eventHub) return;
|
|
843
|
+
const entry = getOrCreateEntry(strapi);
|
|
844
|
+
if (typeof strapi.eventHub.subscribe === "function") {
|
|
845
|
+
const unsubscribe = strapi.eventHub.subscribe(
|
|
846
|
+
async (eventName, payload) => {
|
|
847
|
+
await dispatchEventToAutomation(strapi, eventName, payload);
|
|
848
|
+
}
|
|
849
|
+
);
|
|
850
|
+
entry.usesCatchAll = true;
|
|
851
|
+
if (typeof unsubscribe === "function") {
|
|
852
|
+
entry.unsubs.push(unsubscribe);
|
|
853
|
+
}
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
for (const evt of MONITORED_EVENTS) {
|
|
857
|
+
ensureEventHubListener(strapi, evt);
|
|
858
|
+
}
|
|
859
|
+
const automationService = strapi.plugin(PLUGIN_ID).service("automationService");
|
|
860
|
+
const existingRules = await automationService.listRules();
|
|
861
|
+
for (const rule of existingRules) {
|
|
862
|
+
if (rule.eventName) {
|
|
863
|
+
ensureEventHubListener(strapi, rule.eventName);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function cleanupEventHubListeners(strapi) {
|
|
868
|
+
const entry = hubRegistry.get(strapi);
|
|
869
|
+
if (!entry) return;
|
|
870
|
+
for (const unsub of entry.unsubs) {
|
|
871
|
+
try {
|
|
872
|
+
unsub();
|
|
873
|
+
} catch {
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
hubRegistry.delete(strapi);
|
|
877
|
+
}
|
|
878
|
+
const bootstrap = async ({ strapi }) => {
|
|
879
|
+
const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
|
|
880
|
+
await settingsService.getSettings();
|
|
881
|
+
const campaignService = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
882
|
+
await campaignService.seedStarterCampaigns();
|
|
883
|
+
const automationService = strapi.plugin(PLUGIN_ID).service("automationService");
|
|
884
|
+
await automationService.seedStarterRules();
|
|
885
|
+
await registerEventHubListeners(strapi);
|
|
886
|
+
if (strapi.admin?.services?.permission?.actionProvider?.registerMany) {
|
|
887
|
+
await strapi.admin.services.permission.actionProvider.registerMany([
|
|
888
|
+
{
|
|
889
|
+
section: "plugins",
|
|
890
|
+
displayName: "Read Broadcast Campaigns & Delivery Ledger",
|
|
891
|
+
uid: "read",
|
|
892
|
+
pluginName: PLUGIN_ID
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
section: "plugins",
|
|
896
|
+
displayName: "Manage Broadcast Campaigns",
|
|
897
|
+
uid: "campaigns.manage",
|
|
898
|
+
pluginName: PLUGIN_ID
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
section: "plugins",
|
|
902
|
+
displayName: "Send & Dispatch Broadcasts",
|
|
903
|
+
uid: "campaigns.send",
|
|
904
|
+
pluginName: PLUGIN_ID
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
section: "plugins",
|
|
908
|
+
displayName: "Manage EventHub Automation Rules",
|
|
909
|
+
uid: "automations.manage",
|
|
910
|
+
pluginName: PLUGIN_ID
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
section: "plugins",
|
|
914
|
+
displayName: "Manage Broadcast Transport Settings",
|
|
915
|
+
uid: "settings.manage",
|
|
916
|
+
pluginName: PLUGIN_ID
|
|
917
|
+
}
|
|
918
|
+
]);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
const config = {
|
|
922
|
+
default: DEFAULT_BROADCAST_SETTINGS,
|
|
923
|
+
validator() {
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
const kind$2 = "collectionType";
|
|
927
|
+
const collectionName$2 = "broadcast_automation_rules";
|
|
928
|
+
const info$2 = { "singularName": "automation-rule", "pluralName": "automation-rules", "displayName": "Broadcast Automation Rule", "description": "EventHub-driven automated multi-channel broadcast rule" };
|
|
929
|
+
const options$2 = { "draftAndPublish": false };
|
|
930
|
+
const pluginOptions$2 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
|
|
931
|
+
const attributes$2 = { "name": { "type": "string", "required": true }, "slug": { "type": "uid", "targetField": "name", "required": true }, "eventName": { "type": "string", "required": true }, "targetContentType": { "type": "string" }, "topic": { "type": "string", "default": "announcements", "required": true }, "channels": { "type": "json", "required": true, "default": ["telegram"] }, "active": { "type": "boolean", "default": true }, "priority": { "type": "enumeration", "enum": ["low", "normal", "high", "urgent"], "default": "normal" }, "subjectTemplate": { "type": "string", "required": true }, "bodyTextTemplate": { "type": "text", "required": true }, "bodyHtmlTemplate": { "type": "richtext" }, "ctaLabelTemplate": { "type": "string" }, "ctaUrlTemplate": { "type": "string" }, "translations": { "type": "json", "default": {} }, "lastTriggeredAt": { "type": "datetime" }, "triggerCount": { "type": "integer", "default": 0, "min": 0 } };
|
|
932
|
+
const schema$2 = {
|
|
933
|
+
kind: kind$2,
|
|
934
|
+
collectionName: collectionName$2,
|
|
935
|
+
info: info$2,
|
|
936
|
+
options: options$2,
|
|
937
|
+
pluginOptions: pluginOptions$2,
|
|
938
|
+
attributes: attributes$2
|
|
939
|
+
};
|
|
940
|
+
const automationRule = {
|
|
941
|
+
schema: schema$2
|
|
942
|
+
};
|
|
943
|
+
const kind$1 = "collectionType";
|
|
944
|
+
const collectionName$1 = "broadcast_campaigns";
|
|
945
|
+
const info$1 = { "singularName": "campaign", "pluralName": "campaigns", "displayName": "Broadcast Campaign", "description": "Omnichannel Email, Telegram & WhatsApp broadcast campaign" };
|
|
946
|
+
const options$1 = { "draftAndPublish": false };
|
|
947
|
+
const pluginOptions$1 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
|
|
948
|
+
const attributes$1 = { "title": { "type": "string", "required": true }, "slug": { "type": "uid", "targetField": "title", "required": true }, "topic": { "type": "string", "default": "announcements", "required": true }, "channels": { "type": "json", "required": true, "default": ["email", "telegram"] }, "priority": { "type": "enumeration", "enum": ["low", "normal", "high", "urgent"], "default": "normal", "required": true }, "status": { "type": "enumeration", "enum": ["draft", "scheduled", "sending", "sent", "failed", "cancelled"], "default": "draft", "required": true }, "scheduledAt": { "type": "datetime" }, "sentAt": { "type": "datetime" }, "subject": { "type": "string", "required": true }, "bodyText": { "type": "text", "required": true }, "bodyHtml": { "type": "richtext" }, "ctaLabel": { "type": "string" }, "ctaUrl": { "type": "string" }, "mediaUrl": { "type": "string" }, "emailTemplateRef": { "type": "string" }, "whatsappTemplateName": { "type": "string" }, "whatsappGroupIds": { "type": "json", "default": [] }, "telegramChatId": { "type": "string" }, "telegramThreadId": { "type": "integer" }, "translations": { "type": "json", "default": {} }, "secondaryDate": { "type": "json", "default": {} }, "secondaryDateLabel": { "type": "string" }, "stats": { "type": "json", "default": {} }, "metadata": { "type": "json", "default": {} } };
|
|
949
|
+
const schema$1 = {
|
|
950
|
+
kind: kind$1,
|
|
951
|
+
collectionName: collectionName$1,
|
|
952
|
+
info: info$1,
|
|
953
|
+
options: options$1,
|
|
954
|
+
pluginOptions: pluginOptions$1,
|
|
955
|
+
attributes: attributes$1
|
|
956
|
+
};
|
|
957
|
+
const campaign = {
|
|
958
|
+
schema: schema$1
|
|
959
|
+
};
|
|
960
|
+
const kind = "collectionType";
|
|
961
|
+
const collectionName = "broadcast_delivery_logs";
|
|
962
|
+
const info = { "singularName": "delivery-log", "pluralName": "delivery-logs", "displayName": "Broadcast Delivery Log", "description": "Idempotent, PII-masked multi-channel delivery audit log" };
|
|
963
|
+
const options = { "draftAndPublish": false };
|
|
964
|
+
const pluginOptions = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
|
|
965
|
+
const attributes = { "idempotencyKey": { "type": "string", "required": true, "unique": true }, "campaignSlug": { "type": "string", "required": true }, "channel": { "type": "enumeration", "enum": ["email", "telegram", "whatsapp"], "required": true }, "driver": { "type": "string", "required": true }, "recipientMasked": { "type": "string", "required": true }, "locale": { "type": "string", "default": "es", "required": true }, "status": { "type": "enumeration", "enum": ["sent", "simulated", "skipped_duplicate", "failed"], "required": true }, "externalMessageId": { "type": "string" }, "errorMessage": { "type": "text" }, "sentAt": { "type": "datetime", "required": true }, "secondaryDateLabel": { "type": "string" } };
|
|
966
|
+
const schema = {
|
|
967
|
+
kind,
|
|
968
|
+
collectionName,
|
|
969
|
+
info,
|
|
970
|
+
options,
|
|
971
|
+
pluginOptions,
|
|
972
|
+
attributes
|
|
973
|
+
};
|
|
974
|
+
const deliveryLog = {
|
|
975
|
+
schema
|
|
976
|
+
};
|
|
977
|
+
const contentTypes = {
|
|
978
|
+
campaign,
|
|
979
|
+
"automation-rule": automationRule,
|
|
980
|
+
"delivery-log": deliveryLog
|
|
981
|
+
};
|
|
982
|
+
const SECRET_MASK_ONLY_RE = /^[•*]+$/;
|
|
983
|
+
function computeIdempotencyKey(campaignSlug, channel, recipient, options2 = {}) {
|
|
984
|
+
const normalizedRecipient = String(recipient || "").trim().toLowerCase();
|
|
985
|
+
const digest = node_crypto.createHmac("sha256", "vicacha-broadcasts-idempotency-v1").update(`${campaignSlug}:${channel}:${normalizedRecipient}`, "utf8").digest("hex").slice(0, 24);
|
|
986
|
+
const baseKey = `${campaignSlug}:${channel}:${digest}`;
|
|
987
|
+
return options2.isTest ? `test:${baseKey}` : baseKey;
|
|
988
|
+
}
|
|
989
|
+
function generateUniqueSlugSuffix() {
|
|
990
|
+
return `${Date.now().toString(36)}-${node_crypto.randomBytes(3).toString("hex")}`;
|
|
991
|
+
}
|
|
992
|
+
function redactSecretsFromMessage(rawMessage, secrets = []) {
|
|
993
|
+
let sanitized = String(rawMessage || "");
|
|
994
|
+
if (!sanitized) return "";
|
|
995
|
+
for (const candidate of secrets) {
|
|
996
|
+
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
|
|
997
|
+
if (trimmed.length < 4 || SECRET_MASK_ONLY_RE.test(trimmed)) continue;
|
|
998
|
+
sanitized = sanitized.split(trimmed).join("[REDACTED]");
|
|
999
|
+
const encoded = encodeURIComponent(trimmed);
|
|
1000
|
+
if (encoded !== trimmed && encoded.length >= 4) {
|
|
1001
|
+
sanitized = sanitized.split(encoded).join("[REDACTED]");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
sanitized = sanitized.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]");
|
|
1005
|
+
sanitized = sanitized.replace(/\b\d{6,}:[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]");
|
|
1006
|
+
sanitized = sanitized.replace(
|
|
1007
|
+
/Bearer\s+[A-Z0-9._~+/=-]+/gi,
|
|
1008
|
+
"Bearer [REDACTED]"
|
|
1009
|
+
);
|
|
1010
|
+
sanitized = sanitized.replace(
|
|
1011
|
+
/((?:api[_-]?key|access[_-]?token|token)=)[^&\s"']+/gi,
|
|
1012
|
+
"$1[REDACTED]"
|
|
1013
|
+
);
|
|
1014
|
+
return sanitized;
|
|
1015
|
+
}
|
|
1016
|
+
const TELEGRAM_BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]+$/;
|
|
1017
|
+
const FETCH_TIMEOUT_MS = 15e3;
|
|
1018
|
+
function clampLimit(val, defaultLimit) {
|
|
1019
|
+
if (val === void 0 || val === null || val === "") return defaultLimit;
|
|
1020
|
+
const parsed = typeof val === "number" ? val : typeof val === "string" ? Number(val) : Number.NaN;
|
|
1021
|
+
if (!Number.isFinite(parsed)) return defaultLimit;
|
|
1022
|
+
return Math.min(100, Math.max(1, Math.trunc(parsed)));
|
|
1023
|
+
}
|
|
1024
|
+
function buildTelegramIdempotencyKey(payload, targetChatId) {
|
|
1025
|
+
const hasThread = typeof payload.threadId === "number" && payload.threadId > 0;
|
|
1026
|
+
const threadKeySuffix = hasThread ? `:${payload.threadId}` : "";
|
|
1027
|
+
return computeIdempotencyKey(
|
|
1028
|
+
payload.campaignSlug,
|
|
1029
|
+
"telegram",
|
|
1030
|
+
`${targetChatId}${threadKeySuffix}:${payload.locale}`,
|
|
1031
|
+
{ isTest: payload.isTestDispatch }
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
function makeResult(channel, driver, recipientMasked, locale, status, idempotencyKey, extra) {
|
|
1035
|
+
return {
|
|
1036
|
+
channel,
|
|
1037
|
+
driver,
|
|
1038
|
+
recipientMasked,
|
|
1039
|
+
locale,
|
|
1040
|
+
status,
|
|
1041
|
+
idempotencyKey,
|
|
1042
|
+
...extra
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function parseTargetList(raw) {
|
|
1046
|
+
if (!raw) return [];
|
|
1047
|
+
const items = Array.isArray(raw) ? raw : String(raw).split(",");
|
|
1048
|
+
return Array.from(
|
|
1049
|
+
new Set(items.map((item) => String(item).trim()).filter(Boolean))
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
function resolveTelegramTargets(campaign2, settings) {
|
|
1053
|
+
const fromCampaign = parseTargetList(campaign2.telegramChatId);
|
|
1054
|
+
if (fromCampaign.length > 0) return fromCampaign;
|
|
1055
|
+
const fromTopic = parseTargetList(
|
|
1056
|
+
settings.telegramTopicChatMap?.[campaign2.topic]
|
|
1057
|
+
);
|
|
1058
|
+
if (fromTopic.length > 0) return fromTopic;
|
|
1059
|
+
return parseTargetList(settings.telegramDefaultChatId);
|
|
1060
|
+
}
|
|
1061
|
+
function resolveWhatsAppRecipients(campaign2, settings, subscribers, isTestDispatch) {
|
|
1062
|
+
const configuredGroupsAndNumbers = isTestDispatch ? [] : [
|
|
1063
|
+
...parseTargetList(settings.whatsappDefaultRecipients),
|
|
1064
|
+
...parseTargetList(settings.whatsappTopicGroupMap?.[campaign2.topic]),
|
|
1065
|
+
...parseTargetList(campaign2.whatsappGroupIds)
|
|
1066
|
+
];
|
|
1067
|
+
const rawPhoneRecipients = [
|
|
1068
|
+
...subscribers.filter((r) => Boolean(r.phone)),
|
|
1069
|
+
...configuredGroupsAndNumbers.map((phone) => ({
|
|
1070
|
+
phone,
|
|
1071
|
+
fullName: "Comunidad",
|
|
1072
|
+
locale: settings.defaultLocale || "es",
|
|
1073
|
+
topics: [campaign2.topic]
|
|
1074
|
+
}))
|
|
1075
|
+
];
|
|
1076
|
+
const seenWhatsAppTargets = /* @__PURE__ */ new Set();
|
|
1077
|
+
const deduplicated = [];
|
|
1078
|
+
for (const recipient of rawPhoneRecipients) {
|
|
1079
|
+
if (!recipient.phone) continue;
|
|
1080
|
+
const normKey = normalizeWhatsAppTarget(recipient.phone).normalized;
|
|
1081
|
+
if (!normKey || seenWhatsAppTargets.has(normKey)) continue;
|
|
1082
|
+
seenWhatsAppTargets.add(normKey);
|
|
1083
|
+
deduplicated.push(recipient);
|
|
1084
|
+
}
|
|
1085
|
+
return deduplicated;
|
|
1086
|
+
}
|
|
1087
|
+
function createEmptyStats() {
|
|
1088
|
+
return {
|
|
1089
|
+
totalRecipients: 0,
|
|
1090
|
+
sentCount: 0,
|
|
1091
|
+
simulatedCount: 0,
|
|
1092
|
+
skippedCount: 0,
|
|
1093
|
+
failedCount: 0,
|
|
1094
|
+
byChannel: {}
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function recordResultInStats(stats, result) {
|
|
1098
|
+
stats.totalRecipients += 1;
|
|
1099
|
+
const bucket = stats.byChannel[result.channel] || {
|
|
1100
|
+
sent: 0,
|
|
1101
|
+
simulated: 0,
|
|
1102
|
+
skipped: 0,
|
|
1103
|
+
failed: 0
|
|
1104
|
+
};
|
|
1105
|
+
if (result.status === "sent") {
|
|
1106
|
+
stats.sentCount += 1;
|
|
1107
|
+
bucket.sent += 1;
|
|
1108
|
+
} else if (result.status === "simulated") {
|
|
1109
|
+
stats.simulatedCount += 1;
|
|
1110
|
+
bucket.simulated += 1;
|
|
1111
|
+
} else if (result.status === "skipped_duplicate") {
|
|
1112
|
+
stats.skippedCount += 1;
|
|
1113
|
+
bucket.skipped += 1;
|
|
1114
|
+
} else {
|
|
1115
|
+
stats.failedCount += 1;
|
|
1116
|
+
bucket.failed += 1;
|
|
1117
|
+
}
|
|
1118
|
+
stats.byChannel[result.channel] = bucket;
|
|
1119
|
+
}
|
|
1120
|
+
function resolveEffectiveEmailTemplateRef(payload, settings) {
|
|
1121
|
+
if (settings.enableEmailDesignerBridge === false) return "";
|
|
1122
|
+
return String(
|
|
1123
|
+
payload.emailTemplateRef || settings.defaultEmailTemplateRef || ""
|
|
1124
|
+
).trim();
|
|
1125
|
+
}
|
|
1126
|
+
function buildEmailTemplateVariables(payload, settings, inlineHtml) {
|
|
1127
|
+
const extraEntries = Object.entries(payload.templateData || {}).map(
|
|
1128
|
+
([k, v]) => [
|
|
1129
|
+
k,
|
|
1130
|
+
v !== void 0 && v !== null ? escapeHtml(String(v)) : void 0
|
|
1131
|
+
]
|
|
1132
|
+
);
|
|
1133
|
+
return {
|
|
1134
|
+
subject: escapeHtml(payload.subject),
|
|
1135
|
+
bodyText: escapeHtml(payload.text),
|
|
1136
|
+
bodyHtml: inlineHtml,
|
|
1137
|
+
text: escapeHtml(payload.text),
|
|
1138
|
+
html: inlineHtml,
|
|
1139
|
+
ctaLabel: escapeHtml(payload.ctaLabel || ""),
|
|
1140
|
+
ctaUrl: escapeHtml(payload.ctaUrl || settings.siteBaseUrl),
|
|
1141
|
+
unsubscribeUrl: escapeHtml(payload.unsubscribeUrl || ""),
|
|
1142
|
+
secondaryDate: escapeHtml(payload.secondaryDateLabel || ""),
|
|
1143
|
+
organizationName: escapeHtml(settings.organizationName),
|
|
1144
|
+
siteBaseUrl: escapeHtml(settings.siteBaseUrl),
|
|
1145
|
+
...Object.fromEntries(extraEntries)
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function buildTelegramRequestPayload(payload, targetChatId) {
|
|
1149
|
+
const normalizedSubject = String(payload.subject || "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
1150
|
+
const safeSubject = escapeHtml(normalizedSubject);
|
|
1151
|
+
const safeBody = sanitizeTelegramHtml(payload.htmlBody);
|
|
1152
|
+
const text = safeBody ? `<b>${safeSubject}</b>
|
|
1153
|
+
|
|
1154
|
+
${safeBody}` : `<b>${safeSubject}</b>`;
|
|
1155
|
+
const bodyPayload = {
|
|
1156
|
+
chat_id: targetChatId,
|
|
1157
|
+
text,
|
|
1158
|
+
parse_mode: "HTML",
|
|
1159
|
+
disable_web_page_preview: false
|
|
1160
|
+
};
|
|
1161
|
+
if (typeof payload.threadId === "number" && payload.threadId > 0) {
|
|
1162
|
+
bodyPayload.message_thread_id = payload.threadId;
|
|
1163
|
+
}
|
|
1164
|
+
if (payload.ctaLabel && payload.ctaUrl && isSafeHttpUrl(payload.ctaUrl)) {
|
|
1165
|
+
bodyPayload.reply_markup = {
|
|
1166
|
+
inline_keyboard: [[{ text: payload.ctaLabel, url: payload.ctaUrl }]]
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
return bodyPayload;
|
|
1170
|
+
}
|
|
1171
|
+
async function executeTelegramHttpSend(payload, targetChatId, botToken) {
|
|
1172
|
+
const cleanToken = String(botToken || "").trim();
|
|
1173
|
+
if (!TELEGRAM_BOT_TOKEN_RE.test(cleanToken)) {
|
|
1174
|
+
throw new Error("Invalid Telegram bot token format");
|
|
1175
|
+
}
|
|
1176
|
+
const endpoint = `https://api.telegram.org/bot${cleanToken}/sendMessage`;
|
|
1177
|
+
const bodyPayload = buildTelegramRequestPayload(payload, targetChatId);
|
|
1178
|
+
const response = await fetch(endpoint, {
|
|
1179
|
+
method: "POST",
|
|
1180
|
+
headers: { "Content-Type": "application/json" },
|
|
1181
|
+
body: JSON.stringify(bodyPayload),
|
|
1182
|
+
redirect: "error",
|
|
1183
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1184
|
+
});
|
|
1185
|
+
const json = await response.json();
|
|
1186
|
+
if (!response.ok || !json.ok) {
|
|
1187
|
+
throw new Error(json.description || `Telegram HTTP ${response.status}`);
|
|
1188
|
+
}
|
|
1189
|
+
return String(json.result?.message_id || Date.now());
|
|
1190
|
+
}
|
|
1191
|
+
async function sendViaMetaCloudApi(payload, settings, evolutionNumber, fullText) {
|
|
1192
|
+
const phoneId = String(settings.whatsappPhoneNumberId || "").trim();
|
|
1193
|
+
if (!/^[A-Za-z0-9_-]+$/.test(phoneId)) {
|
|
1194
|
+
throw new Error("Invalid Meta WABA phoneNumberId format");
|
|
1195
|
+
}
|
|
1196
|
+
const endpoint = `https://graph.facebook.com/v20.0/${encodeURIComponent(
|
|
1197
|
+
phoneId
|
|
1198
|
+
)}/messages`;
|
|
1199
|
+
const reqBody = payload.templateName ? {
|
|
1200
|
+
messaging_product: "whatsapp",
|
|
1201
|
+
to: evolutionNumber,
|
|
1202
|
+
type: "template",
|
|
1203
|
+
template: {
|
|
1204
|
+
name: payload.templateName,
|
|
1205
|
+
language: { code: payload.locale }
|
|
1206
|
+
}
|
|
1207
|
+
} : {
|
|
1208
|
+
messaging_product: "whatsapp",
|
|
1209
|
+
to: evolutionNumber,
|
|
1210
|
+
type: "text",
|
|
1211
|
+
text: { preview_url: true, body: fullText }
|
|
1212
|
+
};
|
|
1213
|
+
const res = await fetch(endpoint, {
|
|
1214
|
+
method: "POST",
|
|
1215
|
+
headers: {
|
|
1216
|
+
"Authorization": `Bearer ${settings.whatsappAccessToken}`,
|
|
1217
|
+
"Content-Type": "application/json"
|
|
1218
|
+
},
|
|
1219
|
+
body: JSON.stringify(reqBody),
|
|
1220
|
+
redirect: "error",
|
|
1221
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1222
|
+
});
|
|
1223
|
+
const json = await res.json();
|
|
1224
|
+
if (!res.ok) {
|
|
1225
|
+
throw new Error(json.error?.message || `Meta Cloud HTTP ${res.status}`);
|
|
1226
|
+
}
|
|
1227
|
+
return json.messages?.[0]?.id || `waba_${Date.now()}`;
|
|
1228
|
+
}
|
|
1229
|
+
async function sendViaSelfHostedGateway(driver, settings, target, fullText) {
|
|
1230
|
+
const gatewayCheck = validateGatewayUrl(settings.whatsappGatewayUrl || "", {
|
|
1231
|
+
allowLocalhost: Boolean(settings.allowLocalhostGateways)
|
|
1232
|
+
});
|
|
1233
|
+
if (!gatewayCheck.valid) throw new Error(gatewayCheck.errors.join("; "));
|
|
1234
|
+
const rawSession = String(
|
|
1235
|
+
settings.whatsappGatewaySession || "default"
|
|
1236
|
+
).trim();
|
|
1237
|
+
if (!isValidGatewaySession(rawSession)) {
|
|
1238
|
+
throw new Error("Invalid WhatsApp gateway session identifier");
|
|
1239
|
+
}
|
|
1240
|
+
let baseUrl = settings.whatsappGatewayUrl || "";
|
|
1241
|
+
while (baseUrl.endsWith("/")) {
|
|
1242
|
+
baseUrl = baseUrl.slice(0, -1);
|
|
1243
|
+
}
|
|
1244
|
+
const session = encodeURIComponent(rawSession);
|
|
1245
|
+
const endpoint = driver === "evolution" ? `${baseUrl}/message/sendText/${session}` : `${baseUrl}/api/sendText`;
|
|
1246
|
+
const headers = {
|
|
1247
|
+
"Content-Type": "application/json"
|
|
1248
|
+
};
|
|
1249
|
+
if (settings.whatsappGatewayApiKey) {
|
|
1250
|
+
headers.apikey = settings.whatsappGatewayApiKey;
|
|
1251
|
+
headers["X-Api-Key"] = settings.whatsappGatewayApiKey;
|
|
1252
|
+
}
|
|
1253
|
+
const body = driver === "evolution" ? { number: target.evolutionNumber, text: fullText } : {
|
|
1254
|
+
session: rawSession,
|
|
1255
|
+
chatId: target.chatId,
|
|
1256
|
+
text: fullText
|
|
1257
|
+
};
|
|
1258
|
+
const res = await fetch(endpoint, {
|
|
1259
|
+
method: "POST",
|
|
1260
|
+
headers,
|
|
1261
|
+
body: JSON.stringify(body),
|
|
1262
|
+
redirect: "error",
|
|
1263
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1264
|
+
});
|
|
1265
|
+
if (!res.ok) throw new Error(`${driver} gateway HTTP ${res.status}`);
|
|
1266
|
+
return `${driver}_${Date.now()}`;
|
|
1267
|
+
}
|
|
1268
|
+
async function executeWhatsAppHttpSend(driver, payload, settings, target) {
|
|
1269
|
+
const safeCtaUrl = payload.ctaUrl && isSafeHttpUrl(payload.ctaUrl) ? payload.ctaUrl : void 0;
|
|
1270
|
+
const fullText = safeCtaUrl ? `${payload.textBody}
|
|
1271
|
+
|
|
1272
|
+
🔗 ${safeCtaUrl}` : payload.textBody;
|
|
1273
|
+
if (driver === "meta-cloud") {
|
|
1274
|
+
return sendViaMetaCloudApi(
|
|
1275
|
+
payload,
|
|
1276
|
+
settings,
|
|
1277
|
+
target.evolutionNumber,
|
|
1278
|
+
fullText
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
return sendViaSelfHostedGateway(driver, settings, target, fullText);
|
|
1282
|
+
}
|
|
1283
|
+
function hasWhatsAppCredentials(driver, settings) {
|
|
1284
|
+
if (driver === "meta-cloud") {
|
|
1285
|
+
return Boolean(
|
|
1286
|
+
settings.whatsappPhoneNumberId && settings.whatsappAccessToken
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
return Boolean(settings.whatsappGatewayUrl);
|
|
1290
|
+
}
|
|
1291
|
+
function toPlainString(val) {
|
|
1292
|
+
if (typeof val !== "string") return void 0;
|
|
1293
|
+
const trimmed = val.trim();
|
|
1294
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1295
|
+
}
|
|
1296
|
+
function respondError(ctx, err) {
|
|
1297
|
+
const message = err?.message || "Invalid request";
|
|
1298
|
+
if (/not found$/i.test(message)) {
|
|
1299
|
+
if (typeof ctx.notFound === "function") {
|
|
1300
|
+
return ctx.notFound(message);
|
|
1301
|
+
}
|
|
1302
|
+
ctx.status = 404;
|
|
1303
|
+
ctx.body = { error: message };
|
|
1304
|
+
return void 0;
|
|
1305
|
+
}
|
|
1306
|
+
if (typeof ctx.badRequest === "function") {
|
|
1307
|
+
return ctx.badRequest(message);
|
|
1308
|
+
}
|
|
1309
|
+
ctx.status = 400;
|
|
1310
|
+
ctx.body = { error: message };
|
|
1311
|
+
return void 0;
|
|
1312
|
+
}
|
|
1313
|
+
const broadcastController = ({ strapi }) => ({
|
|
1314
|
+
async getSettings(ctx) {
|
|
1315
|
+
const svc = strapi.plugin(PLUGIN_ID).service("settings");
|
|
1316
|
+
const data = await svc.getPublicSafeSettings();
|
|
1317
|
+
ctx.body = { data };
|
|
1318
|
+
},
|
|
1319
|
+
async updateSettings(ctx) {
|
|
1320
|
+
const svc = strapi.plugin(PLUGIN_ID).service("settings");
|
|
1321
|
+
try {
|
|
1322
|
+
await svc.updateSettings(
|
|
1323
|
+
ctx.request.body || {}
|
|
1324
|
+
);
|
|
1325
|
+
const data = await svc.getPublicSafeSettings();
|
|
1326
|
+
ctx.body = { data };
|
|
1327
|
+
return void 0;
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
return respondError(ctx, err);
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
async listStarterTemplates(ctx) {
|
|
1333
|
+
ctx.body = { data: STARTER_CAMPAIGN_TEMPLATES };
|
|
1334
|
+
},
|
|
1335
|
+
async listEmailTemplates(ctx) {
|
|
1336
|
+
const svc = strapi.plugin(PLUGIN_ID).service("transportService");
|
|
1337
|
+
const data = await svc.listEmailDesignerTemplates();
|
|
1338
|
+
ctx.body = { data };
|
|
1339
|
+
},
|
|
1340
|
+
async listCampaigns(ctx) {
|
|
1341
|
+
const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1342
|
+
const data = await svc.listCampaigns({
|
|
1343
|
+
status: toPlainString(ctx.query?.status),
|
|
1344
|
+
topic: toPlainString(ctx.query?.topic)
|
|
1345
|
+
});
|
|
1346
|
+
ctx.body = { data };
|
|
1347
|
+
},
|
|
1348
|
+
async createCampaign(ctx) {
|
|
1349
|
+
const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1350
|
+
try {
|
|
1351
|
+
const data = await svc.createCampaign(
|
|
1352
|
+
ctx.request.body || {}
|
|
1353
|
+
);
|
|
1354
|
+
ctx.status = 201;
|
|
1355
|
+
ctx.body = { data };
|
|
1356
|
+
return void 0;
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
return respondError(ctx, err);
|
|
1359
|
+
}
|
|
1360
|
+
},
|
|
1361
|
+
async updateCampaign(ctx) {
|
|
1362
|
+
const documentId = toPlainString(ctx.params?.documentId) || "";
|
|
1363
|
+
const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1364
|
+
try {
|
|
1365
|
+
const data = await svc.updateCampaign(
|
|
1366
|
+
documentId,
|
|
1367
|
+
ctx.request.body || {}
|
|
1368
|
+
);
|
|
1369
|
+
ctx.body = { data };
|
|
1370
|
+
return void 0;
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
return respondError(ctx, err);
|
|
1373
|
+
}
|
|
1374
|
+
},
|
|
1375
|
+
async createFromTemplate(ctx) {
|
|
1376
|
+
const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1377
|
+
try {
|
|
1378
|
+
const body = ctx.request.body || {};
|
|
1379
|
+
const templateId = toPlainString(body.templateId) || "";
|
|
1380
|
+
const data = await svc.createFromStarterTemplate(templateId);
|
|
1381
|
+
ctx.status = 201;
|
|
1382
|
+
ctx.body = { data };
|
|
1383
|
+
return void 0;
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
return respondError(ctx, err);
|
|
1386
|
+
}
|
|
1387
|
+
},
|
|
1388
|
+
async sendCampaign(ctx) {
|
|
1389
|
+
const documentId = toPlainString(ctx.params?.documentId) || "";
|
|
1390
|
+
const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1391
|
+
try {
|
|
1392
|
+
const rawBody = ctx.request.body && typeof ctx.request.body === "object" && !Array.isArray(ctx.request.body) ? ctx.request.body : {};
|
|
1393
|
+
const customRecipients = Array.isArray(rawBody.customRecipients) ? rawBody.customRecipients.filter(
|
|
1394
|
+
(item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
|
1395
|
+
) : void 0;
|
|
1396
|
+
const data = await svc.dispatchCampaign(documentId, {
|
|
1397
|
+
customRecipients,
|
|
1398
|
+
testRecipientEmail: toPlainString(rawBody.testRecipientEmail),
|
|
1399
|
+
testRecipientPhone: toPlainString(rawBody.testRecipientPhone),
|
|
1400
|
+
testTelegramChatId: toPlainString(rawBody.testTelegramChatId)
|
|
1401
|
+
});
|
|
1402
|
+
ctx.body = { data };
|
|
1403
|
+
return void 0;
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
return respondError(ctx, err);
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
async listAutomationRules(ctx) {
|
|
1409
|
+
const svc = strapi.plugin(PLUGIN_ID).service("automationService");
|
|
1410
|
+
const data = await svc.listRules();
|
|
1411
|
+
ctx.body = { data };
|
|
1412
|
+
},
|
|
1413
|
+
async createAutomationRule(ctx) {
|
|
1414
|
+
const svc = strapi.plugin(PLUGIN_ID).service("automationService");
|
|
1415
|
+
try {
|
|
1416
|
+
const data = await svc.createRule(
|
|
1417
|
+
ctx.request.body || {}
|
|
1418
|
+
);
|
|
1419
|
+
ctx.status = 201;
|
|
1420
|
+
ctx.body = { data };
|
|
1421
|
+
return void 0;
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
return respondError(ctx, err);
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1426
|
+
async updateAutomationRule(ctx) {
|
|
1427
|
+
const documentId = toPlainString(ctx.params?.documentId) || "";
|
|
1428
|
+
const svc = strapi.plugin(PLUGIN_ID).service("automationService");
|
|
1429
|
+
try {
|
|
1430
|
+
const data = await svc.updateRule(
|
|
1431
|
+
documentId,
|
|
1432
|
+
ctx.request.body || {}
|
|
1433
|
+
);
|
|
1434
|
+
ctx.body = { data };
|
|
1435
|
+
return void 0;
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
return respondError(ctx, err);
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1440
|
+
async listDeliveryLogs(ctx) {
|
|
1441
|
+
const svc = strapi.plugin(PLUGIN_ID).service("transportService");
|
|
1442
|
+
const rawChannel = toPlainString(ctx.query?.channel);
|
|
1443
|
+
const channel = rawChannel && SUPPORTED_CHANNELS.includes(rawChannel) ? rawChannel : void 0;
|
|
1444
|
+
const data = await svc.listDeliveryLogs({
|
|
1445
|
+
campaignSlug: toPlainString(ctx.query?.campaignSlug),
|
|
1446
|
+
channel,
|
|
1447
|
+
limit: clampLimit(ctx.query?.limit, 100)
|
|
1448
|
+
});
|
|
1449
|
+
ctx.body = { data };
|
|
1450
|
+
},
|
|
1451
|
+
async listPublicChannels(ctx) {
|
|
1452
|
+
const svc = strapi.plugin(PLUGIN_ID).service("settings");
|
|
1453
|
+
const settings = await svc.getSettings();
|
|
1454
|
+
const safeLinks = (settings.publicChannelLinks || []).filter(
|
|
1455
|
+
(link) => isSafeHttpUrl(link?.inviteUrl)
|
|
1456
|
+
);
|
|
1457
|
+
ctx.body = {
|
|
1458
|
+
data: {
|
|
1459
|
+
organizationName: settings.organizationName,
|
|
1460
|
+
defaultLocale: settings.defaultLocale,
|
|
1461
|
+
supportedLocales: settings.supportedLocales,
|
|
1462
|
+
availableTopics: settings.availableTopics,
|
|
1463
|
+
publicChannelLinks: safeLinks
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
},
|
|
1467
|
+
async listPublicArchive(ctx) {
|
|
1468
|
+
const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1469
|
+
const data = await svc.listPublicArchive({
|
|
1470
|
+
topic: toPlainString(ctx.query?.topic),
|
|
1471
|
+
limit: clampLimit(ctx.query?.limit, 20)
|
|
1472
|
+
});
|
|
1473
|
+
ctx.body = { data };
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
const controllers = {
|
|
1477
|
+
broadcastController
|
|
1478
|
+
};
|
|
1479
|
+
const destroy = ({ strapi }) => {
|
|
1480
|
+
cleanupEventHubListeners(strapi);
|
|
1481
|
+
strapi.log.debug("[@vicacha-devs/strapi-plugin-broadcasts] Destroyed");
|
|
1482
|
+
};
|
|
1483
|
+
const DANGEROUS_KEYS$1 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1484
|
+
function sanitizeAttributeDefinition(attrDef) {
|
|
1485
|
+
const safe = {};
|
|
1486
|
+
for (const [key, val] of Object.entries(attrDef)) {
|
|
1487
|
+
if (DANGEROUS_KEYS$1.has(key) || !Object.hasOwn(attrDef, key)) continue;
|
|
1488
|
+
safe[key] = val;
|
|
1489
|
+
}
|
|
1490
|
+
return safe;
|
|
1491
|
+
}
|
|
1492
|
+
function applyAttributeExtension(schema2, attrName, attrDef) {
|
|
1493
|
+
if (!schema2 || DANGEROUS_KEYS$1.has(attrName)) return;
|
|
1494
|
+
const safeDef = sanitizeAttributeDefinition(attrDef);
|
|
1495
|
+
if (schema2.attributes) {
|
|
1496
|
+
const prev = Object.hasOwn(schema2.attributes, attrName) ? schema2.attributes[attrName] : {};
|
|
1497
|
+
schema2.attributes[attrName] = { ...prev, ...safeDef };
|
|
1498
|
+
}
|
|
1499
|
+
if (schema2.__schema__?.attributes) {
|
|
1500
|
+
const prev = Object.hasOwn(schema2.__schema__.attributes, attrName) ? schema2.__schema__.attributes[attrName] : {};
|
|
1501
|
+
schema2.__schema__.attributes[attrName] = { ...prev, ...safeDef };
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
function extendContentTypeSchema(strapi, targetKey, extraAttributes) {
|
|
1505
|
+
if (!targetKey || DANGEROUS_KEYS$1.has(targetKey) || !extraAttributes || typeof extraAttributes !== "object" || Array.isArray(extraAttributes)) {
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const resolvedUid = targetKey.startsWith("plugin::") ? targetKey : `plugin::${PLUGIN_ID}.${targetKey}`;
|
|
1509
|
+
if (DANGEROUS_KEYS$1.has(resolvedUid)) return;
|
|
1510
|
+
const contentTypes2 = strapi.contentTypes;
|
|
1511
|
+
const schema2 = contentTypes2 && Object.hasOwn(contentTypes2, resolvedUid) ? contentTypes2[resolvedUid] : void 0;
|
|
1512
|
+
for (const [attrName, attrDef] of Object.entries(extraAttributes)) {
|
|
1513
|
+
if (!attrName || DANGEROUS_KEYS$1.has(attrName) || !Object.hasOwn(extraAttributes, attrName) || !attrDef || typeof attrDef !== "object" || Array.isArray(attrDef)) {
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
applyAttributeExtension(schema2, attrName, attrDef);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const register = ({ strapi }) => {
|
|
1520
|
+
const config2 = strapi.config?.get?.(`plugin::${PLUGIN_ID}`) ?? {};
|
|
1521
|
+
const extensions = config2.schemaExtensions;
|
|
1522
|
+
if (extensions && typeof extensions === "object" && !Array.isArray(extensions)) {
|
|
1523
|
+
for (const [targetKey, extraAttributes] of Object.entries(extensions)) {
|
|
1524
|
+
if (!Object.hasOwn(extensions, targetKey)) continue;
|
|
1525
|
+
extendContentTypeSchema(strapi, targetKey, extraAttributes);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
strapi.log.debug(
|
|
1529
|
+
"[@vicacha-devs/strapi-plugin-broadcasts] Registered omnichannel broadcasts plugin"
|
|
1530
|
+
);
|
|
1531
|
+
};
|
|
1532
|
+
function adminPolicies(...actionUids) {
|
|
1533
|
+
return [
|
|
1534
|
+
"admin::isAuthenticatedAdmin",
|
|
1535
|
+
{
|
|
1536
|
+
name: "admin::hasPermissions",
|
|
1537
|
+
config: {
|
|
1538
|
+
actions: actionUids.map((uid) => `plugin::${PLUGIN_ID}.${uid}`)
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
];
|
|
1542
|
+
}
|
|
1543
|
+
const admin = {
|
|
1544
|
+
type: "admin",
|
|
1545
|
+
routes: [
|
|
1546
|
+
{
|
|
1547
|
+
method: "GET",
|
|
1548
|
+
path: "/settings",
|
|
1549
|
+
handler: "broadcastController.getSettings",
|
|
1550
|
+
config: { policies: adminPolicies("settings.manage") }
|
|
1551
|
+
},
|
|
1552
|
+
{
|
|
1553
|
+
method: "PUT",
|
|
1554
|
+
path: "/settings",
|
|
1555
|
+
handler: "broadcastController.updateSettings",
|
|
1556
|
+
config: { policies: adminPolicies("settings.manage") }
|
|
1557
|
+
},
|
|
1558
|
+
{
|
|
1559
|
+
method: "GET",
|
|
1560
|
+
path: "/templates",
|
|
1561
|
+
handler: "broadcastController.listStarterTemplates",
|
|
1562
|
+
config: { policies: adminPolicies("read") }
|
|
1563
|
+
},
|
|
1564
|
+
{
|
|
1565
|
+
method: "GET",
|
|
1566
|
+
path: "/email-templates",
|
|
1567
|
+
handler: "broadcastController.listEmailTemplates",
|
|
1568
|
+
config: { policies: adminPolicies("read") }
|
|
1569
|
+
},
|
|
1570
|
+
{
|
|
1571
|
+
method: "GET",
|
|
1572
|
+
path: "/campaigns",
|
|
1573
|
+
handler: "broadcastController.listCampaigns",
|
|
1574
|
+
config: { policies: adminPolicies("read") }
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
method: "POST",
|
|
1578
|
+
path: "/campaigns",
|
|
1579
|
+
handler: "broadcastController.createCampaign",
|
|
1580
|
+
config: { policies: adminPolicies("campaigns.manage") }
|
|
1581
|
+
},
|
|
1582
|
+
{
|
|
1583
|
+
method: "PUT",
|
|
1584
|
+
path: "/campaigns/:documentId",
|
|
1585
|
+
handler: "broadcastController.updateCampaign",
|
|
1586
|
+
config: { policies: adminPolicies("campaigns.manage") }
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
method: "POST",
|
|
1590
|
+
path: "/campaigns/from-template",
|
|
1591
|
+
handler: "broadcastController.createFromTemplate",
|
|
1592
|
+
config: { policies: adminPolicies("campaigns.manage") }
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
method: "POST",
|
|
1596
|
+
path: "/campaigns/:documentId/send",
|
|
1597
|
+
handler: "broadcastController.sendCampaign",
|
|
1598
|
+
config: { policies: adminPolicies("campaigns.send") }
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
method: "GET",
|
|
1602
|
+
path: "/automation-rules",
|
|
1603
|
+
handler: "broadcastController.listAutomationRules",
|
|
1604
|
+
config: { policies: adminPolicies("read") }
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
method: "POST",
|
|
1608
|
+
path: "/automation-rules",
|
|
1609
|
+
handler: "broadcastController.createAutomationRule",
|
|
1610
|
+
config: { policies: adminPolicies("automations.manage") }
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
method: "PUT",
|
|
1614
|
+
path: "/automation-rules/:documentId",
|
|
1615
|
+
handler: "broadcastController.updateAutomationRule",
|
|
1616
|
+
config: { policies: adminPolicies("automations.manage") }
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
method: "GET",
|
|
1620
|
+
path: "/delivery-logs",
|
|
1621
|
+
handler: "broadcastController.listDeliveryLogs",
|
|
1622
|
+
config: { policies: adminPolicies("read") }
|
|
1623
|
+
}
|
|
1624
|
+
]
|
|
1625
|
+
};
|
|
1626
|
+
const contentApi = {
|
|
1627
|
+
type: "content-api",
|
|
1628
|
+
routes: [
|
|
1629
|
+
{
|
|
1630
|
+
method: "GET",
|
|
1631
|
+
path: "/public/channels",
|
|
1632
|
+
handler: "broadcastController.listPublicChannels",
|
|
1633
|
+
config: {
|
|
1634
|
+
policies: [],
|
|
1635
|
+
auth: false
|
|
1636
|
+
}
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
method: "GET",
|
|
1640
|
+
path: "/public/archive",
|
|
1641
|
+
handler: "broadcastController.listPublicArchive",
|
|
1642
|
+
config: {
|
|
1643
|
+
policies: [],
|
|
1644
|
+
auth: false
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
]
|
|
1648
|
+
};
|
|
1649
|
+
const routes = {
|
|
1650
|
+
"content-api": contentApi,
|
|
1651
|
+
admin
|
|
1652
|
+
};
|
|
1653
|
+
function buildRuleDocumentData(rule, channels, fallbackActive = true) {
|
|
1654
|
+
const safeBodyTemplate = String(rule.bodyTextTemplate || "");
|
|
1655
|
+
return {
|
|
1656
|
+
slug: rule.slug,
|
|
1657
|
+
name: rule.name,
|
|
1658
|
+
eventName: rule.eventName,
|
|
1659
|
+
targetContentType: rule.targetContentType,
|
|
1660
|
+
topic: rule.topic || "announcements",
|
|
1661
|
+
channels,
|
|
1662
|
+
active: rule.active ?? fallbackActive,
|
|
1663
|
+
priority: rule.priority || "normal",
|
|
1664
|
+
subjectTemplate: rule.subjectTemplate,
|
|
1665
|
+
bodyTextTemplate: safeBodyTemplate,
|
|
1666
|
+
bodyHtmlTemplate: rule.bodyHtmlTemplate || `<p>${escapeHtml(safeBodyTemplate)}</p>`,
|
|
1667
|
+
ctaLabelTemplate: rule.ctaLabelTemplate,
|
|
1668
|
+
ctaUrlTemplate: rule.ctaUrlTemplate,
|
|
1669
|
+
translations: rule.translations || {}
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
function buildPresetRulePayload(preset) {
|
|
1673
|
+
return {
|
|
1674
|
+
slug: preset.slug,
|
|
1675
|
+
name: preset.name,
|
|
1676
|
+
eventName: preset.eventName,
|
|
1677
|
+
targetContentType: preset.targetContentType,
|
|
1678
|
+
topic: preset.topic || "announcements",
|
|
1679
|
+
channels: preset.channels || ["telegram"],
|
|
1680
|
+
active: preset.active ?? true,
|
|
1681
|
+
priority: preset.priority || "normal",
|
|
1682
|
+
subjectTemplate: preset.subjectTemplate || "📢 {{title}}",
|
|
1683
|
+
bodyTextTemplate: preset.bodyTextTemplate || "{{title}}: {{message}} ({{secondaryDate}})",
|
|
1684
|
+
bodyHtmlTemplate: preset.bodyHtmlTemplate,
|
|
1685
|
+
ctaLabelTemplate: preset.ctaLabelTemplate,
|
|
1686
|
+
ctaUrlTemplate: preset.ctaUrlTemplate,
|
|
1687
|
+
translations: preset.translations || {}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
function doesRuleMatchTrigger(rule, eventName, payload) {
|
|
1691
|
+
if (!rule.active || rule.eventName !== eventName) return false;
|
|
1692
|
+
if (rule.targetContentType && payload.model && String(payload.model) !== rule.targetContentType) {
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
return true;
|
|
1696
|
+
}
|
|
1697
|
+
function firstNonEmptyString(record, keys, fallback) {
|
|
1698
|
+
for (const key of keys) {
|
|
1699
|
+
const val = record[key];
|
|
1700
|
+
if (val !== void 0 && val !== null && String(val) !== "") {
|
|
1701
|
+
return String(val);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return fallback;
|
|
1705
|
+
}
|
|
1706
|
+
function extractTriggerVariables(eventName, payload, settings, secondaryDateFormatted) {
|
|
1707
|
+
const entryRecord = payload.entry ?? payload;
|
|
1708
|
+
return {
|
|
1709
|
+
title: firstNonEmptyString(
|
|
1710
|
+
entryRecord,
|
|
1711
|
+
["title", "name", "subject"],
|
|
1712
|
+
eventName
|
|
1713
|
+
),
|
|
1714
|
+
message: firstNonEmptyString(
|
|
1715
|
+
entryRecord,
|
|
1716
|
+
["message", "summary", "description"],
|
|
1717
|
+
""
|
|
1718
|
+
),
|
|
1719
|
+
summary: firstNonEmptyString(
|
|
1720
|
+
entryRecord,
|
|
1721
|
+
["summary", "description", "message"],
|
|
1722
|
+
""
|
|
1723
|
+
),
|
|
1724
|
+
code: firstNonEmptyString(entryRecord, ["code", "slug"], "event"),
|
|
1725
|
+
organizationName: settings.organizationName,
|
|
1726
|
+
siteBaseUrl: settings.siteBaseUrl,
|
|
1727
|
+
secondaryDate: secondaryDateFormatted
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
function buildAutoCampaignInput(rule, vars) {
|
|
1731
|
+
let rawCode = String(vars.code || "item").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "");
|
|
1732
|
+
while (rawCode.endsWith("-")) {
|
|
1733
|
+
rawCode = rawCode.slice(0, -1);
|
|
1734
|
+
}
|
|
1735
|
+
const safeCode = rawCode || "item";
|
|
1736
|
+
const uniqueSuffix = generateUniqueSlugSuffix();
|
|
1737
|
+
const bodyHtml = rule.bodyHtmlTemplate ? interpolateBroadcastTemplate(rule.bodyHtmlTemplate, vars, {
|
|
1738
|
+
escapeHtmlValues: true
|
|
1739
|
+
}) : `<p>${escapeHtml(interpolateBroadcastTemplate(rule.bodyTextTemplate, vars))}</p>`;
|
|
1740
|
+
const rawCtaUrl = rule.ctaUrlTemplate ? interpolateBroadcastTemplate(rule.ctaUrlTemplate, vars) : void 0;
|
|
1741
|
+
return {
|
|
1742
|
+
slug: `auto-${rule.slug}-${safeCode}-${uniqueSuffix}`,
|
|
1743
|
+
title: `[Auto] ${rule.name}: ${vars.title}`,
|
|
1744
|
+
topic: rule.topic,
|
|
1745
|
+
channels: rule.channels,
|
|
1746
|
+
priority: rule.priority,
|
|
1747
|
+
subject: interpolateBroadcastTemplate(rule.subjectTemplate, vars),
|
|
1748
|
+
bodyText: interpolateBroadcastTemplate(rule.bodyTextTemplate, vars),
|
|
1749
|
+
bodyHtml,
|
|
1750
|
+
ctaLabel: rule.ctaLabelTemplate ? interpolateBroadcastTemplate(rule.ctaLabelTemplate, vars) : void 0,
|
|
1751
|
+
ctaUrl: rawCtaUrl && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : void 0
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
function isSelfBroadcastEvent(eventName, payload) {
|
|
1755
|
+
const normalizedEvent = String(eventName || "").trim().toLowerCase();
|
|
1756
|
+
if (!normalizedEvent || normalizedEvent.startsWith("broadcasts.")) {
|
|
1757
|
+
return true;
|
|
1758
|
+
}
|
|
1759
|
+
const model = typeof payload?.model === "string" ? payload.model.trim().toLowerCase() : "";
|
|
1760
|
+
const uid = typeof payload?.uid === "string" ? payload.uid.trim().toLowerCase() : "";
|
|
1761
|
+
return model.startsWith("plugin::broadcasts.") || uid.startsWith("plugin::broadcasts.");
|
|
1762
|
+
}
|
|
1763
|
+
const createAutomationService = ({ strapi }) => ({
|
|
1764
|
+
getSettingsService() {
|
|
1765
|
+
return strapi.plugin(PLUGIN_ID).service("settings");
|
|
1766
|
+
},
|
|
1767
|
+
getCampaignService() {
|
|
1768
|
+
return strapi.plugin(PLUGIN_ID).service("campaignService");
|
|
1769
|
+
},
|
|
1770
|
+
async listRules() {
|
|
1771
|
+
const docs = await strapi.documents(AUTOMATION_RULE_UID).findMany({
|
|
1772
|
+
limit: 100
|
|
1773
|
+
});
|
|
1774
|
+
return docs;
|
|
1775
|
+
},
|
|
1776
|
+
async getRuleBySlug(slug) {
|
|
1777
|
+
const docs = await strapi.documents(AUTOMATION_RULE_UID).findMany({
|
|
1778
|
+
filters: { slug },
|
|
1779
|
+
limit: 1
|
|
1780
|
+
});
|
|
1781
|
+
return docs[0] || null;
|
|
1782
|
+
},
|
|
1783
|
+
async findRuleByDocIdOrSlug(documentIdOrSlug) {
|
|
1784
|
+
const byDoc = await strapi.documents(AUTOMATION_RULE_UID).findOne({
|
|
1785
|
+
documentId: documentIdOrSlug
|
|
1786
|
+
});
|
|
1787
|
+
return byDoc ?? await this.getRuleBySlug(documentIdOrSlug);
|
|
1788
|
+
},
|
|
1789
|
+
async createRule(input) {
|
|
1790
|
+
const rawChannels = input.channels === void 0 ? ["telegram"] : input.channels;
|
|
1791
|
+
const validation = validateAutomationRuleInput({
|
|
1792
|
+
...input,
|
|
1793
|
+
channels: rawChannels
|
|
1794
|
+
});
|
|
1795
|
+
if (!validation.valid) {
|
|
1796
|
+
throw new Error(validation.errors.join("; "));
|
|
1797
|
+
}
|
|
1798
|
+
const channels = normalizeChannels(rawChannels, ["telegram"]);
|
|
1799
|
+
const candidate = {
|
|
1800
|
+
...input
|
|
1801
|
+
};
|
|
1802
|
+
const created = await strapi.documents(AUTOMATION_RULE_UID).create({
|
|
1803
|
+
data: {
|
|
1804
|
+
...buildRuleDocumentData(candidate, channels, true),
|
|
1805
|
+
triggerCount: 0
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
if (candidate.eventName) {
|
|
1809
|
+
ensureEventHubListener(strapi, candidate.eventName);
|
|
1810
|
+
}
|
|
1811
|
+
return created;
|
|
1812
|
+
},
|
|
1813
|
+
async updateRule(documentIdOrSlug, input) {
|
|
1814
|
+
const existing = await this.findRuleByDocIdOrSlug(documentIdOrSlug);
|
|
1815
|
+
if (!existing?.documentId) {
|
|
1816
|
+
throw new Error(`Automation rule "${documentIdOrSlug}" not found`);
|
|
1817
|
+
}
|
|
1818
|
+
const rawChannels = input.channels === void 0 ? existing.channels : input.channels;
|
|
1819
|
+
const resolvedBodyHtmlTemplate = input.bodyHtmlTemplate !== void 0 ? input.bodyHtmlTemplate : input.bodyTextTemplate !== void 0 && (input.bodyTextTemplate !== existing.bodyTextTemplate || !existing.bodyHtmlTemplate) ? `<p>${escapeHtml(String(input.bodyTextTemplate))}</p>` : existing.bodyHtmlTemplate;
|
|
1820
|
+
const mergedForValidation = {
|
|
1821
|
+
...existing,
|
|
1822
|
+
...input,
|
|
1823
|
+
bodyHtmlTemplate: resolvedBodyHtmlTemplate,
|
|
1824
|
+
channels: rawChannels
|
|
1825
|
+
};
|
|
1826
|
+
const validation = validateAutomationRuleInput(mergedForValidation);
|
|
1827
|
+
if (!validation.valid) {
|
|
1828
|
+
throw new Error(validation.errors.join("; "));
|
|
1829
|
+
}
|
|
1830
|
+
const channels = normalizeChannels(rawChannels, ["telegram"]);
|
|
1831
|
+
const merged = {
|
|
1832
|
+
...mergedForValidation,
|
|
1833
|
+
channels
|
|
1834
|
+
};
|
|
1835
|
+
const updated = await strapi.documents(AUTOMATION_RULE_UID).update({
|
|
1836
|
+
documentId: existing.documentId,
|
|
1837
|
+
data: buildRuleDocumentData(merged, channels, existing.active ?? true)
|
|
1838
|
+
});
|
|
1839
|
+
if (merged.eventName) {
|
|
1840
|
+
ensureEventHubListener(strapi, merged.eventName);
|
|
1841
|
+
}
|
|
1842
|
+
return updated || merged;
|
|
1843
|
+
},
|
|
1844
|
+
async seedStarterRules() {
|
|
1845
|
+
const settings = await this.getSettingsService().getSettings();
|
|
1846
|
+
const presets = settings.presetAutomationRules?.length ? settings.presetAutomationRules : STARTER_AUTOMATION_RULES;
|
|
1847
|
+
let seededCount = 0;
|
|
1848
|
+
for (const preset of presets) {
|
|
1849
|
+
if (!preset.slug || !preset.name || !preset.eventName) continue;
|
|
1850
|
+
const existing = await this.getRuleBySlug(preset.slug);
|
|
1851
|
+
if (existing) continue;
|
|
1852
|
+
await this.createRule(buildPresetRulePayload(preset));
|
|
1853
|
+
seededCount += 1;
|
|
1854
|
+
}
|
|
1855
|
+
return seededCount;
|
|
1856
|
+
},
|
|
1857
|
+
async executeSingleTriggerRule(rule, vars) {
|
|
1858
|
+
const campaignService = this.getCampaignService();
|
|
1859
|
+
const campaign2 = await campaignService.createCampaign(
|
|
1860
|
+
buildAutoCampaignInput(rule, vars)
|
|
1861
|
+
);
|
|
1862
|
+
const dispatched = await campaignService.dispatchCampaign(
|
|
1863
|
+
campaign2.documentId || campaign2.slug
|
|
1864
|
+
);
|
|
1865
|
+
if (rule.documentId) {
|
|
1866
|
+
await strapi.documents(AUTOMATION_RULE_UID).update({
|
|
1867
|
+
documentId: rule.documentId,
|
|
1868
|
+
data: {
|
|
1869
|
+
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1870
|
+
triggerCount: (rule.triggerCount || 0) + 1
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
return {
|
|
1875
|
+
ruleSlug: rule.slug,
|
|
1876
|
+
campaignSlug: dispatched.campaign.slug,
|
|
1877
|
+
results: dispatched.results
|
|
1878
|
+
};
|
|
1879
|
+
},
|
|
1880
|
+
async handleEventTrigger(eventName, payload = {}) {
|
|
1881
|
+
if (isSelfBroadcastEvent(eventName, payload)) {
|
|
1882
|
+
return [];
|
|
1883
|
+
}
|
|
1884
|
+
const settings = await this.getSettingsService().getSettings();
|
|
1885
|
+
if (!settings.enableEventHubAutomations) return [];
|
|
1886
|
+
const allRules = await this.listRules();
|
|
1887
|
+
const matchingRules = allRules.filter(
|
|
1888
|
+
(rule) => doesRuleMatchTrigger(rule, eventName, payload)
|
|
1889
|
+
);
|
|
1890
|
+
if (matchingRules.length === 0) return [];
|
|
1891
|
+
const secondaryCal = settings.secondaryCalendars?.[0] || "islamic-umalqura";
|
|
1892
|
+
const secondaryDate = formatSecondaryDate(
|
|
1893
|
+
(/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
1894
|
+
secondaryCal,
|
|
1895
|
+
settings.defaultLocale || "es"
|
|
1896
|
+
);
|
|
1897
|
+
const vars = extractTriggerVariables(
|
|
1898
|
+
eventName,
|
|
1899
|
+
payload,
|
|
1900
|
+
settings,
|
|
1901
|
+
secondaryDate.formatted
|
|
1902
|
+
);
|
|
1903
|
+
const outcomes = [];
|
|
1904
|
+
for (const rule of matchingRules) {
|
|
1905
|
+
outcomes.push(await this.executeSingleTriggerRule(rule, vars));
|
|
1906
|
+
}
|
|
1907
|
+
return outcomes;
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
function buildCampaignDocumentData(candidate, channels, resolvedStatus = "draft") {
|
|
1911
|
+
const safeBodyText = String(candidate.bodyText || "");
|
|
1912
|
+
return {
|
|
1913
|
+
title: candidate.title,
|
|
1914
|
+
slug: candidate.slug,
|
|
1915
|
+
topic: candidate.topic || "announcements",
|
|
1916
|
+
channels,
|
|
1917
|
+
priority: candidate.priority || "normal",
|
|
1918
|
+
status: resolvedStatus,
|
|
1919
|
+
scheduledAt: candidate.scheduledAt,
|
|
1920
|
+
subject: candidate.subject,
|
|
1921
|
+
bodyText: safeBodyText,
|
|
1922
|
+
bodyHtml: candidate.bodyHtml || `<p>${escapeHtml(safeBodyText)}</p>`,
|
|
1923
|
+
ctaLabel: candidate.ctaLabel,
|
|
1924
|
+
ctaUrl: candidate.ctaUrl,
|
|
1925
|
+
mediaUrl: candidate.mediaUrl,
|
|
1926
|
+
emailTemplateRef: candidate.emailTemplateRef,
|
|
1927
|
+
whatsappTemplateName: candidate.whatsappTemplateName,
|
|
1928
|
+
whatsappGroupIds: Array.isArray(candidate.whatsappGroupIds) ? candidate.whatsappGroupIds : [],
|
|
1929
|
+
telegramChatId: candidate.telegramChatId,
|
|
1930
|
+
telegramThreadId: candidate.telegramThreadId,
|
|
1931
|
+
translations: candidate.translations || {}
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
function buildPresetCampaignPayload(preset) {
|
|
1935
|
+
return {
|
|
1936
|
+
slug: preset.slug,
|
|
1937
|
+
title: preset.title,
|
|
1938
|
+
topic: preset.topic || "announcements",
|
|
1939
|
+
channels: preset.channels || ["email", "telegram"],
|
|
1940
|
+
priority: preset.priority || "normal",
|
|
1941
|
+
subject: preset.subject || preset.title,
|
|
1942
|
+
bodyText: preset.bodyText || preset.title,
|
|
1943
|
+
bodyHtml: preset.bodyHtml,
|
|
1944
|
+
ctaLabel: preset.ctaLabel,
|
|
1945
|
+
ctaUrl: preset.ctaUrl,
|
|
1946
|
+
emailTemplateRef: preset.emailTemplateRef,
|
|
1947
|
+
translations: preset.translations || {}
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
function mapRawSubscriber(sub, defaultLocale) {
|
|
1951
|
+
return {
|
|
1952
|
+
email: typeof sub.email === "string" ? sub.email : void 0,
|
|
1953
|
+
phone: typeof sub.phone === "string" ? sub.phone : void 0,
|
|
1954
|
+
fullName: typeof sub.fullName === "string" ? sub.fullName : void 0,
|
|
1955
|
+
locale: typeof sub.locale === "string" && sub.locale ? sub.locale : defaultLocale,
|
|
1956
|
+
topics: Array.isArray(sub.topics) ? sub.topics.map((t) => String(t)) : ["announcements"],
|
|
1957
|
+
unsubscribeToken: typeof sub.unsubscribeToken === "string" ? sub.unsubscribeToken : void 0
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
async function dispatchTelegramChannel(transport, campaign2, settings, secondaryDateLabel, isTestDispatch, testTelegramChatId, stats, results) {
|
|
1961
|
+
if (isTestDispatch && !testTelegramChatId) {
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const channelLocale = settings.defaultLocale || "es";
|
|
1965
|
+
const localized = resolveLocalizedCampaignCopy(
|
|
1966
|
+
campaign2,
|
|
1967
|
+
channelLocale,
|
|
1968
|
+
settings.defaultLocale
|
|
1969
|
+
);
|
|
1970
|
+
const vars = {
|
|
1971
|
+
recipientName: "Comunidad",
|
|
1972
|
+
organizationName: settings.organizationName,
|
|
1973
|
+
secondaryDate: secondaryDateLabel,
|
|
1974
|
+
siteBaseUrl: settings.siteBaseUrl
|
|
1975
|
+
};
|
|
1976
|
+
const tgTargets = isTestDispatch ? parseTargetList(testTelegramChatId) : resolveTelegramTargets(campaign2, settings);
|
|
1977
|
+
const threadId = campaign2.telegramThreadId ?? settings.telegramTopicThreadMap?.[campaign2.topic];
|
|
1978
|
+
const htmlBody = localized.bodyHtml ? interpolateBroadcastTemplate(localized.bodyHtml, vars, {
|
|
1979
|
+
escapeHtmlValues: true
|
|
1980
|
+
}) : escapeHtml(interpolateBroadcastTemplate(localized.bodyText, vars));
|
|
1981
|
+
const interpolatedCtaUrl = localized.ctaUrl ? interpolateBroadcastTemplate(localized.ctaUrl, vars) : void 0;
|
|
1982
|
+
for (const chatId of tgTargets) {
|
|
1983
|
+
const tgRes = await transport.sendTelegramBroadcast({
|
|
1984
|
+
campaignSlug: campaign2.slug,
|
|
1985
|
+
chatId,
|
|
1986
|
+
threadId,
|
|
1987
|
+
locale: channelLocale,
|
|
1988
|
+
subject: interpolateBroadcastTemplate(localized.subject, vars),
|
|
1989
|
+
htmlBody,
|
|
1990
|
+
ctaLabel: localized.ctaLabel ? interpolateBroadcastTemplate(localized.ctaLabel, vars) : void 0,
|
|
1991
|
+
ctaUrl: interpolatedCtaUrl && isSafeHttpUrl(interpolatedCtaUrl) ? interpolatedCtaUrl : void 0,
|
|
1992
|
+
secondaryDateLabel,
|
|
1993
|
+
isTestDispatch
|
|
1994
|
+
});
|
|
1995
|
+
results.push(tgRes);
|
|
1996
|
+
recordResultInStats(stats, tgRes);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
function buildEmailSendPayloadForRecipient(campaign2, recipient, settings, secondaryDateLabel, isTestDispatch) {
|
|
2000
|
+
const locale = recipient.locale || settings.defaultLocale || "es";
|
|
2001
|
+
const localized = resolveLocalizedCampaignCopy(
|
|
2002
|
+
campaign2,
|
|
2003
|
+
locale,
|
|
2004
|
+
settings.defaultLocale
|
|
2005
|
+
);
|
|
2006
|
+
let baseSite = settings.siteBaseUrl;
|
|
2007
|
+
while (baseSite.endsWith("/")) {
|
|
2008
|
+
baseSite = baseSite.slice(0, -1);
|
|
2009
|
+
}
|
|
2010
|
+
const rawUnsubscribeUrl = recipient.unsubscribeToken ? `${baseSite}/api/inbox/public/unsubscribe?token=${encodeURIComponent(
|
|
2011
|
+
recipient.unsubscribeToken
|
|
2012
|
+
)}` : `${baseSite}/${encodeURIComponent(locale)}/unsubscribe`;
|
|
2013
|
+
const unsubscribeUrl = stripHeaderControlChars(rawUnsubscribeUrl);
|
|
2014
|
+
const recipientName = recipient.fullName || recipient.email;
|
|
2015
|
+
const vars = {
|
|
2016
|
+
recipientName,
|
|
2017
|
+
organizationName: settings.organizationName,
|
|
2018
|
+
secondaryDate: secondaryDateLabel,
|
|
2019
|
+
siteBaseUrl: settings.siteBaseUrl,
|
|
2020
|
+
unsubscribeUrl
|
|
2021
|
+
};
|
|
2022
|
+
const html = localized.bodyHtml ? interpolateBroadcastTemplate(localized.bodyHtml, vars, {
|
|
2023
|
+
escapeHtmlValues: true
|
|
2024
|
+
}) : `<p>${escapeHtml(interpolateBroadcastTemplate(localized.bodyText, vars))}</p>`;
|
|
2025
|
+
const resolvedCtaUrl = localized.ctaUrl ? interpolateBroadcastTemplate(localized.ctaUrl, vars) : void 0;
|
|
2026
|
+
return {
|
|
2027
|
+
campaignSlug: campaign2.slug,
|
|
2028
|
+
recipientEmail: recipient.email,
|
|
2029
|
+
locale,
|
|
2030
|
+
subject: stripHeaderControlChars(
|
|
2031
|
+
interpolateBroadcastTemplate(localized.subject, vars)
|
|
2032
|
+
),
|
|
2033
|
+
text: interpolateBroadcastTemplate(localized.bodyText, vars),
|
|
2034
|
+
html,
|
|
2035
|
+
ctaLabel: localized.ctaLabel ? interpolateBroadcastTemplate(localized.ctaLabel, vars) : void 0,
|
|
2036
|
+
ctaUrl: resolvedCtaUrl && isSafeHttpUrl(resolvedCtaUrl) ? resolvedCtaUrl : void 0,
|
|
2037
|
+
unsubscribeUrl,
|
|
2038
|
+
emailTemplateRef: campaign2.emailTemplateRef,
|
|
2039
|
+
secondaryDateLabel,
|
|
2040
|
+
templateData: {
|
|
2041
|
+
recipientName,
|
|
2042
|
+
title: campaign2.title,
|
|
2043
|
+
topic: campaign2.topic
|
|
2044
|
+
},
|
|
2045
|
+
isTestDispatch
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
async function dispatchEmailChannel(transport, campaign2, settings, recipients, secondaryDateLabel, isTestDispatch, stats, results) {
|
|
2049
|
+
for (const recipient of recipients) {
|
|
2050
|
+
if (!recipient.email) continue;
|
|
2051
|
+
const payload = buildEmailSendPayloadForRecipient(
|
|
2052
|
+
campaign2,
|
|
2053
|
+
recipient,
|
|
2054
|
+
settings,
|
|
2055
|
+
secondaryDateLabel,
|
|
2056
|
+
isTestDispatch
|
|
2057
|
+
);
|
|
2058
|
+
const emailRes = await transport.sendEmailBroadcast(payload);
|
|
2059
|
+
results.push(emailRes);
|
|
2060
|
+
recordResultInStats(stats, emailRes);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
async function dispatchWhatsAppChannel(transport, campaign2, settings, recipients, isTestDispatch, secondaryDateLabel, stats, results) {
|
|
2064
|
+
const waRecipients = resolveWhatsAppRecipients(
|
|
2065
|
+
campaign2,
|
|
2066
|
+
settings,
|
|
2067
|
+
recipients,
|
|
2068
|
+
isTestDispatch
|
|
2069
|
+
);
|
|
2070
|
+
for (const recipient of waRecipients) {
|
|
2071
|
+
if (!recipient.phone) continue;
|
|
2072
|
+
const locale = recipient.locale || settings.defaultLocale || "es";
|
|
2073
|
+
const localized = resolveLocalizedCampaignCopy(
|
|
2074
|
+
campaign2,
|
|
2075
|
+
locale,
|
|
2076
|
+
settings.defaultLocale
|
|
2077
|
+
);
|
|
2078
|
+
const vars = {
|
|
2079
|
+
recipientName: recipient.fullName || "Comunidad",
|
|
2080
|
+
organizationName: settings.organizationName,
|
|
2081
|
+
secondaryDate: secondaryDateLabel,
|
|
2082
|
+
siteBaseUrl: settings.siteBaseUrl
|
|
2083
|
+
};
|
|
2084
|
+
const resolvedCtaUrl = localized.ctaUrl ? interpolateBroadcastTemplate(localized.ctaUrl, vars) : void 0;
|
|
2085
|
+
const waRes = await transport.sendWhatsAppBroadcast({
|
|
2086
|
+
campaignSlug: campaign2.slug,
|
|
2087
|
+
recipientPhone: recipient.phone,
|
|
2088
|
+
locale,
|
|
2089
|
+
textBody: interpolateBroadcastTemplate(localized.bodyText, vars),
|
|
2090
|
+
templateName: campaign2.whatsappTemplateName,
|
|
2091
|
+
ctaUrl: resolvedCtaUrl && isSafeHttpUrl(resolvedCtaUrl) ? resolvedCtaUrl : void 0,
|
|
2092
|
+
secondaryDateLabel,
|
|
2093
|
+
isTestDispatch
|
|
2094
|
+
});
|
|
2095
|
+
results.push(waRes);
|
|
2096
|
+
recordResultInStats(stats, waRes);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
async function dispatchAllActiveChannels(params) {
|
|
2100
|
+
const {
|
|
2101
|
+
transport,
|
|
2102
|
+
campaign: campaign2,
|
|
2103
|
+
settings,
|
|
2104
|
+
recipients,
|
|
2105
|
+
isTestDispatch,
|
|
2106
|
+
testTelegramChatId,
|
|
2107
|
+
secondaryDateLabel,
|
|
2108
|
+
stats
|
|
2109
|
+
} = params;
|
|
2110
|
+
const results = [];
|
|
2111
|
+
const activeChannels = normalizeChannels(campaign2.channels);
|
|
2112
|
+
if (activeChannels.includes("telegram") && settings.telegramEnabled) {
|
|
2113
|
+
await dispatchTelegramChannel(
|
|
2114
|
+
transport,
|
|
2115
|
+
campaign2,
|
|
2116
|
+
settings,
|
|
2117
|
+
secondaryDateLabel,
|
|
2118
|
+
isTestDispatch,
|
|
2119
|
+
testTelegramChatId,
|
|
2120
|
+
stats,
|
|
2121
|
+
results
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
if (activeChannels.includes("email")) {
|
|
2125
|
+
await dispatchEmailChannel(
|
|
2126
|
+
transport,
|
|
2127
|
+
campaign2,
|
|
2128
|
+
settings,
|
|
2129
|
+
recipients,
|
|
2130
|
+
secondaryDateLabel,
|
|
2131
|
+
isTestDispatch,
|
|
2132
|
+
stats,
|
|
2133
|
+
results
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
if (activeChannels.includes("whatsapp") && settings.whatsappEnabled) {
|
|
2137
|
+
await dispatchWhatsAppChannel(
|
|
2138
|
+
transport,
|
|
2139
|
+
campaign2,
|
|
2140
|
+
settings,
|
|
2141
|
+
recipients,
|
|
2142
|
+
isTestDispatch,
|
|
2143
|
+
secondaryDateLabel,
|
|
2144
|
+
stats,
|
|
2145
|
+
results
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
return results;
|
|
2149
|
+
}
|
|
2150
|
+
const ALLOWED_INITIAL_STATUSES = /* @__PURE__ */ new Set([
|
|
2151
|
+
"draft",
|
|
2152
|
+
"scheduled"
|
|
2153
|
+
]);
|
|
2154
|
+
const ALLOWED_USER_STATUS_TRANSITIONS = {
|
|
2155
|
+
draft: ["draft", "scheduled", "cancelled"],
|
|
2156
|
+
scheduled: ["draft", "scheduled", "cancelled"],
|
|
2157
|
+
sending: [],
|
|
2158
|
+
sent: ["sent"],
|
|
2159
|
+
failed: ["draft", "scheduled", "cancelled"],
|
|
2160
|
+
cancelled: ["cancelled", "draft"]
|
|
2161
|
+
};
|
|
2162
|
+
function resolveInitialStatus(rawStatus) {
|
|
2163
|
+
if (rawStatus === void 0) return "draft";
|
|
2164
|
+
if (!ALLOWED_INITIAL_STATUSES.has(rawStatus)) {
|
|
2165
|
+
throw new Error('Initial campaign status must be "draft" or "scheduled"');
|
|
2166
|
+
}
|
|
2167
|
+
return rawStatus;
|
|
2168
|
+
}
|
|
2169
|
+
function resolveUpdatedStatus(currentStatus, requestedStatus) {
|
|
2170
|
+
if (currentStatus === "sending") {
|
|
2171
|
+
throw new Error("Cannot modify a campaign while it is sending");
|
|
2172
|
+
}
|
|
2173
|
+
if (requestedStatus === void 0 || requestedStatus === currentStatus) {
|
|
2174
|
+
return currentStatus;
|
|
2175
|
+
}
|
|
2176
|
+
const allowed = ALLOWED_USER_STATUS_TRANSITIONS[currentStatus] || [];
|
|
2177
|
+
if (!allowed.includes(requestedStatus)) {
|
|
2178
|
+
throw new Error(
|
|
2179
|
+
`Invalid campaign status transition from "${currentStatus}" to "${requestedStatus}"`
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
return requestedStatus;
|
|
2183
|
+
}
|
|
2184
|
+
function computeCampaignSecondaryDate(campaign2, settings) {
|
|
2185
|
+
if (campaign2.secondaryDate?.formatted) {
|
|
2186
|
+
return campaign2.secondaryDate;
|
|
2187
|
+
}
|
|
2188
|
+
const secondaryCal = settings.secondaryCalendars?.[0] || "islamic-umalqura";
|
|
2189
|
+
return formatSecondaryDate(
|
|
2190
|
+
(/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2191
|
+
secondaryCal,
|
|
2192
|
+
settings.defaultLocale || "es"
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
function toOptionalPlainString(val) {
|
|
2196
|
+
if (typeof val !== "string") return void 0;
|
|
2197
|
+
const trimmed = val.trim();
|
|
2198
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
2199
|
+
}
|
|
2200
|
+
function sanitizeDispatchOptions(rawOptions, defaultLocale) {
|
|
2201
|
+
const safeObj = rawOptions && typeof rawOptions === "object" && !Array.isArray(rawOptions) ? rawOptions : {};
|
|
2202
|
+
const testRecipientEmail = toOptionalPlainString(safeObj.testRecipientEmail);
|
|
2203
|
+
const testRecipientPhone = toOptionalPlainString(safeObj.testRecipientPhone);
|
|
2204
|
+
const testTelegramChatId = toOptionalPlainString(safeObj.testTelegramChatId);
|
|
2205
|
+
const hasCustomRecipientsList = Array.isArray(safeObj.customRecipients) && safeObj.customRecipients.length > 0;
|
|
2206
|
+
const customRecipients = hasCustomRecipientsList ? safeObj.customRecipients.filter(
|
|
2207
|
+
(item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
|
2208
|
+
).map((item) => mapRawSubscriber(item, defaultLocale)) : [];
|
|
2209
|
+
return {
|
|
2210
|
+
hasCustomRecipientsList,
|
|
2211
|
+
customRecipients,
|
|
2212
|
+
testRecipientEmail,
|
|
2213
|
+
testRecipientPhone,
|
|
2214
|
+
testTelegramChatId
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
async function buildSelectedRecipients(campaign2, settings, options2, resolveTopicRecipients) {
|
|
2218
|
+
const clean = sanitizeDispatchOptions(
|
|
2219
|
+
options2,
|
|
2220
|
+
settings.defaultLocale || "es"
|
|
2221
|
+
);
|
|
2222
|
+
const isTestDispatch = Boolean(
|
|
2223
|
+
clean.testRecipientEmail || clean.testRecipientPhone || clean.testTelegramChatId
|
|
2224
|
+
);
|
|
2225
|
+
if (isTestDispatch) {
|
|
2226
|
+
return {
|
|
2227
|
+
isTestDispatch: true,
|
|
2228
|
+
testTelegramChatId: clean.testTelegramChatId,
|
|
2229
|
+
recipients: [
|
|
2230
|
+
{
|
|
2231
|
+
email: clean.testRecipientEmail,
|
|
2232
|
+
phone: clean.testRecipientPhone,
|
|
2233
|
+
telegramChatId: clean.testTelegramChatId,
|
|
2234
|
+
fullName: "Test Recipient",
|
|
2235
|
+
locale: settings.defaultLocale,
|
|
2236
|
+
topics: [campaign2.topic]
|
|
2237
|
+
}
|
|
2238
|
+
]
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
if (clean.hasCustomRecipientsList) {
|
|
2242
|
+
return { isTestDispatch: false, recipients: clean.customRecipients };
|
|
2243
|
+
}
|
|
2244
|
+
return {
|
|
2245
|
+
isTestDispatch: false,
|
|
2246
|
+
recipients: await resolveTopicRecipients(campaign2.topic)
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
const createCampaignService = ({ strapi }) => {
|
|
2250
|
+
const activeDispatches = /* @__PURE__ */ new Set();
|
|
2251
|
+
return {
|
|
2252
|
+
getSettingsService() {
|
|
2253
|
+
return strapi.plugin(PLUGIN_ID).service("settings");
|
|
2254
|
+
},
|
|
2255
|
+
getTransportService() {
|
|
2256
|
+
return strapi.plugin(PLUGIN_ID).service("transportService");
|
|
2257
|
+
},
|
|
2258
|
+
async listCampaigns(params) {
|
|
2259
|
+
const filters = {};
|
|
2260
|
+
if (typeof params?.status === "string" && params.status.trim()) {
|
|
2261
|
+
filters.status = params.status.trim();
|
|
2262
|
+
}
|
|
2263
|
+
if (typeof params?.topic === "string" && params.topic.trim()) {
|
|
2264
|
+
filters.topic = params.topic.trim();
|
|
2265
|
+
}
|
|
2266
|
+
const docs = await strapi.documents(CAMPAIGN_UID).findMany({ filters, limit: 100 });
|
|
2267
|
+
return docs;
|
|
2268
|
+
},
|
|
2269
|
+
async listPublicArchive(params) {
|
|
2270
|
+
const filters = { status: "sent" };
|
|
2271
|
+
if (typeof params?.topic === "string" && params.topic.trim()) {
|
|
2272
|
+
filters.topic = params.topic.trim();
|
|
2273
|
+
}
|
|
2274
|
+
const docs = await strapi.documents(CAMPAIGN_UID).findMany({
|
|
2275
|
+
filters,
|
|
2276
|
+
limit: clampLimit(params?.limit, 20)
|
|
2277
|
+
});
|
|
2278
|
+
return docs.map((c) => ({
|
|
2279
|
+
slug: c.slug,
|
|
2280
|
+
title: c.title,
|
|
2281
|
+
topic: c.topic,
|
|
2282
|
+
subject: c.subject,
|
|
2283
|
+
bodyText: c.bodyText,
|
|
2284
|
+
ctaLabel: c.ctaLabel,
|
|
2285
|
+
ctaUrl: c.ctaUrl,
|
|
2286
|
+
sentAt: c.sentAt,
|
|
2287
|
+
secondaryDateLabel: c.secondaryDateLabel,
|
|
2288
|
+
translations: c.translations
|
|
2289
|
+
}));
|
|
2290
|
+
},
|
|
2291
|
+
async getCampaignBySlug(slug) {
|
|
2292
|
+
const docs = await strapi.documents(CAMPAIGN_UID).findMany({
|
|
2293
|
+
filters: { slug },
|
|
2294
|
+
limit: 1
|
|
2295
|
+
});
|
|
2296
|
+
return docs[0] || null;
|
|
2297
|
+
},
|
|
2298
|
+
async findCampaignByDocIdOrSlug(documentIdOrSlug) {
|
|
2299
|
+
const byDoc = await strapi.documents(CAMPAIGN_UID).findOne({
|
|
2300
|
+
documentId: documentIdOrSlug
|
|
2301
|
+
});
|
|
2302
|
+
return byDoc ?? await this.getCampaignBySlug(documentIdOrSlug);
|
|
2303
|
+
},
|
|
2304
|
+
async createCampaign(input) {
|
|
2305
|
+
const initialStatus = resolveInitialStatus(input.status);
|
|
2306
|
+
const rawChannels = input.channels === void 0 ? ["email"] : input.channels;
|
|
2307
|
+
const validation = validateCampaignInput({
|
|
2308
|
+
...input,
|
|
2309
|
+
channels: rawChannels
|
|
2310
|
+
});
|
|
2311
|
+
if (!validation.valid) {
|
|
2312
|
+
throw new Error(validation.errors.join("; "));
|
|
2313
|
+
}
|
|
2314
|
+
const channels = normalizeChannels(rawChannels);
|
|
2315
|
+
const candidate = {
|
|
2316
|
+
...input,
|
|
2317
|
+
emailTemplateRef: input.emailTemplateRef !== void 0 ? String(input.emailTemplateRef).trim() : void 0
|
|
2318
|
+
};
|
|
2319
|
+
const settings = await this.getSettingsService().getSettings();
|
|
2320
|
+
const secondaryCal = settings.secondaryCalendars?.[0] || "islamic-umalqura";
|
|
2321
|
+
const baseIso = input.scheduledAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
2322
|
+
const referenceIso = baseIso.slice(0, 10);
|
|
2323
|
+
const secondaryDate = formatSecondaryDate(
|
|
2324
|
+
referenceIso,
|
|
2325
|
+
secondaryCal,
|
|
2326
|
+
settings.defaultLocale || "es"
|
|
2327
|
+
);
|
|
2328
|
+
const created = await strapi.documents(CAMPAIGN_UID).create({
|
|
2329
|
+
data: {
|
|
2330
|
+
...buildCampaignDocumentData(candidate, channels, initialStatus),
|
|
2331
|
+
secondaryDate,
|
|
2332
|
+
secondaryDateLabel: secondaryDate.formatted,
|
|
2333
|
+
stats: createEmptyStats(),
|
|
2334
|
+
metadata: candidate.metadata || {}
|
|
2335
|
+
}
|
|
2336
|
+
});
|
|
2337
|
+
return created;
|
|
2338
|
+
},
|
|
2339
|
+
async updateCampaign(documentIdOrSlug, input) {
|
|
2340
|
+
const existing = await this.findCampaignByDocIdOrSlug(documentIdOrSlug);
|
|
2341
|
+
if (!existing?.documentId) {
|
|
2342
|
+
throw new Error(`Campaign "${documentIdOrSlug}" not found`);
|
|
2343
|
+
}
|
|
2344
|
+
const nextStatus = resolveUpdatedStatus(
|
|
2345
|
+
existing.status || "draft",
|
|
2346
|
+
input.status
|
|
2347
|
+
);
|
|
2348
|
+
const rawChannels = input.channels === void 0 ? existing.channels : input.channels;
|
|
2349
|
+
const resolvedBodyHtml = input.bodyHtml !== void 0 ? input.bodyHtml : input.bodyText !== void 0 && (input.bodyText !== existing.bodyText || !existing.bodyHtml) ? `<p>${escapeHtml(String(input.bodyText))}</p>` : existing.bodyHtml;
|
|
2350
|
+
const resolvedEmailTemplateRef = input.emailTemplateRef !== void 0 ? String(input.emailTemplateRef).trim() : existing.emailTemplateRef;
|
|
2351
|
+
const mergedForValidation = {
|
|
2352
|
+
...existing,
|
|
2353
|
+
...input,
|
|
2354
|
+
bodyHtml: resolvedBodyHtml,
|
|
2355
|
+
emailTemplateRef: resolvedEmailTemplateRef,
|
|
2356
|
+
channels: rawChannels,
|
|
2357
|
+
status: nextStatus
|
|
2358
|
+
};
|
|
2359
|
+
const validation = validateCampaignInput(mergedForValidation);
|
|
2360
|
+
if (!validation.valid) {
|
|
2361
|
+
throw new Error(validation.errors.join("; "));
|
|
2362
|
+
}
|
|
2363
|
+
const channels = normalizeChannels(rawChannels);
|
|
2364
|
+
const merged = {
|
|
2365
|
+
...mergedForValidation,
|
|
2366
|
+
channels
|
|
2367
|
+
};
|
|
2368
|
+
const updated = await strapi.documents(CAMPAIGN_UID).update({
|
|
2369
|
+
documentId: existing.documentId,
|
|
2370
|
+
data: buildCampaignDocumentData(merged, channels, nextStatus)
|
|
2371
|
+
});
|
|
2372
|
+
return updated || merged;
|
|
2373
|
+
},
|
|
2374
|
+
async createFromStarterTemplate(templateId) {
|
|
2375
|
+
const tpl = STARTER_CAMPAIGN_TEMPLATES.find(
|
|
2376
|
+
(item) => item.id === templateId || item.slug === templateId
|
|
2377
|
+
);
|
|
2378
|
+
if (!tpl) {
|
|
2379
|
+
throw new Error(`Starter campaign template "${templateId}" not found`);
|
|
2380
|
+
}
|
|
2381
|
+
const existing = await this.getCampaignBySlug(tpl.slug);
|
|
2382
|
+
if (existing) return existing;
|
|
2383
|
+
return this.createCampaign(buildPresetCampaignPayload(tpl));
|
|
2384
|
+
},
|
|
2385
|
+
async seedStarterCampaigns() {
|
|
2386
|
+
const settings = await this.getSettingsService().getSettings();
|
|
2387
|
+
const presets = settings.presetCampaigns?.length ? settings.presetCampaigns : STARTER_CAMPAIGN_TEMPLATES;
|
|
2388
|
+
let seededCount = 0;
|
|
2389
|
+
for (const preset of presets) {
|
|
2390
|
+
if (!preset.slug || !preset.title) continue;
|
|
2391
|
+
const existing = await this.getCampaignBySlug(preset.slug);
|
|
2392
|
+
if (existing) continue;
|
|
2393
|
+
await this.createCampaign(buildPresetCampaignPayload(preset));
|
|
2394
|
+
seededCount += 1;
|
|
2395
|
+
}
|
|
2396
|
+
return seededCount;
|
|
2397
|
+
},
|
|
2398
|
+
async resolveRecipientsForTopic(topic) {
|
|
2399
|
+
const settings = await this.getSettingsService().getSettings();
|
|
2400
|
+
if (!settings.enableInboxSubscriberBridge) return [];
|
|
2401
|
+
try {
|
|
2402
|
+
const inboxPlugin = strapi.plugin("inbox");
|
|
2403
|
+
const subscriberService = inboxPlugin?.service("subscriberService");
|
|
2404
|
+
if (subscriberService?.listSubscribers) {
|
|
2405
|
+
const list = await subscriberService.listSubscribers({
|
|
2406
|
+
status: "confirmed",
|
|
2407
|
+
topic
|
|
2408
|
+
});
|
|
2409
|
+
return list.map(
|
|
2410
|
+
(sub) => mapRawSubscriber(sub, settings.defaultLocale || "es")
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
2413
|
+
} catch {
|
|
2414
|
+
}
|
|
2415
|
+
return [];
|
|
2416
|
+
},
|
|
2417
|
+
async selectCampaignRecipients(campaign2, settings, options2) {
|
|
2418
|
+
return buildSelectedRecipients(
|
|
2419
|
+
campaign2,
|
|
2420
|
+
settings,
|
|
2421
|
+
options2,
|
|
2422
|
+
(topic) => this.resolveRecipientsForTopic(topic)
|
|
2423
|
+
);
|
|
2424
|
+
},
|
|
2425
|
+
async finalizeDispatchedCampaign(campaign2, secondaryDate, secondaryDateLabel, stats) {
|
|
2426
|
+
const finalStatus = stats.totalRecipients > 0 && stats.failedCount === stats.totalRecipients ? "failed" : "sent";
|
|
2427
|
+
const sentAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2428
|
+
let updatedCampaign = {
|
|
2429
|
+
...campaign2,
|
|
2430
|
+
status: finalStatus,
|
|
2431
|
+
sentAt,
|
|
2432
|
+
secondaryDateLabel,
|
|
2433
|
+
stats
|
|
2434
|
+
};
|
|
2435
|
+
if (campaign2.documentId) {
|
|
2436
|
+
const updated = await strapi.documents(CAMPAIGN_UID).update({
|
|
2437
|
+
documentId: campaign2.documentId,
|
|
2438
|
+
data: {
|
|
2439
|
+
status: finalStatus,
|
|
2440
|
+
sentAt,
|
|
2441
|
+
secondaryDate,
|
|
2442
|
+
secondaryDateLabel,
|
|
2443
|
+
stats
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
if (updated) updatedCampaign = updated;
|
|
2447
|
+
}
|
|
2448
|
+
strapi.eventHub?.emit("broadcasts.campaign.sent", {
|
|
2449
|
+
slug: updatedCampaign.slug,
|
|
2450
|
+
topic: updatedCampaign.topic,
|
|
2451
|
+
stats
|
|
2452
|
+
});
|
|
2453
|
+
return updatedCampaign;
|
|
2454
|
+
},
|
|
2455
|
+
async dispatchCampaign(documentIdOrSlug, options2 = {}) {
|
|
2456
|
+
const acquiredLocks = [];
|
|
2457
|
+
const tryAcquireBatch = (keys, label) => {
|
|
2458
|
+
const toAcquire = Array.from(
|
|
2459
|
+
new Set(keys.filter((k) => Boolean(k) && !acquiredLocks.includes(k)))
|
|
2460
|
+
);
|
|
2461
|
+
for (const key of toAcquire) {
|
|
2462
|
+
if (activeDispatches.has(key)) {
|
|
2463
|
+
throw new Error(`Campaign "${label}" is already being dispatched`);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
for (const key of toAcquire) {
|
|
2467
|
+
activeDispatches.add(key);
|
|
2468
|
+
acquiredLocks.push(key);
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
2471
|
+
const rawRef = String(documentIdOrSlug || "").trim();
|
|
2472
|
+
tryAcquireBatch([`req:${rawRef}`], rawRef || documentIdOrSlug);
|
|
2473
|
+
let campaign2 = null;
|
|
2474
|
+
let transitionedToSending = false;
|
|
2475
|
+
try {
|
|
2476
|
+
campaign2 = await this.findCampaignByDocIdOrSlug(rawRef);
|
|
2477
|
+
if (!campaign2) {
|
|
2478
|
+
throw new Error(`Campaign "${documentIdOrSlug}" not found`);
|
|
2479
|
+
}
|
|
2480
|
+
const canonicalKeys = [`slug:${campaign2.slug}`, `req:${campaign2.slug}`];
|
|
2481
|
+
if (campaign2.documentId) {
|
|
2482
|
+
canonicalKeys.push(
|
|
2483
|
+
`doc:${campaign2.documentId}`,
|
|
2484
|
+
`req:${campaign2.documentId}`
|
|
2485
|
+
);
|
|
2486
|
+
}
|
|
2487
|
+
tryAcquireBatch(canonicalKeys, campaign2.slug);
|
|
2488
|
+
if (campaign2.status === "cancelled") {
|
|
2489
|
+
throw new Error(
|
|
2490
|
+
`Cannot dispatch cancelled campaign "${campaign2.slug}"`
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
if (campaign2.status === "sending") {
|
|
2494
|
+
throw new Error(`Campaign "${campaign2.slug}" is already sending`);
|
|
2495
|
+
}
|
|
2496
|
+
const settings = await this.getSettingsService().getSettings();
|
|
2497
|
+
const transport = this.getTransportService();
|
|
2498
|
+
const secondaryDate = computeCampaignSecondaryDate(campaign2, settings);
|
|
2499
|
+
const secondaryDateLabel = campaign2.secondaryDateLabel || secondaryDate.formatted;
|
|
2500
|
+
const { isTestDispatch, testTelegramChatId, recipients } = await this.selectCampaignRecipients(
|
|
2501
|
+
campaign2,
|
|
2502
|
+
settings,
|
|
2503
|
+
options2
|
|
2504
|
+
);
|
|
2505
|
+
if (!isTestDispatch && campaign2.documentId) {
|
|
2506
|
+
await strapi.documents(CAMPAIGN_UID).update({
|
|
2507
|
+
documentId: campaign2.documentId,
|
|
2508
|
+
data: { status: "sending" }
|
|
2509
|
+
});
|
|
2510
|
+
transitionedToSending = true;
|
|
2511
|
+
}
|
|
2512
|
+
const stats = createEmptyStats();
|
|
2513
|
+
const results = await dispatchAllActiveChannels({
|
|
2514
|
+
transport,
|
|
2515
|
+
campaign: campaign2,
|
|
2516
|
+
settings,
|
|
2517
|
+
recipients,
|
|
2518
|
+
isTestDispatch,
|
|
2519
|
+
testTelegramChatId,
|
|
2520
|
+
secondaryDateLabel,
|
|
2521
|
+
stats
|
|
2522
|
+
});
|
|
2523
|
+
if (isTestDispatch) {
|
|
2524
|
+
return { campaign: campaign2, results };
|
|
2525
|
+
}
|
|
2526
|
+
const updatedCampaign = await this.finalizeDispatchedCampaign(
|
|
2527
|
+
campaign2,
|
|
2528
|
+
secondaryDate,
|
|
2529
|
+
secondaryDateLabel,
|
|
2530
|
+
stats
|
|
2531
|
+
);
|
|
2532
|
+
return { campaign: updatedCampaign, results };
|
|
2533
|
+
} catch (err) {
|
|
2534
|
+
if (transitionedToSending && campaign2?.documentId) {
|
|
2535
|
+
try {
|
|
2536
|
+
await strapi.documents(CAMPAIGN_UID).update({
|
|
2537
|
+
documentId: campaign2.documentId,
|
|
2538
|
+
data: { status: "failed" }
|
|
2539
|
+
});
|
|
2540
|
+
} catch {
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
throw err;
|
|
2544
|
+
} finally {
|
|
2545
|
+
for (const key of acquiredLocks) {
|
|
2546
|
+
activeDispatches.delete(key);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
};
|
|
2552
|
+
const SETTINGS_KEY = "settings";
|
|
2553
|
+
const MASKED_SECRET = "••••••••";
|
|
2554
|
+
const SECRET_MASK_RE = /^[•*]+$/;
|
|
2555
|
+
const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
2556
|
+
const ALLOWED_SETTINGS_KEYS = /* @__PURE__ */ new Set([
|
|
2557
|
+
"primaryCalendar",
|
|
2558
|
+
"secondaryCalendars",
|
|
2559
|
+
"defaultLocale",
|
|
2560
|
+
"supportedLocales",
|
|
2561
|
+
"availableTopics",
|
|
2562
|
+
"dryRun",
|
|
2563
|
+
"enableInboxSubscriberBridge",
|
|
2564
|
+
"enableEventHubAutomations",
|
|
2565
|
+
"enableEmailDesignerBridge",
|
|
2566
|
+
"defaultEmailTemplateRef",
|
|
2567
|
+
"siteBaseUrl",
|
|
2568
|
+
"organizationName",
|
|
2569
|
+
"emailFromAddress",
|
|
2570
|
+
"emailReplyToAddress",
|
|
2571
|
+
"telegramEnabled",
|
|
2572
|
+
"telegramBotToken",
|
|
2573
|
+
"telegramDefaultChatId",
|
|
2574
|
+
"telegramTopicChatMap",
|
|
2575
|
+
"telegramTopicThreadMap",
|
|
2576
|
+
"whatsappEnabled",
|
|
2577
|
+
"whatsappDriver",
|
|
2578
|
+
"whatsappPhoneNumberId",
|
|
2579
|
+
"whatsappAccessToken",
|
|
2580
|
+
"whatsappGatewayUrl",
|
|
2581
|
+
"whatsappGatewaySession",
|
|
2582
|
+
"whatsappGatewayApiKey",
|
|
2583
|
+
"whatsappDefaultRecipients",
|
|
2584
|
+
"whatsappTopicGroupMap",
|
|
2585
|
+
"allowLocalhostGateways",
|
|
2586
|
+
"publicChannelLinks",
|
|
2587
|
+
"presetCampaigns",
|
|
2588
|
+
"presetAutomationRules"
|
|
2589
|
+
]);
|
|
2590
|
+
const NESTED_MAP_KEYS = /* @__PURE__ */ new Set([
|
|
2591
|
+
"telegramTopicChatMap",
|
|
2592
|
+
"telegramTopicThreadMap",
|
|
2593
|
+
"whatsappTopicGroupMap"
|
|
2594
|
+
]);
|
|
2595
|
+
function sanitizePlainMap(val) {
|
|
2596
|
+
if (!val || typeof val !== "object" || Array.isArray(val)) return {};
|
|
2597
|
+
const clean = {};
|
|
2598
|
+
for (const [k, v] of Object.entries(val)) {
|
|
2599
|
+
if (DANGEROUS_KEYS.has(k) || !Object.hasOwn(val, k)) continue;
|
|
2600
|
+
clean[k] = v;
|
|
2601
|
+
}
|
|
2602
|
+
return clean;
|
|
2603
|
+
}
|
|
2604
|
+
function pickAllowedSettings(raw) {
|
|
2605
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2606
|
+
return {};
|
|
2607
|
+
}
|
|
2608
|
+
const clean = {};
|
|
2609
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
2610
|
+
if (DANGEROUS_KEYS.has(key) || !Object.hasOwn(raw, key) || !ALLOWED_SETTINGS_KEYS.has(key)) {
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
clean[key] = NESTED_MAP_KEYS.has(key) ? sanitizePlainMap(value) : value;
|
|
2614
|
+
}
|
|
2615
|
+
return clean;
|
|
2616
|
+
}
|
|
2617
|
+
function maskSecret(val) {
|
|
2618
|
+
if (!val || SECRET_MASK_RE.test(val.trim())) return "";
|
|
2619
|
+
return MASKED_SECRET;
|
|
2620
|
+
}
|
|
2621
|
+
function resolveSecretValue(incoming, current) {
|
|
2622
|
+
if (incoming === void 0 || incoming === null) {
|
|
2623
|
+
return current ?? "";
|
|
2624
|
+
}
|
|
2625
|
+
const str = String(incoming);
|
|
2626
|
+
const trimmed = str.trim();
|
|
2627
|
+
if (trimmed.length > 0 && SECRET_MASK_RE.test(trimmed)) {
|
|
2628
|
+
return current ?? "";
|
|
2629
|
+
}
|
|
2630
|
+
return str;
|
|
2631
|
+
}
|
|
2632
|
+
function resolveSecondaryCalendars(incoming, fallback) {
|
|
2633
|
+
if (!Array.isArray(incoming)) return fallback;
|
|
2634
|
+
return incoming.filter(
|
|
2635
|
+
(cal) => isSupportedCalendar(cal)
|
|
2636
|
+
);
|
|
2637
|
+
}
|
|
2638
|
+
const createSettingsService = ({ strapi }) => ({
|
|
2639
|
+
getPluginStore() {
|
|
2640
|
+
return strapi.store({
|
|
2641
|
+
type: "plugin",
|
|
2642
|
+
name: PLUGIN_ID
|
|
2643
|
+
});
|
|
2644
|
+
},
|
|
2645
|
+
getConfigDefaults() {
|
|
2646
|
+
const rawConfig = pickAllowedSettings(
|
|
2647
|
+
strapi.config.get(`plugin::${PLUGIN_ID}`, {})
|
|
2648
|
+
);
|
|
2649
|
+
const primaryCalendar = isSupportedCalendar(
|
|
2650
|
+
rawConfig.primaryCalendar
|
|
2651
|
+
) ? rawConfig.primaryCalendar : DEFAULT_BROADCAST_SETTINGS.primaryCalendar;
|
|
2652
|
+
const secondaryCalendars = resolveSecondaryCalendars(
|
|
2653
|
+
rawConfig.secondaryCalendars,
|
|
2654
|
+
DEFAULT_BROADCAST_SETTINGS.secondaryCalendars
|
|
2655
|
+
);
|
|
2656
|
+
return {
|
|
2657
|
+
...DEFAULT_BROADCAST_SETTINGS,
|
|
2658
|
+
...rawConfig,
|
|
2659
|
+
primaryCalendar,
|
|
2660
|
+
secondaryCalendars,
|
|
2661
|
+
whatsappDriver: isSupportedWhatsAppDriver(rawConfig.whatsappDriver) ? rawConfig.whatsappDriver : DEFAULT_BROADCAST_SETTINGS.whatsappDriver
|
|
2662
|
+
};
|
|
2663
|
+
},
|
|
2664
|
+
async getSettings() {
|
|
2665
|
+
const defaults = this.getConfigDefaults();
|
|
2666
|
+
const store = this.getPluginStore();
|
|
2667
|
+
const rawStored = await store.get({ key: SETTINGS_KEY });
|
|
2668
|
+
const stored = pickAllowedSettings(rawStored);
|
|
2669
|
+
if (!rawStored) {
|
|
2670
|
+
return defaults;
|
|
2671
|
+
}
|
|
2672
|
+
return {
|
|
2673
|
+
...defaults,
|
|
2674
|
+
...stored
|
|
2675
|
+
};
|
|
2676
|
+
},
|
|
2677
|
+
async getPublicSafeSettings() {
|
|
2678
|
+
const full = await this.getSettings();
|
|
2679
|
+
return {
|
|
2680
|
+
...full,
|
|
2681
|
+
telegramBotToken: maskSecret(full.telegramBotToken),
|
|
2682
|
+
whatsappAccessToken: maskSecret(full.whatsappAccessToken),
|
|
2683
|
+
whatsappGatewayApiKey: maskSecret(full.whatsappGatewayApiKey)
|
|
2684
|
+
};
|
|
2685
|
+
},
|
|
2686
|
+
async updateSettings(rawPartial) {
|
|
2687
|
+
const partial = pickAllowedSettings(rawPartial);
|
|
2688
|
+
const current = await this.getSettings();
|
|
2689
|
+
const effectiveAllowLocalhost = Boolean(
|
|
2690
|
+
partial.allowLocalhostGateways ?? current.allowLocalhostGateways
|
|
2691
|
+
);
|
|
2692
|
+
const effectiveGatewayUrl = String(
|
|
2693
|
+
partial.whatsappGatewayUrl ?? current.whatsappGatewayUrl ?? ""
|
|
2694
|
+
).trim();
|
|
2695
|
+
if (effectiveGatewayUrl) {
|
|
2696
|
+
const check = validateGatewayUrl(effectiveGatewayUrl, {
|
|
2697
|
+
allowLocalhost: effectiveAllowLocalhost
|
|
2698
|
+
});
|
|
2699
|
+
if (!check.valid) {
|
|
2700
|
+
throw new Error(check.errors.join("; "));
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
const effectiveSiteBaseUrl = String(
|
|
2704
|
+
partial.siteBaseUrl ?? current.siteBaseUrl ?? ""
|
|
2705
|
+
).trim();
|
|
2706
|
+
if (effectiveSiteBaseUrl && !isSafeHttpUrl(effectiveSiteBaseUrl)) {
|
|
2707
|
+
throw new Error("siteBaseUrl must be a valid http or https URL");
|
|
2708
|
+
}
|
|
2709
|
+
if (partial.publicChannelLinks !== void 0) {
|
|
2710
|
+
const linksCheck = validatePublicChannelLinks(partial.publicChannelLinks);
|
|
2711
|
+
if (!linksCheck.valid) {
|
|
2712
|
+
throw new Error(linksCheck.errors.join("; "));
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
if (partial.whatsappGatewaySession !== void 0) {
|
|
2716
|
+
const candidateSession = String(partial.whatsappGatewaySession).trim();
|
|
2717
|
+
if (candidateSession && !isValidGatewaySession(candidateSession)) {
|
|
2718
|
+
throw new Error(
|
|
2719
|
+
"whatsappGatewaySession must be 1-64 alphanumeric, underscore, or hyphen characters"
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
const effectiveGatewaySession = String(
|
|
2724
|
+
partial.whatsappGatewaySession ?? current.whatsappGatewaySession ?? "default"
|
|
2725
|
+
).trim() || "default";
|
|
2726
|
+
if (!isValidGatewaySession(effectiveGatewaySession)) {
|
|
2727
|
+
throw new Error(
|
|
2728
|
+
"whatsappGatewaySession must be 1-64 alphanumeric, underscore, or hyphen characters"
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
const next = {
|
|
2732
|
+
...current,
|
|
2733
|
+
...partial,
|
|
2734
|
+
siteBaseUrl: effectiveSiteBaseUrl || current.siteBaseUrl,
|
|
2735
|
+
whatsappGatewaySession: effectiveGatewaySession,
|
|
2736
|
+
emailFromAddress: stripHeaderControlChars(
|
|
2737
|
+
partial.emailFromAddress ?? current.emailFromAddress
|
|
2738
|
+
),
|
|
2739
|
+
emailReplyToAddress: stripHeaderControlChars(
|
|
2740
|
+
partial.emailReplyToAddress ?? current.emailReplyToAddress
|
|
2741
|
+
),
|
|
2742
|
+
primaryCalendar: isSupportedCalendar(partial.primaryCalendar) ? partial.primaryCalendar : current.primaryCalendar,
|
|
2743
|
+
secondaryCalendars: resolveSecondaryCalendars(
|
|
2744
|
+
partial.secondaryCalendars,
|
|
2745
|
+
current.secondaryCalendars
|
|
2746
|
+
),
|
|
2747
|
+
whatsappDriver: isSupportedWhatsAppDriver(partial.whatsappDriver) ? partial.whatsappDriver : current.whatsappDriver,
|
|
2748
|
+
telegramBotToken: resolveSecretValue(
|
|
2749
|
+
partial.telegramBotToken,
|
|
2750
|
+
current.telegramBotToken
|
|
2751
|
+
),
|
|
2752
|
+
whatsappAccessToken: resolveSecretValue(
|
|
2753
|
+
partial.whatsappAccessToken,
|
|
2754
|
+
current.whatsappAccessToken
|
|
2755
|
+
),
|
|
2756
|
+
whatsappGatewayApiKey: resolveSecretValue(
|
|
2757
|
+
partial.whatsappGatewayApiKey,
|
|
2758
|
+
current.whatsappGatewayApiKey
|
|
2759
|
+
)
|
|
2760
|
+
};
|
|
2761
|
+
const store = this.getPluginStore();
|
|
2762
|
+
await store.set({
|
|
2763
|
+
key: SETTINGS_KEY,
|
|
2764
|
+
value: next
|
|
2765
|
+
});
|
|
2766
|
+
return next;
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
const EMAIL_DESIGNER_TEMPLATE_UID = "plugin::email-designer-5.email-designer-template";
|
|
2770
|
+
const KNOWN_LOCALES = ["es", "en", "ar", "tr", "fr", "pt", "de", "it"];
|
|
2771
|
+
function getNormalizedTags(row) {
|
|
2772
|
+
if (!Array.isArray(row.tags)) return [];
|
|
2773
|
+
return row.tags.map((t) => String(t).toLowerCase());
|
|
2774
|
+
}
|
|
2775
|
+
async function fetchRawEmailDesignerTemplates(strapi) {
|
|
2776
|
+
try {
|
|
2777
|
+
const dbQuery = strapi.db?.query?.(EMAIL_DESIGNER_TEMPLATE_UID);
|
|
2778
|
+
if (dbQuery?.findMany) {
|
|
2779
|
+
const rows = await dbQuery.findMany({
|
|
2780
|
+
orderBy: { templateReferenceId: "asc" },
|
|
2781
|
+
limit: 200
|
|
2782
|
+
});
|
|
2783
|
+
if (Array.isArray(rows)) return rows;
|
|
2784
|
+
}
|
|
2785
|
+
} catch {
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
const docs = await strapi.documents(
|
|
2789
|
+
EMAIL_DESIGNER_TEMPLATE_UID
|
|
2790
|
+
).findMany({ limit: 200 });
|
|
2791
|
+
if (Array.isArray(docs)) return docs;
|
|
2792
|
+
} catch {
|
|
2793
|
+
}
|
|
2794
|
+
return [];
|
|
2795
|
+
}
|
|
2796
|
+
function toTemplateSummary(row) {
|
|
2797
|
+
const refId = Number(row.templateReferenceId ?? row.id ?? 0);
|
|
2798
|
+
const name = String(row.name || "").trim();
|
|
2799
|
+
if (!refId && !name) return null;
|
|
2800
|
+
return {
|
|
2801
|
+
templateReferenceId: refId,
|
|
2802
|
+
name: name || `Template #${refId}`,
|
|
2803
|
+
subject: typeof row.subject === "string" ? row.subject : void 0,
|
|
2804
|
+
tags: Array.isArray(row.tags) ? row.tags.map((t) => String(t)) : void 0,
|
|
2805
|
+
updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : void 0
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
async function listEmailDesignerTemplatesFromDb(strapi) {
|
|
2809
|
+
const rows = await fetchRawEmailDesignerTemplates(strapi);
|
|
2810
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2811
|
+
const summaries = [];
|
|
2812
|
+
for (const row of rows) {
|
|
2813
|
+
const summary = toTemplateSummary(row);
|
|
2814
|
+
if (!summary) continue;
|
|
2815
|
+
const key = `${summary.templateReferenceId}:${summary.name}`;
|
|
2816
|
+
if (seen.has(key)) continue;
|
|
2817
|
+
seen.add(key);
|
|
2818
|
+
summaries.push(summary);
|
|
2819
|
+
}
|
|
2820
|
+
return summaries.sort(
|
|
2821
|
+
(a, b) => a.templateReferenceId - b.templateReferenceId
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
function findDirectLocalizedTemplate(rows, trimmedRef, normLocale) {
|
|
2825
|
+
const lowerRef = trimmedRef.toLowerCase();
|
|
2826
|
+
const expectedName = `${lowerRef}_${normLocale}`;
|
|
2827
|
+
const normalizedGroupTag = trimmedRef.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2828
|
+
return rows.find((r) => {
|
|
2829
|
+
const rowName = String(r.name || "").toLowerCase();
|
|
2830
|
+
if (rowName === expectedName) return true;
|
|
2831
|
+
const rowTags = getNormalizedTags(r);
|
|
2832
|
+
return (rowTags.includes(lowerRef) || rowTags.includes(normalizedGroupTag)) && rowTags.includes(normLocale);
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
function findSiblingBySuffix(rows, primary, normLocale) {
|
|
2836
|
+
if (typeof primary.name !== "string") return void 0;
|
|
2837
|
+
const suffixMatch = primary.name.match(/^(.+)_([a-z]{2,3})$/i);
|
|
2838
|
+
if (!suffixMatch || suffixMatch[2].toLowerCase() === normLocale) {
|
|
2839
|
+
return void 0;
|
|
2840
|
+
}
|
|
2841
|
+
const targetSiblingName = `${suffixMatch[1]}_${normLocale}`.toLowerCase();
|
|
2842
|
+
return rows.find(
|
|
2843
|
+
(r) => String(r.name || "").toLowerCase() === targetSiblingName
|
|
2844
|
+
);
|
|
2845
|
+
}
|
|
2846
|
+
function findSiblingByCategoryTags(rows, primary, normLocale) {
|
|
2847
|
+
const primaryTags = getNormalizedTags(primary);
|
|
2848
|
+
if (primaryTags.includes(normLocale)) return void 0;
|
|
2849
|
+
const categoryTags = primaryTags.filter(
|
|
2850
|
+
(t) => !KNOWN_LOCALES.includes(t) && t !== "visitor" && t !== "community"
|
|
2851
|
+
);
|
|
2852
|
+
if (categoryTags.length === 0) return void 0;
|
|
2853
|
+
return rows.find((r) => {
|
|
2854
|
+
const rTags = getNormalizedTags(r);
|
|
2855
|
+
return rTags.includes(normLocale) && categoryTags.some((cat) => rTags.includes(cat));
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
function findPrimaryTemplate(rows, trimmed) {
|
|
2859
|
+
const numericRef = /^\d+$/.test(trimmed) ? Number(trimmed) : null;
|
|
2860
|
+
const lowerTrimmed = trimmed.toLowerCase();
|
|
2861
|
+
return rows.find(
|
|
2862
|
+
(r) => numericRef !== null && Number(r.templateReferenceId) === numericRef || String(r.name || "").toLowerCase() === lowerTrimmed
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2865
|
+
function findSiblingTemplate(rows, primary, normLocale) {
|
|
2866
|
+
if (!normLocale) return void 0;
|
|
2867
|
+
return findSiblingBySuffix(rows, primary, normLocale) ?? findSiblingByCategoryTags(rows, primary, normLocale);
|
|
2868
|
+
}
|
|
2869
|
+
async function resolveEmailDesignerTemplateFromDb(strapi, templateRef, locale) {
|
|
2870
|
+
const trimmed = String(templateRef || "").trim();
|
|
2871
|
+
if (!trimmed) return null;
|
|
2872
|
+
const rows = await fetchRawEmailDesignerTemplates(strapi);
|
|
2873
|
+
if (rows.length === 0) return null;
|
|
2874
|
+
const normLocale = locale ? locale.toLowerCase() : void 0;
|
|
2875
|
+
if (normLocale) {
|
|
2876
|
+
const localizedDirect = findDirectLocalizedTemplate(
|
|
2877
|
+
rows,
|
|
2878
|
+
trimmed,
|
|
2879
|
+
normLocale
|
|
2880
|
+
);
|
|
2881
|
+
if (localizedDirect) return localizedDirect;
|
|
2882
|
+
}
|
|
2883
|
+
const primary = findPrimaryTemplate(rows, trimmed);
|
|
2884
|
+
if (!primary) return null;
|
|
2885
|
+
return findSiblingTemplate(rows, primary, normLocale) ?? primary;
|
|
2886
|
+
}
|
|
2887
|
+
async function trySendViaPluginService(strapi, numericTemplateId, recipientEmail, fromAddress, replyToAddress, headers, subject, templateVars) {
|
|
2888
|
+
if (!numericTemplateId) return null;
|
|
2889
|
+
try {
|
|
2890
|
+
const emailDesignerPlugin = strapi.plugin("email-designer-5");
|
|
2891
|
+
const emailDesignerService = emailDesignerPlugin?.service("email");
|
|
2892
|
+
if (emailDesignerService?.sendTemplatedEmail) {
|
|
2893
|
+
await emailDesignerService.sendTemplatedEmail(
|
|
2894
|
+
{
|
|
2895
|
+
to: stripHeaderControlChars(recipientEmail),
|
|
2896
|
+
from: stripHeaderControlChars(fromAddress),
|
|
2897
|
+
replyTo: stripHeaderControlChars(replyToAddress),
|
|
2898
|
+
headers
|
|
2899
|
+
},
|
|
2900
|
+
{
|
|
2901
|
+
templateReferenceId: numericTemplateId,
|
|
2902
|
+
subject: stripHeaderControlChars(subject)
|
|
2903
|
+
},
|
|
2904
|
+
templateVars
|
|
2905
|
+
);
|
|
2906
|
+
return `ed5_${numericTemplateId}_${Date.now()}`;
|
|
2907
|
+
}
|
|
2908
|
+
} catch {
|
|
2909
|
+
}
|
|
2910
|
+
return null;
|
|
2911
|
+
}
|
|
2912
|
+
async function trySendEmailViaDesigner5(params) {
|
|
2913
|
+
const {
|
|
2914
|
+
strapi,
|
|
2915
|
+
effectiveTemplateRef,
|
|
2916
|
+
locale,
|
|
2917
|
+
recipientEmail,
|
|
2918
|
+
fromAddress,
|
|
2919
|
+
replyToAddress,
|
|
2920
|
+
subject,
|
|
2921
|
+
text,
|
|
2922
|
+
headers,
|
|
2923
|
+
templateVars,
|
|
2924
|
+
interpolate
|
|
2925
|
+
} = params;
|
|
2926
|
+
const resolvedTemplate = await resolveEmailDesignerTemplateFromDb(
|
|
2927
|
+
strapi,
|
|
2928
|
+
effectiveTemplateRef,
|
|
2929
|
+
locale
|
|
2930
|
+
);
|
|
2931
|
+
const numericTemplateId = resolvedTemplate?.templateReferenceId ?? (/^\d+$/.test(effectiveTemplateRef) ? Number(effectiveTemplateRef) : null);
|
|
2932
|
+
const pluginSentId = await trySendViaPluginService(
|
|
2933
|
+
strapi,
|
|
2934
|
+
numericTemplateId,
|
|
2935
|
+
recipientEmail,
|
|
2936
|
+
fromAddress,
|
|
2937
|
+
replyToAddress,
|
|
2938
|
+
headers,
|
|
2939
|
+
subject,
|
|
2940
|
+
templateVars
|
|
2941
|
+
);
|
|
2942
|
+
if (pluginSentId) return pluginSentId;
|
|
2943
|
+
const emailService = strapi.plugin("email")?.service("email");
|
|
2944
|
+
if (!resolvedTemplate?.bodyHtml || !emailService?.send) {
|
|
2945
|
+
return null;
|
|
2946
|
+
}
|
|
2947
|
+
const renderedHtml = interpolate(resolvedTemplate.bodyHtml, templateVars);
|
|
2948
|
+
const renderedText = resolvedTemplate.bodyText ? interpolate(resolvedTemplate.bodyText, {
|
|
2949
|
+
...templateVars,
|
|
2950
|
+
subject,
|
|
2951
|
+
bodyText: text
|
|
2952
|
+
}) : text;
|
|
2953
|
+
const renderedSubject = stripHeaderControlChars(
|
|
2954
|
+
subject || (resolvedTemplate.subject ? interpolate(resolvedTemplate.subject, { ...templateVars, subject }) : "")
|
|
2955
|
+
);
|
|
2956
|
+
await emailService.send({
|
|
2957
|
+
to: stripHeaderControlChars(recipientEmail),
|
|
2958
|
+
from: stripHeaderControlChars(fromAddress),
|
|
2959
|
+
replyTo: stripHeaderControlChars(replyToAddress),
|
|
2960
|
+
subject: renderedSubject,
|
|
2961
|
+
text: renderedText,
|
|
2962
|
+
html: renderedHtml,
|
|
2963
|
+
headers
|
|
2964
|
+
});
|
|
2965
|
+
return `ed5_${resolvedTemplate.templateReferenceId || effectiveTemplateRef}_${Date.now()}`;
|
|
2966
|
+
}
|
|
2967
|
+
function buildSafeListUnsubscribeHeader(rawUnsubscribeUrl) {
|
|
2968
|
+
if (!rawUnsubscribeUrl) return {};
|
|
2969
|
+
const cleaned = stripHeaderControlChars(rawUnsubscribeUrl);
|
|
2970
|
+
if (!cleaned || !isSafeHttpUrl(cleaned)) return {};
|
|
2971
|
+
return { "List-Unsubscribe": `<${cleaned}>` };
|
|
2972
|
+
}
|
|
2973
|
+
async function hasAlreadyDeliveredInDb(strapi, idempotencyKey) {
|
|
2974
|
+
const existing = await strapi.documents(DELIVERY_LOG_UID).findMany({
|
|
2975
|
+
filters: { idempotencyKey, status: { $in: ["sent", "simulated"] } },
|
|
2976
|
+
limit: 1
|
|
2977
|
+
});
|
|
2978
|
+
return existing.length > 0;
|
|
2979
|
+
}
|
|
2980
|
+
async function upsertDeliveryLogInDb(strapi, entry, secrets = []) {
|
|
2981
|
+
const sentAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2982
|
+
const safeEntry = {
|
|
2983
|
+
...entry,
|
|
2984
|
+
errorMessage: entry.errorMessage ? redactSecretsFromMessage(entry.errorMessage, secrets) : void 0
|
|
2985
|
+
};
|
|
2986
|
+
const existing = await strapi.documents(DELIVERY_LOG_UID).findMany({
|
|
2987
|
+
filters: { idempotencyKey: safeEntry.idempotencyKey },
|
|
2988
|
+
limit: 1
|
|
2989
|
+
});
|
|
2990
|
+
const existingDocId = typeof existing[0]?.documentId === "string" ? existing[0].documentId : void 0;
|
|
2991
|
+
if (existingDocId) {
|
|
2992
|
+
const updated = await strapi.documents(DELIVERY_LOG_UID).update({
|
|
2993
|
+
documentId: existingDocId,
|
|
2994
|
+
data: { ...safeEntry, sentAt }
|
|
2995
|
+
});
|
|
2996
|
+
return updated ?? {
|
|
2997
|
+
...safeEntry,
|
|
2998
|
+
documentId: existingDocId,
|
|
2999
|
+
sentAt
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
const created = await strapi.documents(DELIVERY_LOG_UID).create({
|
|
3003
|
+
data: { ...safeEntry, sentAt }
|
|
3004
|
+
});
|
|
3005
|
+
return created;
|
|
3006
|
+
}
|
|
3007
|
+
async function listDeliveryLogsFromDb(strapi, params) {
|
|
3008
|
+
const filters = {};
|
|
3009
|
+
if (typeof params?.campaignSlug === "string" && params.campaignSlug.trim()) {
|
|
3010
|
+
filters.campaignSlug = params.campaignSlug.trim();
|
|
3011
|
+
}
|
|
3012
|
+
if (typeof params?.channel === "string" && SUPPORTED_CHANNELS.includes(params.channel)) {
|
|
3013
|
+
filters.channel = params.channel;
|
|
3014
|
+
}
|
|
3015
|
+
const docs = await strapi.documents(DELIVERY_LOG_UID).findMany({
|
|
3016
|
+
filters,
|
|
3017
|
+
limit: clampLimit(params?.limit, 100)
|
|
3018
|
+
});
|
|
3019
|
+
return docs;
|
|
3020
|
+
}
|
|
3021
|
+
function extractSecretList(settings) {
|
|
3022
|
+
if (!settings) return [];
|
|
3023
|
+
return [
|
|
3024
|
+
settings.telegramBotToken,
|
|
3025
|
+
settings.whatsappAccessToken,
|
|
3026
|
+
settings.whatsappGatewayApiKey
|
|
3027
|
+
];
|
|
3028
|
+
}
|
|
3029
|
+
function sanitizeDispatchResult(result, secrets) {
|
|
3030
|
+
return {
|
|
3031
|
+
...result,
|
|
3032
|
+
errorMessage: result.errorMessage ? redactSecretsFromMessage(result.errorMessage, secrets) : void 0
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
async function sendLiveEmailViaProviders(strapi, payload, settings, effectiveTemplateRef) {
|
|
3036
|
+
const headers = buildSafeListUnsubscribeHeader(payload.unsubscribeUrl);
|
|
3037
|
+
const safeSubject = stripHeaderControlChars(payload.subject);
|
|
3038
|
+
const safeFrom = stripHeaderControlChars(settings.emailFromAddress);
|
|
3039
|
+
const safeReplyTo = stripHeaderControlChars(settings.emailReplyToAddress);
|
|
3040
|
+
const safeRecipient = stripHeaderControlChars(payload.recipientEmail);
|
|
3041
|
+
const inlineHtml = payload.html || `<p>${escapeHtml(payload.text)}</p>`;
|
|
3042
|
+
const templateVars = buildEmailTemplateVariables(
|
|
3043
|
+
{ ...payload, subject: safeSubject },
|
|
3044
|
+
settings,
|
|
3045
|
+
inlineHtml
|
|
3046
|
+
);
|
|
3047
|
+
if (effectiveTemplateRef) {
|
|
3048
|
+
const externalMessageId = await trySendEmailViaDesigner5({
|
|
3049
|
+
strapi,
|
|
3050
|
+
effectiveTemplateRef,
|
|
3051
|
+
locale: payload.locale,
|
|
3052
|
+
recipientEmail: safeRecipient,
|
|
3053
|
+
fromAddress: safeFrom,
|
|
3054
|
+
replyToAddress: safeReplyTo,
|
|
3055
|
+
subject: safeSubject,
|
|
3056
|
+
text: payload.text,
|
|
3057
|
+
headers,
|
|
3058
|
+
templateVars,
|
|
3059
|
+
interpolate: interpolateBroadcastTemplate
|
|
3060
|
+
});
|
|
3061
|
+
if (externalMessageId) {
|
|
3062
|
+
return {
|
|
3063
|
+
driver: "email-designer-5",
|
|
3064
|
+
status: "sent",
|
|
3065
|
+
externalMessageId
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
const emailService = strapi.plugin("email")?.service("email");
|
|
3070
|
+
if (!emailService?.send) {
|
|
3071
|
+
return {
|
|
3072
|
+
driver: "strapi-email-fallback",
|
|
3073
|
+
status: "simulated",
|
|
3074
|
+
externalMessageId: `sim_fallback_${Date.now()}`
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
await emailService.send({
|
|
3078
|
+
to: safeRecipient,
|
|
3079
|
+
from: safeFrom,
|
|
3080
|
+
replyTo: safeReplyTo,
|
|
3081
|
+
subject: safeSubject,
|
|
3082
|
+
text: payload.text,
|
|
3083
|
+
html: inlineHtml,
|
|
3084
|
+
headers
|
|
3085
|
+
});
|
|
3086
|
+
return {
|
|
3087
|
+
driver: "strapi-email",
|
|
3088
|
+
status: "sent",
|
|
3089
|
+
externalMessageId: `email_${Date.now()}`
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
const createTransportService = ({ strapi }) => ({
|
|
3093
|
+
getSettingsService() {
|
|
3094
|
+
return strapi.plugin(PLUGIN_ID).service("settings");
|
|
3095
|
+
},
|
|
3096
|
+
async fetchRawEmailDesignerTemplates() {
|
|
3097
|
+
return fetchRawEmailDesignerTemplates(strapi);
|
|
3098
|
+
},
|
|
3099
|
+
async listEmailDesignerTemplates() {
|
|
3100
|
+
return listEmailDesignerTemplatesFromDb(strapi);
|
|
3101
|
+
},
|
|
3102
|
+
async resolveEmailDesignerTemplate(templateRef, locale) {
|
|
3103
|
+
return resolveEmailDesignerTemplateFromDb(strapi, templateRef, locale);
|
|
3104
|
+
},
|
|
3105
|
+
async hasAlreadyDelivered(idempotencyKey) {
|
|
3106
|
+
return hasAlreadyDeliveredInDb(strapi, idempotencyKey);
|
|
3107
|
+
},
|
|
3108
|
+
async recordDeliveryLog(entry, secrets = []) {
|
|
3109
|
+
return upsertDeliveryLogInDb(strapi, entry, secrets);
|
|
3110
|
+
},
|
|
3111
|
+
async listDeliveryLogs(params) {
|
|
3112
|
+
return listDeliveryLogsFromDb(strapi, params);
|
|
3113
|
+
},
|
|
3114
|
+
async persistResult(campaignSlug, secondaryDateLabel, result, settings) {
|
|
3115
|
+
const secrets = extractSecretList(settings);
|
|
3116
|
+
const safeResult = sanitizeDispatchResult(result, secrets);
|
|
3117
|
+
try {
|
|
3118
|
+
await this.recordDeliveryLog(
|
|
3119
|
+
{
|
|
3120
|
+
...safeResult,
|
|
3121
|
+
campaignSlug,
|
|
3122
|
+
secondaryDateLabel
|
|
3123
|
+
},
|
|
3124
|
+
secrets
|
|
3125
|
+
);
|
|
3126
|
+
} catch (logErr) {
|
|
3127
|
+
const safeErr = redactSecretsFromMessage(
|
|
3128
|
+
logErr?.message || "log write error",
|
|
3129
|
+
secrets
|
|
3130
|
+
);
|
|
3131
|
+
strapi.log?.warn?.(
|
|
3132
|
+
`[@vicacha-devs/strapi-plugin-broadcasts] Delivery log fallback for ${safeResult.idempotencyKey}: ${safeErr}`
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
return safeResult;
|
|
3136
|
+
},
|
|
3137
|
+
async logOutcome(p, channel, driver, recipientMasked, status, idempotencyKey, extra, settings) {
|
|
3138
|
+
return this.persistResult(
|
|
3139
|
+
p.campaignSlug,
|
|
3140
|
+
p.secondaryDateLabel,
|
|
3141
|
+
makeResult(
|
|
3142
|
+
channel,
|
|
3143
|
+
driver,
|
|
3144
|
+
recipientMasked,
|
|
3145
|
+
p.locale,
|
|
3146
|
+
status,
|
|
3147
|
+
idempotencyKey,
|
|
3148
|
+
extra
|
|
3149
|
+
),
|
|
3150
|
+
settings
|
|
3151
|
+
);
|
|
3152
|
+
},
|
|
3153
|
+
async simulateDryRunEmail(payload, effectiveTemplateRef, recipientMasked, idempotencyKey, settings) {
|
|
3154
|
+
const resolvedDryRunTpl = effectiveTemplateRef ? await this.resolveEmailDesignerTemplate(
|
|
3155
|
+
effectiveTemplateRef,
|
|
3156
|
+
payload.locale
|
|
3157
|
+
) : null;
|
|
3158
|
+
const useDesigner = Boolean(effectiveTemplateRef);
|
|
3159
|
+
const refSuffix = resolvedDryRunTpl?.templateReferenceId || effectiveTemplateRef;
|
|
3160
|
+
return this.logOutcome(
|
|
3161
|
+
payload,
|
|
3162
|
+
"email",
|
|
3163
|
+
useDesigner ? "email-designer-5-dryrun" : "strapi-email-dryrun",
|
|
3164
|
+
recipientMasked,
|
|
3165
|
+
"simulated",
|
|
3166
|
+
idempotencyKey,
|
|
3167
|
+
{
|
|
3168
|
+
externalMessageId: useDesigner ? `sim_ed5_${refSuffix}_${Date.now()}` : `sim_email_${Date.now()}`
|
|
3169
|
+
},
|
|
3170
|
+
settings
|
|
3171
|
+
);
|
|
3172
|
+
},
|
|
3173
|
+
async executeLiveEmailSend(payload, settings, effectiveTemplateRef, recipientMasked, idempotencyKey) {
|
|
3174
|
+
const sent = await sendLiveEmailViaProviders(
|
|
3175
|
+
strapi,
|
|
3176
|
+
payload,
|
|
3177
|
+
settings,
|
|
3178
|
+
effectiveTemplateRef
|
|
3179
|
+
);
|
|
3180
|
+
return this.logOutcome(
|
|
3181
|
+
payload,
|
|
3182
|
+
"email",
|
|
3183
|
+
sent.driver,
|
|
3184
|
+
recipientMasked,
|
|
3185
|
+
sent.status,
|
|
3186
|
+
idempotencyKey,
|
|
3187
|
+
{ externalMessageId: sent.externalMessageId },
|
|
3188
|
+
settings
|
|
3189
|
+
);
|
|
3190
|
+
},
|
|
3191
|
+
async sendEmailBroadcast(payload) {
|
|
3192
|
+
const settings = await this.getSettingsService().getSettings();
|
|
3193
|
+
const idempotencyKey = computeIdempotencyKey(
|
|
3194
|
+
payload.campaignSlug,
|
|
3195
|
+
"email",
|
|
3196
|
+
payload.recipientEmail,
|
|
3197
|
+
{ isTest: payload.isTestDispatch }
|
|
3198
|
+
);
|
|
3199
|
+
const recipientMasked = maskRecipientIdentifier(payload.recipientEmail);
|
|
3200
|
+
const effectiveTemplateRef = resolveEffectiveEmailTemplateRef(
|
|
3201
|
+
payload,
|
|
3202
|
+
settings
|
|
3203
|
+
);
|
|
3204
|
+
const defaultDriver = effectiveTemplateRef ? "email-designer-5" : "strapi-email";
|
|
3205
|
+
if (!payload.isTestDispatch && await this.hasAlreadyDelivered(idempotencyKey)) {
|
|
3206
|
+
return makeResult(
|
|
3207
|
+
"email",
|
|
3208
|
+
defaultDriver,
|
|
3209
|
+
recipientMasked,
|
|
3210
|
+
payload.locale,
|
|
3211
|
+
"skipped_duplicate",
|
|
3212
|
+
idempotencyKey
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
if (settings.dryRun) {
|
|
3216
|
+
return this.simulateDryRunEmail(
|
|
3217
|
+
payload,
|
|
3218
|
+
effectiveTemplateRef,
|
|
3219
|
+
recipientMasked,
|
|
3220
|
+
idempotencyKey,
|
|
3221
|
+
settings
|
|
3222
|
+
);
|
|
3223
|
+
}
|
|
3224
|
+
try {
|
|
3225
|
+
return await this.executeLiveEmailSend(
|
|
3226
|
+
payload,
|
|
3227
|
+
settings,
|
|
3228
|
+
effectiveTemplateRef,
|
|
3229
|
+
recipientMasked,
|
|
3230
|
+
idempotencyKey
|
|
3231
|
+
);
|
|
3232
|
+
} catch (err) {
|
|
3233
|
+
return this.logOutcome(
|
|
3234
|
+
payload,
|
|
3235
|
+
"email",
|
|
3236
|
+
defaultDriver,
|
|
3237
|
+
recipientMasked,
|
|
3238
|
+
"failed",
|
|
3239
|
+
idempotencyKey,
|
|
3240
|
+
{ errorMessage: err?.message || "Email delivery failed" },
|
|
3241
|
+
settings
|
|
3242
|
+
);
|
|
3243
|
+
}
|
|
3244
|
+
},
|
|
3245
|
+
async sendTelegramBroadcast(payload) {
|
|
3246
|
+
const settings = await this.getSettingsService().getSettings();
|
|
3247
|
+
const targetChatId = payload.chatId || settings.telegramDefaultChatId;
|
|
3248
|
+
const idempotencyKey = buildTelegramIdempotencyKey(payload, targetChatId);
|
|
3249
|
+
const recipientMasked = maskRecipientIdentifier(targetChatId);
|
|
3250
|
+
if (!payload.isTestDispatch && await this.hasAlreadyDelivered(idempotencyKey)) {
|
|
3251
|
+
return makeResult(
|
|
3252
|
+
"telegram",
|
|
3253
|
+
"telegram-bot-api",
|
|
3254
|
+
recipientMasked,
|
|
3255
|
+
payload.locale,
|
|
3256
|
+
"skipped_duplicate",
|
|
3257
|
+
idempotencyKey
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
if (settings.dryRun || !settings.telegramBotToken) {
|
|
3261
|
+
return this.logOutcome(
|
|
3262
|
+
payload,
|
|
3263
|
+
"telegram",
|
|
3264
|
+
"telegram-bot-dryrun",
|
|
3265
|
+
recipientMasked,
|
|
3266
|
+
"simulated",
|
|
3267
|
+
idempotencyKey,
|
|
3268
|
+
{ externalMessageId: `sim_tg_${Date.now()}` },
|
|
3269
|
+
settings
|
|
3270
|
+
);
|
|
3271
|
+
}
|
|
3272
|
+
try {
|
|
3273
|
+
const externalMessageId = await executeTelegramHttpSend(
|
|
3274
|
+
payload,
|
|
3275
|
+
targetChatId,
|
|
3276
|
+
settings.telegramBotToken
|
|
3277
|
+
);
|
|
3278
|
+
return this.logOutcome(
|
|
3279
|
+
payload,
|
|
3280
|
+
"telegram",
|
|
3281
|
+
"telegram-bot-api",
|
|
3282
|
+
recipientMasked,
|
|
3283
|
+
"sent",
|
|
3284
|
+
idempotencyKey,
|
|
3285
|
+
{ externalMessageId },
|
|
3286
|
+
settings
|
|
3287
|
+
);
|
|
3288
|
+
} catch (err) {
|
|
3289
|
+
return this.logOutcome(
|
|
3290
|
+
payload,
|
|
3291
|
+
"telegram",
|
|
3292
|
+
"telegram-bot-api",
|
|
3293
|
+
recipientMasked,
|
|
3294
|
+
"failed",
|
|
3295
|
+
idempotencyKey,
|
|
3296
|
+
{
|
|
3297
|
+
errorMessage: err?.message || "Telegram delivery failed"
|
|
3298
|
+
},
|
|
3299
|
+
settings
|
|
3300
|
+
);
|
|
3301
|
+
}
|
|
3302
|
+
},
|
|
3303
|
+
async sendWhatsAppBroadcast(payload) {
|
|
3304
|
+
const settings = await this.getSettingsService().getSettings();
|
|
3305
|
+
const target = normalizeWhatsAppTarget(payload.recipientPhone);
|
|
3306
|
+
const driver = settings.whatsappDriver || "meta-cloud";
|
|
3307
|
+
const idempotencyKey = computeIdempotencyKey(
|
|
3308
|
+
payload.campaignSlug,
|
|
3309
|
+
"whatsapp",
|
|
3310
|
+
target.normalized,
|
|
3311
|
+
{ isTest: payload.isTestDispatch }
|
|
3312
|
+
);
|
|
3313
|
+
const recipientMasked = maskRecipientIdentifier(target.normalized);
|
|
3314
|
+
if (!payload.isTestDispatch && await this.hasAlreadyDelivered(idempotencyKey)) {
|
|
3315
|
+
return makeResult(
|
|
3316
|
+
"whatsapp",
|
|
3317
|
+
driver,
|
|
3318
|
+
recipientMasked,
|
|
3319
|
+
payload.locale,
|
|
3320
|
+
"skipped_duplicate",
|
|
3321
|
+
idempotencyKey
|
|
3322
|
+
);
|
|
3323
|
+
}
|
|
3324
|
+
if (settings.dryRun || !hasWhatsAppCredentials(driver, settings)) {
|
|
3325
|
+
return this.logOutcome(
|
|
3326
|
+
payload,
|
|
3327
|
+
"whatsapp",
|
|
3328
|
+
`${driver}-dryrun`,
|
|
3329
|
+
recipientMasked,
|
|
3330
|
+
"simulated",
|
|
3331
|
+
idempotencyKey,
|
|
3332
|
+
{ externalMessageId: `sim_wa_${Date.now()}` },
|
|
3333
|
+
settings
|
|
3334
|
+
);
|
|
3335
|
+
}
|
|
3336
|
+
if (driver === "meta-cloud" && target.isGroupOrChannel) {
|
|
3337
|
+
return this.logOutcome(
|
|
3338
|
+
payload,
|
|
3339
|
+
"whatsapp",
|
|
3340
|
+
"meta-cloud",
|
|
3341
|
+
recipientMasked,
|
|
3342
|
+
"failed",
|
|
3343
|
+
idempotencyKey,
|
|
3344
|
+
{
|
|
3345
|
+
errorMessage: "Meta WhatsApp Cloud API does not support @g.us group or @newsletter channel JIDs; use evolution or openwa gateway driver"
|
|
3346
|
+
},
|
|
3347
|
+
settings
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
try {
|
|
3351
|
+
const externalMessageId = await executeWhatsAppHttpSend(
|
|
3352
|
+
driver,
|
|
3353
|
+
payload,
|
|
3354
|
+
settings,
|
|
3355
|
+
target
|
|
3356
|
+
);
|
|
3357
|
+
return this.logOutcome(
|
|
3358
|
+
payload,
|
|
3359
|
+
"whatsapp",
|
|
3360
|
+
driver,
|
|
3361
|
+
recipientMasked,
|
|
3362
|
+
"sent",
|
|
3363
|
+
idempotencyKey,
|
|
3364
|
+
{ externalMessageId },
|
|
3365
|
+
settings
|
|
3366
|
+
);
|
|
3367
|
+
} catch (err) {
|
|
3368
|
+
return this.logOutcome(
|
|
3369
|
+
payload,
|
|
3370
|
+
"whatsapp",
|
|
3371
|
+
driver,
|
|
3372
|
+
recipientMasked,
|
|
3373
|
+
"failed",
|
|
3374
|
+
idempotencyKey,
|
|
3375
|
+
{
|
|
3376
|
+
errorMessage: err?.message || "WhatsApp delivery failed"
|
|
3377
|
+
},
|
|
3378
|
+
settings
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
3383
|
+
const services = {
|
|
3384
|
+
settings: createSettingsService,
|
|
3385
|
+
transportService: createTransportService,
|
|
3386
|
+
campaignService: createCampaignService,
|
|
3387
|
+
automationService: createAutomationService
|
|
3388
|
+
};
|
|
3389
|
+
const index = {
|
|
3390
|
+
register,
|
|
3391
|
+
bootstrap,
|
|
3392
|
+
destroy,
|
|
3393
|
+
config,
|
|
3394
|
+
contentTypes,
|
|
3395
|
+
controllers,
|
|
3396
|
+
routes,
|
|
3397
|
+
services
|
|
3398
|
+
};
|
|
3399
|
+
exports.default = index;
|