diff --git a/.env.sample b/.env.sample index 11681223..b647924c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,11 +1,14 @@ NODE_ENV= +RETRYABLE_MONITORING_SLACK_WEBHOOK_URL= RETRYABLE_MONITORING_SLACK_TOKEN= RETRYABLE_MONITORING_SLACK_CHANNEL= RETRYABLE_MONITORING_PRIVATE_KEY= +BATCH_POSTER_MONITORING_SLACK_WEBHOOK_URL= BATCH_POSTER_MONITORING_SLACK_TOKEN= BATCH_POSTER_MONITORING_SLACK_CHANNEL= +ASSERTION_MONITORING_SLACK_WEBHOOK_URL= ASSERTION_MONITORING_SLACK_TOKEN= ASSERTION_MONITORING_SLACK_CHANNEL= diff --git a/README.md b/README.md index bfb75660..c3b0b895 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,17 @@ cp config.example.json config.json cp .env.sample .env ``` -2. Set up Slack alerts in `.env` (optional): +2. Set up Slack alerts in `.env` (optional). For each monitor, set either a webhook URL (preferred, scoped to a single channel) or a token + channel; the webhook takes precedence if both are set: ```bash NODE_ENV=CI +RETRYABLE_MONITORING_SLACK_WEBHOOK_URL=your-slack-webhook-url RETRYABLE_MONITORING_SLACK_TOKEN=your-slack-token RETRYABLE_MONITORING_SLACK_CHANNEL=your-slack-channel +BATCH_POSTER_MONITORING_SLACK_WEBHOOK_URL=your-slack-webhook-url BATCH_POSTER_MONITORING_SLACK_TOKEN=your-slack-token BATCH_POSTER_MONITORING_SLACK_CHANNEL=your-slack-channel +ASSERTION_MONITORING_SLACK_WEBHOOK_URL=your-slack-webhook-url ASSERTION_MONITORING_SLACK_TOKEN=your-slack-token ASSERTION_MONITORING_SLACK_CHANNEL=your-slack-channel ``` diff --git a/packages/assertion-monitor/README.md b/packages/assertion-monitor/README.md index bbc7882c..db5f6587 100644 --- a/packages/assertion-monitor/README.md +++ b/packages/assertion-monitor/README.md @@ -25,6 +25,7 @@ Examples: yarn assertion-monitor --configPath=custom.json Use custom config file Environment Variables: + ASSERTION_MONITORING_SLACK_WEBHOOK_URL Slack webhook URL for alerts (preferred over token+channel) ASSERTION_MONITORING_SLACK_TOKEN Slack API token for alerts ASSERTION_MONITORING_SLACK_CHANNEL Slack channel for alerts ``` diff --git a/packages/assertion-monitor/reportAssertionMonitorAlertToSlack.ts b/packages/assertion-monitor/reportAssertionMonitorAlertToSlack.ts index a7f18b54..6bc02812 100644 --- a/packages/assertion-monitor/reportAssertionMonitorAlertToSlack.ts +++ b/packages/assertion-monitor/reportAssertionMonitorAlertToSlack.ts @@ -1,6 +1,7 @@ import { createSlackPoster } from 'utils' export const reportAssertionMonitorErrorToSlack = createSlackPoster({ + webhookUrlEnvVar: 'ASSERTION_MONITORING_SLACK_WEBHOOK_URL', tokenEnvVar: 'ASSERTION_MONITORING_SLACK_TOKEN', channelEnvVar: 'ASSERTION_MONITORING_SLACK_CHANNEL', }) diff --git a/packages/batch-poster-monitor/README.md b/packages/batch-poster-monitor/README.md index f341fd68..7e69b4f0 100644 --- a/packages/batch-poster-monitor/README.md +++ b/packages/batch-poster-monitor/README.md @@ -25,6 +25,7 @@ Examples: yarn batch-poster-monitor --configPath=custom.json Use custom config file Environment Variables: + BATCH_POSTER_MONITORING_SLACK_WEBHOOK_URL Slack webhook URL for alerts (preferred over token+channel) BATCH_POSTER_MONITORING_SLACK_TOKEN Slack API token for alerts BATCH_POSTER_MONITORING_SLACK_CHANNEL Slack channel for alerts ``` diff --git a/packages/batch-poster-monitor/reportBatchPosterAlertToSlack.ts b/packages/batch-poster-monitor/reportBatchPosterAlertToSlack.ts index 52925c15..4feb02bc 100644 --- a/packages/batch-poster-monitor/reportBatchPosterAlertToSlack.ts +++ b/packages/batch-poster-monitor/reportBatchPosterAlertToSlack.ts @@ -1,6 +1,7 @@ import { createSlackPoster } from 'utils' export const reportBatchPosterErrorToSlack = createSlackPoster({ + webhookUrlEnvVar: 'BATCH_POSTER_MONITORING_SLACK_WEBHOOK_URL', tokenEnvVar: 'BATCH_POSTER_MONITORING_SLACK_TOKEN', channelEnvVar: 'BATCH_POSTER_MONITORING_SLACK_CHANNEL', }) diff --git a/packages/retryable-monitor/README.md b/packages/retryable-monitor/README.md index 51ef3ed7..765d82e6 100644 --- a/packages/retryable-monitor/README.md +++ b/packages/retryable-monitor/README.md @@ -29,6 +29,7 @@ Examples: yarn retryable-monitor --enableAlerting --writeToNotion Enables Slack alerts and syncs retryable data to Notion Environment Variables: + RETRYABLE_MONITORING_SLACK_WEBHOOK_URL Slack webhook URL for alerts (preferred over token+channel) RETRYABLE_MONITORING_SLACK_TOKEN Slack API token for alerts RETRYABLE_MONITORING_SLACK_CHANNEL Slack channel for alerts RETRYABLE_MONITORING_NOTION_TOKEN Notion integration token diff --git a/packages/retryable-monitor/handlers/slack/postSlackMessage.ts b/packages/retryable-monitor/handlers/slack/postSlackMessage.ts index b29963e5..487da249 100644 --- a/packages/retryable-monitor/handlers/slack/postSlackMessage.ts +++ b/packages/retryable-monitor/handlers/slack/postSlackMessage.ts @@ -1,6 +1,7 @@ import { createSlackPoster } from 'utils' export const postSlackMessage = createSlackPoster({ + webhookUrlEnvVar: 'RETRYABLE_MONITORING_SLACK_WEBHOOK_URL', tokenEnvVar: 'RETRYABLE_MONITORING_SLACK_TOKEN', channelEnvVar: 'RETRYABLE_MONITORING_SLACK_CHANNEL', }) diff --git a/packages/utils/__test__/createSlackPoster.test.ts b/packages/utils/__test__/createSlackPoster.test.ts index 271c244d..7f4733ae 100644 --- a/packages/utils/__test__/createSlackPoster.test.ts +++ b/packages/utils/__test__/createSlackPoster.test.ts @@ -5,7 +5,12 @@ vi.mock('../postSlackMessage', () => ({ postSlackMessage: vi.fn().mockResolvedValue({ ok: true }), })) +vi.mock('../postSlackMessageViaWebhook', () => ({ + postSlackMessageViaWebhook: vi.fn().mockResolvedValue({ ok: true }), +})) + import { postSlackMessage } from '../postSlackMessage' +import { postSlackMessageViaWebhook } from '../postSlackMessageViaWebhook' describe('createSlackPoster', () => { const originalEnv = process.env @@ -15,6 +20,7 @@ describe('createSlackPoster', () => { process.env = { ...originalEnv } delete process.env.TEST_SLACK_TOKEN delete process.env.TEST_SLACK_CHANNEL + delete process.env.TEST_SLACK_WEBHOOK_URL delete process.env.NODE_ENV }) @@ -40,6 +46,84 @@ describe('createSlackPoster', () => { expect(() => poster({ message: 'test' })).toThrow('Slack channel is required.') }) + test('throws with either-or message when webhook is supported but nothing is configured', () => { + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + expect(() => poster({ message: 'test' })).toThrow( + 'Slack configuration is required: set TEST_SLACK_WEBHOOK_URL (preferred) or both TEST_SLACK_TOKEN and TEST_SLACK_CHANNEL.' + ) + }) + + test('throws with either-or message when webhook is supported and only token is set', () => { + process.env.TEST_SLACK_TOKEN = 'token' + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + expect(() => poster({ message: 'test' })).toThrow( + 'Slack configuration is required: set TEST_SLACK_WEBHOOK_URL (preferred) or both TEST_SLACK_TOKEN and TEST_SLACK_CHANNEL.' + ) + }) + + test('posts via webhook when only webhook is configured', () => { + process.env.TEST_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/x' + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + + poster({ message: 'Hello' }) + + expect(postSlackMessageViaWebhook).toHaveBeenCalledWith({ + webhookUrl: 'https://hooks.slack.com/services/x', + message: 'Hello', + }) + expect(postSlackMessage).not.toHaveBeenCalled() + }) + + test('webhook takes precedence when both webhook and token+channel are configured', () => { + process.env.TEST_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/x' + process.env.TEST_SLACK_TOKEN = 'token' + process.env.TEST_SLACK_CHANNEL = 'channel' + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + + poster({ message: 'Hello' }) + + expect(postSlackMessageViaWebhook).toHaveBeenCalledWith({ + webhookUrl: 'https://hooks.slack.com/services/x', + message: 'Hello', + }) + expect(postSlackMessage).not.toHaveBeenCalled() + }) + + test('falls back to token+channel when webhook is supported but not set', () => { + process.env.TEST_SLACK_TOKEN = 'token' + process.env.TEST_SLACK_CHANNEL = 'channel' + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + + poster({ message: 'Hello' }) + + expect(postSlackMessage).toHaveBeenCalledWith({ + slackToken: 'token', + slackChannel: 'channel', + message: 'Hello', + }) + expect(postSlackMessageViaWebhook).not.toHaveBeenCalled() + }) + test('skips posting in DEV mode', () => { process.env.TEST_SLACK_TOKEN = 'token' process.env.TEST_SLACK_CHANNEL = 'channel' @@ -54,6 +138,20 @@ describe('createSlackPoster', () => { expect(postSlackMessage).not.toHaveBeenCalled() }) + test('skips posting in DEV mode with webhook config', () => { + process.env.TEST_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/x' + process.env.NODE_ENV = 'DEV' + + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + + expect(poster({ message: 'test' })).toBeUndefined() + expect(postSlackMessageViaWebhook).not.toHaveBeenCalled() + }) + test('skips posting in CI mode when message is "success"', () => { process.env.TEST_SLACK_TOKEN = 'token' process.env.TEST_SLACK_CHANNEL = 'channel' @@ -68,6 +166,20 @@ describe('createSlackPoster', () => { expect(postSlackMessage).not.toHaveBeenCalled() }) + test('skips posting in CI mode when message is "success" with webhook config', () => { + process.env.TEST_SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/x' + process.env.NODE_ENV = 'CI' + + const poster = createSlackPoster({ + tokenEnvVar: 'TEST_SLACK_TOKEN', + channelEnvVar: 'TEST_SLACK_CHANNEL', + webhookUrlEnvVar: 'TEST_SLACK_WEBHOOK_URL', + }) + + expect(poster({ message: 'success' })).toBeUndefined() + expect(postSlackMessageViaWebhook).not.toHaveBeenCalled() + }) + test('posts in CI mode when message is not "success"', () => { process.env.TEST_SLACK_TOKEN = 'token' process.env.TEST_SLACK_CHANNEL = 'channel' diff --git a/packages/utils/createSlackPoster.ts b/packages/utils/createSlackPoster.ts index 8079ef08..04ab9b9f 100644 --- a/packages/utils/createSlackPoster.ts +++ b/packages/utils/createSlackPoster.ts @@ -1,25 +1,45 @@ import { postSlackMessage } from './postSlackMessage' +import { postSlackMessageViaWebhook } from './postSlackMessageViaWebhook' export const createSlackPoster = ({ tokenEnvVar, channelEnvVar, + webhookUrlEnvVar, }: { tokenEnvVar: string channelEnvVar: string + webhookUrlEnvVar?: string }) => { return ({ message }: { message: string }) => { + const slackWebhookUrl = webhookUrlEnvVar + ? process.env[webhookUrlEnvVar] + : undefined const slackToken = process.env[tokenEnvVar] const slackChannel = process.env[channelEnvVar] - if (!slackToken) throw new Error('Slack token is required.') - if (!slackChannel) throw new Error('Slack channel is required.') + if (!slackWebhookUrl && !(slackToken && slackChannel)) { + if (webhookUrlEnvVar) { + throw new Error( + `Slack configuration is required: set ${webhookUrlEnvVar} (preferred) or both ${tokenEnvVar} and ${channelEnvVar}.` + ) + } + if (!slackToken) throw new Error('Slack token is required.') + throw new Error('Slack channel is required.') + } if (process.env.NODE_ENV === 'DEV') return if (process.env.NODE_ENV === 'CI' && message === 'success') return + if (slackWebhookUrl) { + return postSlackMessageViaWebhook({ + webhookUrl: slackWebhookUrl, + message, + }) + } + return postSlackMessage({ - slackToken, - slackChannel, + slackToken: slackToken as string, + slackChannel: slackChannel as string, message, }) } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 61dc528f..7d7e9812 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -2,6 +2,7 @@ export * from './types' export * from './config' export { getExplorerUrlPrefixes } from './getExplorerUrlPrefixes' export { postSlackMessage } from './postSlackMessage' +export { postSlackMessageViaWebhook } from './postSlackMessageViaWebhook' export { createSlackPoster } from './createSlackPoster' export { parseAmount } from './amountUtils' export { resolveRollupAddress } from './resolveRollupAddress' diff --git a/packages/utils/postSlackMessageViaWebhook.ts b/packages/utils/postSlackMessageViaWebhook.ts new file mode 100644 index 00000000..4b719284 --- /dev/null +++ b/packages/utils/postSlackMessageViaWebhook.ts @@ -0,0 +1,35 @@ +import { sanitizeSlackMessage } from './sanitizeSlackMessage' +import { formatGitHubCIInfo } from './githubCIUtils' + +export const postSlackMessageViaWebhook = async ({ + webhookUrl, + message, +}: { + webhookUrl: string + message: string +}) => { + // Append GitHub CI run information if available + const ciInfo = formatGitHubCIInfo() + const messageWithCIInfo = ciInfo ? `${message}\n\n${ciInfo}` : message + + console.log(`>>> Posting message to Slack (webhook) -> ${messageWithCIInfo}`) + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: sanitizeSlackMessage(messageWithCIInfo), + unfurl_links: false, + }), + }) + + if (!response.ok) { + throw new Error( + `Failed to post Slack message via webhook: ${ + response.status + } ${await response.text()}` + ) + } + + return response +}