From d4036d7b552d756309c6e567e0f570f7cf5b1e02 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Thu, 23 Apr 2026 13:10:50 -0300 Subject: [PATCH 1/6] feat: offchain vote ens resolution --- .../triggers/offchain-vote-cast-trigger.service.test.ts | 3 +-- .../services/triggers/offchain-vote-cast-trigger.service.ts | 4 ++-- .../tests/telegram/offchain-vote-cast-trigger.test.ts | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.test.ts b/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.test.ts index 6e20d94c..c0678d32 100644 --- a/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.test.ts +++ b/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.test.ts @@ -75,7 +75,6 @@ function buildExpectedMessage(vote: OffchainVoteWithDaoId): string { : offchainVoteCastMessages.withoutReason; return replacePlaceholders(template, { - address: vote.voter, daoId: vote.daoId, proposalTitle: vote.proposalTitle, ...(hasReason && { reason: vote.reason! }), @@ -89,7 +88,7 @@ function buildExpectedPayload(vote: OffchainVoteWithDaoId): NotificationPayload channelUserId: STUB_USER.channel_user_id, message: buildExpectedMessage(vote), bot_token: undefined, - metadata: undefined, + metadata: { addresses: { address: vote.voter } }, }; } diff --git a/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.ts b/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.ts index a252fd40..9a99ab0c 100644 --- a/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.ts +++ b/apps/dispatcher/src/services/triggers/offchain-vote-cast-trigger.service.ts @@ -131,7 +131,8 @@ export class OffchainVoteCastTriggerHandler extends BaseTriggerHandler { expect(message.text).toContain('🗳️'); expect(message.text).toContain(proposalTitle); expect(message.text).toContain('Reason: "Fully support this initiative!"'); + expect(message.text).toContain('vitalik.eth'); + expect(message.text).not.toContain(voterAddress); }); test('should NOT send duplicate notifications for same offchain vote', async () => { From 63a8267babdc296eb6fea5b7563da3a0aa8e7a46 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Mon, 4 May 2026 16:52:20 -0300 Subject: [PATCH 2/6] feat: render notification buttons in rows --- .../src/interfaces/notification.interface.ts | 4 +-- .../src/services/bot/slack-bot.service.ts | 2 +- .../src/services/bot/telegram-bot.service.ts | 23 ++++++------- .../services/batch-notification.service.ts | 6 ++-- .../services/triggers/base-trigger.service.ts | 2 +- ...-offchain-proposal-trigger.service.test.ts | 6 ++-- .../new-proposal-trigger.service.test.ts | 4 +-- .../non-voting-handler.service.test.ts | 10 +++--- ...-proposal-finished-trigger.service.test.ts | 12 +++---- packages/messages/src/triggers/buttons.ts | 34 ++++++++----------- 10 files changed, 49 insertions(+), 54 deletions(-) diff --git a/apps/consumers/src/interfaces/notification.interface.ts b/apps/consumers/src/interfaces/notification.interface.ts index e0e6f525..efd735ba 100644 --- a/apps/consumers/src/interfaces/notification.interface.ts +++ b/apps/consumers/src/interfaces/notification.interface.ts @@ -15,10 +15,10 @@ export interface NotificationPayload { hash: string; chainId: number; }; - buttons?: Array<{ + buttons?: Array; + }>>; [key: string]: any; }; } diff --git a/apps/consumers/src/services/bot/slack-bot.service.ts b/apps/consumers/src/services/bot/slack-bot.service.ts index e56a993a..fc5e2666 100644 --- a/apps/consumers/src/services/bot/slack-bot.service.ts +++ b/apps/consumers/src/services/bot/slack-bot.service.ts @@ -253,7 +253,7 @@ export class SlackBotService implements BotServiceInterface { // Append UTM tracking params to button URLs const triggerType = payload.metadata?.triggerType; - const buttons = payload.metadata?.buttons?.map(btn => ({ + const buttons = payload.metadata?.buttons?.flat().map(btn => ({ text: btn.text, url: triggerType ? appendUtmParams(btn.url, { source: 'notification', medium: 'slack', campaign: triggerType }) diff --git a/apps/consumers/src/services/bot/telegram-bot.service.ts b/apps/consumers/src/services/bot/telegram-bot.service.ts index a283d839..a045e38c 100644 --- a/apps/consumers/src/services/bot/telegram-bot.service.ts +++ b/apps/consumers/src/services/bot/telegram-bot.service.ts @@ -256,19 +256,16 @@ export class TelegramBotService implements BotServiceInterface { // Append UTM tracking params to button URLs const triggerType = payload.metadata?.triggerType; - const buttons = payload.metadata?.buttons?.map(btn => ({ - text: btn.text, - url: triggerType - ? appendUtmParams(btn.url, { source: 'notification', medium: 'telegram', campaign: triggerType }) - : btn.url - })); - - // Build inline keyboard if buttons are provided - const replyMarkup = buttons ? { - inline_keyboard: [[ - ...buttons.map(btn => ({ text: btn.text, url: btn.url })) - ]] - } : undefined; + const withUtm = (url: string) => triggerType + ? appendUtmParams(url, { source: 'notification', medium: 'telegram', campaign: triggerType }) + : url; + + const buttonRows = payload.metadata?.buttons; + const replyMarkup = buttonRows + ? Markup.inlineKeyboard( + buttonRows.map(row => row.map(btn => Markup.button.url(btn.text, withUtm(btn.url)))) + ).reply_markup + : undefined; const sentMessage = await this.telegramClient.sendMessage( payload.channelUserId, diff --git a/apps/dispatcher/src/services/batch-notification.service.ts b/apps/dispatcher/src/services/batch-notification.service.ts index c0b5decf..ec2bee87 100644 --- a/apps/dispatcher/src/services/batch-notification.service.ts +++ b/apps/dispatcher/src/services/batch-notification.service.ts @@ -88,7 +88,7 @@ export class BatchNotificationService { validNotifications: BatchNotificationData[], messageGenerator: (address: string) => string, metadataGenerator?: (address: string) => Record, - buttonsGenerator?: (address: string) => Array<{ text: string; url: string }> + buttonsGenerator?: (address: string) => Array> ): Promise { const sendPromises: Promise[] = []; const allNotificationsToMark: Notification[] = []; @@ -132,7 +132,7 @@ export class BatchNotificationService { followerMap: Map, message: string, metadata: Record | undefined, - buttons: Array<{ text: string; url: string }> | undefined, + buttons: Array> | undefined, sendPromises: Promise[] ): void { for (const notification of notificationsToSend) { @@ -178,7 +178,7 @@ export class BatchNotificationService { eventIdGenerator: (address: string) => string, messageGenerator: (address: string) => string, metadataGenerator?: (address: string) => Record, - buttonsGenerator?: (address: string) => Array<{ text: string; url: string }> + buttonsGenerator?: (address: string) => Array> ): Promise { const batchData = await this.prepareBatchData(addresses, daoId, eventIdGenerator, triggerType); const validNotifications = this.filterValidNotifications(batchData); diff --git a/apps/dispatcher/src/services/triggers/base-trigger.service.ts b/apps/dispatcher/src/services/triggers/base-trigger.service.ts index a98a97f8..66cc8fd8 100644 --- a/apps/dispatcher/src/services/triggers/base-trigger.service.ts +++ b/apps/dispatcher/src/services/triggers/base-trigger.service.ts @@ -69,7 +69,7 @@ export abstract class BaseTriggerHandler implements TriggerHandler { eventId: string, daoId: string, metadata?: { transaction?: { hash: string; chainId: number }; [key: string]: any, addresses?: Record }, - buttons?: Array<{ text: string; url: string }> + buttons?: Array> ): Promise { const supportedSubscribers = subscribers.filter(subscriber => this.notificationFactory.supportsChannel(subscriber.channel) diff --git a/apps/dispatcher/src/services/triggers/new-offchain-proposal-trigger.service.test.ts b/apps/dispatcher/src/services/triggers/new-offchain-proposal-trigger.service.test.ts index 8aae48d9..b5a53d57 100644 --- a/apps/dispatcher/src/services/triggers/new-offchain-proposal-trigger.service.test.ts +++ b/apps/dispatcher/src/services/triggers/new-offchain-proposal-trigger.service.test.ts @@ -128,7 +128,7 @@ describe('NewOffchainProposalTriggerHandler', () => { await handler.handleMessage(message); - const buttons = notificationClient.sentPayloads[0].metadata?.buttons; + const buttons = notificationClient.sentPayloads[0].metadata?.buttons?.flat(); expect(buttons).toBeDefined(); expect(buttons[0].text).toBe('Cast your vote'); expect(buttons[0].url).toBe('https://snapshot.org/#/test-dao/proposal/snap-1'); @@ -147,7 +147,7 @@ describe('NewOffchainProposalTriggerHandler', () => { await handler.handleMessage(message); - const buttons = notificationClient.sentPayloads[0].metadata?.buttons; + const buttons = notificationClient.sentPayloads[0].metadata?.buttons?.flat(); expect(buttons).toHaveLength(2); expect(buttons[1].text).toBe('View Discussion'); expect(buttons[1].url).toBe('https://forum.example.com/123'); @@ -166,7 +166,7 @@ describe('NewOffchainProposalTriggerHandler', () => { await handler.handleMessage(message); - const buttons = notificationClient.sentPayloads[0].metadata?.buttons; + const buttons = notificationClient.sentPayloads[0].metadata?.buttons?.flat(); expect(buttons).toHaveLength(1); }); diff --git a/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts b/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts index 90177cdc..a656f5cb 100644 --- a/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts +++ b/apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts @@ -160,7 +160,7 @@ describe('NewProposalTriggerHandler', () => { await handler.handleMessage(mockMessage); const call = mockNotificationClient.sendNotification.mock.calls[0][0]; - const buttons = call.metadata?.buttons; + const buttons = call.metadata?.buttons?.flat(); expect(buttons).toBeDefined(); expect(buttons.some((b: any) => b.text.includes('Request a call-data review'))).toBe(true); }); @@ -178,7 +178,7 @@ describe('NewProposalTriggerHandler', () => { await handler.handleMessage(mockMessage); const call = mockNotificationClient.sendNotification.mock.calls[0][0]; - const buttons = call.metadata?.buttons; + const buttons = call.metadata?.buttons?.flat(); expect(buttons).toBeDefined(); expect(buttons.some((b: any) => b.text.includes('Request a call-data review'))).toBe(false); }); diff --git a/apps/dispatcher/src/services/triggers/non-voting-handler.service.test.ts b/apps/dispatcher/src/services/triggers/non-voting-handler.service.test.ts index 253c3ed7..6ddc4820 100644 --- a/apps/dispatcher/src/services/triggers/non-voting-handler.service.test.ts +++ b/apps/dispatcher/src/services/triggers/non-voting-handler.service.test.ts @@ -210,10 +210,12 @@ describe('NonVotingHandler', () => { 'nonVoterAddress': TestAddresses.ADDRESS_LONG }, buttons: [ - { - text: 'Check previous votes', - url: `https://anticapture.com/ENS/holders-and-delegates?tab=delegates&drawerAddress=${TestAddresses.ADDRESS_LONG}` - } + [ + { + text: 'Check previous votes', + url: `https://anticapture.com/ENS/holders-and-delegates?tab=delegates&drawerAddress=${TestAddresses.ADDRESS_LONG}` + } + ] ] } }); diff --git a/apps/dispatcher/src/services/triggers/offchain-proposal-finished-trigger.service.test.ts b/apps/dispatcher/src/services/triggers/offchain-proposal-finished-trigger.service.test.ts index 304d673a..15bb2104 100644 --- a/apps/dispatcher/src/services/triggers/offchain-proposal-finished-trigger.service.test.ts +++ b/apps/dispatcher/src/services/triggers/offchain-proposal-finished-trigger.service.test.ts @@ -88,7 +88,7 @@ describe('OffchainProposalFinishedTriggerHandler', () => { channelUserId: '123', message: '📊 Snapshot proposal "Grant Program" has ended on DAO test-dao', metadata: { - buttons: [{ text: 'View proposal results', url: 'https://snapshot.org/#/test-dao/proposal/snap-1' }], + buttons: [[{ text: 'View proposal results', url: 'https://snapshot.org/#/test-dao/proposal/snap-1' }]], }, }]); }); @@ -113,7 +113,7 @@ describe('OffchainProposalFinishedTriggerHandler', () => { channelUserId: '123', message: '📊 A Snapshot proposal has ended on DAO test-dao', metadata: { - buttons: [{ text: 'View proposal results', url: 'https://snapshot.org/#/test-dao/proposal/snap-1' }], + buttons: [[{ text: 'View proposal results', url: 'https://snapshot.org/#/test-dao/proposal/snap-1' }]], }, }]); }); @@ -176,7 +176,7 @@ describe('OffchainProposalFinishedTriggerHandler', () => { channelUserId: '123', message: '📊 Snapshot proposal "Test" has ended on DAO test-dao', metadata: { - buttons: [{ text: 'View proposal results', url: 'https://snapshot.org/#/test-dao/proposal/snap-1' }], + buttons: [[{ text: 'View proposal results', url: 'https://snapshot.org/#/test-dao/proposal/snap-1' }]], }, }]); }); @@ -200,17 +200,17 @@ describe('OffchainProposalFinishedTriggerHandler', () => { { userId: 'user-1', channel: 'telegram', channelUserId: '123', message: '📊 Snapshot proposal "Proposal A" has ended on DAO dao-a', - metadata: { buttons: [{ text: 'View proposal results', url: 'https://snapshot.org/#/dao-a/proposal/snap-1' }] }, + metadata: { buttons: [[{ text: 'View proposal results', url: 'https://snapshot.org/#/dao-a/proposal/snap-1' }]] }, }, { userId: 'user-2', channel: 'telegram', channelUserId: '456', message: '📊 Snapshot proposal "Proposal A" has ended on DAO dao-a', - metadata: { buttons: [{ text: 'View proposal results', url: 'https://snapshot.org/#/dao-a/proposal/snap-1' }] }, + metadata: { buttons: [[{ text: 'View proposal results', url: 'https://snapshot.org/#/dao-a/proposal/snap-1' }]] }, }, { userId: 'user-1', channel: 'telegram', channelUserId: '123', message: '📊 Snapshot proposal "Proposal B" has ended on DAO dao-b', - metadata: { buttons: [{ text: 'View proposal results', url: 'https://snapshot.org/#/dao-b/proposal/snap-2' }] }, + metadata: { buttons: [[{ text: 'View proposal results', url: 'https://snapshot.org/#/dao-b/proposal/snap-2' }]] }, }, ]); }); diff --git a/packages/messages/src/triggers/buttons.ts b/packages/messages/src/triggers/buttons.ts index 19425904..a5a0b224 100644 --- a/packages/messages/src/triggers/buttons.ts +++ b/packages/messages/src/triggers/buttons.ts @@ -121,13 +121,11 @@ export interface BuildButtonsParams { const explorerService = new ExplorerService(); /** - * Build buttons for a notification - * Always includes CTA button with dynamic URL, optionally includes scan button + * Build buttons for a notification, organized as rows. + * Each inner array is a row rendered side-by-side; outer array stacks rows top-to-bottom. */ -export function buildButtons(params: BuildButtonsParams): Button[] { - const buttons: Button[] = []; +export function buildButtons(params: BuildButtonsParams): Button[][] { const config = ctaButtonConfigs[params.triggerType]; - const url = config.buildUrl({ daoId: params.daoId, address: params.address, @@ -135,28 +133,26 @@ export function buildButtons(params: BuildButtonsParams): Button[] { proposalUrl: params.proposalUrl }); - buttons.push({ text: config.text, url }); + const mainRow: Button[] = [{ text: config.text, url }]; - // Add discussion button if forum URL is available if (params.discussionUrl) { - buttons.push({ text: discussionButtonText, url: params.discussionUrl }); + mainRow.push({ text: discussionButtonText, url: params.discussionUrl }); + } + + if (params.txHash && params.chainId) { + const scanUrl = explorerService.getTransactionLink(params.chainId, params.txHash); + if (scanUrl) mainRow.push({ text: scanButtonText, url: scanUrl }); } - // Add calldata review button for new proposals when DAO doesn't natively support it + const rows: Button[][] = [mainRow]; + + // Calldata review gets its own row when the DAO doesn't natively support it if (params.alreadySupportCalldataReview === false) { const message = encodeURIComponent( `Hi, I'd like to request a call-data review for proposal ${params.proposalId ?? 'unknown'} in ${params.daoId ?? 'unknown'}.` ); - buttons.push({ text: '🔎 Request a call-data review', url: `https://t.me/Zeugh?text=${message}` }); - } - - // Add scan button if transaction info is available - if (params.txHash && params.chainId) { - const scanUrl = explorerService.getTransactionLink(params.chainId, params.txHash); - if (scanUrl) { - buttons.push({ text: scanButtonText, url: scanUrl }); - } + rows.push([{ text: '🔎 Request a call-data review', url: `https://t.me/Zeugh?text=${message}` }]); } - return buttons; + return rows; } From 18faf50ccc93d0d1634652e71b918db595335f2f Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Mon, 4 May 2026 16:52:23 -0300 Subject: [PATCH 3/6] test: isolate integrated-test service ports --- apps/integrated-tests/src/config/services.ts | 8 ++++++-- apps/integrated-tests/src/setup/services/apps.ts | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/integrated-tests/src/config/services.ts b/apps/integrated-tests/src/config/services.ts index e5b36420..5982a69d 100644 --- a/apps/integrated-tests/src/config/services.ts +++ b/apps/integrated-tests/src/config/services.ts @@ -4,13 +4,17 @@ import { env } from './env'; -// Generate random port to avoid conflicts in parallel tests +// Generate random ports to avoid conflicts in parallel tests const randomPort = 14000 + Math.floor(Math.random() * 1000); +const randomDispatcherPort = 15000 + Math.floor(Math.random() * 1000); +const randomLogicSystemPort = 16000 + Math.floor(Math.random() * 1000); export const serviceConfig = { - // Service ports + // Service ports ports: { subscriptionServer: randomPort, + dispatcher: randomDispatcherPort, + logicSystem: randomLogicSystemPort, }, // Service URLs diff --git a/apps/integrated-tests/src/setup/services/apps.ts b/apps/integrated-tests/src/setup/services/apps.ts index f15910a1..fe5c9f0a 100644 --- a/apps/integrated-tests/src/setup/services/apps.ts +++ b/apps/integrated-tests/src/setup/services/apps.ts @@ -54,6 +54,8 @@ export type TestApps = { const TEST_CONFIG = { ports: { subscriptionServer: serviceConfig.ports.subscriptionServer, + dispatcher: serviceConfig.ports.dispatcher, + logicSystem: serviceConfig.ports.logicSystem, }, urls: { subscriptionServer: `http://127.0.0.1:${serviceConfig.ports.subscriptionServer}`, @@ -205,9 +207,10 @@ const startDispatcher = async ( mockHttpClient: any ): Promise => { const dispatcherApp = new DispatcherApp( - TEST_CONFIG.urls.subscriptionServer, + TEST_CONFIG.urls.subscriptionServer, rabbitmqUrl, TEST_CONFIG.urls.mockGraphQL, + TEST_CONFIG.ports.dispatcher, mockHttpClient ); await dispatcherApp.start(); @@ -231,6 +234,7 @@ const startLogicSystem = async ( QueryInput_Proposals_Status_Items.Active, mockHttpClient, rabbitmqUrl, + TEST_CONFIG.ports.logicSystem, oneYearAgo ); await logicSystemApp.start(); From a055774bcd66d24a09407f2cabf5f8b74c66e52c Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Mon, 4 May 2026 16:52:27 -0300 Subject: [PATCH 4/6] test: raise rabbitmq container startup timeout --- apps/integrated-tests/src/setup/jest/jest-global-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/integrated-tests/src/setup/jest/jest-global-setup.ts b/apps/integrated-tests/src/setup/jest/jest-global-setup.ts index fd9ba0e1..a3f63a91 100644 --- a/apps/integrated-tests/src/setup/jest/jest-global-setup.ts +++ b/apps/integrated-tests/src/setup/jest/jest-global-setup.ts @@ -13,7 +13,7 @@ declare global { */ export default async function globalSetup() { const container = await new RabbitMQContainer() - .withStartupTimeout(30000) + .withStartupTimeout(60000) .start(); let amqpUrl = container.getAmqpUrl(); From 0b088375962a83eb5ee8271efc6a6517a64f161f Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Mon, 4 May 2026 16:52:30 -0300 Subject: [PATCH 5/6] test: extend user-preferences repo migration timeout --- .../user-notification-preferences.repository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/subscription-server/src/repositories/user-notification-preferences.repository.test.ts b/apps/subscription-server/src/repositories/user-notification-preferences.repository.test.ts index dcfd8c9d..ad18f968 100644 --- a/apps/subscription-server/src/repositories/user-notification-preferences.repository.test.ts +++ b/apps/subscription-server/src/repositories/user-notification-preferences.repository.test.ts @@ -49,7 +49,7 @@ beforeAll(async () => { db = createTestDb(); await db.migrate.latest(); repo = new UserNotificationPreferencesRepository(db); -}); +}, 30_000); afterAll(async () => { await db.destroy(); From 97884baa27c54253427c490e1de4e0fa3948aba6 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Thu, 7 May 2026 14:41:04 -0300 Subject: [PATCH 6/6] refactor: node_version_on_workflow --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 54d2f030..efe97230 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,10 +18,10 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "18" + node-version: "20" - uses: pnpm/action-setup@v2 with: - version: latest + version: 10.14.0 - name: Get pnpm store directory shell: bash run: |