omni-notify-mcp 1.3.14 → 1.3.21
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/README.md +259 -205
- package/dist/index.js +62 -37
- package/dist/ui/messaging/notificationEngine.js +168 -43
- package/dist/ui/server.js +376 -276
- package/package.json +4 -27
- package/scripts/idle-check.ps1 +47 -0
- package/ui/public/index.html +538 -538
- package/ui/public/style.css +793 -794
- package/dist/channels/desktop.js +0 -48
- package/dist/channels/discord.js +0 -20
- package/dist/channels/email.js +0 -29
- package/dist/channels/ntfy.js +0 -22
- package/dist/channels/pushover.js +0 -26
- package/dist/channels/slack.js +0 -36
- package/dist/channels/sms.js +0 -27
- package/dist/channels/teams.js +0 -43
- package/dist/channels/telegram.js +0 -19
- package/dist/channels/whatsapp.js +0 -11
- package/dist/config.js +0 -16
- package/dist/ui/messaging/types.js +0 -1
package/dist/index.js
CHANGED
|
@@ -241,35 +241,41 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
241
241
|
capabilities: {
|
|
242
242
|
experimental: { "claude/channel": {} },
|
|
243
243
|
},
|
|
244
|
-
instructions: "This is the
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
250
|
-
"
|
|
251
|
-
"
|
|
252
|
-
"ALWAYS call `notify` when: (a) a task that took
|
|
253
|
-
"just finished
|
|
254
|
-
"
|
|
255
|
-
"
|
|
256
|
-
"server decide routing.
|
|
257
|
-
"
|
|
258
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
"
|
|
267
|
-
"
|
|
268
|
-
"
|
|
269
|
-
"the
|
|
270
|
-
"
|
|
271
|
-
"
|
|
272
|
-
"
|
|
244
|
+
instructions: "This server is how you reach the user — and only you can decide to use it: nothing " +
|
|
245
|
+
"here delivers a message the user never asked for unless you or your own loop calls a " +
|
|
246
|
+
"tool. Four tools hold that conversation over whatever channels the server has " +
|
|
247
|
+
"configured: `notify` sends the user a message; `ask` sends a question and waits for " +
|
|
248
|
+
"their answer; `wait_for_inbox` waits up to 50s and returns the messages the user sent " +
|
|
249
|
+
"you; `reply` answers the user's most recent message back over the channel they are " +
|
|
250
|
+
"reading. Call `wait_for_inbox` whenever you finish a task or would otherwise be " +
|
|
251
|
+
"waiting, then call it again right after handling anything it returns.\n\n" +
|
|
252
|
+
"ALWAYS call `notify` when: (a) a task that took more than about a minute of wall-clock " +
|
|
253
|
+
"time just finished, success or failure; (b) you have a question or need a decision; " +
|
|
254
|
+
"(c) something important happened the user needs to know now. Never pre-check whether " +
|
|
255
|
+
"the user is at their desk — the server applies Do Not Disturb and idle gating itself, " +
|
|
256
|
+
"so fire `notify` and let the server decide routing. Fire it even when you are unsure: " +
|
|
257
|
+
"an unwanted notification the server holds back costs nothing, while a missed one " +
|
|
258
|
+
"costs the user hours.\n\n" +
|
|
259
|
+
"Read the result of every `notify` call. `outcome=delivered` means at least one channel " +
|
|
260
|
+
"carried it. `outcome=failed` or `outcome=blocked` means it reached nobody — never " +
|
|
261
|
+
"assume the user was told: say so in your reply and tell them no notification channel " +
|
|
262
|
+
"is configured.\n\n" +
|
|
263
|
+
"Echo the COMPLETE, UNTRUNCATED message in your chat output as well — never shorten it " +
|
|
264
|
+
"with '…' or a summary. Never name a delivery channel (Telegram, SMS, desktop, etc.) — " +
|
|
265
|
+
"those are server internals. Say 'notif' or 'notification' to refer to the act of " +
|
|
266
|
+
"notifying.\n\n" +
|
|
267
|
+
"NEVER TRUNCATE — THE SERVER CHUNKS FOR YOU.\n" +
|
|
268
|
+
"The hard limit is 500 characters per message, and the server enforces it by splitting " +
|
|
269
|
+
"for you: send the COMPLETE message (up to 5000 characters) in a single `notify` call " +
|
|
270
|
+
"and any body over 500 characters is automatically split into numbered parts — " +
|
|
271
|
+
"'(1/N) ...', '(2/N) ...' — each 500 characters or less and delivered in order, so you " +
|
|
272
|
+
"never hand-split and never lose the tail. A `too_big` error means you exceeded the " +
|
|
273
|
+
"5000-character cap; it never means you should shorten the message. Decide everything " +
|
|
274
|
+
"the user must see — every fact, file path, line number, recommendation — send it all " +
|
|
275
|
+
"in one call, and echo it in full in chat.\n\n" +
|
|
276
|
+
"When the user asks you to remember a rule or change how you should act, call " +
|
|
277
|
+
"`update_instructions` with the full updated rules block: it replaces the previous " +
|
|
278
|
+
"block atomically so the rule survives session restarts and context compaction.",
|
|
273
279
|
});
|
|
274
280
|
// Thin proxy: forward a tool call to the HTTP server and return its content
|
|
275
281
|
// block array verbatim. Error shape matches what the SDK expects from tool
|
|
@@ -302,7 +308,11 @@ async function sendNotifyChunked(message, priority) {
|
|
|
302
308
|
.map(t => { const i = t.indexOf(inboxMarker); return i === -1 ? null : t.slice(i); })
|
|
303
309
|
.filter((b) => b !== null);
|
|
304
310
|
const inboxSuffix = inboxBlocks.length ? `\n\n${inboxBlocks.join("\n\n")}` : "";
|
|
305
|
-
|
|
311
|
+
// Structural, never a string match: the server answers with an explicit
|
|
312
|
+
// `outcome=` token, so a send that reached nobody cannot read as a delivery
|
|
313
|
+
// merely because it failed to match some happy-path phrase.
|
|
314
|
+
const outcomeOf = (t) => (t.match(/outcome=(\w+)/)?.[1] ?? "unknown");
|
|
315
|
+
const delivered = texts.filter(t => outcomeOf(t) === "delivered").length;
|
|
306
316
|
const n = chunks.length;
|
|
307
317
|
let summary;
|
|
308
318
|
if (delivered === n) {
|
|
@@ -310,18 +320,27 @@ async function sendNotifyChunked(message, priority) {
|
|
|
310
320
|
}
|
|
311
321
|
else {
|
|
312
322
|
const stripInbox = (t) => { const i = t.indexOf(inboxMarker); return (i === -1 ? t : t.slice(0, i)).trim(); };
|
|
313
|
-
const failedSummaries = [...new Set(texts.filter(t =>
|
|
323
|
+
const failedSummaries = [...new Set(texts.filter(t => outcomeOf(t) !== "delivered").map(stripInbox))];
|
|
314
324
|
const said = failedSummaries.map(s => `"${s}"`).join(", ");
|
|
315
325
|
summary = delivered === 0
|
|
316
|
-
? `
|
|
326
|
+
? `NOT DELIVERED — 0 of ${n} chunks reached any channel. Server said: ${said}`
|
|
317
327
|
: `Delivered ${delivered}/${n} chunks (${message.length} chars); ${n - delivered} reached no channel — server said: ${said}.`;
|
|
318
328
|
}
|
|
329
|
+
// A chunk that reached nobody is an ERROR result, not a report the caller
|
|
330
|
+
// reads past: an escalation that silently went nowhere is the whole defect.
|
|
331
|
+
if (delivered < n) {
|
|
332
|
+
return { content: [{ type: "text", text: `${summary}${inboxSuffix}` }], isError: true };
|
|
333
|
+
}
|
|
319
334
|
return { content: [{ type: "text", text: `${summary}${inboxSuffix}` }] };
|
|
320
335
|
}
|
|
321
336
|
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
"
|
|
337
|
+
"The result always carries an explicit outcome: `outcome=delivered` means at least one " +
|
|
338
|
+
"channel carried the message, while `outcome=failed` or `outcome=blocked` means it " +
|
|
339
|
+
"reached NOBODY — never assume the user was told. " +
|
|
340
|
+
"One message is limited to 500 characters; the 5000-character cap on this tool is only " +
|
|
341
|
+
"the outer bound. Send the COMPLETE message in one call — anything over 500 characters " +
|
|
342
|
+
"is split for you into numbered '(1/N) ...' parts, each 500 characters or less, and " +
|
|
343
|
+
"delivered in order. Never truncate to fit a part.", {
|
|
325
344
|
message: z.string().max(5000),
|
|
326
345
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
327
346
|
}, async ({ message, priority }) => sendNotifyChunked(message, priority));
|
|
@@ -334,7 +353,12 @@ server.tool("wait_for_inbox", "Block until an unsolicited user message arrives o
|
|
|
334
353
|
"delivery across every MCP client (messages come back as tool results).", {
|
|
335
354
|
timeout_seconds: z.number().min(5).max(55).default(50),
|
|
336
355
|
}, async (args) => proxyToolCall("wait_for_inbox", args));
|
|
337
|
-
server.tool("get_idle_seconds", "
|
|
356
|
+
server.tool("get_idle_seconds", "Periodic heartbeat: how long since the user's last keyboard/mouse input, and drains " +
|
|
357
|
+
"the inbox as a side effect. Two distinct answers — 'idle: measured N seconds since " +
|
|
358
|
+
"the last keyboard/mouse input' is a reading; 'idle: could not measure' means this " +
|
|
359
|
+
"machine cannot report user activity at all, in which case gating is skipped and the " +
|
|
360
|
+
"server delivers. Neither is a number for you to compare against a threshold; the " +
|
|
361
|
+
"server gates internally.", {}, async () => proxyToolCall("get_idle_seconds", {}));
|
|
338
362
|
server.tool("get_idle_config", "Server's idle gating policy. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_config", {}));
|
|
339
363
|
server.tool("get_dnd_status", "Current DND state. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_dnd_status", {}));
|
|
340
364
|
server.tool("update_instructions", "Persist behavioral instructions for this client into CLAUDE.md so they survive " +
|
|
@@ -349,7 +373,8 @@ server.tool("update_instructions", "Persist behavioral instructions for this cli
|
|
|
349
373
|
// it straight through `notify` so it flows to whatever channel the user is
|
|
350
374
|
// actually reading (Telegram, desktop, email, ...).
|
|
351
375
|
server.tool("reply", "Reply to the user's most recent channel message. Routes through notify so " +
|
|
352
|
-
"the response reaches whichever channel the user is reading."
|
|
376
|
+
"the response reaches whichever channel the user is reading. The result " +
|
|
377
|
+
"carries the same `outcome=` contract as notify — an error means it reached nobody.", {
|
|
353
378
|
message: z.string().max(2000).describe("The reply text to deliver"),
|
|
354
379
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
355
380
|
}, async ({ message, priority }) => {
|
|
@@ -1,65 +1,190 @@
|
|
|
1
|
+
// A failed measurement is a NAMED state, never a number. `get_idle_seconds`
|
|
2
|
+
// answers -1 when it could not read, and -1 sits in-band with every real
|
|
3
|
+
// threshold comparison (a negative is always "under" the threshold), so a
|
|
4
|
+
// failed read and a genuine reading are indistinguishable once it is a number.
|
|
5
|
+
// Converting at the producer keeps the distinction for every consumer.
|
|
6
|
+
export function idleReading(seconds) {
|
|
7
|
+
return seconds >= 0 ? { measurable: true, seconds } : { measurable: false };
|
|
8
|
+
}
|
|
9
|
+
export function formatIdleReading(reading) {
|
|
10
|
+
return reading.measurable
|
|
11
|
+
? `idle: measured ${reading.seconds} seconds since the last keyboard/mouse input`
|
|
12
|
+
: "idle: could not measure — user activity unknown, so activity gating is skipped";
|
|
13
|
+
}
|
|
14
|
+
const EVERY_PRIORITY = ["low", "normal", "high"];
|
|
15
|
+
// GATE: which priorities reach each channel, in send order, and what an at-keyboard user holds back — a channel is never attempted for a priority outside its list
|
|
16
|
+
const CHANNEL_ROUTES = [
|
|
17
|
+
{ channel: "desktop", priorities: ["normal", "high"], atKeyboard: "desktopSignal" },
|
|
18
|
+
{ channel: "telegram", priorities: ["normal", "high"], atKeyboard: "heldBack" },
|
|
19
|
+
{ channel: "email", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
|
|
20
|
+
{ channel: "ntfy", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
|
|
21
|
+
{ channel: "discord", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
|
|
22
|
+
{ channel: "slack", priorities: EVERY_PRIORITY, atKeyboard: "channelLog" },
|
|
23
|
+
{ channel: "teams", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
|
|
24
|
+
{ channel: "sms", priorities: ["high"], atKeyboard: "heldBack" },
|
|
25
|
+
];
|
|
26
|
+
export function describePriorityRouting() {
|
|
27
|
+
return EVERY_PRIORITY
|
|
28
|
+
.map((priority) => `${priority}=${CHANNEL_ROUTES.filter((route) => route.priorities.includes(priority)).map((route) => route.channel).join("+")}`)
|
|
29
|
+
.join("; ");
|
|
30
|
+
}
|
|
1
31
|
export function computeDesktopOnlyMode(priority, policy, ctx) {
|
|
32
|
+
const idleKnown = ctx.idle.measurable;
|
|
2
33
|
if (priority === "high") {
|
|
3
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
desktopOnly: false,
|
|
36
|
+
idle: ctx.idle,
|
|
37
|
+
idleKnown,
|
|
38
|
+
reason: "priority=high — every enabled channel is used and activity gating is waived",
|
|
39
|
+
};
|
|
4
40
|
}
|
|
5
41
|
if (policy.dndActive) {
|
|
6
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
desktopOnly: false,
|
|
44
|
+
suppressedReason: "dnd",
|
|
45
|
+
idle: ctx.idle,
|
|
46
|
+
idleKnown,
|
|
47
|
+
reason: "DND active — no channel was attempted",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// The measurement FAILED: a deliberate fail-open. Stated as its own branch so
|
|
51
|
+
// it can never be reached by accident of a comparison against a sentinel.
|
|
52
|
+
if (policy.idleEnabled && !ctx.inTelegramConversation && !ctx.idle.measurable) {
|
|
53
|
+
return {
|
|
54
|
+
desktopOnly: false,
|
|
55
|
+
idle: ctx.idle,
|
|
56
|
+
idleKnown,
|
|
57
|
+
reason: "user activity could not be measured — gating skipped and every enabled channel is used",
|
|
58
|
+
};
|
|
7
59
|
}
|
|
8
|
-
|
|
60
|
+
const idleGated = policy.idleEnabled
|
|
61
|
+
&& !ctx.inTelegramConversation
|
|
62
|
+
&& ctx.idle.measurable
|
|
63
|
+
&& ctx.idle.seconds < policy.idleThresholdSeconds;
|
|
64
|
+
if (ctx.uiActive || idleGated) {
|
|
65
|
+
const why = ctx.uiActive
|
|
66
|
+
? "the UI is open"
|
|
67
|
+
: `idle ${ctx.idle.measurable ? ctx.idle.seconds : 0}s is under the ${policy.idleThresholdSeconds}s threshold`;
|
|
9
68
|
if (policy.alwaysDesktopWhenActive) {
|
|
10
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
desktopOnly: true,
|
|
71
|
+
idle: ctx.idle,
|
|
72
|
+
idleKnown,
|
|
73
|
+
reason: `${why} — desktop only, remote channels skipped`,
|
|
74
|
+
};
|
|
11
75
|
}
|
|
12
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
desktopOnly: false,
|
|
78
|
+
suppressedReason: "idle",
|
|
79
|
+
idle: ctx.idle,
|
|
80
|
+
idleKnown,
|
|
81
|
+
reason: `${why} and alwaysDesktopWhenActive is false — no desktop signal, only the Slack channel log is used`,
|
|
82
|
+
};
|
|
13
83
|
}
|
|
14
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
desktopOnly: false,
|
|
86
|
+
idle: ctx.idle,
|
|
87
|
+
idleKnown,
|
|
88
|
+
reason: "the user is considered away — every enabled channel is used",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function reachWhileAtKeyboard(decision) {
|
|
92
|
+
if (decision.suppressedReason === "idle")
|
|
93
|
+
return ["channelLog"];
|
|
94
|
+
if (decision.desktopOnly)
|
|
95
|
+
return ["desktopSignal", "channelLog"];
|
|
96
|
+
return ["heldBack", "desktopSignal", "channelLog"];
|
|
15
97
|
}
|
|
16
98
|
export async function sendWithRouting(options) {
|
|
17
|
-
const { message, priority, senders, policy, ctx
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
99
|
+
const { message, priority, senders, policy, ctx } = options;
|
|
100
|
+
const enabled = {
|
|
101
|
+
desktop: options.enableDesktop,
|
|
102
|
+
telegram: options.enableTelegram,
|
|
103
|
+
email: options.enableEmail,
|
|
104
|
+
ntfy: options.enableNtfy,
|
|
105
|
+
discord: options.enableDiscord,
|
|
106
|
+
slack: options.enableSlack,
|
|
107
|
+
teams: options.enableTeams,
|
|
108
|
+
sms: options.enableSms,
|
|
109
|
+
};
|
|
110
|
+
const decision = computeDesktopOnlyMode(priority, policy, ctx);
|
|
111
|
+
if (decision.suppressedReason === "dnd") {
|
|
112
|
+
return {
|
|
113
|
+
delivered: [],
|
|
114
|
+
attempted: [],
|
|
115
|
+
errors: [],
|
|
116
|
+
suppressedReason: "DND active",
|
|
117
|
+
outcome: "suppressed",
|
|
118
|
+
summary: `Suppressed — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
|
|
119
|
+
};
|
|
21
120
|
}
|
|
22
121
|
const delivered = [];
|
|
23
122
|
const errors = [];
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
|
|
123
|
+
const attempted = [];
|
|
124
|
+
const reach = reachWhileAtKeyboard(decision);
|
|
125
|
+
for (const route of CHANNEL_ROUTES) {
|
|
126
|
+
if (!enabled[route.channel] || !route.priorities.includes(priority) || !reach.includes(route.atKeyboard))
|
|
127
|
+
continue;
|
|
128
|
+
attempted.push(route.channel);
|
|
129
|
+
// OWNER: ITER/LOOP — one channel's provider failing must not stop the remaining channels — its reason is recorded per channel and the outcome below reports failed when no channel delivered
|
|
29
130
|
try {
|
|
30
|
-
await
|
|
31
|
-
delivered.push(
|
|
131
|
+
await senders[route.channel](message, priority);
|
|
132
|
+
delivered.push(route.channel);
|
|
32
133
|
}
|
|
33
134
|
catch (err) {
|
|
34
|
-
|
|
35
|
-
errors.push(`${name}: ${msg}`);
|
|
135
|
+
errors.push(`${route.channel}: ${err instanceof Error ? err.message : String(err)}`);
|
|
36
136
|
}
|
|
37
|
-
};
|
|
38
|
-
if (priority !== "low") {
|
|
39
|
-
if (enableDesktop) {
|
|
40
|
-
await trySend("desktop", senders.desktop ? () => senders.desktop(message) : undefined);
|
|
41
|
-
}
|
|
42
|
-
if (!desktopOnly && enableTelegram) {
|
|
43
|
-
await trySend("telegram", senders.telegram ? () => senders.telegram(message) : undefined);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
if (!desktopOnly && enableEmail) {
|
|
47
|
-
await trySend("email", senders.email ? () => senders.email(message) : undefined);
|
|
48
137
|
}
|
|
49
|
-
|
|
50
|
-
|
|
138
|
+
// A send that reached nobody is its own outcome — never a value the caller
|
|
139
|
+
// has to compare against a happy-path string to notice.
|
|
140
|
+
if (delivered.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
delivered,
|
|
143
|
+
attempted,
|
|
144
|
+
errors,
|
|
145
|
+
outcome: "delivered",
|
|
146
|
+
summary: `Delivered to ${delivered.length} channel${delivered.length === 1 ? "" : "s"}: ${delivered.join(", ")} — ${decision.reason}.`,
|
|
147
|
+
};
|
|
51
148
|
}
|
|
52
|
-
if (
|
|
53
|
-
|
|
149
|
+
if (decision.suppressedReason === "idle" && attempted.length === 0) {
|
|
150
|
+
return {
|
|
151
|
+
delivered,
|
|
152
|
+
attempted,
|
|
153
|
+
errors,
|
|
154
|
+
suppressedReason: "idle",
|
|
155
|
+
outcome: "suppressed",
|
|
156
|
+
summary: `Suppressed — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
|
|
157
|
+
};
|
|
54
158
|
}
|
|
55
|
-
|
|
56
|
-
|
|
159
|
+
// Nothing was even eligible to try — this is NOT a hold (the branches above
|
|
160
|
+
// already caught dnd/idle) and it is NOT a per-channel send error (nothing
|
|
161
|
+
// was attempted, so `errors` is necessarily empty). It is either "nothing is
|
|
162
|
+
// turned on at all" or "something is turned on but doesn't reach this
|
|
163
|
+
// priority/activity state" — story #438: the old text here ("reached 0 of 0
|
|
164
|
+
// enabled channels; the server log names which send failed") actively lied
|
|
165
|
+
// in this branch, since nothing was ever attempted and no log line exists to
|
|
166
|
+
// name. Naming what IS enabled (or that nothing is) is the whole difference
|
|
167
|
+
// between an agent that can act on the failure and one that cannot.
|
|
168
|
+
if (attempted.length === 0) {
|
|
169
|
+
const enabledChannels = Object.keys(enabled).filter((c) => enabled[c]);
|
|
170
|
+
const configHint = enabledChannels.length === 0
|
|
171
|
+
? "no channel is enabled at all — enable at least one (desktop, telegram, email, ntfy, discord, slack, teams, sms) in the settings UI before calling notify again"
|
|
172
|
+
: `${enabledChannels.join(", ")} ${enabledChannels.length === 1 ? "is" : "are"} enabled but none of them reaches priority=${priority} while ${reach.join("/")}-class channels are reachable — check each channel's priority range and activity gating`;
|
|
173
|
+
return {
|
|
174
|
+
delivered,
|
|
175
|
+
attempted,
|
|
176
|
+
errors,
|
|
177
|
+
outcome: "failed",
|
|
178
|
+
summary: `NOT DELIVERED — no channel was eligible to try: ${configHint} — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
|
|
179
|
+
};
|
|
57
180
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
181
|
+
return {
|
|
182
|
+
delivered,
|
|
183
|
+
attempted,
|
|
184
|
+
errors,
|
|
185
|
+
outcome: "failed",
|
|
186
|
+
summary: `NOT DELIVERED — reached 0 of ${attempted.length} enabled channel${attempted.length === 1 ? "" : "s"}`
|
|
187
|
+
+ `; all ${errors.length} failed: ${errors.join("; ")}`
|
|
188
|
+
+ ` — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
|
|
189
|
+
};
|
|
65
190
|
}
|