diff --git a/apps/consumers/src/interfaces/bot.interface.ts b/apps/consumers/src/interfaces/bot.interface.ts index 1d154227..23102f03 100644 --- a/apps/consumers/src/interfaces/bot.interface.ts +++ b/apps/consumers/src/interfaces/bot.interface.ts @@ -10,6 +10,7 @@ export interface ContextWithSession extends Context { walletAction?: 'add' | 'remove'; walletsToRemove?: Set; awaitingWalletInput?: boolean; + fromStart?: boolean; }; } diff --git a/apps/consumers/src/interfaces/slack-context.interface.ts b/apps/consumers/src/interfaces/slack-context.interface.ts index 921e3e1b..fc691b44 100644 --- a/apps/consumers/src/interfaces/slack-context.interface.ts +++ b/apps/consumers/src/interfaces/slack-context.interface.ts @@ -33,6 +33,7 @@ export interface SlackBodyWithIds { }; }; } | string; // Can be string for DialogSubmitAction or object for BlockAction + actions?: Array<{ action_id?: string; value?: string; type?: string }>; } /** @@ -56,6 +57,7 @@ export interface SlackSession { type: 'wallet' | 'dao'; action: 'add' | 'remove'; }; + fromStart?: boolean; } /** diff --git a/apps/consumers/src/services/bot/slack-bot.service.ts b/apps/consumers/src/services/bot/slack-bot.service.ts index 4e826da6..80aec5f8 100644 --- a/apps/consumers/src/services/bot/slack-bot.service.ts +++ b/apps/consumers/src/services/bot/slack-bot.service.ts @@ -48,6 +48,11 @@ export class SlackBotService implements BotServiceInterface { // Welcome message actions handlers.action('welcome_select_daos', async (ctx) => { if (this.daoService) { + const channelId = ctx.body.channel?.id; + const workspaceId = ctx.body.team?.id || ctx.body.user?.team_id; + const fullUserId = `${workspaceId}:${channelId}`; + const hasDaos = await this.daoService.hasSubscriptions(fullUserId); + ctx.session.fromStart = !hasDaos; await this.daoService.initialize(ctx); } }); @@ -74,6 +79,12 @@ export class SlackBotService implements BotServiceInterface { handlers.action('dao_confirm_subscribe', async (ctx) => { if (this.daoService) { await this.daoService.confirm(ctx); + + // If from onboarding flow, trigger wallet setup + if (ctx.session.fromStart && this.walletService) { + await this.walletService.showOnboardingWallet(ctx); + ctx.session.fromStart = false; + } } }); @@ -81,6 +92,7 @@ export class SlackBotService implements BotServiceInterface { await ctx.ack(); }); + handlers.action('wallet_checkboxes', async (ctx) => { await ctx.ack(); }); @@ -94,6 +106,8 @@ export class SlackBotService implements BotServiceInterface { handlers.action('wallet_add', async (ctx) => { if (this.walletService) { + const firstAction = ctx.body.actions?.[0]; + ctx.session.fromStart = firstAction?.value === 'onboarding'; await this.walletService.startAddWallet(ctx); } }); diff --git a/apps/consumers/src/services/bot/telegram-bot.service.ts b/apps/consumers/src/services/bot/telegram-bot.service.ts index 97d2e815..d699b622 100644 --- a/apps/consumers/src/services/bot/telegram-bot.service.ts +++ b/apps/consumers/src/services/bot/telegram-bot.service.ts @@ -9,7 +9,7 @@ import { telegramMessages, uiMessages, ExplorerService, appendUtmParams } from ' import { TelegramDAOService } from '../dao/telegram-dao.service'; import { TelegramWalletService } from '../wallet/telegram-wallet.service'; import { EnsResolverService } from '../ens-resolver.service'; -import { MatchedContext } from '../../interfaces/bot.interface'; +import { ContextWithSession, MatchedContext } from '../../interfaces/bot.interface'; import { NotificationPayload } from '../../interfaces/notification.interface'; import { TelegramClientInterface } from '../../interfaces/telegram-client.interface'; import { BotServiceInterface } from '../../interfaces/bot-service.interface'; @@ -51,14 +51,11 @@ export class TelegramBotService implements BotServiceInterface { private setupCommands(): void { this.telegramClient.setupHandlers((handlers) => { handlers.command(/^start$/i, async (ctx) => { - await ctx.reply(uiMessages.welcome, this.createPersistentKeyboard()); + await this.replyStartFlow(ctx); }); handlers.command(/^learn_more$/i, async (ctx) => { - await ctx.reply(uiMessages.help, { - parse_mode: 'HTML', - ...this.createPersistentKeyboard() - }); + await this.replyLearnMore(ctx); }); handlers.command(/^daos$/i, async (ctx) => { @@ -78,45 +75,78 @@ export class TelegramBotService implements BotServiceInterface { }); handlers.hears(uiMessages.buttons.learnMore, async (ctx) => { - await ctx.reply(uiMessages.help, { - parse_mode: 'HTML', - ...this.createPersistentKeyboard() - }); + await this.replyLearnMore(ctx); + }); + + handlers.action(/^start$/, async (ctx) => { + await ctx.answerCbQuery(); + if (ctx.session) ctx.session.fromStart = true; + await this.daoService.initialize(ctx); }); handlers.action(/^dao_toggle_(\w+)$/, async (ctx) => { + await ctx.answerCbQuery(); const matchedCtx = ctx as MatchedContext; const daoName = matchedCtx.match[1]; await this.daoService.toggle(ctx, daoName); - await ctx.answerCbQuery(); }); handlers.action(/^dao_confirm$/, async (ctx) => { + await ctx.answerCbQuery(); await this.daoService.confirm(ctx); + await this.triggerWalletFlowIfFromStart(ctx); + }); + + handlers.action(/^dao_select_all$/, async (ctx) => { + await ctx.answerCbQuery(); + await this.daoService.selectAll(ctx); + }); + + handlers.action(/^dao_unselect_all$/, async (ctx) => { await ctx.answerCbQuery(); + await this.daoService.unselectAll(ctx); }); // Wallet action handlers handlers.action(/^wallet_add$/, async (ctx) => { - await this.walletService.addWallet(ctx); await ctx.answerCbQuery(); + await this.walletService.addWallet(ctx); }); handlers.action(/^wallet_remove$/, async (ctx) => { - await this.walletService.removeWallet(ctx); await ctx.answerCbQuery(); + await this.walletService.removeWallet(ctx); }); handlers.action(/^wallet_toggle_(.+)$/, async (ctx) => { + await ctx.answerCbQuery(); const matchedCtx = ctx as MatchedContext; const address = matchedCtx.match[1]; await this.walletService.toggleWalletForRemoval(ctx, address); - await ctx.answerCbQuery(); }); handlers.action(/^wallet_confirm_remove$/, async (ctx) => { + await ctx.answerCbQuery(); await this.walletService.confirmRemoval(ctx); + }); + + handlers.action(/^learn_more_start$/, async (ctx) => { await ctx.answerCbQuery(); + await this.replyStartFlow(ctx); + }); + + handlers.action(/^learn_more_daos$/, async (ctx) => { + await ctx.answerCbQuery(); + await this.daoService.initialize(ctx); + }); + + handlers.action(/^learn_more_wallets$/, async (ctx) => { + await ctx.answerCbQuery(); + await this.walletService.initialize(ctx); + }); + + handlers.action(/^learn_more_settings$/, async (ctx) => { + await ctx.answerCbQuery(uiMessages.buttons.settingsComingSoon); }); handlers.on('message', async (ctx, next) => { @@ -134,6 +164,46 @@ export class TelegramBotService implements BotServiceInterface { }); } + private async replyStartFlow(ctx: ContextWithSession): Promise { + await ctx.reply(uiMessages.welcome, this.createPersistentKeyboard()); + await ctx.reply(uiMessages.welcomeDao, { + reply_markup: { + inline_keyboard: [ + [{ text: uiMessages.buttons.daos, callback_data: 'start' }] + ] + } + }); + } + + private async replyLearnMore(ctx: ContextWithSession): Promise { + await ctx.reply(uiMessages.help, { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [ + [ + { text: uiMessages.buttons.start, callback_data: 'learn_more_start' }, + { text: uiMessages.buttons.daos, callback_data: 'learn_more_daos' }, + ], + [ + { text: uiMessages.buttons.wallets, callback_data: 'learn_more_wallets' }, + { text: uiMessages.buttons.settings, callback_data: 'learn_more_settings' }, + ] + ] + } + }); + } + + private async triggerWalletFlowIfFromStart(ctx: ContextWithSession): Promise { + if (!ctx.session?.fromStart) return; + const user = ctx.from?.id; + if (user) { + const userWallets = await this.walletService.getUserWalletsWithDisplayNames(user.toString(), 'telegram'); + if (!userWallets || userWallets.length === 0) { + await this.walletService.initialize(ctx, true); + } + } + } + async launch(): Promise { await this.telegramClient.launch(); } diff --git a/apps/consumers/src/services/dao/base-dao.service.ts b/apps/consumers/src/services/dao/base-dao.service.ts index 4d7bada9..026bd2cd 100644 --- a/apps/consumers/src/services/dao/base-dao.service.ts +++ b/apps/consumers/src/services/dao/base-dao.service.ts @@ -32,6 +32,14 @@ export abstract class BaseDAOService { return await this.anticaptureClient.getDAOs(); } + /** + * Check if user has any DAO subscriptions + */ + public async hasSubscriptions(userId: string): Promise { + const subs = await this.getUserSubscriptions(userId); + return subs.length > 0; + } + /** * Get user's current DAO subscriptions */ diff --git a/apps/consumers/src/services/dao/telegram-dao.service.ts b/apps/consumers/src/services/dao/telegram-dao.service.ts index 20330feb..3f801d4d 100644 --- a/apps/consumers/src/services/dao/telegram-dao.service.ts +++ b/apps/consumers/src/services/dao/telegram-dao.service.ts @@ -89,13 +89,8 @@ export class TelegramDAOService extends BaseDAOService { * Toggle DAO selection when user clicks a button */ async toggle(ctx: ContextWithSession, daoName: string): Promise { - const chatId = ctx.chat?.id; - const messageId = ctx.callbackQuery?.message?.message_id; - if (!chatId || !messageId) return; - this.ensureSession(ctx); - // Toggle selection in session const normalizedDaoName = daoName.toUpperCase(); if (ctx.session.daoSelections.has(normalizedDaoName)) { ctx.session.daoSelections.delete(normalizedDaoName); @@ -103,15 +98,7 @@ export class TelegramDAOService extends BaseDAOService { ctx.session.daoSelections.add(normalizedDaoName); } - try { - // Update inline keyboard to reflect new state - const daos = await this.fetchAvailableDAOs(); - const keyboard = this.buildInlineKeyboard(daos, ctx.session.daoSelections); - await ctx.editMessageReplyMarkup(keyboard); - } catch (error) { - console.error('Error updating keyboard:', error); - await ctx.answerCbQuery(uiMessages.errors.updateFailed); - } + await this.refreshKeyboard(ctx); } /** @@ -143,6 +130,32 @@ export class TelegramDAOService extends BaseDAOService { await ctx.reply(uiMessages.errors.updateSubscriptionsFailed); } } + + async selectAll(ctx: ContextWithSession): Promise { + this.ensureSession(ctx); + const daos = await this.fetchAvailableDAOs(); + daos.forEach(dao => ctx.session.daoSelections.add(dao.id.toUpperCase())); + await this.refreshKeyboard(ctx); + } + + async unselectAll(ctx: ContextWithSession): Promise { + this.ensureSession(ctx); + ctx.session.daoSelections.clear(); + await this.refreshKeyboard(ctx); + } + + /** + * Refresh the inline keyboard to reflect current session selections + */ + private async refreshKeyboard(ctx: ContextWithSession): Promise { + try { + const daos = await this.fetchAvailableDAOs(); + const keyboard = this.buildInlineKeyboard(daos, ctx.session.daoSelections); + await ctx.editMessageReplyMarkup(keyboard); + } catch (error) { + console.error('Error updating keyboard:', error); + } + } /** * Build Telegram inline keyboard for DAO selection @@ -160,7 +173,6 @@ export class TelegramDAOService extends BaseDAOService { }; }); - // Group buttons into rows of 4 const BUTTONS_PER_ROW = 3; const daoButtonRows: any[][] = []; @@ -171,9 +183,12 @@ export class TelegramDAOService extends BaseDAOService { return { inline_keyboard: [ ...daoButtonRows, - // Confirm button row [ - { text: uiMessages.confirmSelection, callback_data: 'dao_confirm' } + { text: uiMessages.buttons.selectAll, callback_data: 'dao_select_all' }, + { text: uiMessages.buttons.unselectAll, callback_data: 'dao_unselect_all' }, + ], + [ + { text: uiMessages.confirmSelection, callback_data: 'dao_confirm' }, ] ] }; @@ -182,7 +197,7 @@ export class TelegramDAOService extends BaseDAOService { /** * Show confirmation message after updating subscriptions */ - private async showConfirmationMessage(ctx: any, selectedDAOs: Set): Promise { + private async showConfirmationMessage(ctx: ContextWithSession, selectedDAOs: Set): Promise { if (selectedDAOs.size > 0) { const daoList = this.formatDAOListWithBullets(selectedDAOs); diff --git a/apps/consumers/src/services/wallet/slack-wallet.service.ts b/apps/consumers/src/services/wallet/slack-wallet.service.ts index d9595af8..6054dc11 100644 --- a/apps/consumers/src/services/wallet/slack-wallet.service.ts +++ b/apps/consumers/src/services/wallet/slack-wallet.service.ts @@ -11,10 +11,11 @@ import { EnsResolverService } from '../ens-resolver.service'; import { SlackCommandContext, SlackActionContext, - SlackViewContext + SlackViewContext, } from '../../interfaces/slack-context.interface'; import { walletEmptyState, + walletOnboardingPromptBlocks, successMessage, walletSelectionList } from '../../utils/slack-blocks-templates'; @@ -29,6 +30,19 @@ export class SlackWalletService extends BaseWalletService { super(subscriptionApi, ensResolver); } + /** + * Show wallet prompt during onboarding flow (after DAO selection) + */ + async showOnboardingWallet(context: SlackActionContext): Promise { + if (context.respond) { + await context.respond({ + replace_original: false, + blocks: walletOnboardingPromptBlocks(), + response_type: 'in_channel' + }); + } + } + /** * Display the wallet management interface * Always shows list with add/remove buttons @@ -147,13 +161,17 @@ export class SlackWalletService extends BaseWalletService { throw new Error('No trigger_id available for modal'); } + // Encode channel + fromStart flag in private_metadata + const fromStart = context.session?.fromStart || false; + const metadata = JSON.stringify({ channelId, fromStart }); + // Open modal with wallet input await context.client.views.open({ trigger_id: triggerId, view: { type: 'modal', callback_id: 'wallet_add_modal', - private_metadata: channelId, + private_metadata: metadata, title: { type: 'plain_text', text: 'Add Wallet' @@ -214,7 +232,18 @@ export class SlackWalletService extends BaseWalletService { */ async processWalletSubmission(context: SlackViewContext): Promise { const workspaceId = context.body.team?.id || context.body.user?.team_id; - const channelId = context.view.private_metadata || context.body.user?.id; + + // Parse private_metadata (may be JSON with fromStart flag or plain channelId) + let channelId: string | undefined; + let fromStart = false; + try { + const parsed = JSON.parse(context.view.private_metadata); + channelId = parsed.channelId; + fromStart = parsed.fromStart || false; + } catch { + channelId = context.view.private_metadata || context.body.user?.id; + } + const fullUserId = `${workspaceId}:${channelId}`; try { @@ -258,6 +287,15 @@ export class SlackWalletService extends BaseWalletService { channel: channelId, blocks: successMessage(replacePlaceholders(slackMessages.wallet.addSuccess, { displayName })) }); + + // Send onboarding complete message if from start flow + if (fromStart) { + await context.client.chat.postMessage({ + channel: channelId, + text: slackMessages.wallet.onboardingComplete + }); + context.session.fromStart = false; + } } } catch (error) { console.error('Error processing wallet submission:', error); diff --git a/apps/consumers/src/services/wallet/telegram-wallet.service.ts b/apps/consumers/src/services/wallet/telegram-wallet.service.ts index 45949c77..40cd132d 100644 --- a/apps/consumers/src/services/wallet/telegram-wallet.service.ts +++ b/apps/consumers/src/services/wallet/telegram-wallet.service.ts @@ -33,7 +33,7 @@ export class TelegramWalletService extends BaseWalletService { /** * Display the wallet management interface */ - async initialize(ctx: ContextWithSession): Promise { + async initialize(ctx: ContextWithSession, fromStart: boolean = false): Promise { const userId = ctx.from?.id; if (!userId) return; @@ -43,17 +43,14 @@ export class TelegramWalletService extends BaseWalletService { // Get user's current wallets const wallets = await this.getUserWalletsWithDisplayNames(userId.toString(), 'telegram'); - let message = uiMessages.wallet.selection; + const prefix = fromStart ? uiMessages.wallet.selectionPrefix : ''; + let message = `${prefix}${uiMessages.wallet.selection}`; - if (wallets.length === 0) { - message = uiMessages.wallet.noWallets; - } else { - // Show current wallets with ENS names when available + if (wallets.length > 0) { const walletList = wallets.map((wallet, index) => `${index + 1}. ${wallet.displayName || wallet.address}` ); - - message = `${uiMessages.wallet.selection}\n\n${walletList.join('\n')}`; + message += `\n\n${walletList.join('\n')}`; } const keyboard = { @@ -145,6 +142,10 @@ export class TelegramWalletService extends BaseWalletService { if (result.success) { await ctx.reply(uiMessages.wallet.success); + if (ctx.session.fromStart) { + await ctx.reply(uiMessages.wallet.onboardingComplete); + ctx.session.fromStart = false; + } } else { await ctx.reply(`${uiMessages.status.error} ${result.message}`); } diff --git a/apps/consumers/src/utils/slack-blocks-templates.ts b/apps/consumers/src/utils/slack-blocks-templates.ts index 9c4802f7..1b95c9c1 100644 --- a/apps/consumers/src/utils/slack-blocks-templates.ts +++ b/apps/consumers/src/utils/slack-blocks-templates.ts @@ -99,7 +99,9 @@ export const checkboxSelectionList = ( ] }, { type: 'divider' }, - actions(button(slackMessages.dao.confirmButton, confirmActionId, { style: confirmButtonStyle })) + actions( + button(slackMessages.dao.confirmButton, confirmActionId, { style: confirmButtonStyle }) + ) ]; }; @@ -162,6 +164,15 @@ export const walletSelectionList = ( ); }; +/** + * Onboarding wallet prompt (after DAO selection): "Next step" message + Add wallet button. + * Shared so showOnboardingWallet and list logic use the same block structure. + */ +export const walletOnboardingPromptBlocks = (): KnownBlock[] => [ + section(slackMessages.wallet.selectionPrefix + slackMessages.wallet.listHeader), + actions(button(slackMessages.wallet.buttonAdd, 'wallet_add', { style: 'primary', value: 'onboarding' })) +]; + /** * Wallet empty state */ diff --git a/packages/messages/src/ui/common.ts b/packages/messages/src/ui/common.ts index 152079d2..821adee8 100644 --- a/packages/messages/src/ui/common.ts +++ b/packages/messages/src/ui/common.ts @@ -5,21 +5,24 @@ export const uiMessages = { // Welcome and help messages welcome: `πŸ”” Welcome to the Anticapture notification system! -Spotting the "oh no" before it hits your treasury. +Spotting the "oh no" before it hits your treasury.`, -➑️ To start using the system, you'll need to add the DAOs you want to receive notifications from by clicking on "DAOs". -➑️ After that, click on "Tracked Wallets" and add your wallet address to receive custom notifications.`, + welcomeDao:`➑️ To get started, add the DAOs you want to track. + + You will receive alerts for: +- New Proposals on the DAO +- Proposal results when it finishes +- Vote reminders + +Stay ahead of governance risk. Stay informed.`, help: `What is Anticapture? A governance security research, notification system and dashboard that tracks social dynamics, governance models, and contracts, ensuring security and preventing malicious capture. What is this bot for? -Get notified about risks, changes and proposals that you care about the DAOs you're in. +Get notified about risks, changes and proposals that you care about in the DAOs you follow. -Commands that might be useful -/start -/learn_more -/daos`, +Actions that might be useful`, unknownCommand: '❌ Unknown command. Use /learn_more to see available commands.', @@ -38,15 +41,33 @@ You'll be notified when things get spicy:`, myWallets: 'πŸ“ Tracked Wallets', addWallet: 'βž• Add wallet', removeWallet: '❌ Remove wallet', - confirmRemoval: 'πŸ—‘οΈ Confirm removal' + confirmRemoval: 'πŸ—‘οΈ Confirm removal', + start: 'πŸš€ Start', + wallets: 'πŸ“ Wallets', + settings: 'βš™οΈ Settings', + selectAll: 'Select all', + unselectAll: 'Unselect all', + settingsComingSoon: 'βš™οΈ Settings coming soon!' }, // Wallet management messages wallet: { - selection: `Here's the wallets you have added to receive custom notifications:`, + selectionPrefix: 'πŸ‘‰ Next step: ', + selection: `Add your wallet address + +This allows us to personalize alerts based on your governance activity and delegations. + +You'll receive notifications for: +- Delegation changes that affect your voting power +- Transfers that affect your voting power +- Non-voting alerts +- Vote confirmations + +Stay aware of how changes in governance affect you.`, input: 'πŸ‘‰ Please enter your address or ENS name:', processing: '⏱️ Hang tight, we\'re just connecting your data…', success: 'βœ… All set! Your wallet has been added.', + onboardingComplete: `πŸŽ‰ You're all set! Stay tuned β€” we'll notify you as soon as something important happens in your DAOs.`, error: '❌ Invalid wallet address. Please try again.', removeConfirmation: 'Select the wallets you want to remove:', removeSuccess: 'βœ… Selected wallets have been removed.', diff --git a/packages/messages/src/ui/slack.ts b/packages/messages/src/ui/slack.ts index 897bc705..1ec732f6 100644 --- a/packages/messages/src/ui/slack.ts +++ b/packages/messages/src/ui/slack.ts @@ -20,7 +20,20 @@ export const slackMessages = { // Wallet management messages wallet: { - listHeader: '*Your Wallet Addresses:*', + /** Same content as Telegram wallet onboarding (common.ts wallet.selection) for consistency */ + listHeader: `*Add your wallet address* + +This allows us to personalize alerts based on your governance activity and delegations. + +You'll receive notifications for: +β€’ Delegation changes that affect your voting power +β€’ Transfers that affect your voting power +β€’ Non-voting alerts +β€’ Vote confirmations + +Stay aware of how changes in governance affect you.`, + selectionPrefix: '*Next step:* ', + onboardingComplete: 'πŸŽ‰ You\'re all set! Stay tuned β€” we\'ll notify you as soon as something important happens in your DAOs.', emptyList: "You can add wallets and receive custom notifications related to them and the DAOs you follow!", instructions: "Use '/anticapture wallet add' or '/anticapture wallet remove' to manage your wallets", buttonAdd: 'Add Wallet', @@ -75,18 +88,12 @@ export const slackMessages = { type: 'section', text: { type: 'mrkdwn', - text: '✨ *[Mission Initiated: Navigating the Governance Space]*\n\n' + - 'Set up your dashboard to stay on course with proposal signals from the DAOs you participate in.\n\n' + - '\n\n' + - '*πŸ’Ž Mission Features:*\n' + - 'β€’ Receive real-time alerts for new proposals\n' + - 'β€’ Be reminded when a voting window is open β€” don\'t miss your chance to engage\n' + - 'β€’ Get mission reports with proposal outcomes\n' + - 'β€’ Lock in the DAOs you want to monitor directly\n' + - 'β€’ Connect your wallet and sync your DAOs of interest\n' + - 'β€’ Track if your addresses are actively voting\n\n' + - '\n\n' + - '*Another links that might be useful*' + text: '*What is Anticapture?*\n' + + 'A governance security research, notification system and dashboard that tracks social dynamics, governance models, and contracts, ensuring security and preventing malicious capture.\n\n' + + '*What is this bot for?*\n' + + 'Get notified about risks, changes and proposals that you care about in the DAOs you follow.\n\n' + + 'Use the `/anticapture` command to get started.\n\n' + + '*Links that might be useful*' } }, { @@ -133,9 +140,13 @@ export const slackMessages = { type: 'section', text: { type: 'mrkdwn', - text: '🚨 *Anticapture Notification System*\n\n' + - 'Spot the "oh no" before it reaches your treasury.\n\n' + - '*Manage your notification preferences:*' + text: 'πŸ”” *Welcome to the Anticapture notification system!*\n\n' + + '_Spotting the "oh no" before it hits your treasury._\n\n' + + 'You will receive alerts for:\n' + + 'β€’ New Proposals on the DAO\n' + + 'β€’ Proposal results when it finishes\n' + + 'β€’ Vote reminders\n\n' + + 'Stay ahead of governance risk. Stay informed.' } }, {