@made-by-moonlight/athene-plugin-notifier-composio 0.9.1
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 +22 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1325 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +651 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
import { getNotificationDataV3, recordActivityEvent, } from "@made-by-moonlight/athene-core";
|
|
2
|
+
// Module-level guard so we only emit notifier.dep_missing once per process.
|
|
3
|
+
let depMissingEmitted = false;
|
|
4
|
+
/** Test-only: reset the once-per-process dep_missing guard. */
|
|
5
|
+
export function _resetDepMissingEmittedForTesting() {
|
|
6
|
+
depMissingEmitted = false;
|
|
7
|
+
}
|
|
8
|
+
export const manifest = {
|
|
9
|
+
name: "composio",
|
|
10
|
+
slot: "notifier",
|
|
11
|
+
description: "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
|
|
12
|
+
version: "0.1.0",
|
|
13
|
+
};
|
|
14
|
+
const PRIORITY_EMOJI = {
|
|
15
|
+
urgent: "\u{1F6A8}",
|
|
16
|
+
action: "\u{1F449}",
|
|
17
|
+
warning: "\u{26A0}\u{FE0F}",
|
|
18
|
+
info: "\u{2139}\u{FE0F}",
|
|
19
|
+
};
|
|
20
|
+
function getSubjectPRUrl(event) {
|
|
21
|
+
return getNotificationDataV3(event.data)?.subject.pr?.url;
|
|
22
|
+
}
|
|
23
|
+
function getCIStatus(event) {
|
|
24
|
+
return getNotificationDataV3(event.data)?.ci?.status;
|
|
25
|
+
}
|
|
26
|
+
function getFailedCheckNames(event) {
|
|
27
|
+
return getNotificationDataV3(event.data)?.ci?.failedChecks?.map((check) => check.name) ?? [];
|
|
28
|
+
}
|
|
29
|
+
const APP_TOOL_SLUG = {
|
|
30
|
+
slack: "SLACK_SEND_MESSAGE",
|
|
31
|
+
discord: "DISCORDBOT_CREATE_MESSAGE",
|
|
32
|
+
gmail: "GMAIL_SEND_EMAIL",
|
|
33
|
+
};
|
|
34
|
+
const DEFAULT_TOOL_VERSION = {
|
|
35
|
+
slack: "20260508_00",
|
|
36
|
+
discord: "20260429_01",
|
|
37
|
+
gmail: "20260506_01",
|
|
38
|
+
};
|
|
39
|
+
const VALID_APPS = new Set(["slack", "discord", "gmail"]);
|
|
40
|
+
const VALID_DISCORD_MODES = new Set(["webhook", "bot"]);
|
|
41
|
+
const DEFAULT_COMPOSIO_USER_ID = "aoagent";
|
|
42
|
+
const GMAIL_SUBJECT = "Athene Notification";
|
|
43
|
+
const GMAIL_POST_SUBJECT = "Athene Message";
|
|
44
|
+
const DISCORD_WEBHOOK_TOOL_SLUG = "DISCORDBOT_EXECUTE_WEBHOOK";
|
|
45
|
+
const DISCORD_EMBED_TITLE_MAX = 256;
|
|
46
|
+
const DISCORD_EMBED_DESCRIPTION_MAX = 4096;
|
|
47
|
+
const DISCORD_FIELD_NAME_MAX = 256;
|
|
48
|
+
const DISCORD_FIELD_VALUE_MAX = 1024;
|
|
49
|
+
const DISCORD_MAX_FIELDS = 25;
|
|
50
|
+
function isComposioToolsClient(value) {
|
|
51
|
+
return (value !== null &&
|
|
52
|
+
typeof value === "object" &&
|
|
53
|
+
"tools" in value &&
|
|
54
|
+
typeof value.tools?.execute === "function");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Lazy-load the bundled @composio/core SDK.
|
|
58
|
+
*
|
|
59
|
+
* Dynamic import keeps the plugin lightweight at module-load time and lets
|
|
60
|
+
* tests inject a mock client at the I/O boundary.
|
|
61
|
+
*/
|
|
62
|
+
async function loadComposioSDK(apiKey) {
|
|
63
|
+
try {
|
|
64
|
+
const mod = (await import("@composio/core"));
|
|
65
|
+
const ComposioClass = (mod.Composio ??
|
|
66
|
+
mod.default?.Composio ??
|
|
67
|
+
mod.default);
|
|
68
|
+
if (typeof ComposioClass !== "function") {
|
|
69
|
+
throw new Error("Could not find Composio class in @composio/core module");
|
|
70
|
+
}
|
|
71
|
+
const client = new ComposioClass({ apiKey });
|
|
72
|
+
if (!isComposioToolsClient(client)) {
|
|
73
|
+
throw new Error("Composio SDK client does not expose tools.execute()");
|
|
74
|
+
}
|
|
75
|
+
return client;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
const code = err instanceof Error ? err.code : undefined;
|
|
80
|
+
if (message.includes("Cannot find module") ||
|
|
81
|
+
message.includes("Cannot find package") ||
|
|
82
|
+
message.includes("MODULE_NOT_FOUND") ||
|
|
83
|
+
code === "ERR_MODULE_NOT_FOUND") {
|
|
84
|
+
// User-actionable. Emit once per process so RCA can answer
|
|
85
|
+
// "why is the composio notifier silent?" without spamming on every notify call.
|
|
86
|
+
if (!depMissingEmitted) {
|
|
87
|
+
depMissingEmitted = true;
|
|
88
|
+
recordActivityEvent({
|
|
89
|
+
source: "notifier",
|
|
90
|
+
kind: "notifier.dep_missing",
|
|
91
|
+
level: "error",
|
|
92
|
+
summary: "Composio SDK (@composio/core) is not installed",
|
|
93
|
+
data: {
|
|
94
|
+
plugin: "notifier-composio",
|
|
95
|
+
package: "@composio/core",
|
|
96
|
+
installHint: "pnpm add @composio/core",
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function stringConfig(config, key) {
|
|
106
|
+
const value = config?.[key];
|
|
107
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
108
|
+
}
|
|
109
|
+
function resolveEnvReference(value) {
|
|
110
|
+
if (!value)
|
|
111
|
+
return undefined;
|
|
112
|
+
const match = value.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/);
|
|
113
|
+
if (!match)
|
|
114
|
+
return value;
|
|
115
|
+
return process.env[match[1] ?? match[2] ?? ""];
|
|
116
|
+
}
|
|
117
|
+
function boolConfig(config, key) {
|
|
118
|
+
return config?.[key] === true;
|
|
119
|
+
}
|
|
120
|
+
function parseDiscordWebhookUrl(webhookUrl) {
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = new URL(webhookUrl);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error("[notifier-composio] Invalid Discord webhookUrl.");
|
|
127
|
+
}
|
|
128
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
129
|
+
const webhookIndex = segments.findIndex((segment) => segment === "webhooks");
|
|
130
|
+
const webhookId = webhookIndex >= 0 ? segments[webhookIndex + 1] : undefined;
|
|
131
|
+
const webhookToken = webhookIndex >= 0 ? segments[webhookIndex + 2] : undefined;
|
|
132
|
+
if (!webhookId || !webhookToken) {
|
|
133
|
+
throw new Error("[notifier-composio] Invalid Discord webhookUrl. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
webhookId: decodeURIComponent(webhookId),
|
|
137
|
+
webhookToken: decodeURIComponent(webhookToken),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function resolveDiscordMode(config, defaultApp, webhookUrl) {
|
|
141
|
+
if (defaultApp !== "discord")
|
|
142
|
+
return undefined;
|
|
143
|
+
const mode = stringConfig(config, "mode");
|
|
144
|
+
if (mode) {
|
|
145
|
+
if (!VALID_DISCORD_MODES.has(mode)) {
|
|
146
|
+
throw new Error(`[notifier-composio] Invalid Discord mode: "${mode}". Must be one of: webhook, bot`);
|
|
147
|
+
}
|
|
148
|
+
return mode;
|
|
149
|
+
}
|
|
150
|
+
return webhookUrl ? "webhook" : "bot";
|
|
151
|
+
}
|
|
152
|
+
function formatNotifyText(event) {
|
|
153
|
+
const emoji = PRIORITY_EMOJI[event.priority];
|
|
154
|
+
const parts = [`${emoji} *${event.type}* — ${event.sessionId}`, event.message];
|
|
155
|
+
const prUrl = getSubjectPRUrl(event);
|
|
156
|
+
if (prUrl) {
|
|
157
|
+
parts.push(`PR: ${prUrl}`);
|
|
158
|
+
}
|
|
159
|
+
const ciStatus = getCIStatus(event);
|
|
160
|
+
if (ciStatus) {
|
|
161
|
+
const failedChecks = getFailedCheckNames(event);
|
|
162
|
+
const failedCheckText = failedChecks.length > 0 ? ` (failed: ${failedChecks.join(", ")})` : "";
|
|
163
|
+
parts.push(`CI: ${ciStatus}${failedCheckText}`);
|
|
164
|
+
}
|
|
165
|
+
return parts.join("\n");
|
|
166
|
+
}
|
|
167
|
+
function formatActionsText(event, actions) {
|
|
168
|
+
const base = formatNotifyText(event);
|
|
169
|
+
const actionLines = actions.map((a) => {
|
|
170
|
+
if (a.url)
|
|
171
|
+
return `- ${a.label}: ${a.url}`;
|
|
172
|
+
return `- ${a.label}`;
|
|
173
|
+
});
|
|
174
|
+
return `${base}\n\nActions:\n${actionLines.join("\n")}`;
|
|
175
|
+
}
|
|
176
|
+
const DISCORD_SUCCESS_TONE = {
|
|
177
|
+
emoji: "\u{2705}",
|
|
178
|
+
label: "Complete",
|
|
179
|
+
color: 0x57f287,
|
|
180
|
+
};
|
|
181
|
+
const DISCORD_PRIORITY_TONE = {
|
|
182
|
+
urgent: {
|
|
183
|
+
emoji: "\u{1F6A8}",
|
|
184
|
+
label: "Urgent",
|
|
185
|
+
color: 0xed4245,
|
|
186
|
+
},
|
|
187
|
+
action: {
|
|
188
|
+
emoji: "\u{1F449}",
|
|
189
|
+
label: "Action required",
|
|
190
|
+
color: 0x5865f2,
|
|
191
|
+
},
|
|
192
|
+
warning: {
|
|
193
|
+
emoji: "\u{26A0}\u{FE0F}",
|
|
194
|
+
label: "Warning",
|
|
195
|
+
color: 0xfee75c,
|
|
196
|
+
},
|
|
197
|
+
info: {
|
|
198
|
+
emoji: "\u{2139}\u{FE0F}",
|
|
199
|
+
label: "Information",
|
|
200
|
+
color: 0x3498db,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
function titleCaseStatus(value) {
|
|
204
|
+
return value
|
|
205
|
+
.split(/[_\s.-]+/)
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
208
|
+
.join(" ");
|
|
209
|
+
}
|
|
210
|
+
function priorityLabel(priority) {
|
|
211
|
+
switch (priority) {
|
|
212
|
+
case "urgent":
|
|
213
|
+
return "Urgent";
|
|
214
|
+
case "action":
|
|
215
|
+
return "Action required";
|
|
216
|
+
case "warning":
|
|
217
|
+
return "Warning";
|
|
218
|
+
case "info":
|
|
219
|
+
return "Information";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function truncate(value, maxLength = 90) {
|
|
223
|
+
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
|
224
|
+
}
|
|
225
|
+
function truncateUnicode(value, maxLength) {
|
|
226
|
+
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
|
|
227
|
+
}
|
|
228
|
+
function discordToneForEvent(event) {
|
|
229
|
+
if (event.type === "merge.ready")
|
|
230
|
+
return { ...DISCORD_SUCCESS_TONE, label: "Ready to merge" };
|
|
231
|
+
if (event.type === "summary.all_complete") {
|
|
232
|
+
return { ...DISCORD_SUCCESS_TONE, label: "All complete" };
|
|
233
|
+
}
|
|
234
|
+
if (event.type === "ci.failing" || event.type === "session.stuck") {
|
|
235
|
+
return DISCORD_PRIORITY_TONE.urgent;
|
|
236
|
+
}
|
|
237
|
+
if (event.type === "review.changes_requested")
|
|
238
|
+
return DISCORD_PRIORITY_TONE.warning;
|
|
239
|
+
return DISCORD_PRIORITY_TONE[event.priority] ?? DISCORD_PRIORITY_TONE.info;
|
|
240
|
+
}
|
|
241
|
+
function formatDiscordTitle(event, data) {
|
|
242
|
+
const pr = data?.subject.pr;
|
|
243
|
+
switch (event.type) {
|
|
244
|
+
case "ci.failing":
|
|
245
|
+
return pr ? `CI failing on PR #${pr.number}` : "CI failing";
|
|
246
|
+
case "merge.ready":
|
|
247
|
+
return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge";
|
|
248
|
+
case "review.changes_requested":
|
|
249
|
+
return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested";
|
|
250
|
+
case "session.needs_input":
|
|
251
|
+
return "Agent needs input";
|
|
252
|
+
case "session.stuck":
|
|
253
|
+
return "Agent may be stuck";
|
|
254
|
+
case "session.killed":
|
|
255
|
+
case "session.exited":
|
|
256
|
+
return "Agent exited";
|
|
257
|
+
case "pr.closed":
|
|
258
|
+
return pr ? `PR #${pr.number} closed` : "Pull request closed";
|
|
259
|
+
case "summary.all_complete":
|
|
260
|
+
return "All sessions complete";
|
|
261
|
+
default:
|
|
262
|
+
return titleCaseStatus(event.type);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function formatDiscordValue(value) {
|
|
266
|
+
if (value === undefined || value === null || value === "")
|
|
267
|
+
return "Not available";
|
|
268
|
+
return truncateUnicode(String(value), DISCORD_FIELD_VALUE_MAX);
|
|
269
|
+
}
|
|
270
|
+
function appendDiscordField(fields, name, value, inline = true) {
|
|
271
|
+
if (value === undefined || value === null || value === "")
|
|
272
|
+
return;
|
|
273
|
+
if (fields.length >= DISCORD_MAX_FIELDS)
|
|
274
|
+
return;
|
|
275
|
+
fields.push({
|
|
276
|
+
name: truncateUnicode(name, DISCORD_FIELD_NAME_MAX),
|
|
277
|
+
value: formatDiscordValue(value),
|
|
278
|
+
inline,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function formatDiscordMarkdownLink(label, url) {
|
|
282
|
+
const safeLabel = label.replaceAll("[", "").replaceAll("]", "").replace(/[()]/g, "");
|
|
283
|
+
const safeUrl = url.replace(/\)/g, "%29");
|
|
284
|
+
return `[${safeLabel}](${safeUrl})`;
|
|
285
|
+
}
|
|
286
|
+
function formatDiscordBranch(data) {
|
|
287
|
+
const pr = data?.subject.pr;
|
|
288
|
+
if (pr?.branch && pr.baseBranch)
|
|
289
|
+
return `${pr.branch} -> ${pr.baseBranch}`;
|
|
290
|
+
return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch;
|
|
291
|
+
}
|
|
292
|
+
function formatDiscordCheck(check) {
|
|
293
|
+
const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status;
|
|
294
|
+
const label = `${check.name}: ${status}`;
|
|
295
|
+
return check.url ? formatDiscordMarkdownLink(label, check.url) : label;
|
|
296
|
+
}
|
|
297
|
+
function isAbsoluteHttpUrl(value) {
|
|
298
|
+
if (!value)
|
|
299
|
+
return false;
|
|
300
|
+
try {
|
|
301
|
+
const parsed = new URL(value);
|
|
302
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function formatDiscordCiStatus(data) {
|
|
309
|
+
if (!data.ci?.status)
|
|
310
|
+
return "";
|
|
311
|
+
const ciEmoji = data.ci.status === "passing" ? "\u{2705}" : "\u{274C}";
|
|
312
|
+
const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? [];
|
|
313
|
+
const failedCheckText = failedChecks.length > 0 ? `\nFailed: ${failedChecks.join(", ")}` : "";
|
|
314
|
+
return `${ciEmoji} ${titleCaseStatus(data.ci.status)}${failedCheckText}`;
|
|
315
|
+
}
|
|
316
|
+
function appendDiscordDataFields(fields, data) {
|
|
317
|
+
if (!data)
|
|
318
|
+
return;
|
|
319
|
+
const pr = data.subject.pr;
|
|
320
|
+
const issue = data.subject.issue;
|
|
321
|
+
const branch = formatDiscordBranch(data);
|
|
322
|
+
appendDiscordField(fields, "Pull Request", pr
|
|
323
|
+
? `${formatDiscordMarkdownLink(`#${pr.number}`, pr.url)}${pr.title ? ` - ${pr.title}` : ""}`
|
|
324
|
+
: undefined, false);
|
|
325
|
+
appendDiscordField(fields, "Branch", branch);
|
|
326
|
+
appendDiscordField(fields, "Issue", issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined);
|
|
327
|
+
appendDiscordField(fields, "CI", data.ci?.status ? formatDiscordCiStatus(data) : undefined);
|
|
328
|
+
appendDiscordField(fields, "Review", data.review?.decision ? titleCaseStatus(data.review.decision) : undefined);
|
|
329
|
+
appendDiscordField(fields, "Review Threads", data.review?.unresolvedThreads);
|
|
330
|
+
appendDiscordField(fields, "Merge", typeof data.merge?.ready === "boolean" ? (data.merge.ready ? "Ready" : "Not ready") : undefined);
|
|
331
|
+
appendDiscordField(fields, "Conflicts", typeof data.merge?.conflicts === "boolean"
|
|
332
|
+
? data.merge.conflicts
|
|
333
|
+
? "Found"
|
|
334
|
+
: "None"
|
|
335
|
+
: undefined);
|
|
336
|
+
appendDiscordField(fields, "Sync", typeof data.merge?.isBehind === "boolean"
|
|
337
|
+
? data.merge.isBehind
|
|
338
|
+
? "Behind base"
|
|
339
|
+
: "Up to date"
|
|
340
|
+
: undefined);
|
|
341
|
+
appendDiscordField(fields, "Transition", data.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined);
|
|
342
|
+
appendDiscordField(fields, "Reaction", data.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined);
|
|
343
|
+
appendDiscordField(fields, "Escalation", data.escalation ? `${data.escalation.attempts} attempts (${data.escalation.cause})` : undefined);
|
|
344
|
+
const checks = (data.ci?.failedChecks ?? []).slice(0, 8).map(formatDiscordCheck);
|
|
345
|
+
appendDiscordField(fields, "Checks", checks.length > 0 ? checks.join("\n") : undefined, false);
|
|
346
|
+
appendDiscordField(fields, "Blockers", data.merge?.blockers?.length ? data.merge.blockers.slice(0, 8).join("\n") : undefined, false);
|
|
347
|
+
const links = [
|
|
348
|
+
...(pr?.url ? [formatDiscordMarkdownLink("Pull request", pr.url)] : []),
|
|
349
|
+
...(data.review?.url ? [formatDiscordMarkdownLink("Review", data.review.url)] : []),
|
|
350
|
+
];
|
|
351
|
+
appendDiscordField(fields, "Links", links.length > 0 ? links.join(" | ") : undefined, false);
|
|
352
|
+
}
|
|
353
|
+
function appendDiscordActionField(fields, data, actions) {
|
|
354
|
+
const seen = new Set();
|
|
355
|
+
const links = [];
|
|
356
|
+
const prUrl = data?.subject.pr?.url;
|
|
357
|
+
if (prUrl) {
|
|
358
|
+
links.push(formatDiscordMarkdownLink("View PR", prUrl));
|
|
359
|
+
seen.add(prUrl);
|
|
360
|
+
}
|
|
361
|
+
const reviewUrl = data?.review?.url;
|
|
362
|
+
if (reviewUrl && !seen.has(reviewUrl)) {
|
|
363
|
+
links.push(formatDiscordMarkdownLink("View Review", reviewUrl));
|
|
364
|
+
seen.add(reviewUrl);
|
|
365
|
+
}
|
|
366
|
+
for (const action of actions ?? []) {
|
|
367
|
+
if (action.url) {
|
|
368
|
+
if (seen.has(action.url))
|
|
369
|
+
continue;
|
|
370
|
+
links.push(formatDiscordMarkdownLink(action.label, action.url));
|
|
371
|
+
seen.add(action.url);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (isAbsoluteHttpUrl(action.callbackEndpoint)) {
|
|
375
|
+
links.push(formatDiscordMarkdownLink(action.label, action.callbackEndpoint));
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
links.push(`\`${action.label}\``);
|
|
379
|
+
}
|
|
380
|
+
appendDiscordField(fields, "Actions", links.slice(0, 8).join(" | "), false);
|
|
381
|
+
}
|
|
382
|
+
function buildDiscordComponents(data, actions) {
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
const buttons = [];
|
|
385
|
+
const addButton = (label, url) => {
|
|
386
|
+
if (seen.has(url) || buttons.length >= 5)
|
|
387
|
+
return;
|
|
388
|
+
buttons.push({ type: 2, style: 5, label: truncateUnicode(label, 80), url });
|
|
389
|
+
seen.add(url);
|
|
390
|
+
};
|
|
391
|
+
const prUrl = data?.subject.pr?.url;
|
|
392
|
+
if (prUrl)
|
|
393
|
+
addButton("View PR", prUrl);
|
|
394
|
+
const reviewUrl = data?.review?.url;
|
|
395
|
+
if (reviewUrl)
|
|
396
|
+
addButton("View Review", reviewUrl);
|
|
397
|
+
for (const action of actions ?? []) {
|
|
398
|
+
if (action.url)
|
|
399
|
+
addButton(action.label, action.url);
|
|
400
|
+
else if (isAbsoluteHttpUrl(action.callbackEndpoint))
|
|
401
|
+
addButton(action.label, action.callbackEndpoint);
|
|
402
|
+
}
|
|
403
|
+
return buttons.length > 0 ? [{ type: 1, components: buttons }] : [];
|
|
404
|
+
}
|
|
405
|
+
function formatDiscordDescription(event, data) {
|
|
406
|
+
const subtitle = data?.subject.pr?.title ?? data?.subject.summary;
|
|
407
|
+
const description = subtitle ? `**${subtitle}**\n${event.message}` : event.message;
|
|
408
|
+
return truncateUnicode(description, DISCORD_EMBED_DESCRIPTION_MAX);
|
|
409
|
+
}
|
|
410
|
+
function formatDiscordFallback(event, data) {
|
|
411
|
+
const tone = discordToneForEvent(event);
|
|
412
|
+
return truncateUnicode(`${tone.label}: ${formatDiscordTitle(event, data)} — ${event.message}`, 2000);
|
|
413
|
+
}
|
|
414
|
+
function formatDiscordMessagePayload(event, actions) {
|
|
415
|
+
const data = getNotificationDataV3(event.data);
|
|
416
|
+
const tone = discordToneForEvent(event);
|
|
417
|
+
const fields = [];
|
|
418
|
+
appendDiscordField(fields, "Project", event.projectId);
|
|
419
|
+
appendDiscordField(fields, "Session", event.sessionId);
|
|
420
|
+
appendDiscordField(fields, "Priority", tone.label);
|
|
421
|
+
appendDiscordDataFields(fields, data);
|
|
422
|
+
appendDiscordActionField(fields, data, actions);
|
|
423
|
+
const components = buildDiscordComponents(data, actions);
|
|
424
|
+
return {
|
|
425
|
+
content: formatDiscordFallback(event, data),
|
|
426
|
+
embeds: [
|
|
427
|
+
{
|
|
428
|
+
title: truncateUnicode(`${tone.emoji} ${formatDiscordTitle(event, data)}`, DISCORD_EMBED_TITLE_MAX),
|
|
429
|
+
description: formatDiscordDescription(event, data),
|
|
430
|
+
color: tone.color,
|
|
431
|
+
...(data?.subject.pr?.url ? { url: data.subject.pr.url } : {}),
|
|
432
|
+
fields,
|
|
433
|
+
timestamp: event.timestamp.toISOString(),
|
|
434
|
+
footer: { text: "Athene" },
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
...(components.length > 0 ? { components } : {}),
|
|
438
|
+
allowed_mentions: { parse: [] },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const SLACK_SUCCESS_TONE = {
|
|
442
|
+
emoji: ":white_check_mark:",
|
|
443
|
+
label: "Complete",
|
|
444
|
+
color: "#2EB67D",
|
|
445
|
+
};
|
|
446
|
+
const SLACK_PRIORITY_TONE = {
|
|
447
|
+
urgent: {
|
|
448
|
+
emoji: ":rotating_light:",
|
|
449
|
+
label: "Urgent",
|
|
450
|
+
color: "#E01E5A",
|
|
451
|
+
},
|
|
452
|
+
action: {
|
|
453
|
+
emoji: ":point_right:",
|
|
454
|
+
label: "Action required",
|
|
455
|
+
color: "#6157D8",
|
|
456
|
+
},
|
|
457
|
+
warning: {
|
|
458
|
+
emoji: ":warning:",
|
|
459
|
+
label: "Warning",
|
|
460
|
+
color: "#ECB22E",
|
|
461
|
+
},
|
|
462
|
+
info: {
|
|
463
|
+
emoji: ":information_source:",
|
|
464
|
+
label: "Information",
|
|
465
|
+
color: "#36C5F0",
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
function escapeSlackText(value) {
|
|
469
|
+
return value
|
|
470
|
+
.replace(/&/g, "&")
|
|
471
|
+
.replace(/</g, "<")
|
|
472
|
+
.replace(/>/g, ">")
|
|
473
|
+
.replace(/\*/g, "*")
|
|
474
|
+
.replace(/_/g, "_")
|
|
475
|
+
.replace(/~/g, "~")
|
|
476
|
+
.replace(/`/g, "`");
|
|
477
|
+
}
|
|
478
|
+
function formatSlackDate(date) {
|
|
479
|
+
const timestamp = Math.floor(date.getTime() / 1000);
|
|
480
|
+
return `<!date^${timestamp}^{date_short_pretty} {time}|${date.toISOString()}>`;
|
|
481
|
+
}
|
|
482
|
+
function slackToneForEvent(event) {
|
|
483
|
+
if (event.type === "merge.ready")
|
|
484
|
+
return { ...SLACK_SUCCESS_TONE, label: "Ready to merge" };
|
|
485
|
+
if (event.type === "summary.all_complete")
|
|
486
|
+
return { ...SLACK_SUCCESS_TONE, label: "All complete" };
|
|
487
|
+
if (event.type === "ci.failing" || event.type === "session.stuck")
|
|
488
|
+
return SLACK_PRIORITY_TONE.urgent;
|
|
489
|
+
if (event.type === "review.changes_requested")
|
|
490
|
+
return SLACK_PRIORITY_TONE.warning;
|
|
491
|
+
return SLACK_PRIORITY_TONE[event.priority] ?? SLACK_PRIORITY_TONE.info;
|
|
492
|
+
}
|
|
493
|
+
function formatSlackTitle(event, data) {
|
|
494
|
+
return formatDiscordTitle(event, data);
|
|
495
|
+
}
|
|
496
|
+
function formatSlackField(label, value) {
|
|
497
|
+
return {
|
|
498
|
+
type: "mrkdwn",
|
|
499
|
+
text: `*${escapeSlackText(label)}*\n${escapeSlackText(value === undefined || value === null || value === "" ? "Not available" : String(value))}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function buildSlackFieldBlocks(event, data) {
|
|
503
|
+
const pr = data?.subject.pr;
|
|
504
|
+
const issue = data?.subject.issue;
|
|
505
|
+
const branch = formatDiscordBranch(data);
|
|
506
|
+
const fields = [
|
|
507
|
+
formatSlackField("Project", event.projectId),
|
|
508
|
+
formatSlackField("Session", event.sessionId),
|
|
509
|
+
formatSlackField("Priority", slackToneForEvent(event).label),
|
|
510
|
+
...(pr
|
|
511
|
+
? [formatSlackField("Pull Request", `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}`)]
|
|
512
|
+
: []),
|
|
513
|
+
...(branch ? [formatSlackField("Branch", branch)] : []),
|
|
514
|
+
...(issue
|
|
515
|
+
? [formatSlackField("Issue", `${issue.id}${issue.title ? ` - ${issue.title}` : ""}`)]
|
|
516
|
+
: []),
|
|
517
|
+
...(data?.ci?.status ? [formatSlackField("CI", titleCaseStatus(data.ci.status))] : []),
|
|
518
|
+
...(data?.review?.decision
|
|
519
|
+
? [formatSlackField("Review", titleCaseStatus(data.review.decision))]
|
|
520
|
+
: []),
|
|
521
|
+
...(typeof data?.merge?.ready === "boolean"
|
|
522
|
+
? [formatSlackField("Merge", data.merge.ready ? "Ready" : "Not ready")]
|
|
523
|
+
: []),
|
|
524
|
+
...(typeof data?.merge?.isBehind === "boolean"
|
|
525
|
+
? [formatSlackField("Sync", data.merge.isBehind ? "Behind base" : "Up to date")]
|
|
526
|
+
: []),
|
|
527
|
+
].slice(0, 10);
|
|
528
|
+
return fields.length > 0 ? [{ type: "section", fields }] : [];
|
|
529
|
+
}
|
|
530
|
+
function buildSlackStatusContext(data) {
|
|
531
|
+
if (!data)
|
|
532
|
+
return [];
|
|
533
|
+
const context = [];
|
|
534
|
+
if (data.ci?.status) {
|
|
535
|
+
const ciEmoji = data.ci.status === "passing" ? ":white_check_mark:" : ":x:";
|
|
536
|
+
const failedChecks = data.ci.failedChecks?.map((check) => escapeSlackText(check.name)) ?? [];
|
|
537
|
+
const failedText = failedChecks.length > 0 ? ` | Failed: ${failedChecks.join(", ")}` : "";
|
|
538
|
+
context.push(`${ciEmoji} CI: ${escapeSlackText(data.ci.status)}${failedText}`);
|
|
539
|
+
}
|
|
540
|
+
if (typeof data.merge?.conflicts === "boolean") {
|
|
541
|
+
context.push(data.merge.conflicts
|
|
542
|
+
? ":x: Merge conflicts detected"
|
|
543
|
+
: ":white_check_mark: No merge conflicts");
|
|
544
|
+
}
|
|
545
|
+
if (typeof data.review?.unresolvedThreads === "number") {
|
|
546
|
+
context.push(`:speech_balloon: Review threads: ${data.review.unresolvedThreads}`);
|
|
547
|
+
}
|
|
548
|
+
if (data.merge?.blockers?.length) {
|
|
549
|
+
context.push(`:no_entry: Blockers: ${data.merge.blockers.slice(0, 5).map(escapeSlackText).join(", ")}`);
|
|
550
|
+
}
|
|
551
|
+
if (context.length === 0)
|
|
552
|
+
return [];
|
|
553
|
+
return [
|
|
554
|
+
{
|
|
555
|
+
type: "context",
|
|
556
|
+
elements: [{ type: "mrkdwn", text: context.join(" • ") }],
|
|
557
|
+
},
|
|
558
|
+
];
|
|
559
|
+
}
|
|
560
|
+
function sanitizeSlackActionId(label, index) {
|
|
561
|
+
const sanitized = label
|
|
562
|
+
.toLowerCase()
|
|
563
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
564
|
+
.replace(/^_|_$/g, "");
|
|
565
|
+
return `ao_${sanitized ? `${sanitized}_${index}` : `action_${index}`}`;
|
|
566
|
+
}
|
|
567
|
+
function buildSlackButton(label, url, style) {
|
|
568
|
+
return {
|
|
569
|
+
type: "button",
|
|
570
|
+
text: { type: "plain_text", text: truncateUnicode(label, 75), emoji: true },
|
|
571
|
+
url,
|
|
572
|
+
...(style ? { style } : {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function buildSlackActionElements(data, actions) {
|
|
576
|
+
const elements = [];
|
|
577
|
+
const seenUrls = new Set();
|
|
578
|
+
const prUrl = data?.subject.pr?.url;
|
|
579
|
+
const reviewUrl = data?.review?.url;
|
|
580
|
+
if (prUrl) {
|
|
581
|
+
elements.push(buildSlackButton("View PR", prUrl, "primary"));
|
|
582
|
+
seenUrls.add(prUrl);
|
|
583
|
+
}
|
|
584
|
+
if (reviewUrl && !seenUrls.has(reviewUrl)) {
|
|
585
|
+
elements.push(buildSlackButton("View Review", reviewUrl));
|
|
586
|
+
seenUrls.add(reviewUrl);
|
|
587
|
+
}
|
|
588
|
+
for (const [index, action] of (actions ?? []).entries()) {
|
|
589
|
+
if (action.url) {
|
|
590
|
+
if (seenUrls.has(action.url))
|
|
591
|
+
continue;
|
|
592
|
+
elements.push(buildSlackButton(action.label, action.url, elements.length === 0 ? "primary" : undefined));
|
|
593
|
+
seenUrls.add(action.url);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (!action.callbackEndpoint)
|
|
597
|
+
continue;
|
|
598
|
+
const label = truncateUnicode(action.label, 75);
|
|
599
|
+
const lower = label.toLowerCase();
|
|
600
|
+
elements.push({
|
|
601
|
+
type: "button",
|
|
602
|
+
text: { type: "plain_text", text: label, emoji: true },
|
|
603
|
+
action_id: sanitizeSlackActionId(label, index),
|
|
604
|
+
value: action.callbackEndpoint,
|
|
605
|
+
...(lower.includes("kill") || lower.includes("cancel") ? { style: "danger" } : {}),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return elements.slice(0, 5);
|
|
609
|
+
}
|
|
610
|
+
function buildSlackAttachment(event, actions) {
|
|
611
|
+
const data = getNotificationDataV3(event.data);
|
|
612
|
+
const tone = slackToneForEvent(event);
|
|
613
|
+
const title = formatSlackTitle(event, data);
|
|
614
|
+
const subtitle = data?.subject.pr?.title ?? data?.subject.summary;
|
|
615
|
+
const blocks = [
|
|
616
|
+
{
|
|
617
|
+
type: "header",
|
|
618
|
+
text: {
|
|
619
|
+
type: "plain_text",
|
|
620
|
+
text: truncateUnicode(`${tone.emoji} ${title}`, 150),
|
|
621
|
+
emoji: true,
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
type: "section",
|
|
626
|
+
text: {
|
|
627
|
+
type: "mrkdwn",
|
|
628
|
+
text: `${subtitle ? `*${escapeSlackText(subtitle)}*\n` : ""}${escapeSlackText(event.message)}`,
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
...buildSlackFieldBlocks(event, data),
|
|
632
|
+
...buildSlackStatusContext(data),
|
|
633
|
+
{
|
|
634
|
+
type: "context",
|
|
635
|
+
elements: [
|
|
636
|
+
{
|
|
637
|
+
type: "mrkdwn",
|
|
638
|
+
text: `Sent by Athene • ${formatSlackDate(event.timestamp)}`,
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
];
|
|
643
|
+
const actionElements = buildSlackActionElements(data, actions);
|
|
644
|
+
if (actionElements.length > 0) {
|
|
645
|
+
blocks.push({
|
|
646
|
+
type: "actions",
|
|
647
|
+
elements: actionElements,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
blocks.push({ type: "divider" });
|
|
651
|
+
return {
|
|
652
|
+
color: tone.color,
|
|
653
|
+
fallback: `${tone.label}: ${title} — ${event.message}`,
|
|
654
|
+
blocks,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function formatSlackMessagePayload(event, actions) {
|
|
658
|
+
const attachment = buildSlackAttachment(event, actions);
|
|
659
|
+
return {
|
|
660
|
+
markdown_text: attachment.fallback,
|
|
661
|
+
text: attachment.fallback,
|
|
662
|
+
attachments: JSON.stringify([attachment]),
|
|
663
|
+
unfurl_links: false,
|
|
664
|
+
unfurl_media: false,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function formatEmailSubject(event) {
|
|
668
|
+
const data = getNotificationDataV3(event.data);
|
|
669
|
+
const pr = data?.subject.pr;
|
|
670
|
+
switch (event.type) {
|
|
671
|
+
case "ci.failing":
|
|
672
|
+
return pr ? `[AO] CI failing on PR #${pr.number}` : "[AO] CI failing";
|
|
673
|
+
case "merge.ready":
|
|
674
|
+
return pr ? `[AO] PR #${pr.number} ready to merge` : "[AO] Merge ready";
|
|
675
|
+
case "review.changes_requested":
|
|
676
|
+
return pr ? `[AO] Changes requested on PR #${pr.number}` : "[AO] Review changes requested";
|
|
677
|
+
case "session.needs_input":
|
|
678
|
+
return `[AO] Agent needs input: ${event.sessionId}`;
|
|
679
|
+
case "session.stuck":
|
|
680
|
+
return `[AO] Agent stuck: ${event.sessionId}`;
|
|
681
|
+
case "session.killed":
|
|
682
|
+
case "session.exited":
|
|
683
|
+
return `[AO] Agent exited: ${event.sessionId}`;
|
|
684
|
+
case "pr.closed":
|
|
685
|
+
return pr ? `[AO] PR #${pr.number} closed` : "[AO] PR closed";
|
|
686
|
+
case "summary.all_complete":
|
|
687
|
+
return "[AO] All sessions complete";
|
|
688
|
+
default:
|
|
689
|
+
return `[AO] ${titleCaseStatus(event.type)}: ${event.sessionId}`;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const HTML_ESCAPE = {
|
|
693
|
+
"&": "&",
|
|
694
|
+
"<": "<",
|
|
695
|
+
">": ">",
|
|
696
|
+
'"': """,
|
|
697
|
+
"'": "'",
|
|
698
|
+
};
|
|
699
|
+
function escapeHtml(value) {
|
|
700
|
+
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE[char] ?? char);
|
|
701
|
+
}
|
|
702
|
+
function formatHtmlValue(value) {
|
|
703
|
+
if (value === undefined || value === null || value === "")
|
|
704
|
+
return "Not available";
|
|
705
|
+
return escapeHtml(String(value));
|
|
706
|
+
}
|
|
707
|
+
const EMAIL_TONES = {
|
|
708
|
+
info: {
|
|
709
|
+
label: "Information",
|
|
710
|
+
headerBackground: "#0f172a",
|
|
711
|
+
accent: "#2563eb",
|
|
712
|
+
badgeBackground: "#dbeafe",
|
|
713
|
+
badgeText: "#1e40af",
|
|
714
|
+
},
|
|
715
|
+
action: {
|
|
716
|
+
label: "Action required",
|
|
717
|
+
headerBackground: "#1e3a8a",
|
|
718
|
+
accent: "#4f46e5",
|
|
719
|
+
badgeBackground: "#e0e7ff",
|
|
720
|
+
badgeText: "#3730a3",
|
|
721
|
+
},
|
|
722
|
+
warning: {
|
|
723
|
+
label: "Warning",
|
|
724
|
+
headerBackground: "#78350f",
|
|
725
|
+
accent: "#d97706",
|
|
726
|
+
badgeBackground: "#fef3c7",
|
|
727
|
+
badgeText: "#92400e",
|
|
728
|
+
},
|
|
729
|
+
urgent: {
|
|
730
|
+
label: "Urgent",
|
|
731
|
+
headerBackground: "#7f1d1d",
|
|
732
|
+
accent: "#dc2626",
|
|
733
|
+
badgeBackground: "#fee2e2",
|
|
734
|
+
badgeText: "#991b1b",
|
|
735
|
+
},
|
|
736
|
+
success: {
|
|
737
|
+
label: "Complete",
|
|
738
|
+
headerBackground: "#064e3b",
|
|
739
|
+
accent: "#16a34a",
|
|
740
|
+
badgeBackground: "#dcfce7",
|
|
741
|
+
badgeText: "#166534",
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
function emailToneForEvent(event) {
|
|
745
|
+
if (event.type === "merge.ready" || event.type === "summary.all_complete") {
|
|
746
|
+
return {
|
|
747
|
+
...EMAIL_TONES.success,
|
|
748
|
+
label: event.type === "merge.ready" ? "Ready to merge" : "All complete",
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (event.type === "ci.failing" || event.type === "session.stuck")
|
|
752
|
+
return EMAIL_TONES.urgent;
|
|
753
|
+
if (event.type === "review.changes_requested" || event.priority === "warning") {
|
|
754
|
+
return EMAIL_TONES.warning;
|
|
755
|
+
}
|
|
756
|
+
if (event.priority === "action")
|
|
757
|
+
return EMAIL_TONES.action;
|
|
758
|
+
if (event.priority === "urgent")
|
|
759
|
+
return EMAIL_TONES.urgent;
|
|
760
|
+
return EMAIL_TONES.info;
|
|
761
|
+
}
|
|
762
|
+
function formatEmailTitle(event, data) {
|
|
763
|
+
const pr = data?.subject.pr;
|
|
764
|
+
switch (event.type) {
|
|
765
|
+
case "ci.failing":
|
|
766
|
+
return pr ? `CI is failing on PR #${pr.number}` : "CI is failing";
|
|
767
|
+
case "merge.ready":
|
|
768
|
+
return pr ? `PR #${pr.number} is ready to merge` : "Pull request is ready to merge";
|
|
769
|
+
case "review.changes_requested":
|
|
770
|
+
return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested";
|
|
771
|
+
case "session.needs_input":
|
|
772
|
+
return "Agent needs input";
|
|
773
|
+
case "session.stuck":
|
|
774
|
+
return "Agent may be stuck";
|
|
775
|
+
case "session.killed":
|
|
776
|
+
case "session.exited":
|
|
777
|
+
return "Agent exited";
|
|
778
|
+
case "pr.closed":
|
|
779
|
+
return pr ? `PR #${pr.number} closed` : "Pull request closed";
|
|
780
|
+
case "summary.all_complete":
|
|
781
|
+
return "All sessions complete";
|
|
782
|
+
default:
|
|
783
|
+
return titleCaseStatus(event.type);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function formatEmailSubtitle(event, data) {
|
|
787
|
+
return data?.subject.pr?.title ?? data?.subject.summary ?? event.message;
|
|
788
|
+
}
|
|
789
|
+
function formatHtmlStatusCard(card, fallback) {
|
|
790
|
+
return `<td style="padding:6px;width:50%;vertical-align:top;">
|
|
791
|
+
<div style="border:1px solid #e5e7eb;border-radius:10px;background:#ffffff;padding:14px 16px;">
|
|
792
|
+
<div style="font-size:12px;line-height:16px;color:#6b7280;font-weight:700;text-transform:uppercase;letter-spacing:.04em;">${escapeHtml(card.label)}</div>
|
|
793
|
+
<div style="margin-top:8px;display:inline-block;border-radius:999px;background:${card.background ?? fallback.badgeBackground};color:${card.color ?? fallback.badgeText};font-size:13px;line-height:18px;font-weight:800;padding:5px 10px;">${escapeHtml(card.value)}</div>
|
|
794
|
+
</div>
|
|
795
|
+
</td>`;
|
|
796
|
+
}
|
|
797
|
+
function formatHtmlStatusCards(cards, tone) {
|
|
798
|
+
if (cards.length === 0)
|
|
799
|
+
return "";
|
|
800
|
+
const rows = [];
|
|
801
|
+
for (let index = 0; index < cards.length; index += 2) {
|
|
802
|
+
const first = cards[index];
|
|
803
|
+
const second = cards[index + 1];
|
|
804
|
+
rows.push(`<tr>
|
|
805
|
+
${formatHtmlStatusCard(first, tone)}
|
|
806
|
+
${second ? formatHtmlStatusCard(second, tone) : '<td style="padding:6px;width:50%;"></td>'}
|
|
807
|
+
</tr>`);
|
|
808
|
+
}
|
|
809
|
+
return `<tr>
|
|
810
|
+
<td style="padding:6px 22px 4px;">
|
|
811
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
|
812
|
+
${rows.join("")}
|
|
813
|
+
</table>
|
|
814
|
+
</td>
|
|
815
|
+
</tr>`;
|
|
816
|
+
}
|
|
817
|
+
function formatHtmlDetailRow(label, value) {
|
|
818
|
+
return `<tr>
|
|
819
|
+
<td style="padding:9px 0;color:#6b7280;font-size:13px;line-height:18px;width:150px;vertical-align:top;">${escapeHtml(label)}</td>
|
|
820
|
+
<td style="padding:9px 0;color:#111827;font-size:13px;line-height:18px;font-weight:600;vertical-align:top;">${formatHtmlValue(value)}</td>
|
|
821
|
+
</tr>`;
|
|
822
|
+
}
|
|
823
|
+
function formatHtmlDetails(rows) {
|
|
824
|
+
const renderedRows = rows
|
|
825
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
|
826
|
+
.map(([label, value]) => formatHtmlDetailRow(label, value));
|
|
827
|
+
if (renderedRows.length === 0)
|
|
828
|
+
return "";
|
|
829
|
+
return `<div style="border-top:1px solid #e5e7eb;padding-top:18px;">
|
|
830
|
+
<div style="font-size:12px;line-height:16px;color:#6b7280;font-weight:800;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;">Details</div>
|
|
831
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
|
832
|
+
${renderedRows.join("")}
|
|
833
|
+
</table>
|
|
834
|
+
</div>`;
|
|
835
|
+
}
|
|
836
|
+
function formatHtmlList(title, items) {
|
|
837
|
+
const filtered = items.filter(Boolean);
|
|
838
|
+
if (filtered.length === 0)
|
|
839
|
+
return "";
|
|
840
|
+
return `<div style="margin-top:18px;">
|
|
841
|
+
<div style="font-size:12px;line-height:16px;color:#6b7280;font-weight:800;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;">${escapeHtml(title)}</div>
|
|
842
|
+
<ul style="margin:0;padding:0 0 0 18px;color:#374151;font-size:13px;line-height:21px;">
|
|
843
|
+
${filtered.map((item) => `<li style="margin:0 0 6px;">${item}</li>`).join("")}
|
|
844
|
+
</ul>
|
|
845
|
+
</div>`;
|
|
846
|
+
}
|
|
847
|
+
function formatHtmlCheck(check) {
|
|
848
|
+
const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status;
|
|
849
|
+
const label = `${check.name}: ${status}`;
|
|
850
|
+
if (!check.url)
|
|
851
|
+
return escapeHtml(label);
|
|
852
|
+
return `<a href="${escapeHtml(check.url)}" style="color:#2563eb;text-decoration:none;font-weight:700;">${escapeHtml(label)}</a>`;
|
|
853
|
+
}
|
|
854
|
+
function formatHtmlContextList(data) {
|
|
855
|
+
const items = Object.entries(data)
|
|
856
|
+
.filter(([, value]) => ["string", "number", "boolean"].includes(typeof value))
|
|
857
|
+
.slice(0, 8)
|
|
858
|
+
.map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(truncate(String(value)))}`);
|
|
859
|
+
return formatHtmlList("Context", items);
|
|
860
|
+
}
|
|
861
|
+
function formatHtmlActionButtons(actions) {
|
|
862
|
+
if (!actions || actions.length === 0)
|
|
863
|
+
return "";
|
|
864
|
+
const buttons = actions
|
|
865
|
+
.map((action) => {
|
|
866
|
+
const target = action.url ?? action.callbackEndpoint;
|
|
867
|
+
if (!target) {
|
|
868
|
+
return `<span style="display:inline-block;margin:0 8px 8px 0;border:1px solid #d1d5db;border-radius:8px;padding:10px 14px;color:#374151;font-size:13px;font-weight:700;">${escapeHtml(action.label)}</span>`;
|
|
869
|
+
}
|
|
870
|
+
return `<a href="${escapeHtml(target)}" style="display:inline-block;margin:0 8px 8px 0;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;padding:10px 14px;font-size:13px;font-weight:800;">${escapeHtml(action.label)}</a>`;
|
|
871
|
+
})
|
|
872
|
+
.join("");
|
|
873
|
+
return `<div style="margin-top:18px;">
|
|
874
|
+
<div style="font-size:12px;line-height:16px;color:#6b7280;font-weight:800;text-transform:uppercase;letter-spacing:.04em;margin-bottom:10px;">Actions</div>
|
|
875
|
+
${buttons}
|
|
876
|
+
</div>`;
|
|
877
|
+
}
|
|
878
|
+
function buildEmailStatusCards(event, data) {
|
|
879
|
+
const cards = [
|
|
880
|
+
{ label: "Status", value: priorityLabel(event.priority) },
|
|
881
|
+
{ label: "Event", value: titleCaseStatus(event.type) },
|
|
882
|
+
];
|
|
883
|
+
if (!data)
|
|
884
|
+
return cards;
|
|
885
|
+
if (data.ci?.status) {
|
|
886
|
+
const failing = data.ci.status === "failing";
|
|
887
|
+
cards.push({
|
|
888
|
+
label: "CI",
|
|
889
|
+
value: titleCaseStatus(data.ci.status),
|
|
890
|
+
color: failing ? "#991b1b" : "#166534",
|
|
891
|
+
background: failing ? "#fee2e2" : "#dcfce7",
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
if (data.review?.decision) {
|
|
895
|
+
const approved = data.review.decision === "approved";
|
|
896
|
+
cards.push({
|
|
897
|
+
label: "Review",
|
|
898
|
+
value: titleCaseStatus(data.review.decision),
|
|
899
|
+
color: approved ? "#166534" : "#92400e",
|
|
900
|
+
background: approved ? "#dcfce7" : "#fef3c7",
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
if (typeof data.merge?.ready === "boolean") {
|
|
904
|
+
cards.push({
|
|
905
|
+
label: "Merge",
|
|
906
|
+
value: data.merge.ready ? "Ready" : "Not ready",
|
|
907
|
+
color: data.merge.ready ? "#166534" : "#92400e",
|
|
908
|
+
background: data.merge.ready ? "#dcfce7" : "#fef3c7",
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
if (typeof data.merge?.conflicts === "boolean") {
|
|
912
|
+
cards.push({
|
|
913
|
+
label: "Conflicts",
|
|
914
|
+
value: data.merge.conflicts ? "Found" : "None",
|
|
915
|
+
color: data.merge.conflicts ? "#991b1b" : "#166534",
|
|
916
|
+
background: data.merge.conflicts ? "#fee2e2" : "#dcfce7",
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
if (typeof data.merge?.isBehind === "boolean") {
|
|
920
|
+
cards.push({
|
|
921
|
+
label: "Sync",
|
|
922
|
+
value: data.merge.isBehind ? "Behind base" : "Up to date",
|
|
923
|
+
color: data.merge.isBehind ? "#92400e" : "#166534",
|
|
924
|
+
background: data.merge.isBehind ? "#fef3c7" : "#dcfce7",
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
if (typeof data.review?.unresolvedThreads === "number") {
|
|
928
|
+
cards.push({
|
|
929
|
+
label: "Threads",
|
|
930
|
+
value: String(data.review.unresolvedThreads),
|
|
931
|
+
color: data.review.unresolvedThreads > 0 ? "#92400e" : "#166534",
|
|
932
|
+
background: data.review.unresolvedThreads > 0 ? "#fef3c7" : "#dcfce7",
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
return cards.slice(0, 8);
|
|
936
|
+
}
|
|
937
|
+
function formatEmailHtml(event, actions) {
|
|
938
|
+
const data = getNotificationDataV3(event.data);
|
|
939
|
+
const tone = emailToneForEvent(event);
|
|
940
|
+
const pr = data?.subject.pr;
|
|
941
|
+
const issue = data?.subject.issue;
|
|
942
|
+
const title = formatEmailTitle(event, data);
|
|
943
|
+
const subtitle = formatEmailSubtitle(event, data);
|
|
944
|
+
const branchLine = pr?.branch && pr.baseBranch
|
|
945
|
+
? `${pr.branch} -> ${pr.baseBranch}`
|
|
946
|
+
: (pr?.branch ?? pr?.baseBranch);
|
|
947
|
+
const primaryUrl = pr?.url ?? data?.review?.url;
|
|
948
|
+
const primaryLabel = pr?.url ? "View pull request" : data?.review?.url ? "View review" : "";
|
|
949
|
+
const primaryCta = primaryUrl
|
|
950
|
+
? `<a href="${escapeHtml(primaryUrl)}" style="display:inline-block;background:${tone.accent};color:#ffffff;text-decoration:none;border-radius:8px;padding:13px 18px;font-size:14px;line-height:18px;font-weight:800;">${escapeHtml(primaryLabel)}</a>`
|
|
951
|
+
: "";
|
|
952
|
+
const detailRows = [
|
|
953
|
+
["Project", event.projectId],
|
|
954
|
+
["Session", event.sessionId],
|
|
955
|
+
["Pull Request", pr ? `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}` : undefined],
|
|
956
|
+
["Branch", branchLine],
|
|
957
|
+
["Issue", issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined],
|
|
958
|
+
[
|
|
959
|
+
"Transition",
|
|
960
|
+
data?.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined,
|
|
961
|
+
],
|
|
962
|
+
["Reaction", data?.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined],
|
|
963
|
+
[
|
|
964
|
+
"Escalation",
|
|
965
|
+
data?.escalation
|
|
966
|
+
? `${data.escalation.attempts} attempts (${data.escalation.cause})`
|
|
967
|
+
: undefined,
|
|
968
|
+
],
|
|
969
|
+
["Time", event.timestamp.toISOString()],
|
|
970
|
+
];
|
|
971
|
+
const checks = (data?.ci?.failedChecks ?? []).slice(0, 10).map(formatHtmlCheck);
|
|
972
|
+
const blockers = (data?.merge?.blockers ?? []).slice(0, 10).map(escapeHtml);
|
|
973
|
+
const links = [
|
|
974
|
+
...(pr?.url
|
|
975
|
+
? [
|
|
976
|
+
`<a href="${escapeHtml(pr.url)}" style="color:#2563eb;text-decoration:none;font-weight:700;">Pull request</a>`,
|
|
977
|
+
]
|
|
978
|
+
: []),
|
|
979
|
+
...(data?.review?.url
|
|
980
|
+
? [
|
|
981
|
+
`<a href="${escapeHtml(data.review.url)}" style="color:#2563eb;text-decoration:none;font-weight:700;">Review</a>`,
|
|
982
|
+
]
|
|
983
|
+
: []),
|
|
984
|
+
];
|
|
985
|
+
return `<!doctype html>
|
|
986
|
+
<html>
|
|
987
|
+
<head>
|
|
988
|
+
<meta charset="utf-8">
|
|
989
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
990
|
+
<title>${escapeHtml(formatEmailSubject(event))}</title>
|
|
991
|
+
</head>
|
|
992
|
+
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;">
|
|
993
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f3f4f6;padding:28px 0;">
|
|
994
|
+
<tr>
|
|
995
|
+
<td align="center" style="padding:0 16px;">
|
|
996
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:680px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;">
|
|
997
|
+
<tr>
|
|
998
|
+
<td style="background:${tone.headerBackground};padding:24px 28px;">
|
|
999
|
+
<div style="font-size:12px;line-height:16px;color:${tone.badgeBackground};font-weight:800;text-transform:uppercase;letter-spacing:.08em;">${escapeHtml(tone.label)}</div>
|
|
1000
|
+
<h1 style="margin:10px 0 0;color:#ffffff;font-size:24px;line-height:31px;font-weight:800;">${escapeHtml(title)}</h1>
|
|
1001
|
+
<p style="margin:10px 0 0;color:#cbd5e1;font-size:14px;line-height:22px;">${escapeHtml(subtitle)}</p>
|
|
1002
|
+
</td>
|
|
1003
|
+
</tr>
|
|
1004
|
+
<tr>
|
|
1005
|
+
<td style="padding:24px 28px 10px;">
|
|
1006
|
+
<p style="margin:0;color:#374151;font-size:15px;line-height:24px;">${escapeHtml(event.message)}</p>
|
|
1007
|
+
<div style="margin-top:18px;">${primaryCta}</div>
|
|
1008
|
+
</td>
|
|
1009
|
+
</tr>
|
|
1010
|
+
${formatHtmlStatusCards(buildEmailStatusCards(event, data), tone)}
|
|
1011
|
+
<tr>
|
|
1012
|
+
<td style="padding:12px 28px 6px;">
|
|
1013
|
+
${formatHtmlDetails(detailRows)}
|
|
1014
|
+
${formatHtmlList("Checks", checks)}
|
|
1015
|
+
${formatHtmlList("Blockers", blockers)}
|
|
1016
|
+
${formatHtmlList("Links", links)}
|
|
1017
|
+
${data ? "" : formatHtmlContextList(event.data)}
|
|
1018
|
+
${formatHtmlActionButtons(actions)}
|
|
1019
|
+
</td>
|
|
1020
|
+
</tr>
|
|
1021
|
+
<tr>
|
|
1022
|
+
<td style="padding:18px 28px 24px;">
|
|
1023
|
+
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:13px 14px;color:#6b7280;font-size:12px;line-height:18px;">
|
|
1024
|
+
Sent by Athene.
|
|
1025
|
+
</div>
|
|
1026
|
+
</td>
|
|
1027
|
+
</tr>
|
|
1028
|
+
</table>
|
|
1029
|
+
</td>
|
|
1030
|
+
</tr>
|
|
1031
|
+
</table>
|
|
1032
|
+
</body>
|
|
1033
|
+
</html>`;
|
|
1034
|
+
}
|
|
1035
|
+
function formatEmailBody(event, actions) {
|
|
1036
|
+
return formatEmailHtml(event, actions);
|
|
1037
|
+
}
|
|
1038
|
+
function formatPostEmailBody(message) {
|
|
1039
|
+
return `<!doctype html>
|
|
1040
|
+
<html>
|
|
1041
|
+
<head>
|
|
1042
|
+
<meta charset="utf-8">
|
|
1043
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1044
|
+
<title>${escapeHtml(GMAIL_POST_SUBJECT)}</title>
|
|
1045
|
+
</head>
|
|
1046
|
+
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;">
|
|
1047
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f3f4f6;padding:28px 0;">
|
|
1048
|
+
<tr>
|
|
1049
|
+
<td align="center" style="padding:0 16px;">
|
|
1050
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;">
|
|
1051
|
+
<tr>
|
|
1052
|
+
<td style="background:#0f172a;padding:22px 26px;">
|
|
1053
|
+
<div style="font-size:12px;line-height:16px;color:#dbeafe;font-weight:800;text-transform:uppercase;letter-spacing:.08em;">Athene</div>
|
|
1054
|
+
<h1 style="margin:10px 0 0;color:#ffffff;font-size:22px;line-height:29px;font-weight:800;">Message</h1>
|
|
1055
|
+
</td>
|
|
1056
|
+
</tr>
|
|
1057
|
+
<tr>
|
|
1058
|
+
<td style="padding:24px 26px;color:#374151;font-size:15px;line-height:24px;white-space:pre-wrap;">${escapeHtml(message)}</td>
|
|
1059
|
+
</tr>
|
|
1060
|
+
</table>
|
|
1061
|
+
</td>
|
|
1062
|
+
</tr>
|
|
1063
|
+
</table>
|
|
1064
|
+
</body>
|
|
1065
|
+
</html>`;
|
|
1066
|
+
}
|
|
1067
|
+
function isHtmlEmailBody(body) {
|
|
1068
|
+
return /^\s*(?:<!doctype html|<html[\s>])/i.test(body);
|
|
1069
|
+
}
|
|
1070
|
+
function normalizeSlackChannel(channel) {
|
|
1071
|
+
return channel?.replace(/^#/, "");
|
|
1072
|
+
}
|
|
1073
|
+
function formatUnknownError(value) {
|
|
1074
|
+
if (value instanceof Error) {
|
|
1075
|
+
const cause = value.cause;
|
|
1076
|
+
if (cause !== undefined) {
|
|
1077
|
+
const causeMessage = formatUnknownError(cause);
|
|
1078
|
+
if (causeMessage && !value.message.includes(causeMessage)) {
|
|
1079
|
+
return `${value.message}: ${causeMessage}`;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return value.message;
|
|
1083
|
+
}
|
|
1084
|
+
if (typeof value === "string")
|
|
1085
|
+
return value;
|
|
1086
|
+
try {
|
|
1087
|
+
return JSON.stringify(value);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
return String(value);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function formatComposioError(err, app, discordMode) {
|
|
1094
|
+
const message = formatUnknownError(err);
|
|
1095
|
+
const lower = message.toLowerCase();
|
|
1096
|
+
if (lower.includes("connected account") || lower.includes("could not find a connection")) {
|
|
1097
|
+
const setupCommand = setupCommandForApp(app, discordMode);
|
|
1098
|
+
if (app === "discord" && discordMode === "webhook") {
|
|
1099
|
+
return new Error(`[notifier-composio] ${message}. Run \`${setupCommand}\` to create or refresh the Discord webhook connected account for this userId.`);
|
|
1100
|
+
}
|
|
1101
|
+
return new Error(`[notifier-composio] ${message}. Run \`${setupCommand}\`, connect ${app} in Composio, or set connectedAccountId / userId. entityId is still supported as an alias for userId.`);
|
|
1102
|
+
}
|
|
1103
|
+
return err instanceof Error ? err : new Error(message);
|
|
1104
|
+
}
|
|
1105
|
+
function setupCommandForApp(app, discordMode) {
|
|
1106
|
+
if (app === "discord") {
|
|
1107
|
+
return discordMode === "webhook"
|
|
1108
|
+
? "athene setup composio-discord"
|
|
1109
|
+
: "athene setup composio-discord-bot";
|
|
1110
|
+
}
|
|
1111
|
+
if (app === "gmail")
|
|
1112
|
+
return "athene setup composio-mail";
|
|
1113
|
+
return "athene setup composio";
|
|
1114
|
+
}
|
|
1115
|
+
function buildToolArgs(app, discordMode, text, channelId, channelName, emailTo, webhookUrl, emailSubject = GMAIL_SUBJECT, discordPayload, slackPayload) {
|
|
1116
|
+
if (app === "slack") {
|
|
1117
|
+
const args = slackPayload
|
|
1118
|
+
? { ...slackPayload }
|
|
1119
|
+
: { markdown_text: text };
|
|
1120
|
+
const channel = channelId ?? normalizeSlackChannel(channelName);
|
|
1121
|
+
if (channel)
|
|
1122
|
+
args.channel = channel;
|
|
1123
|
+
return args;
|
|
1124
|
+
}
|
|
1125
|
+
if (app === "discord") {
|
|
1126
|
+
const messagePayload = discordPayload
|
|
1127
|
+
? { ...discordPayload }
|
|
1128
|
+
: {
|
|
1129
|
+
content: text,
|
|
1130
|
+
allowed_mentions: { parse: [] },
|
|
1131
|
+
};
|
|
1132
|
+
if (discordMode === "webhook") {
|
|
1133
|
+
if (!webhookUrl) {
|
|
1134
|
+
throw new Error('[notifier-composio] webhookUrl is required when defaultApp is "discord" and mode is "webhook"');
|
|
1135
|
+
}
|
|
1136
|
+
const parsed = parseDiscordWebhookUrl(webhookUrl);
|
|
1137
|
+
return {
|
|
1138
|
+
webhook_id: parsed.webhookId,
|
|
1139
|
+
webhook_token: parsed.webhookToken,
|
|
1140
|
+
...messagePayload,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
const args = { ...messagePayload };
|
|
1144
|
+
// Discord requires numeric channel IDs — channelName is accepted as a manual fallback.
|
|
1145
|
+
if (channelId)
|
|
1146
|
+
args.channel_id = channelId;
|
|
1147
|
+
else if (channelName)
|
|
1148
|
+
args.channel_id = channelName;
|
|
1149
|
+
else {
|
|
1150
|
+
throw new Error('[notifier-composio] channelId is required when defaultApp is "discord" and mode is "bot"');
|
|
1151
|
+
}
|
|
1152
|
+
return args;
|
|
1153
|
+
}
|
|
1154
|
+
return {
|
|
1155
|
+
recipient_email: emailTo ?? "",
|
|
1156
|
+
subject: emailSubject,
|
|
1157
|
+
body: text,
|
|
1158
|
+
...(isHtmlEmailBody(text) ? { is_html: true } : {}),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function resolveToolVersion(config, app) {
|
|
1162
|
+
const toolVersions = config?.["toolVersions"];
|
|
1163
|
+
if (toolVersions && typeof toolVersions === "object") {
|
|
1164
|
+
const appVersion = toolVersions[app];
|
|
1165
|
+
if (typeof appVersion === "string" && appVersion.trim().length > 0) {
|
|
1166
|
+
return appVersion;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return stringConfig(config, "toolVersion") ?? DEFAULT_TOOL_VERSION[app];
|
|
1170
|
+
}
|
|
1171
|
+
function resolveToolSlug(app, discordMode) {
|
|
1172
|
+
if (app === "discord" && discordMode === "webhook")
|
|
1173
|
+
return DISCORD_WEBHOOK_TOOL_SLUG;
|
|
1174
|
+
return APP_TOOL_SLUG[app];
|
|
1175
|
+
}
|
|
1176
|
+
export function create(config) {
|
|
1177
|
+
const apiKey = resolveEnvReference(stringConfig(config, "composioApiKey")) ?? process.env.COMPOSIO_API_KEY;
|
|
1178
|
+
const defaultApp = typeof config?.defaultApp === "string" && VALID_APPS.has(config.defaultApp)
|
|
1179
|
+
? config.defaultApp
|
|
1180
|
+
: "slack";
|
|
1181
|
+
const channelName = stringConfig(config, "channelName");
|
|
1182
|
+
const channelId = stringConfig(config, "channelId");
|
|
1183
|
+
const webhookUrl = resolveEnvReference(stringConfig(config, "webhookUrl"));
|
|
1184
|
+
const discordMode = resolveDiscordMode(config, defaultApp, webhookUrl);
|
|
1185
|
+
const userId = stringConfig(config, "userId") ??
|
|
1186
|
+
stringConfig(config, "entityId") ??
|
|
1187
|
+
process.env.COMPOSIO_USER_ID ??
|
|
1188
|
+
process.env.COMPOSIO_ENTITY_ID ??
|
|
1189
|
+
DEFAULT_COMPOSIO_USER_ID;
|
|
1190
|
+
const emailTo = stringConfig(config, "emailTo");
|
|
1191
|
+
const toolVersion = resolveToolVersion(config, defaultApp);
|
|
1192
|
+
const forceSkipVersionCheck = boolConfig(config, "dangerouslySkipVersionCheck");
|
|
1193
|
+
const connectedAccountId = stringConfig(config, "connectedAccountId");
|
|
1194
|
+
const clientOverride = config?._clientOverride !== undefined && config._clientOverride !== null
|
|
1195
|
+
? config._clientOverride
|
|
1196
|
+
: undefined;
|
|
1197
|
+
if (clientOverride !== undefined && !isComposioToolsClient(clientOverride)) {
|
|
1198
|
+
throw new Error("[notifier-composio] _clientOverride must expose tools.execute()");
|
|
1199
|
+
}
|
|
1200
|
+
if (typeof config?.defaultApp === "string" && !VALID_APPS.has(config.defaultApp)) {
|
|
1201
|
+
throw new Error(`[notifier-composio] Invalid defaultApp: "${config.defaultApp}". Must be one of: slack, discord, gmail`);
|
|
1202
|
+
}
|
|
1203
|
+
if (defaultApp === "gmail" && !emailTo) {
|
|
1204
|
+
throw new Error('[notifier-composio] emailTo is required when defaultApp is "gmail"');
|
|
1205
|
+
}
|
|
1206
|
+
if (defaultApp === "discord" && discordMode === "webhook" && !webhookUrl) {
|
|
1207
|
+
throw new Error('[notifier-composio] webhookUrl is required when defaultApp is "discord" and mode is "webhook"');
|
|
1208
|
+
}
|
|
1209
|
+
let client = clientOverride;
|
|
1210
|
+
let warnedNoKey = false;
|
|
1211
|
+
let warnedSkipVersion = false;
|
|
1212
|
+
let sdkMissing = false;
|
|
1213
|
+
async function getClient() {
|
|
1214
|
+
if (clientOverride)
|
|
1215
|
+
return clientOverride;
|
|
1216
|
+
if (!apiKey) {
|
|
1217
|
+
if (!warnedNoKey) {
|
|
1218
|
+
console.warn("[notifier-composio] No composioApiKey or COMPOSIO_API_KEY configured — notifications will be no-ops");
|
|
1219
|
+
warnedNoKey = true;
|
|
1220
|
+
}
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
if (sdkMissing)
|
|
1224
|
+
return null;
|
|
1225
|
+
if (client === undefined) {
|
|
1226
|
+
client = await loadComposioSDK(apiKey);
|
|
1227
|
+
if (client === null) {
|
|
1228
|
+
sdkMissing = true;
|
|
1229
|
+
console.warn("[notifier-composio] @composio/core package is not installed — notifications will be no-ops.");
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return client;
|
|
1234
|
+
}
|
|
1235
|
+
async function executeWithTimeout(composio, action, args) {
|
|
1236
|
+
const timeoutMs = 30_000;
|
|
1237
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
1238
|
+
const executeParams = {
|
|
1239
|
+
userId,
|
|
1240
|
+
arguments: args,
|
|
1241
|
+
...(connectedAccountId ? { connectedAccountId } : {}),
|
|
1242
|
+
...(toolVersion ? { version: toolVersion } : { dangerouslySkipVersionCheck: true }),
|
|
1243
|
+
...(forceSkipVersionCheck ? { dangerouslySkipVersionCheck: true } : {}),
|
|
1244
|
+
};
|
|
1245
|
+
if (!toolVersion && !warnedSkipVersion) {
|
|
1246
|
+
console.warn(`[notifier-composio] No toolVersion configured for ${defaultApp}; using Composio latest-version execution.`);
|
|
1247
|
+
warnedSkipVersion = true;
|
|
1248
|
+
}
|
|
1249
|
+
const actionPromise = composio.tools.execute(action, executeParams);
|
|
1250
|
+
// Prevent unhandled rejection if the timeout fires and actionPromise later rejects.
|
|
1251
|
+
actionPromise.catch(() => { });
|
|
1252
|
+
const result = await Promise.race([
|
|
1253
|
+
actionPromise,
|
|
1254
|
+
new Promise((_, reject) => {
|
|
1255
|
+
timeoutSignal.addEventListener("abort", () => {
|
|
1256
|
+
reject(new Error(`[notifier-composio] Composio API call timed out after ${timeoutMs / 1000}s`));
|
|
1257
|
+
}, { once: true });
|
|
1258
|
+
}),
|
|
1259
|
+
]).catch((err) => {
|
|
1260
|
+
throw formatComposioError(err, defaultApp, discordMode);
|
|
1261
|
+
});
|
|
1262
|
+
if (result.successful === false) {
|
|
1263
|
+
throw new Error(`[notifier-composio] Composio action ${action} failed: ${formatUnknownError(result.error ?? "unknown error")}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function assertGmailConnectedAccount() {
|
|
1267
|
+
if (defaultApp === "gmail" && !connectedAccountId) {
|
|
1268
|
+
throw new Error('[notifier-composio] connectedAccountId is required when defaultApp is "gmail". Connect Gmail in Composio, then run `athene setup composio-mail`, or set notifiers.<name>.connectedAccountId.');
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return {
|
|
1272
|
+
name: "composio",
|
|
1273
|
+
async notify(event) {
|
|
1274
|
+
const composio = await getClient();
|
|
1275
|
+
if (!composio)
|
|
1276
|
+
return;
|
|
1277
|
+
assertGmailConnectedAccount();
|
|
1278
|
+
const text = defaultApp === "gmail" ? formatEmailBody(event) : formatNotifyText(event);
|
|
1279
|
+
const emailSubject = defaultApp === "gmail" ? formatEmailSubject(event) : undefined;
|
|
1280
|
+
const discordPayload = defaultApp === "discord" ? formatDiscordMessagePayload(event) : undefined;
|
|
1281
|
+
const slackPayload = defaultApp === "slack" ? formatSlackMessagePayload(event) : undefined;
|
|
1282
|
+
const toolSlug = resolveToolSlug(defaultApp, discordMode);
|
|
1283
|
+
const args = buildToolArgs(defaultApp, discordMode, text, channelId, channelName, emailTo, webhookUrl, emailSubject, discordPayload, slackPayload);
|
|
1284
|
+
await executeWithTimeout(composio, toolSlug, args);
|
|
1285
|
+
},
|
|
1286
|
+
async notifyWithActions(event, actions) {
|
|
1287
|
+
const composio = await getClient();
|
|
1288
|
+
if (!composio)
|
|
1289
|
+
return;
|
|
1290
|
+
assertGmailConnectedAccount();
|
|
1291
|
+
const text = defaultApp === "gmail"
|
|
1292
|
+
? formatEmailBody(event, actions)
|
|
1293
|
+
: formatActionsText(event, actions);
|
|
1294
|
+
const emailSubject = defaultApp === "gmail" ? formatEmailSubject(event) : undefined;
|
|
1295
|
+
const discordPayload = defaultApp === "discord" ? formatDiscordMessagePayload(event, actions) : undefined;
|
|
1296
|
+
const slackPayload = defaultApp === "slack" ? formatSlackMessagePayload(event, actions) : undefined;
|
|
1297
|
+
const toolSlug = resolveToolSlug(defaultApp, discordMode);
|
|
1298
|
+
const args = buildToolArgs(defaultApp, discordMode, text, channelId, channelName, emailTo, webhookUrl, emailSubject, discordPayload, slackPayload);
|
|
1299
|
+
await executeWithTimeout(composio, toolSlug, args);
|
|
1300
|
+
},
|
|
1301
|
+
async post(message, context) {
|
|
1302
|
+
const composio = await getClient();
|
|
1303
|
+
if (!composio)
|
|
1304
|
+
return null;
|
|
1305
|
+
assertGmailConnectedAccount();
|
|
1306
|
+
const channel = context?.channel ?? channelId ?? channelName;
|
|
1307
|
+
const slackChannel = normalizeSlackChannel(channel);
|
|
1308
|
+
const toolSlug = resolveToolSlug(defaultApp, discordMode);
|
|
1309
|
+
const args = defaultApp === "gmail"
|
|
1310
|
+
? {
|
|
1311
|
+
recipient_email: emailTo ?? "",
|
|
1312
|
+
subject: GMAIL_POST_SUBJECT,
|
|
1313
|
+
body: formatPostEmailBody(message),
|
|
1314
|
+
is_html: true,
|
|
1315
|
+
}
|
|
1316
|
+
: defaultApp === "discord"
|
|
1317
|
+
? buildToolArgs(defaultApp, discordMode, message, discordMode === "bot" ? channel : channelId, channelName, emailTo, webhookUrl)
|
|
1318
|
+
: { markdown_text: message, ...(slackChannel ? { channel: slackChannel } : {}) };
|
|
1319
|
+
await executeWithTimeout(composio, toolSlug, args);
|
|
1320
|
+
return null;
|
|
1321
|
+
},
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
export default { manifest, create };
|
|
1325
|
+
//# sourceMappingURL=index.js.map
|