@easy-cms/plugin-notify 0.0.0-stage → 1.32.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kanawoot K.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @easy-cms/plugin-notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Notifications for Easy CMS admins: a Telegram message when a form is sent, an order comes in, or something fails (scheduled publishing, webhooks and email, backups, integrations). Admins connect the chat on the Integrations page. Part of [Easy CMS](https://github.com/maritonx/easy-cms), the embedded, code-first headless CMS for Nuxt and Next.js.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @easy-cms/plugin-notify
9
+ ```
10
+
11
+ Or `pnpm add`, `yarn add` or `bun add`.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { notifyPlugin } from '@easy-cms/plugin-notify'
17
+
18
+ export default defineConfig({
19
+ serverURL: 'https://example.com', // for links to the admin
20
+ // …
21
+ plugins: [notifyPlugin()],
22
+ })
23
+ ```
24
+
25
+ Then create a migration (`easy-cms migrate:create notify`), and in the admin go to **Settings → Integrations → Telegram**: paste the bot token from @BotFather, press **Find Chat ID**, and choose the events.
26
+
27
+ ## Links
28
+
29
+ [Notifications](https://easycms.dev/docs/notifications) · [Documentation](https://easycms.dev/docs) ([ภาษาไทย](https://easycms.dev/th/docs)) · [GitHub](https://github.com/maritonx/easy-cms)
30
+
31
+ MIT License
@@ -0,0 +1,62 @@
1
+ import * as _easy_cms_core from '@easy-cms/core';
2
+ import { IntegrationConfig } from '@easy-cms/core';
3
+
4
+ type Language = 'en' | 'th';
5
+ /** The events admins can be told about. */
6
+ declare const NOTIFY_EVENTS: readonly ["form.submitted", "order.created", "scheduled.failed", "delivery.failed", "backup.failed", "integration.failed"];
7
+ type NotifyEvent = (typeof NOTIFY_EVENTS)[number];
8
+ declare const EVENT_LABELS: Record<NotifyEvent, {
9
+ en: string;
10
+ th: string;
11
+ }>;
12
+ /** A message for people: a title, some lines, and a page of the admin. */
13
+ interface Notice {
14
+ readonly title: string;
15
+ readonly lines: readonly string[];
16
+ /** A path of the admin, e.g. `/backups`. */
17
+ readonly path?: string | undefined;
18
+ /** The link's words. */
19
+ readonly open: string;
20
+ }
21
+
22
+ /**
23
+ * A place notifications go (Telegram first; LINE, Slack and Discord later). Its integration
24
+ * gives the fields admins fill on the Integrations page, beside the ones every channel has
25
+ * (`language`, `events`).
26
+ */
27
+ interface NotifyChannel {
28
+ readonly integration: IntegrationConfig;
29
+ /** Formats a notice for this channel; `link` is the admin page, when `serverURL` is set. */
30
+ readonly format: (notice: Notice, link: string | undefined) => string;
31
+ /** Sends a formatted message; throws with the provider's reason. */
32
+ readonly send: (values: Readonly<Record<string, string>>, text: string) => Promise<void>;
33
+ }
34
+ /** A provider's answer that wasn't OK, for the error a connection shows. */
35
+ declare class ChannelError extends Error {
36
+ /** Retrying won't help, e.g. a revoked token. */
37
+ readonly permanent: boolean;
38
+ constructor(message: string, permanent?: boolean);
39
+ }
40
+
41
+ interface NotifyPluginOptions {
42
+ /** Where notifications go. Default: Telegram. */
43
+ readonly channels?: readonly NotifyChannel[];
44
+ /**
45
+ * Notifications a connection sends an hour, at most; the ones over it are counted and told
46
+ * in one message once the hour allows. Default 60.
47
+ */
48
+ readonly limit?: number;
49
+ /** Slug of the collection that queues notifications. Default `notify-messages`. */
50
+ readonly slug?: string;
51
+ }
52
+ /**
53
+ * Tells admins about events in a chat: new orders, form answers, and failures (scheduled
54
+ * publishing, webhooks and email, backups, integrations). Each channel (Telegram) is an
55
+ * integration admins connect on the Integrations page.
56
+ */
57
+ declare function notifyPlugin(options?: NotifyPluginOptions): _easy_cms_core.TypedPlugin<Record<never, never>>;
58
+
59
+ declare const telegram: NotifyChannel;
60
+
61
+ export { ChannelError, EVENT_LABELS, NOTIFY_EVENTS, notifyPlugin, telegram };
62
+ export type { Language, Notice, NotifyChannel, NotifyEvent, NotifyPluginOptions };
package/dist/index.js ADDED
@@ -0,0 +1,508 @@
1
+ // src/channel.ts
2
+ var ChannelError = class extends Error {
3
+ /** Retrying won't help, e.g. a revoked token. */
4
+ permanent;
5
+ constructor(message, permanent = false) {
6
+ super(message);
7
+ this.name = "ChannelError";
8
+ this.permanent = permanent;
9
+ }
10
+ };
11
+
12
+ // src/messages.ts
13
+ var NOTIFY_EVENTS = [
14
+ "form.submitted",
15
+ "order.created",
16
+ "scheduled.failed",
17
+ "delivery.failed",
18
+ "backup.failed",
19
+ "integration.failed"
20
+ ];
21
+ var EVENT_LABELS = {
22
+ "form.submitted": { en: "A form was sent", th: "\u0E21\u0E35\u0E04\u0E19\u0E2A\u0E48\u0E07\u0E1F\u0E2D\u0E23\u0E4C\u0E21" },
23
+ "order.created": { en: "A new order", th: "\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07\u0E0B\u0E37\u0E49\u0E2D\u0E43\u0E2B\u0E21\u0E48" },
24
+ "scheduled.failed": { en: "Scheduled publishing failed", th: "\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E15\u0E32\u0E21\u0E40\u0E27\u0E25\u0E32\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27" },
25
+ "delivery.failed": { en: "A webhook or email gave up", th: "Webhook \u0E2B\u0E23\u0E37\u0E2D\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E2A\u0E48\u0E07\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08" },
26
+ "backup.failed": { en: "A backup failed", th: "\u0E01\u0E32\u0E23\u0E2A\u0E33\u0E23\u0E2D\u0E07\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27" },
27
+ "integration.failed": { en: "An integration failed", th: "\u0E01\u0E32\u0E23\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E15\u0E48\u0E2D\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27" }
28
+ };
29
+ var str = (v) => typeof v === "string" || typeof v === "number" ? String(v) : "";
30
+ function money(amount, currency, language) {
31
+ if (typeof amount !== "number" || typeof currency !== "string") return "";
32
+ try {
33
+ const format = new Intl.NumberFormat(language, { style: "currency", currency });
34
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
35
+ return format.format(amount / 10 ** digits);
36
+ } catch {
37
+ return `${amount} ${currency}`;
38
+ }
39
+ }
40
+ var T = {
41
+ en: {
42
+ form: "Form",
43
+ from: "From",
44
+ order: "Order",
45
+ total: "Total",
46
+ email: "Email",
47
+ document: "Document",
48
+ error: "Error",
49
+ to: "To",
50
+ attempts: "Attempts",
51
+ connection: "Connection",
52
+ publish: "publish",
53
+ unpublish: "unpublish",
54
+ open: "Open in the admin",
55
+ dropped: (n, limit) => `${n} more notifications were not sent: at most ${limit} an hour.`,
56
+ test: "Easy CMS: test message. Notifications will arrive in this chat."
57
+ },
58
+ th: {
59
+ form: "\u0E1F\u0E2D\u0E23\u0E4C\u0E21",
60
+ from: "\u0E08\u0E32\u0E01",
61
+ order: "\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07\u0E0B\u0E37\u0E49\u0E2D",
62
+ total: "\u0E22\u0E2D\u0E14\u0E23\u0E27\u0E21",
63
+ email: "\u0E2D\u0E35\u0E40\u0E21\u0E25",
64
+ document: "\u0E40\u0E2D\u0E01\u0E2A\u0E32\u0E23",
65
+ error: "\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14",
66
+ to: "\u0E16\u0E36\u0E07",
67
+ attempts: "\u0E08\u0E33\u0E19\u0E27\u0E19\u0E04\u0E23\u0E31\u0E49\u0E07",
68
+ connection: "\u0E01\u0E32\u0E23\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E15\u0E48\u0E2D",
69
+ publish: "\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48",
70
+ unpublish: "\u0E40\u0E25\u0E34\u0E01\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48",
71
+ open: "\u0E40\u0E1B\u0E34\u0E14\u0E43\u0E19\u0E2B\u0E19\u0E49\u0E32 admin",
72
+ dropped: (n, limit) => `\u0E21\u0E35\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E2D\u0E35\u0E01 ${n} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E17\u0E35\u0E48\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E2A\u0E48\u0E07: \u0E2A\u0E48\u0E07\u0E44\u0E14\u0E49\u0E44\u0E21\u0E48\u0E40\u0E01\u0E34\u0E19 ${limit} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E15\u0E48\u0E2D\u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07`,
73
+ test: "Easy CMS: \u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E14\u0E2A\u0E2D\u0E1A \u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E08\u0E30\u0E2A\u0E48\u0E07\u0E21\u0E32\u0E17\u0E35\u0E48\u0E41\u0E0A\u0E15\u0E19\u0E35\u0E49"
74
+ }
75
+ };
76
+ var texts = (language) => T[language];
77
+ function noticeOf(message, language) {
78
+ const notice = describe(message, language);
79
+ return notice && { ...notice, open: T[language].open };
80
+ }
81
+ function describe(message, language) {
82
+ const t = T[language];
83
+ const d = message.data;
84
+ const event = message.event;
85
+ if (!NOTIFY_EVENTS.includes(event)) return null;
86
+ const title = EVENT_LABELS[event][language];
87
+ const line = (label, value) => str(value) ? [`${label}: ${str(value)}`] : [];
88
+ const about = message.about;
89
+ const docPath = about?.collection && about.id !== void 0 ? `/collections/${about.collection}/${about.id}` : void 0;
90
+ switch (event) {
91
+ case "form.submitted": {
92
+ const form = d.form ?? {};
93
+ return {
94
+ title,
95
+ lines: [...line(t.form, form.title ?? form.id), ...line(t.from, d.email)],
96
+ path: docPath
97
+ };
98
+ }
99
+ case "order.created":
100
+ return {
101
+ title,
102
+ lines: [
103
+ ...line(t.order, d.orderNumber ? `#${str(d.orderNumber)}` : d.id),
104
+ ...line(t.total, money(d.total, d.currency, language)),
105
+ ...line(t.email, d.email)
106
+ ],
107
+ path: docPath
108
+ };
109
+ case "scheduled.failed": {
110
+ const action = d.action === "unpublish" ? t.unpublish : d.action ? t.publish : "";
111
+ const target = d.global ?? (d.collection ? `${str(d.collection)} ${str(d.doc)}` : "");
112
+ return {
113
+ title,
114
+ lines: [
115
+ ...line(t.document, `${str(target)}${action ? ` (${action})` : ""}`.trim()),
116
+ ...line(t.error, d.error)
117
+ ],
118
+ path: "/jobs?tab=scheduled"
119
+ };
120
+ }
121
+ case "delivery.failed":
122
+ return {
123
+ title,
124
+ lines: [
125
+ ...line(t.to, d.url ?? d.to),
126
+ ...line(t.attempts, d.attempts),
127
+ ...line(t.error, d.error)
128
+ ],
129
+ path: "/deliveries"
130
+ };
131
+ case "backup.failed":
132
+ return { title, lines: line(t.error, d.error), path: "/backups" };
133
+ case "integration.failed":
134
+ return {
135
+ title,
136
+ lines: [
137
+ ...line(t.connection, d.name ? `${str(d.integration)} \xB7 ${str(d.name)}` : d.integration),
138
+ ...line(t.error, d.error)
139
+ ],
140
+ path: "/integrations"
141
+ };
142
+ }
143
+ }
144
+
145
+ // src/plugin.ts
146
+ import {
147
+ definePlugin
148
+ } from "@easy-cms/core";
149
+
150
+ // src/info.ts
151
+ var INFO = {
152
+ name: "@easy-cms/plugin-notify",
153
+ // The plugin API it is written for (`PLUGIN_API_VERSION` of @easy-cms/core).
154
+ apiVersion: 1,
155
+ ...true ? { version: "1.32.0" } : {}
156
+ };
157
+
158
+ // src/telegram.ts
159
+ var api = () => (process.env.TELEGRAM_API_URL ?? "https://api.telegram.org").replace(/\/$/, "");
160
+ var html = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
161
+ async function call(token, method, body) {
162
+ if (!token) throw new ChannelError("No bot token", true);
163
+ let response;
164
+ try {
165
+ response = await fetch(`${api()}/bot${token}/${method}`, {
166
+ method: "POST",
167
+ headers: { "content-type": "application/json" },
168
+ body: JSON.stringify(body ?? {}),
169
+ signal: AbortSignal.timeout(1e4)
170
+ });
171
+ } catch (error) {
172
+ throw new ChannelError(`Telegram could not be reached: ${error.message}`);
173
+ }
174
+ const answer = await response.json().catch(() => ({ ok: false }));
175
+ if (!answer.ok) {
176
+ const code = answer.error_code ?? response.status;
177
+ throw new ChannelError(
178
+ `Telegram: ${answer.description ?? `HTTP ${response.status}`}`,
179
+ code === 400 || code === 401 || code === 403 || code === 404
180
+ );
181
+ }
182
+ return answer.result;
183
+ }
184
+ var chatLabel = (chat) => {
185
+ const name = chat.title ?? ([chat.first_name, chat.last_name].filter(Boolean).join(" ") || chat.username);
186
+ return `${String(name ?? chat.id)} (${String(chat.type)})`;
187
+ };
188
+ async function chats(token) {
189
+ const updates = await call(token, "getUpdates", { limit: 100 }) ?? [];
190
+ const found = /* @__PURE__ */ new Map();
191
+ for (const update of updates.reverse()) {
192
+ const holder = update.message ?? update.channel_post ?? update.my_chat_member ?? update.edited_message;
193
+ const chat = holder?.chat;
194
+ if (chat?.id !== void 0 && !found.has(String(chat.id)))
195
+ found.set(String(chat.id), chatLabel(chat));
196
+ }
197
+ return [...found].map(([value, label]) => ({ value, label }));
198
+ }
199
+ var fields = [
200
+ {
201
+ name: "token",
202
+ label: { en: "Bot token", th: "Bot token" },
203
+ description: {
204
+ en: "From @BotFather: /newbot, then copy the token.",
205
+ th: "\u0E08\u0E32\u0E01 @BotFather: /newbot \u0E41\u0E25\u0E49\u0E27\u0E04\u0E31\u0E14\u0E25\u0E2D\u0E01 token"
206
+ },
207
+ secret: true,
208
+ required: true
209
+ },
210
+ {
211
+ name: "chatId",
212
+ label: { en: "Chat ID", th: "Chat ID" },
213
+ description: {
214
+ en: "Write to the bot (or add it to a group), then press Find.",
215
+ th: "\u0E2A\u0E48\u0E07\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E2B\u0E32\u0E1A\u0E2D\u0E17 (\u0E2B\u0E23\u0E37\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E1A\u0E2D\u0E17\u0E40\u0E02\u0E49\u0E32\u0E01\u0E25\u0E38\u0E48\u0E21) \u0E41\u0E25\u0E49\u0E27\u0E01\u0E14 \u0E04\u0E49\u0E19\u0E2B\u0E32"
216
+ },
217
+ required: true,
218
+ lookup: (values) => chats(values.token ?? "")
219
+ }
220
+ ];
221
+ var telegram = {
222
+ integration: {
223
+ id: "telegram",
224
+ label: "Telegram",
225
+ category: "notifications",
226
+ description: {
227
+ en: "Tell admins about new orders, form answers and failures in a Telegram chat.",
228
+ th: "\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E40\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07\u0E0B\u0E37\u0E49\u0E2D\u0E43\u0E2B\u0E21\u0E48 \u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E41\u0E25\u0E30\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14\u0E43\u0E19\u0E41\u0E0A\u0E15 Telegram"
229
+ },
230
+ icon: "send",
231
+ docs: "https://easycms.dev/docs/notifications",
232
+ plugin: "@easy-cms/plugin-notify",
233
+ kind: "fields",
234
+ fields,
235
+ env: { token: "TELEGRAM_BOT_TOKEN" },
236
+ // The bot's name; the plugin then sends a test message.
237
+ async test(values) {
238
+ const me = await call(values.token ?? "", "getMe");
239
+ return { ok: true, account: me.username ? `@${me.username}` : me.first_name ?? "bot" };
240
+ }
241
+ },
242
+ format(notice, link) {
243
+ return [
244
+ `<b>${html(notice.title)}</b>`,
245
+ ...notice.lines.map(html),
246
+ ...link ? [`<a href="${html(link)}">${html(notice.open)}</a>`] : []
247
+ ].join("\n");
248
+ },
249
+ async send(values, text) {
250
+ await call(values.token ?? "", "sendMessage", {
251
+ chat_id: values.chatId,
252
+ text,
253
+ parse_mode: "HTML",
254
+ link_preview_options: { is_disabled: true }
255
+ });
256
+ }
257
+ };
258
+
259
+ // src/plugin.ts
260
+ var RETRIES = [6e4, 5 * 6e4, 30 * 6e4];
261
+ var CLAIM = 2 * 6e4;
262
+ var HOUR = 60 * 6e4;
263
+ var KEEP = 7 * 24 * HOUR;
264
+ var nobody = () => false;
265
+ var later = (now, ms) => new Date(now.getTime() + ms).toISOString();
266
+ function notifyPlugin(options = {}) {
267
+ const channels = options.channels ?? [telegram];
268
+ const limit = options.limit ?? 60;
269
+ const slug = options.slug ?? "notify-messages";
270
+ const channelIds = new Set(channels.map((c) => c.integration.id));
271
+ if (!Number.isInteger(limit) || limit < 1)
272
+ throw new Error("notifyPlugin: `limit` must be a whole number above 0");
273
+ return definePlugin((config) => {
274
+ if (config.collections?.some((c) => c.slug === slug))
275
+ throw new Error(`notifyPlugin: there is already a collection "${slug}"; set \`slug\``);
276
+ const adminLanguage = config.admin?.locale === "th" ? "th" : "en";
277
+ const messages = {
278
+ slug,
279
+ labels: { singular: "Notification", plural: "Notifications" },
280
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
281
+ fields: [
282
+ { name: "channel", type: "text", required: true, index: true },
283
+ { name: "connection", type: "text", required: true },
284
+ { name: "event", type: "text", required: true },
285
+ { name: "text", type: "textarea", required: true },
286
+ // pending | sent | failed | dropped (over the limit) | counted (told in a summary)
287
+ { name: "state", type: "text", required: true, index: true },
288
+ { name: "attempts", type: "number" },
289
+ { name: "nextAttemptAt", type: "text", index: true },
290
+ { name: "error", type: "text" }
291
+ ]
292
+ };
293
+ const channelOf = (id) => channels.find((c) => c.integration.id === id);
294
+ const languageOf = (conn) => conn.values.language === "th" || conn.values.language === "en" ? conn.values.language : adminLanguage;
295
+ const linkOf = (cms, path) => {
296
+ const server = cms.config.serverURL?.replace(/\/+$/, "");
297
+ if (!server || !path) return void 0;
298
+ return `${server}${cms.config.admin.path.replace(/\/+$/, "")}${path}`;
299
+ };
300
+ const recent = (cms, row, now) => cms.db.count({
301
+ collection: slug,
302
+ where: {
303
+ and: [
304
+ { channel: { equals: row.channel } },
305
+ { connection: { equals: row.connection } },
306
+ { state: { in: ["pending", "sent", "failed"] } },
307
+ { createdAt: { gt: new Date(now.getTime() - HOUR).toISOString() } }
308
+ ]
309
+ },
310
+ max: limit
311
+ });
312
+ async function deliver(cms, row, now = /* @__PURE__ */ new Date()) {
313
+ const channel = channelOf(row.channel);
314
+ const conn = channel ? await cms.integrations.get(channel.integration.id, { name: String(row.connection) }) : null;
315
+ const attempts = Number(row.attempts ?? 0) + 1;
316
+ const { id, ...data } = row;
317
+ try {
318
+ if (!channel || !conn) throw new ChannelError("The connection is gone", true);
319
+ await channel.send(conn.values, String(row.text));
320
+ await cms.db.update({
321
+ collection: slug,
322
+ id,
323
+ data: { ...data, state: "sent", attempts, error: null, updatedAt: now.toISOString() }
324
+ });
325
+ await cms.integrations.markUsed(channel.integration.id, conn.name);
326
+ } catch (error) {
327
+ const message = error.message;
328
+ const wait = error instanceof ChannelError && error.permanent ? void 0 : RETRIES[attempts - 1];
329
+ await cms.db.update({
330
+ collection: slug,
331
+ id,
332
+ data: {
333
+ ...data,
334
+ attempts,
335
+ state: wait === void 0 ? "failed" : "pending",
336
+ nextAttemptAt: later(now, wait ?? 0),
337
+ error: message.slice(0, 500),
338
+ updatedAt: now.toISOString()
339
+ }
340
+ });
341
+ if (wait === void 0 && channel && conn)
342
+ await cms.integrations.markError(channel.integration.id, message, conn.name);
343
+ }
344
+ }
345
+ async function enqueue(cms, channel, conn, event, text, now = /* @__PURE__ */ new Date()) {
346
+ const key = { channel: channel.integration.id, connection: conn.name };
347
+ const over = await recent(cms, key, now) >= limit;
348
+ const row = await cms.db.create({
349
+ collection: slug,
350
+ data: {
351
+ ...key,
352
+ event,
353
+ text,
354
+ state: over ? "dropped" : "pending",
355
+ attempts: 0,
356
+ nextAttemptAt: later(now, CLAIM),
357
+ error: null,
358
+ createdAt: now.toISOString(),
359
+ updatedAt: now.toISOString()
360
+ }
361
+ });
362
+ if (!over) await deliver(cms, row, now);
363
+ }
364
+ async function listen(message, { cms }) {
365
+ const data = message.data;
366
+ if (message.event === "integration.failed" && channelIds.has(String(data.integration))) return;
367
+ for (const channel of channels) {
368
+ for (const conn of await cms.integrations.all(channel.integration.id)) {
369
+ const wanted = (conn.values.events ?? "").split(",").filter(Boolean);
370
+ if (wanted.length > 0 && !wanted.includes(message.event)) continue;
371
+ const notice = noticeOf(message, languageOf(conn));
372
+ if (!notice) continue;
373
+ await enqueue(
374
+ cms,
375
+ channel,
376
+ conn,
377
+ message.event,
378
+ channel.format(notice, linkOf(cms, notice.path))
379
+ );
380
+ }
381
+ }
382
+ }
383
+ async function summarize(cms, now) {
384
+ const dropped = await cms.db.find({
385
+ collection: slug,
386
+ where: { state: { equals: "dropped" } },
387
+ sort: ["id"],
388
+ limit: 0,
389
+ page: 1
390
+ });
391
+ const groups = /* @__PURE__ */ new Map();
392
+ for (const row of dropped.docs) {
393
+ const key = `${String(row.channel)}
394
+ ${String(row.connection)}`;
395
+ groups.set(key, [...groups.get(key) ?? [], row]);
396
+ }
397
+ for (const [key, rows] of groups) {
398
+ const [channelId = "", connection = ""] = key.split("\n");
399
+ if (await recent(cms, { channel: channelId, connection }, now) >= limit) continue;
400
+ for (const { id, ...data } of rows)
401
+ await cms.db.update({ collection: slug, id, data: { ...data, state: "counted" } });
402
+ const channel = channelOf(channelId);
403
+ const conn = channel && await cms.integrations.get(channelId, { name: connection });
404
+ if (!channel || !conn) continue;
405
+ const t = texts(languageOf(conn));
406
+ const notice = {
407
+ title: "Easy CMS",
408
+ lines: [t.dropped(rows.length, limit)],
409
+ open: t.open
410
+ };
411
+ await enqueue(cms, channel, conn, "notify.dropped", channel.format(notice, void 0), now);
412
+ }
413
+ }
414
+ async function run({ cms, now }) {
415
+ const due = await cms.db.find({
416
+ collection: slug,
417
+ where: {
418
+ and: [{ state: { equals: "pending" } }, { nextAttemptAt: { lte: now.toISOString() } }]
419
+ },
420
+ sort: ["nextAttemptAt"],
421
+ limit: 50,
422
+ page: 1
423
+ });
424
+ for (const found of due.docs) {
425
+ const { id, ...data } = found;
426
+ const claimed = await cms.db.update({
427
+ collection: slug,
428
+ id,
429
+ data: { ...data, nextAttemptAt: later(now, CLAIM) },
430
+ where: {
431
+ and: [
432
+ { state: { equals: "pending" } },
433
+ { nextAttemptAt: { equals: found.nextAttemptAt } }
434
+ ]
435
+ }
436
+ });
437
+ if (claimed) await deliver(cms, { ...found, ...claimed, id }, now);
438
+ }
439
+ await summarize(cms, now);
440
+ const old = await cms.db.find({
441
+ collection: slug,
442
+ where: {
443
+ and: [
444
+ { state: { not_equals: "pending" } },
445
+ { updatedAt: { lt: new Date(now.getTime() - KEEP).toISOString() } }
446
+ ]
447
+ },
448
+ sort: ["id"],
449
+ limit: 500,
450
+ page: 1
451
+ });
452
+ for (const row of old.docs) await cms.db.delete({ collection: slug, id: row.id });
453
+ }
454
+ const integrations = channels.map(({ integration }) => ({
455
+ ...integration,
456
+ fields: [
457
+ ...integration.fields ?? [],
458
+ {
459
+ name: "language",
460
+ label: { en: "Language", th: "\u0E20\u0E32\u0E29\u0E32" },
461
+ type: "select",
462
+ options: [
463
+ { value: "en", label: "English" },
464
+ { value: "th", label: "\u0E20\u0E32\u0E29\u0E32\u0E44\u0E17\u0E22" }
465
+ ],
466
+ description: {
467
+ en: "Of the notifications. Empty: the admin's language.",
468
+ th: "\u0E02\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19 \u0E16\u0E49\u0E32\u0E27\u0E48\u0E32\u0E07: \u0E20\u0E32\u0E29\u0E32\u0E02\u0E2D\u0E07\u0E2B\u0E19\u0E49\u0E32 admin"
469
+ }
470
+ },
471
+ {
472
+ name: "events",
473
+ label: { en: "Events", th: "\u0E40\u0E2B\u0E15\u0E38\u0E01\u0E32\u0E23\u0E13\u0E4C" },
474
+ type: "multiselect",
475
+ options: NOTIFY_EVENTS.map((value) => ({ value, label: EVENT_LABELS[value] })),
476
+ description: { en: "None ticked: all of them.", th: "\u0E16\u0E49\u0E32\u0E44\u0E21\u0E48\u0E40\u0E25\u0E37\u0E2D\u0E01: \u0E17\u0E31\u0E49\u0E07\u0E2B\u0E21\u0E14" }
477
+ }
478
+ ],
479
+ async test(values, args) {
480
+ const channel = channelOf(integration.id);
481
+ try {
482
+ const own = await integration.test?.(values, args);
483
+ if (own && !own.ok) return own;
484
+ const account = own?.account;
485
+ const language = values.language === "th" || values.language === "en" ? values.language : adminLanguage;
486
+ await channel.send(values, texts(language).test);
487
+ return { ok: true, ...account ? { account } : {} };
488
+ } catch (error) {
489
+ return { ok: false, message: error.message };
490
+ }
491
+ }
492
+ }));
493
+ return {
494
+ ...config,
495
+ collections: [...config.collections ?? [], messages],
496
+ integrations: [...config.integrations ?? [], ...integrations],
497
+ jobs: [...config.jobs ?? [], { name: "notify:send", run }],
498
+ listeners: [...config.listeners ?? [], { events: [...NOTIFY_EVENTS], handler: listen }]
499
+ };
500
+ }, INFO);
501
+ }
502
+ export {
503
+ ChannelError,
504
+ EVENT_LABELS,
505
+ NOTIFY_EVENTS,
506
+ notifyPlugin,
507
+ telegram
508
+ };
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@easy-cms/plugin-notify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.32.0",
4
+ "description": "Notifications for Easy CMS admins: Telegram messages when a form is submitted, an order comes in or something fails",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "peerDependencies": {
19
+ "@easy-cms/core": "^1.32.0"
20
+ },
21
+ "devDependencies": {
22
+ "@easy-cms/core": "^1.32.0",
23
+ "@easy-cms/db-sqlite": "^1.32.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=22.12"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/maritonx/easy-cms.git",
34
+ "directory": "packages/plugin-notify"
35
+ },
36
+ "keywords": [
37
+ "easy-cms",
38
+ "easy-cms-plugin",
39
+ "notifications",
40
+ "telegram"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "vitest run"
46
+ }
6
47
  }