Skip to content

Commit 7a5df4a

Browse files
committed
feat: support Discord webhook attachments
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent 94b7aad commit 7a5df4a

3 files changed

Lines changed: 491 additions & 32 deletions

File tree

docs/architecture.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Components are the building blocks of workflows:
102102
| **core** | Utility and data processing | HTTP Request, File Loader, Logic Script, Text Splitter |
103103
| **mcp** | Model Context Protocol servers | MCP Library, AWS MCP Group |
104104
| **it_ops** | IT operations and user management | Atlassian Offboarding, Google Workspace License Unassign, Okta User Offboard |
105-
| **notification** | Alerts and messaging | Slack, Discord, Jira |
105+
| **notification** | Alerts and messaging | Slack, Discord (text, embeds, file attachments), Jira |
106106
| **manual_action** | Human-in-the-loop | Approvals, Forms, Selection |
107107
| **github** | GitHub integrations | Connection Provider, Remove Org Membership |
108108
| **input** | Data sources, triggers, and credential access | Manual Trigger, Text Block, Secret Loader |

worker/src/components/notification/__tests__/discord.test.ts

Lines changed: 161 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { describe, it, expect, mock, beforeEach } from 'bun:test';
22
import { extractPorts } from '@sentris/component-sdk';
3-
import { definition, validateDiscordWebhookUrl } from '../discord';
3+
import {
4+
definition,
5+
validateDiscordWebhookUrl,
6+
normalizeDiscordAttachments,
7+
buildDiscordWebhookRequest,
8+
} from '../discord';
49

510
const VALID_WEBHOOK =
611
'https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz123456';
712

13+
const defaultParams = {
14+
variables: [] as { name: string; type?: string }[],
15+
attachmentFileName: 'report.txt',
16+
attachmentContentFormat: 'text' as const,
17+
attachmentMimeType: 'text/plain',
18+
};
19+
820
describe('Discord Webhook Component', () => {
921
let httpFetchMock: ReturnType<typeof mock>;
1022

@@ -65,7 +77,7 @@ describe('Discord Webhook Component', () => {
6577
},
6678
],
6779
},
68-
params: { variables: [] },
80+
params: defaultParams,
6981
} as any,
7082
mockContext,
7183
);
@@ -88,7 +100,7 @@ describe('Discord Webhook Component', () => {
88100
content: 'Plain text',
89101
embeds: '[{"title": "Join", "description": "{{user}} joined the channel"}]',
90102
},
91-
params: { variables: [] },
103+
params: defaultParams,
92104
} as any,
93105
mockContext,
94106
);
@@ -106,7 +118,7 @@ describe('Discord Webhook Component', () => {
106118
webhookUrl: VALID_WEBHOOK,
107119
content: 'Hello',
108120
},
109-
params: { username: 'Sentris Flow', variables: [] },
121+
params: { ...defaultParams, username: 'Sentris Flow' },
110122
} as any,
111123
mockContext,
112124
);
@@ -133,7 +145,7 @@ describe('Discord Webhook Component', () => {
133145
webhookUrl: VALID_WEBHOOK,
134146
content: 'Hello',
135147
},
136-
params: { variables: [] },
148+
params: defaultParams,
137149
} as any,
138150
mockContext,
139151
);
@@ -149,7 +161,7 @@ describe('Discord Webhook Component', () => {
149161
definition.execute(
150162
{
151163
inputs: { content: 'Hello' },
152-
params: { variables: [] },
164+
params: defaultParams,
153165
} as any,
154166
mockContext,
155167
),
@@ -166,11 +178,148 @@ describe('Discord Webhook Component', () => {
166178
webhookUrl: VALID_WEBHOOK,
167179
content: ' ',
168180
},
169-
params: { variables: [] },
181+
params: {
182+
variables: [],
183+
attachmentFileName: 'report.txt',
184+
attachmentContentFormat: 'text',
185+
attachmentMimeType: 'text/plain',
186+
},
170187
} as any,
171188
mockContext,
172189
),
173-
).rejects.toThrow(/content or embeds/i);
190+
).rejects.toThrow(/content, embeds, or attachments/i);
191+
});
192+
193+
it('sends attachment-only messages via multipart form data', async () => {
194+
const mockContext = createMockContext();
195+
196+
const result = await definition.execute(
197+
{
198+
inputs: {
199+
webhookUrl: VALID_WEBHOOK,
200+
attachmentContent: '{"findings":1}',
201+
},
202+
params: {
203+
...defaultParams,
204+
attachmentFileName: 'report.json',
205+
attachmentContentFormat: 'json',
206+
attachmentMimeType: 'application/json',
207+
},
208+
} as any,
209+
mockContext,
210+
);
211+
212+
expect(result.ok).toBe(true);
213+
const requestInit = httpFetchMock.mock.calls[0][1];
214+
expect(requestInit.headers).toBeUndefined();
215+
expect(requestInit.body).toBeInstanceOf(FormData);
216+
const payload = JSON.parse(String(requestInit.body.get('payload_json')));
217+
expect(payload.attachments).toEqual([{ id: 0, filename: 'report.json' }]);
218+
expect(requestInit.body.get('files[0]')).toBeInstanceOf(Blob);
219+
});
220+
221+
it('sends File Loader objects and embed images using attachment scheme', async () => {
222+
const mockContext = createMockContext();
223+
const pngBytes = Buffer.from('fake-png-bytes');
224+
225+
const result = await definition.execute(
226+
{
227+
inputs: {
228+
webhookUrl: VALID_WEBHOOK,
229+
content: 'Scan complete',
230+
embeds: [
231+
{
232+
title: 'Chart',
233+
image: { url: 'attachment://chart.png' },
234+
},
235+
],
236+
attachments: {
237+
name: 'chart.png',
238+
mimeType: 'image/png',
239+
content: pngBytes.toString('base64'),
240+
},
241+
},
242+
params: defaultParams,
243+
} as any,
244+
mockContext,
245+
);
246+
247+
expect(result.ok).toBe(true);
248+
const requestInit = httpFetchMock.mock.calls[0][1];
249+
const payload = JSON.parse(String(requestInit.body.get('payload_json')));
250+
expect(payload.content).toBe('Scan complete');
251+
expect(payload.embeds[0].image.url).toBe('attachment://chart.png');
252+
expect(payload.attachments).toEqual([{ id: 0, filename: 'chart.png' }]);
253+
});
254+
255+
it('normalizes multiple attachment descriptors', () => {
256+
const parts = normalizeDiscordAttachments({
257+
attachments: [
258+
{
259+
fileName: 'a.txt',
260+
content: 'hello',
261+
contentFormat: 'text',
262+
mimeType: 'text/plain',
263+
},
264+
{
265+
name: 'b.json',
266+
content: Buffer.from('{}').toString('base64'),
267+
mimeType: 'application/json',
268+
contentFormat: 'base64',
269+
},
270+
],
271+
attachmentFileName: 'ignored.txt',
272+
attachmentContentFormat: 'text',
273+
attachmentMimeType: 'text/plain',
274+
});
275+
276+
expect(parts).toHaveLength(2);
277+
expect(parts[0].fileName).toBe('a.txt');
278+
expect(parts[1].fileName).toBe('b.json');
279+
});
280+
281+
it('rejects invalid base64 attachment content', () => {
282+
expect(() =>
283+
normalizeDiscordAttachments({
284+
attachments: {
285+
fileName: 'bad.bin',
286+
content: 'not valid base64!',
287+
contentFormat: 'base64',
288+
mimeType: 'application/octet-stream',
289+
},
290+
attachmentFileName: 'ignored.txt',
291+
attachmentContentFormat: 'text',
292+
attachmentMimeType: 'text/plain',
293+
}),
294+
).toThrow(/base64/i);
295+
});
296+
297+
it('builds JSON requests when no attachments are present', () => {
298+
const request = buildDiscordWebhookRequest({ content: 'hello' }, []);
299+
expect(request.headers).toEqual({ 'Content-Type': 'application/json' });
300+
expect(JSON.parse(String(request.body))).toEqual({ content: 'hello' });
301+
});
302+
303+
it('throws ValidationError for un-serializable embed objects', async () => {
304+
const mockContext = createMockContext();
305+
const circularEmbed: Record<string, unknown> = { title: 'Loop' };
306+
circularEmbed.self = circularEmbed;
307+
308+
await expect(
309+
definition.execute(
310+
{
311+
inputs: {
312+
webhookUrl: VALID_WEBHOOK,
313+
content: 'This should not send',
314+
embeds: circularEmbed,
315+
},
316+
params: defaultParams,
317+
} as any,
318+
mockContext,
319+
),
320+
).rejects.toThrow(/embeds/i);
321+
322+
expect(httpFetchMock).not.toHaveBeenCalled();
174323
});
175324

176325
it('throws ValidationError when content exceeds Discord limit', async () => {
@@ -183,7 +332,7 @@ describe('Discord Webhook Component', () => {
183332
webhookUrl: VALID_WEBHOOK,
184333
content: 'x'.repeat(2001),
185334
},
186-
params: { variables: [] },
335+
params: defaultParams,
187336
} as any,
188337
mockContext,
189338
),
@@ -192,6 +341,7 @@ describe('Discord Webhook Component', () => {
192341

193342
it('resolves dynamic ports for template variables', () => {
194343
const resolved = definition.resolvePorts!({
344+
...defaultParams,
195345
variables: [
196346
{ name: 'error_msg', type: 'string' },
197347
{ name: 'timestamp', type: 'string' },
@@ -201,7 +351,8 @@ describe('Discord Webhook Component', () => {
201351
const ports = extractPorts(resolved.inputs!);
202352
expect(ports.find((portMeta) => portMeta.id === 'webhookUrl')).toBeDefined();
203353
expect(ports.find((portMeta) => portMeta.id === 'content')).toBeDefined();
204-
expect(ports.find((portMeta) => portMeta.id === 'embeds')).toBeDefined();
354+
expect(ports.find((portMeta) => portMeta.id === 'attachments')).toBeDefined();
355+
expect(ports.find((portMeta) => portMeta.id === 'attachmentContent')).toBeDefined();
205356
expect(ports.find((portMeta) => portMeta.id === 'error_msg')).toBeDefined();
206357
expect(ports.find((portMeta) => portMeta.id === 'timestamp')).toBeDefined();
207358
});

0 commit comments

Comments
 (0)