@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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { manifest, create } from "./index.js";
|
|
3
|
+
const { mockToolsExecute, mockConstructorOptions } = vi.hoisted(() => ({
|
|
4
|
+
mockToolsExecute: vi.fn().mockResolvedValue({ successful: true }),
|
|
5
|
+
mockConstructorOptions: [],
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("@composio/core", () => {
|
|
8
|
+
function MockComposio(opts) {
|
|
9
|
+
mockConstructorOptions.push(opts);
|
|
10
|
+
return { tools: { execute: mockToolsExecute } };
|
|
11
|
+
}
|
|
12
|
+
return { Composio: MockComposio };
|
|
13
|
+
});
|
|
14
|
+
function makeEvent(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
id: "evt-1",
|
|
17
|
+
type: "session.spawned",
|
|
18
|
+
priority: "info",
|
|
19
|
+
sessionId: "app-1",
|
|
20
|
+
projectId: "my-project",
|
|
21
|
+
timestamp: new Date("2025-06-15T12:00:00Z"),
|
|
22
|
+
message: "Session app-1 spawned successfully",
|
|
23
|
+
data: {},
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makeV3Data(overrides = {}) {
|
|
28
|
+
return {
|
|
29
|
+
schemaVersion: 3,
|
|
30
|
+
subject: { session: { id: "app-1", projectId: "my-project" } },
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function getSlackAttachment() {
|
|
35
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
36
|
+
return JSON.parse(String(callArgs.arguments.attachments))[0];
|
|
37
|
+
}
|
|
38
|
+
function getSlackActions() {
|
|
39
|
+
const attachment = getSlackAttachment();
|
|
40
|
+
return attachment.blocks.find((block) => block.type === "actions")?.elements ?? [];
|
|
41
|
+
}
|
|
42
|
+
describe("notifier-composio", () => {
|
|
43
|
+
const originalEnv = {
|
|
44
|
+
COMPOSIO_API_KEY: process.env.COMPOSIO_API_KEY,
|
|
45
|
+
COMPOSIO_USER_ID: process.env.COMPOSIO_USER_ID,
|
|
46
|
+
COMPOSIO_ENTITY_ID: process.env.COMPOSIO_ENTITY_ID,
|
|
47
|
+
};
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
mockConstructorOptions.length = 0;
|
|
51
|
+
mockToolsExecute.mockResolvedValue({ successful: true });
|
|
52
|
+
delete process.env.COMPOSIO_API_KEY;
|
|
53
|
+
delete process.env.COMPOSIO_USER_ID;
|
|
54
|
+
delete process.env.COMPOSIO_ENTITY_ID;
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
58
|
+
if (value !== undefined)
|
|
59
|
+
process.env[key] = value;
|
|
60
|
+
else
|
|
61
|
+
Reflect.deleteProperty(process.env, key);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
describe("manifest", () => {
|
|
65
|
+
it("has correct metadata", () => {
|
|
66
|
+
expect(manifest.name).toBe("composio");
|
|
67
|
+
expect(manifest.slot).toBe("notifier");
|
|
68
|
+
});
|
|
69
|
+
it("has a version", () => {
|
|
70
|
+
expect(manifest.version).toBe("0.1.0");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe("create — config parsing", () => {
|
|
74
|
+
it("reads apiKey from config", async () => {
|
|
75
|
+
const notifier = create({ composioApiKey: "test-key" });
|
|
76
|
+
await notifier.notify(makeEvent());
|
|
77
|
+
expect(mockConstructorOptions[0]).toEqual({ apiKey: "test-key" });
|
|
78
|
+
});
|
|
79
|
+
it("reads apiKey from COMPOSIO_API_KEY env var", async () => {
|
|
80
|
+
process.env.COMPOSIO_API_KEY = "env-key";
|
|
81
|
+
const notifier = create();
|
|
82
|
+
await notifier.notify(makeEvent());
|
|
83
|
+
expect(mockConstructorOptions[0]).toEqual({ apiKey: "env-key" });
|
|
84
|
+
});
|
|
85
|
+
it("resolves env placeholders in composioApiKey config", async () => {
|
|
86
|
+
process.env.COMPOSIO_API_KEY = "placeholder-key";
|
|
87
|
+
const notifier = create({ composioApiKey: "${COMPOSIO_API_KEY}" });
|
|
88
|
+
await notifier.notify(makeEvent());
|
|
89
|
+
expect(mockConstructorOptions[0]).toEqual({ apiKey: "placeholder-key" });
|
|
90
|
+
});
|
|
91
|
+
it("throws on invalid defaultApp", () => {
|
|
92
|
+
expect(() => create({ composioApiKey: "k", defaultApp: "telegram" })).toThrow('Invalid defaultApp: "telegram"');
|
|
93
|
+
});
|
|
94
|
+
it("accepts slack as defaultApp", () => {
|
|
95
|
+
expect(() => create({ composioApiKey: "k", defaultApp: "slack" })).not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
it("accepts discord as defaultApp", () => {
|
|
98
|
+
expect(() => create({ composioApiKey: "k", defaultApp: "discord" })).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
it("throws on invalid Discord mode", () => {
|
|
101
|
+
expect(() => create({ composioApiKey: "k", defaultApp: "discord", mode: "voice" })).toThrow('Invalid Discord mode: "voice"');
|
|
102
|
+
});
|
|
103
|
+
it("accepts gmail as defaultApp with emailTo", () => {
|
|
104
|
+
expect(() => create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" })).not.toThrow();
|
|
105
|
+
});
|
|
106
|
+
it("throws when gmail is defaultApp without emailTo", () => {
|
|
107
|
+
expect(() => create({ composioApiKey: "k", defaultApp: "gmail" })).toThrow("emailTo is required");
|
|
108
|
+
});
|
|
109
|
+
it("defaults to slack when defaultApp not specified", async () => {
|
|
110
|
+
const notifier = create({ composioApiKey: "k" });
|
|
111
|
+
await notifier.notify(makeEvent());
|
|
112
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({
|
|
113
|
+
userId: "aoagent",
|
|
114
|
+
arguments: expect.objectContaining({ markdown_text: expect.any(String) }),
|
|
115
|
+
}));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe("notify", () => {
|
|
119
|
+
it("calls SLACK_SEND_MESSAGE for slack app", async () => {
|
|
120
|
+
const notifier = create({ composioApiKey: "k", defaultApp: "slack" });
|
|
121
|
+
await notifier.notify(makeEvent());
|
|
122
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.any(Object));
|
|
123
|
+
});
|
|
124
|
+
it("calls DISCORDBOT_CREATE_MESSAGE for discord bot mode", async () => {
|
|
125
|
+
const notifier = create({
|
|
126
|
+
composioApiKey: "k",
|
|
127
|
+
defaultApp: "discord",
|
|
128
|
+
mode: "bot",
|
|
129
|
+
channelId: "1234567890",
|
|
130
|
+
});
|
|
131
|
+
await notifier.notify(makeEvent());
|
|
132
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({
|
|
133
|
+
arguments: expect.objectContaining({
|
|
134
|
+
channel_id: "1234567890",
|
|
135
|
+
content: expect.stringContaining("Session Spawned"),
|
|
136
|
+
embeds: [
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
title: expect.stringContaining("Session Spawned"),
|
|
139
|
+
fields: expect.arrayContaining([
|
|
140
|
+
expect.objectContaining({ name: "Project", value: "my-project" }),
|
|
141
|
+
expect.objectContaining({ name: "Session", value: "app-1" }),
|
|
142
|
+
]),
|
|
143
|
+
}),
|
|
144
|
+
],
|
|
145
|
+
allowed_mentions: { parse: [] },
|
|
146
|
+
}),
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
it("calls DISCORDBOT_EXECUTE_WEBHOOK for discord webhook mode", async () => {
|
|
150
|
+
const notifier = create({
|
|
151
|
+
composioApiKey: "k",
|
|
152
|
+
defaultApp: "discord",
|
|
153
|
+
mode: "webhook",
|
|
154
|
+
webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token",
|
|
155
|
+
connectedAccountId: "ca_discord_webhook",
|
|
156
|
+
});
|
|
157
|
+
await notifier.notify(makeEvent());
|
|
158
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("DISCORDBOT_EXECUTE_WEBHOOK", expect.objectContaining({
|
|
159
|
+
arguments: expect.objectContaining({
|
|
160
|
+
webhook_id: "1234567890",
|
|
161
|
+
webhook_token: "webhook-token",
|
|
162
|
+
content: expect.stringContaining("Session Spawned"),
|
|
163
|
+
embeds: [
|
|
164
|
+
expect.objectContaining({
|
|
165
|
+
title: expect.stringContaining("Session Spawned"),
|
|
166
|
+
}),
|
|
167
|
+
],
|
|
168
|
+
allowed_mentions: { parse: [] },
|
|
169
|
+
}),
|
|
170
|
+
connectedAccountId: "ca_discord_webhook",
|
|
171
|
+
}));
|
|
172
|
+
});
|
|
173
|
+
it("uses webhook mode when Discord webhookUrl is configured without mode", async () => {
|
|
174
|
+
const notifier = create({
|
|
175
|
+
composioApiKey: "k",
|
|
176
|
+
defaultApp: "discord",
|
|
177
|
+
webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token",
|
|
178
|
+
connectedAccountId: "ca_discord_webhook",
|
|
179
|
+
});
|
|
180
|
+
await notifier.notify(makeEvent());
|
|
181
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("DISCORDBOT_EXECUTE_WEBHOOK", expect.any(Object));
|
|
182
|
+
});
|
|
183
|
+
it("fails fast on invalid Discord webhook URLs", async () => {
|
|
184
|
+
const notifier = create({
|
|
185
|
+
composioApiKey: "k",
|
|
186
|
+
defaultApp: "discord",
|
|
187
|
+
mode: "webhook",
|
|
188
|
+
webhookUrl: "https://discord.com/not-a-webhook",
|
|
189
|
+
});
|
|
190
|
+
await expect(notifier.notify(makeEvent())).rejects.toThrow("Invalid Discord webhookUrl");
|
|
191
|
+
});
|
|
192
|
+
it("calls GMAIL_SEND_EMAIL for gmail app", async () => {
|
|
193
|
+
const notifier = create({
|
|
194
|
+
composioApiKey: "k",
|
|
195
|
+
defaultApp: "gmail",
|
|
196
|
+
emailTo: "test@test.com",
|
|
197
|
+
connectedAccountId: "ca_gmail",
|
|
198
|
+
});
|
|
199
|
+
await notifier.notify(makeEvent());
|
|
200
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("GMAIL_SEND_EMAIL", expect.objectContaining({
|
|
201
|
+
connectedAccountId: "ca_gmail",
|
|
202
|
+
arguments: expect.objectContaining({
|
|
203
|
+
recipient_email: "test@test.com",
|
|
204
|
+
subject: "[AO] Session Spawned: app-1",
|
|
205
|
+
is_html: true,
|
|
206
|
+
}),
|
|
207
|
+
}));
|
|
208
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
209
|
+
expect(callArgs.arguments.body).toContain("<!doctype html>");
|
|
210
|
+
expect(callArgs.arguments.body).toContain("Session Spawned");
|
|
211
|
+
expect(callArgs.arguments.body).toContain("Session app-1 spawned successfully");
|
|
212
|
+
});
|
|
213
|
+
it("formats Gmail CI notifications as a professional HTML email brief", async () => {
|
|
214
|
+
const notifier = create({
|
|
215
|
+
composioApiKey: "k",
|
|
216
|
+
defaultApp: "gmail",
|
|
217
|
+
emailTo: "test@test.com",
|
|
218
|
+
connectedAccountId: "ca_gmail",
|
|
219
|
+
});
|
|
220
|
+
await notifier.notify(makeEvent({
|
|
221
|
+
type: "ci.failing",
|
|
222
|
+
priority: "action",
|
|
223
|
+
sessionId: "demo-agent-19",
|
|
224
|
+
projectId: "demo",
|
|
225
|
+
message: "CI is failing on PR #1579",
|
|
226
|
+
data: makeV3Data({
|
|
227
|
+
subject: {
|
|
228
|
+
session: { id: "demo-agent-19", projectId: "demo" },
|
|
229
|
+
pr: {
|
|
230
|
+
number: 1579,
|
|
231
|
+
title: "Normalize AO notifier payloads",
|
|
232
|
+
url: "https://github.com/slievr/Athene/pull/1579",
|
|
233
|
+
branch: "ao/demo-notifier-harness",
|
|
234
|
+
baseBranch: "main",
|
|
235
|
+
},
|
|
236
|
+
issue: {
|
|
237
|
+
id: "AO-1579",
|
|
238
|
+
title: "Make AO notification payloads API-grade",
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
ci: {
|
|
242
|
+
status: "failing",
|
|
243
|
+
failedChecks: [
|
|
244
|
+
{
|
|
245
|
+
name: "typecheck",
|
|
246
|
+
status: "failed",
|
|
247
|
+
conclusion: "FAILURE",
|
|
248
|
+
url: "https://github.com/slievr/Athene/pull/1579/checks",
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
}));
|
|
254
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
255
|
+
expect(callArgs.arguments.subject).toBe("[AO] CI failing on PR #1579");
|
|
256
|
+
expect(callArgs.arguments.is_html).toBe(true);
|
|
257
|
+
expect(callArgs.arguments.body).toContain("<!doctype html>");
|
|
258
|
+
expect(callArgs.arguments.body).toContain("CI is failing on PR #1579");
|
|
259
|
+
expect(callArgs.arguments.body).toContain("Normalize AO notifier payloads");
|
|
260
|
+
expect(callArgs.arguments.body).toContain("Action required");
|
|
261
|
+
expect(callArgs.arguments.body).toContain("Pull Request");
|
|
262
|
+
expect(callArgs.arguments.body).toContain("#1579 - Normalize AO notifier payloads");
|
|
263
|
+
expect(callArgs.arguments.body).toContain("typecheck: failed/FAILURE");
|
|
264
|
+
expect(callArgs.arguments.body).not.toContain("👉");
|
|
265
|
+
});
|
|
266
|
+
it("routes to channelId when set", async () => {
|
|
267
|
+
const notifier = create({ composioApiKey: "k", channelId: "C123" });
|
|
268
|
+
await notifier.notify(makeEvent());
|
|
269
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
270
|
+
expect(callArgs.arguments.channel).toBe("C123");
|
|
271
|
+
});
|
|
272
|
+
it("routes to normalized channelName when channelId not set", async () => {
|
|
273
|
+
const notifier = create({ composioApiKey: "k", channelName: "#general" });
|
|
274
|
+
await notifier.notify(makeEvent());
|
|
275
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
276
|
+
expect(callArgs.arguments.channel).toBe("general");
|
|
277
|
+
});
|
|
278
|
+
it("formats Slack notifications as rich attachments", async () => {
|
|
279
|
+
const notifier = create({ composioApiKey: "k" });
|
|
280
|
+
await notifier.notify(makeEvent({ priority: "urgent" }));
|
|
281
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
282
|
+
expect(callArgs.arguments.markdown_text).toContain("Urgent");
|
|
283
|
+
expect(callArgs.arguments.text).toContain("Urgent");
|
|
284
|
+
expect(callArgs.arguments.unfurl_links).toBe(false);
|
|
285
|
+
expect(callArgs.arguments.unfurl_media).toBe(false);
|
|
286
|
+
const attachment = getSlackAttachment();
|
|
287
|
+
expect(attachment.color).toBe("#E01E5A");
|
|
288
|
+
expect(attachment.fallback).toContain("Urgent");
|
|
289
|
+
expect(attachment.blocks).toEqual(expect.arrayContaining([
|
|
290
|
+
expect.objectContaining({
|
|
291
|
+
type: "header",
|
|
292
|
+
text: expect.objectContaining({
|
|
293
|
+
text: expect.stringContaining(":rotating_light:"),
|
|
294
|
+
}),
|
|
295
|
+
}),
|
|
296
|
+
expect.objectContaining({
|
|
297
|
+
type: "section",
|
|
298
|
+
fields: expect.arrayContaining([
|
|
299
|
+
expect.objectContaining({ text: expect.stringContaining("*Project*") }),
|
|
300
|
+
expect.objectContaining({ text: expect.stringContaining("my-project") }),
|
|
301
|
+
]),
|
|
302
|
+
}),
|
|
303
|
+
]));
|
|
304
|
+
});
|
|
305
|
+
it("escapes user-controlled Slack mrkdwn characters", async () => {
|
|
306
|
+
const notifier = create({ composioApiKey: "k" });
|
|
307
|
+
await notifier.notify(makeEvent({ message: "Fix *bold* _italic_ ~strike~ `code` & <tag> > done" }));
|
|
308
|
+
const section = getSlackAttachment().blocks.find((block) => block.type === "section");
|
|
309
|
+
expect(section.text.text).toBe("Fix *bold* _italic_ ~strike~ `code` & <tag> > done");
|
|
310
|
+
});
|
|
311
|
+
it("escapes right parentheses in Discord markdown link URLs", async () => {
|
|
312
|
+
const notifier = create({
|
|
313
|
+
composioApiKey: "k",
|
|
314
|
+
defaultApp: "discord",
|
|
315
|
+
mode: "bot",
|
|
316
|
+
channelId: "1234567890",
|
|
317
|
+
});
|
|
318
|
+
await notifier.notify(makeEvent({
|
|
319
|
+
data: makeV3Data({
|
|
320
|
+
subject: {
|
|
321
|
+
session: { id: "app-1", projectId: "my-project" },
|
|
322
|
+
pr: {
|
|
323
|
+
number: 1,
|
|
324
|
+
title: "Parser test",
|
|
325
|
+
url: "https://github.com/org/repo/pull/1?a=(test)",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
}));
|
|
330
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
331
|
+
const pullRequestField = callArgs.arguments.embeds[0].fields.find((field) => field.name === "Pull Request");
|
|
332
|
+
expect(pullRequestField.value).toContain("[#1](https://github.com/org/repo/pull/1?a=(test%29)");
|
|
333
|
+
});
|
|
334
|
+
it("includes subject.pr.url when present in v3 data", async () => {
|
|
335
|
+
const notifier = create({ composioApiKey: "k" });
|
|
336
|
+
await notifier.notify(makeEvent({
|
|
337
|
+
data: makeV3Data({
|
|
338
|
+
subject: {
|
|
339
|
+
session: { id: "app-1", projectId: "my-project" },
|
|
340
|
+
pr: { number: 1, url: "https://github.com/pull/1" },
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
}));
|
|
344
|
+
expect(getSlackActions()).toEqual(expect.arrayContaining([
|
|
345
|
+
expect.objectContaining({
|
|
346
|
+
text: expect.objectContaining({ text: "View PR" }),
|
|
347
|
+
url: "https://github.com/pull/1",
|
|
348
|
+
style: "primary",
|
|
349
|
+
}),
|
|
350
|
+
]));
|
|
351
|
+
});
|
|
352
|
+
it("ignores legacy flat prUrl", async () => {
|
|
353
|
+
const notifier = create({ composioApiKey: "k" });
|
|
354
|
+
await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/pull/1" } }));
|
|
355
|
+
expect(getSlackActions()).toEqual([]);
|
|
356
|
+
expect(getSlackAttachment().fallback).not.toContain("https://github.com/pull/1");
|
|
357
|
+
});
|
|
358
|
+
it("passes userId, connectedAccountId, and default Slack tool version", async () => {
|
|
359
|
+
const notifier = create({
|
|
360
|
+
composioApiKey: "k",
|
|
361
|
+
defaultApp: "slack",
|
|
362
|
+
userId: "user_123",
|
|
363
|
+
connectedAccountId: "ca_123",
|
|
364
|
+
});
|
|
365
|
+
await notifier.notify(makeEvent());
|
|
366
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({
|
|
367
|
+
userId: "user_123",
|
|
368
|
+
connectedAccountId: "ca_123",
|
|
369
|
+
version: "20260508_00",
|
|
370
|
+
}));
|
|
371
|
+
});
|
|
372
|
+
it("keeps entityId as a backward-compatible userId alias", async () => {
|
|
373
|
+
const notifier = create({ composioApiKey: "k", entityId: "legacy-user" });
|
|
374
|
+
await notifier.notify(makeEvent());
|
|
375
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({ userId: "legacy-user" }));
|
|
376
|
+
});
|
|
377
|
+
it("reads userId from COMPOSIO_USER_ID env var", async () => {
|
|
378
|
+
process.env.COMPOSIO_USER_ID = "env-user";
|
|
379
|
+
const notifier = create({ composioApiKey: "k" });
|
|
380
|
+
await notifier.notify(makeEvent());
|
|
381
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({ userId: "env-user" }));
|
|
382
|
+
});
|
|
383
|
+
it("supports configured toolVersion overrides", async () => {
|
|
384
|
+
const notifier = create({ composioApiKey: "k", toolVersion: "20260101_00" });
|
|
385
|
+
await notifier.notify(makeEvent());
|
|
386
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({ version: "20260101_00" }));
|
|
387
|
+
});
|
|
388
|
+
it("supports app-specific toolVersions overrides", async () => {
|
|
389
|
+
const notifier = create({
|
|
390
|
+
composioApiKey: "k",
|
|
391
|
+
toolVersion: "ignored",
|
|
392
|
+
toolVersions: { slack: "20260202_00" },
|
|
393
|
+
});
|
|
394
|
+
await notifier.notify(makeEvent());
|
|
395
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({ version: "20260202_00" }));
|
|
396
|
+
});
|
|
397
|
+
it("passes the default Gmail tool version", async () => {
|
|
398
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
399
|
+
const notifier = create({
|
|
400
|
+
composioApiKey: "k",
|
|
401
|
+
defaultApp: "gmail",
|
|
402
|
+
emailTo: "test@test.com",
|
|
403
|
+
connectedAccountId: "ca_gmail",
|
|
404
|
+
});
|
|
405
|
+
await notifier.notify(makeEvent());
|
|
406
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("GMAIL_SEND_EMAIL", expect.objectContaining({ version: "20260506_01" }));
|
|
407
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("No toolVersion configured"));
|
|
408
|
+
warnSpy.mockRestore();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
describe("notifyWithActions", () => {
|
|
412
|
+
it("includes action labels in text", async () => {
|
|
413
|
+
const notifier = create({ composioApiKey: "k" });
|
|
414
|
+
const actions = [
|
|
415
|
+
{ label: "Merge", url: "https://github.com/merge" },
|
|
416
|
+
{ label: "Kill", callbackEndpoint: "/api/kill" },
|
|
417
|
+
];
|
|
418
|
+
await notifier.notifyWithActions(makeEvent(), actions);
|
|
419
|
+
expect(getSlackActions()).toEqual(expect.arrayContaining([
|
|
420
|
+
expect.objectContaining({
|
|
421
|
+
text: expect.objectContaining({ text: "Merge" }),
|
|
422
|
+
url: "https://github.com/merge",
|
|
423
|
+
style: "primary",
|
|
424
|
+
}),
|
|
425
|
+
expect.objectContaining({
|
|
426
|
+
text: expect.objectContaining({ text: "Kill" }),
|
|
427
|
+
action_id: "ao_kill_1",
|
|
428
|
+
value: "/api/kill",
|
|
429
|
+
style: "danger",
|
|
430
|
+
}),
|
|
431
|
+
]));
|
|
432
|
+
});
|
|
433
|
+
it("includes URL actions as links", async () => {
|
|
434
|
+
const notifier = create({ composioApiKey: "k" });
|
|
435
|
+
const actions = [{ label: "View PR", url: "https://github.com/pull/42" }];
|
|
436
|
+
await notifier.notifyWithActions(makeEvent(), actions);
|
|
437
|
+
expect(getSlackActions()).toEqual(expect.arrayContaining([
|
|
438
|
+
expect.objectContaining({
|
|
439
|
+
text: expect.objectContaining({ text: "View PR" }),
|
|
440
|
+
url: "https://github.com/pull/42",
|
|
441
|
+
}),
|
|
442
|
+
]));
|
|
443
|
+
});
|
|
444
|
+
it("renders callback-only actions without URL", async () => {
|
|
445
|
+
const notifier = create({ composioApiKey: "k" });
|
|
446
|
+
const actions = [{ label: "Restart", callbackEndpoint: "/api/restart" }];
|
|
447
|
+
await notifier.notifyWithActions(makeEvent(), actions);
|
|
448
|
+
expect(getSlackActions()).toEqual(expect.arrayContaining([
|
|
449
|
+
expect.objectContaining({
|
|
450
|
+
text: expect.objectContaining({ text: "Restart" }),
|
|
451
|
+
action_id: "ao_restart_0",
|
|
452
|
+
value: "/api/restart",
|
|
453
|
+
}),
|
|
454
|
+
]));
|
|
455
|
+
});
|
|
456
|
+
it("uses correct tool slug for configured app", async () => {
|
|
457
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
458
|
+
const notifier = create({
|
|
459
|
+
composioApiKey: "k",
|
|
460
|
+
defaultApp: "discord",
|
|
461
|
+
mode: "bot",
|
|
462
|
+
channelId: "1234567890",
|
|
463
|
+
});
|
|
464
|
+
const actions = [{ label: "Test", url: "https://example.com" }];
|
|
465
|
+
await notifier.notifyWithActions(makeEvent(), actions);
|
|
466
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({
|
|
467
|
+
arguments: expect.objectContaining({
|
|
468
|
+
embeds: [
|
|
469
|
+
expect.objectContaining({
|
|
470
|
+
fields: expect.arrayContaining([
|
|
471
|
+
expect.objectContaining({
|
|
472
|
+
name: "Actions",
|
|
473
|
+
value: expect.stringContaining("[Test](https://example.com)"),
|
|
474
|
+
}),
|
|
475
|
+
]),
|
|
476
|
+
}),
|
|
477
|
+
],
|
|
478
|
+
components: [
|
|
479
|
+
{
|
|
480
|
+
type: 1,
|
|
481
|
+
components: [{ type: 2, style: 5, label: "Test", url: "https://example.com" }],
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
}),
|
|
485
|
+
}));
|
|
486
|
+
warnSpy.mockRestore();
|
|
487
|
+
});
|
|
488
|
+
it("formats Gmail actions with a professional subject and action links", async () => {
|
|
489
|
+
const notifier = create({
|
|
490
|
+
composioApiKey: "k",
|
|
491
|
+
defaultApp: "gmail",
|
|
492
|
+
emailTo: "test@test.com",
|
|
493
|
+
connectedAccountId: "ca_gmail",
|
|
494
|
+
});
|
|
495
|
+
const actions = [
|
|
496
|
+
{ label: "Open dashboard", url: "http://localhost:3000" },
|
|
497
|
+
{ label: "Acknowledge", callbackEndpoint: "http://localhost:3000/api/ack" },
|
|
498
|
+
];
|
|
499
|
+
await notifier.notifyWithActions(makeEvent({
|
|
500
|
+
type: "merge.ready",
|
|
501
|
+
priority: "action",
|
|
502
|
+
sessionId: "demo-agent-29",
|
|
503
|
+
projectId: "demo",
|
|
504
|
+
message: "PR #1579 is ready to merge",
|
|
505
|
+
data: makeV3Data({
|
|
506
|
+
subject: {
|
|
507
|
+
session: { id: "demo-agent-29", projectId: "demo" },
|
|
508
|
+
pr: {
|
|
509
|
+
number: 1579,
|
|
510
|
+
title: "Normalize AO notifier payloads",
|
|
511
|
+
url: "https://github.com/slievr/Athene/pull/1579",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
transition: { kind: "session_status", from: "approved", to: "mergeable" },
|
|
515
|
+
ci: { status: "passing" },
|
|
516
|
+
review: { decision: "approved" },
|
|
517
|
+
merge: { ready: true, conflicts: false, isBehind: false },
|
|
518
|
+
}),
|
|
519
|
+
}), actions);
|
|
520
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
521
|
+
expect(callArgs.arguments.subject).toBe("[AO] PR #1579 ready to merge");
|
|
522
|
+
expect(callArgs.arguments.is_html).toBe(true);
|
|
523
|
+
expect(callArgs.arguments.body).toContain("<!doctype html>");
|
|
524
|
+
expect(callArgs.arguments.body).toContain("Ready to merge");
|
|
525
|
+
expect(callArgs.arguments.body).toContain("PR #1579 is ready to merge");
|
|
526
|
+
expect(callArgs.arguments.body).toContain("Normalize AO notifier payloads");
|
|
527
|
+
expect(callArgs.arguments.body).toContain("View pull request");
|
|
528
|
+
expect(callArgs.arguments.body).toContain("Transition");
|
|
529
|
+
expect(callArgs.arguments.body).toContain("approved -> mergeable");
|
|
530
|
+
expect(callArgs.arguments.body).toContain("Passing");
|
|
531
|
+
expect(callArgs.arguments.body).toContain("Approved");
|
|
532
|
+
expect(callArgs.arguments.body).toContain("Ready");
|
|
533
|
+
expect(callArgs.arguments.body).toContain("Actions");
|
|
534
|
+
expect(callArgs.arguments.body).toContain('href="http://localhost:3000"');
|
|
535
|
+
expect(callArgs.arguments.body).toContain('href="http://localhost:3000/api/ack"');
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
describe("post", () => {
|
|
539
|
+
it("sends text payload", async () => {
|
|
540
|
+
const notifier = create({ composioApiKey: "k" });
|
|
541
|
+
await notifier.post("Hello from AO");
|
|
542
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
543
|
+
expect(callArgs.arguments.markdown_text).toBe("Hello from AO");
|
|
544
|
+
});
|
|
545
|
+
it("overrides channel from context", async () => {
|
|
546
|
+
const notifier = create({ composioApiKey: "k", channelName: "#default" });
|
|
547
|
+
await notifier.post("test", { channel: "#override" });
|
|
548
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
549
|
+
expect(callArgs.arguments.channel).toBe("override");
|
|
550
|
+
});
|
|
551
|
+
it("uses Gmail recipient_email for HTML post messages", async () => {
|
|
552
|
+
const notifier = create({
|
|
553
|
+
composioApiKey: "k",
|
|
554
|
+
defaultApp: "gmail",
|
|
555
|
+
emailTo: "test@test.com",
|
|
556
|
+
connectedAccountId: "ca_gmail",
|
|
557
|
+
});
|
|
558
|
+
await notifier.post("Hello from AO");
|
|
559
|
+
expect(mockToolsExecute).toHaveBeenCalledWith("GMAIL_SEND_EMAIL", expect.objectContaining({
|
|
560
|
+
connectedAccountId: "ca_gmail",
|
|
561
|
+
arguments: {
|
|
562
|
+
recipient_email: "test@test.com",
|
|
563
|
+
subject: "Athene Message",
|
|
564
|
+
body: expect.stringContaining("<!doctype html>"),
|
|
565
|
+
is_html: true,
|
|
566
|
+
},
|
|
567
|
+
}));
|
|
568
|
+
const callArgs = mockToolsExecute.mock.calls[0][1];
|
|
569
|
+
expect(callArgs.arguments.body).toContain("Hello from AO");
|
|
570
|
+
});
|
|
571
|
+
it("returns null", async () => {
|
|
572
|
+
const notifier = create({ composioApiKey: "k" });
|
|
573
|
+
const result = await notifier.post("test");
|
|
574
|
+
expect(result).toBeNull();
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
describe("error handling", () => {
|
|
578
|
+
it("throws when SDK returns unsuccessful result", async () => {
|
|
579
|
+
mockToolsExecute.mockResolvedValueOnce({
|
|
580
|
+
successful: false,
|
|
581
|
+
error: "channel not found",
|
|
582
|
+
});
|
|
583
|
+
const notifier = create({ composioApiKey: "k" });
|
|
584
|
+
await expect(notifier.notify(makeEvent())).rejects.toThrow("channel not found");
|
|
585
|
+
});
|
|
586
|
+
it("wraps SDK error with descriptive message", async () => {
|
|
587
|
+
mockToolsExecute.mockResolvedValueOnce({
|
|
588
|
+
successful: false,
|
|
589
|
+
error: undefined,
|
|
590
|
+
});
|
|
591
|
+
const notifier = create({ composioApiKey: "k" });
|
|
592
|
+
await expect(notifier.notify(makeEvent())).rejects.toThrow("unknown error");
|
|
593
|
+
});
|
|
594
|
+
it("adds setup guidance when no connected account is found", async () => {
|
|
595
|
+
mockToolsExecute.mockRejectedValueOnce(new Error("No connected account found for user default for toolkit slack"));
|
|
596
|
+
const notifier = create({ composioApiKey: "k" });
|
|
597
|
+
await expect(notifier.notify(makeEvent())).rejects.toThrow("athene setup composio");
|
|
598
|
+
});
|
|
599
|
+
it("uses mail setup guidance for Gmail connection errors", async () => {
|
|
600
|
+
mockToolsExecute.mockRejectedValueOnce(new Error("No connected account found for user aoagent for toolkit gmail"));
|
|
601
|
+
const notifier = create({
|
|
602
|
+
composioApiKey: "k",
|
|
603
|
+
defaultApp: "gmail",
|
|
604
|
+
emailTo: "test@test.com",
|
|
605
|
+
connectedAccountId: "ca_gmail",
|
|
606
|
+
});
|
|
607
|
+
await expect(notifier.notify(makeEvent())).rejects.toThrow("athene setup composio-mail");
|
|
608
|
+
});
|
|
609
|
+
it("requires connectedAccountId before executing Gmail notifications", async () => {
|
|
610
|
+
const notifier = create({
|
|
611
|
+
composioApiKey: "k",
|
|
612
|
+
defaultApp: "gmail",
|
|
613
|
+
emailTo: "test@test.com",
|
|
614
|
+
});
|
|
615
|
+
await expect(notifier.notify(makeEvent())).rejects.toThrow("connectedAccountId is required");
|
|
616
|
+
expect(mockToolsExecute).not.toHaveBeenCalled();
|
|
617
|
+
});
|
|
618
|
+
it("rejects invalid test client overrides", () => {
|
|
619
|
+
expect(() => create({ composioApiKey: "k", _clientOverride: {} })).toThrow("tools.execute");
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
describe("no-op when no apiKey", () => {
|
|
623
|
+
it("does nothing when no api key", async () => {
|
|
624
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
625
|
+
const notifier = create();
|
|
626
|
+
await notifier.notify(makeEvent());
|
|
627
|
+
expect(mockToolsExecute).not.toHaveBeenCalled();
|
|
628
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No composioApiKey"));
|
|
629
|
+
warnSpy.mockRestore();
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
describe("client override", () => {
|
|
633
|
+
it("supports direct @composio/core tools.execute clients", async () => {
|
|
634
|
+
const execute = vi.fn().mockResolvedValue({ successful: true });
|
|
635
|
+
const notifier = create({
|
|
636
|
+
composioApiKey: "k",
|
|
637
|
+
userId: "user_123",
|
|
638
|
+
connectedAccountId: "ca_123",
|
|
639
|
+
_clientOverride: { tools: { execute } },
|
|
640
|
+
});
|
|
641
|
+
await notifier.notify(makeEvent());
|
|
642
|
+
expect(execute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.objectContaining({
|
|
643
|
+
userId: "user_123",
|
|
644
|
+
connectedAccountId: "ca_123",
|
|
645
|
+
arguments: expect.objectContaining({ markdown_text: expect.any(String) }),
|
|
646
|
+
}));
|
|
647
|
+
expect(mockToolsExecute).not.toHaveBeenCalled();
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
//# sourceMappingURL=index.test.js.map
|