Repository navigation
Expand file tree
/
Copy pathbot.js
More file actions
2238 lines (1916 loc) · 89.5 KB
/
Copy pathbot.js
File metadata and controls
2238 lines (1916 loc) · 89.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, InteractionType, PermissionFlagsBits, Events, MessageFlags } = require('discord.js');
const { getEmbed, getButtons, getActionRows } = require('./utils/embedManager.js');
const dotenv = require('dotenv');
const { getPool } = require('./db/db.js');
const { keepsRole, managerNetworks } = require('./db/roleAccess.js');
const { verifyRegistrationSignature } = require('./verifySignature.js');
const { loadNetworkConfig, isNetworkConfigured, fetchOnChainStatus, determineValidatorState } = require('./statusChecker.js');
const providerChain = require('./providers/chain.js');
const { getTokenName, getDiscordRoleId } = require('./config.js');
const { canRequestSignatureAttempt, recordSignatureAttempt, generateSignatureMessage, generateSignatureTimestamp } = require('./db/signatureAttempts.js');
const { startProviderRegistration, completeProviderRegistration } = require('./providers/registration.js');
// Answers for the provider registration steps.
const PROVIDER_START_REPLIES = {
not_configured: '❌ Provider registration is not available for this network.',
invalid_input: '❌ Enter a numeric provider ID and a valid address.',
no_provider: '❌ No provider with this ID exists in the staking registry.',
not_eligible: '❌ This address is not the admin or the rewards recipient of the provider, and not an owner of a Safe in those roles.',
in_use: 'ℹ️ You already have the role through this provider.'
};
const PROVIDER_ROLE_GRANT_FAILED = '⚠️ Your provider registration is verified, but the bot could not give the role. A server admin was told. Start the registration again after the admin fixes it.';
const PROVIDER_COMPLETE_REPLIES = {
not_found: '❌ Registration record not found. Please start the registration again.',
expired: '❌ This registration has expired. Please start the registration again.',
not_configured: '❌ Provider registration is not available for this network.',
bad_signature: '❌ Signature verification failed. Sign the exact message with the signer key.',
not_eligible: '❌ The signer is no longer the admin or the rewards recipient of the provider.',
inactive: '❌ The provider has no active attester, so the bot cannot give the role.',
registry_changed: '❌ The staking registry changed. Please start the registration again.'
};
const logger = require('./utils/logger.js');
const memberRoles = require('./utils/memberRoles.js');
// A network is offered only when it is configured. An old button or a
// crafted id for a different network gets NETWORK_NOT_AVAILABLE.
const NETWORKS = ['mainnet', 'testnet'];
const REGISTRATION_NOT_AVAILABLE = 'Registration is not available right now.';
const NETWORK_NOT_AVAILABLE = '❌ This network is not available right now.';
const ROLE_GRANT_FAILED = '⚠️ Your registration is verified, but the bot could not give the role. A server admin was told. Start the registration again after the admin fixes it.';
const soloNetworks = () => NETWORKS.filter((network) => isNetworkConfigured(network));
const providerNetworks = () => NETWORKS.filter((network) => providerChain.forNetwork(network));
// Load environment variables
dotenv.config();
// Initialize Discord client with necessary intents
const client = new Client({
// Only Guilds: the bot reacts to its own interactions and changes roles
// through REST (utils/memberRoles.js), so it needs no member or message events.
// Server Members is a privileged intent that Discord reviews for large apps.
intents: [GatewayIntentBits.Guilds],
});
// Create the slash command
const registerCommand = new SlashCommandBuilder()
.setName('register')
.setDescription('Register as an Aztec sequencer')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles);
const checkStatusNowCommand = new SlashCommandBuilder()
.setName('check-status-now')
.setDescription('Check your sequencer status now')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles);
// Command handler
client.once(Events.ClientReady, () => {
logger.info(`✅ Bot is ready! Logged in as ${client.user.tag}`);
logger.info(`🤖 Bot is in ${client.guilds.cache.size} servers`);
// Register slash commands
registerCommands();
});
async function registerCommands() {
const commands = [registerCommand, checkStatusNowCommand];
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN);
try {
logger.info('🔄 Started refreshing application (/) commands.');
if (process.env.DISCORD_GUILD_ID) {
// Register to specific guild (faster for development)
await rest.put(
Routes.applicationGuildCommands(
process.env.DISCORD_APPLICATION_ID,
process.env.DISCORD_GUILD_ID
),
{ body: commands },
);
logger.info('✅ Successfully registered guild-specific (/) commands.');
} else {
// Register globally (takes up to 1 hour to propagate)
await rest.put(
Routes.applicationCommands(process.env.DISCORD_APPLICATION_ID),
{ body: commands },
);
logger.info('✅ Successfully registered global (/) commands.');
}
} catch (error) {
logger.error('❌ Error registering commands:', error);
}
}
// Get user's registration data from database (network-aware)
async function getUserRegistration(userId, network = null) {
try {
const pool = getPool();
let query, params;
if (network) {
// Get registration for specific network - INCLUDE ALL STATUSES for debugging
const networkName = network.charAt(0).toUpperCase() + network.slice(1);
query = `
SELECT * FROM pending_registrations
WHERE discord_user_id = $1 AND network = $2
ORDER BY created_at DESC
LIMIT 1
`;
params = [userId, networkName];
} else {
// Get most recent registration - INCLUDE ALL STATUSES for debugging
query = `
SELECT * FROM pending_registrations
WHERE discord_user_id = $1
ORDER BY created_at DESC
LIMIT 1
`;
params = [userId];
}
const networkName = network ? network.charAt(0).toUpperCase() + network.slice(1) : null;
logger.info(`🔍 Querying database for user ${userId}${network ? ` on network ${networkName}` : ''}...`);
const result = await pool.query(query, params);
logger.info(`📊 Database query result: Found ${result.rows.length} registration(s)`);
if (result.rows.length === 0) {
logger.warn(`❌ No registrations found for user ${userId}${network ? ` on ${networkName}` : ''}`);
return null;
}
const registration = result.rows[0];
logger.info(`✅ Found registration: Status=${registration.status}, Network=${registration.network}, Wallet=${registration.wallet_address}`);
// Return registration regardless of status for now (we'll check status in the calling function)
return registration;
} catch (error) {
logger.error('❌ Error fetching user registration:', error.message);
logger.error('Full error:', error);
return null;
}
}
// Handle Check Status Button Interaction (Network-Aware)
async function handleCheckStatusButton(interaction, network = null) {
try {
// Get user's registration data from database (network-aware if specified)
const userId = interaction.user.id;
const registration = await getUserRegistration(userId, network);
if (!registration) {
const networkText = network ? ` on ${network.charAt(0).toUpperCase() + network.slice(1)}` : '';
return await interaction.reply({
content: `You are not registered as a sequencer${networkText}.`,
flags: MessageFlags.Ephemeral
});
}
// Check if registration status allows status checking
const allowedStatuses = ['completed', 'pending', 'at_risk', 'expired'];
if (!allowedStatuses.includes(registration.status)) {
const networkText = network ? ` on ${network.charAt(0).toUpperCase() + network.slice(1)}` : '';
return await interaction.reply({
content: `Your registration status (${registration.status}) does not allow status checking${networkText}.`,
flags: MessageFlags.Ephemeral
});
}
logger.info(`Checking status for ${interaction.user.username} - ${registration.status}, ${registration.network}`);
if (!isNetworkConfigured(registration.network)) {
return await interaction.reply({ content: NETWORK_NOT_AVAILABLE, flags: MessageFlags.Ephemeral });
}
// Send verification message
const verificationEmbed = getEmbed('statusCheck.verification', {
network: registration.network
});
await interaction.reply({
embeds: [verificationEmbed],
flags: MessageFlags.Ephemeral,
});
// Fetch current on-chain status
const networkConfig = loadNetworkConfig(registration.network.toLowerCase());
const statusResult = await fetchOnChainStatus(
registration.wallet_address,
networkConfig
);
if (!statusResult.success) {
await interaction.editReply({
content: 'Error fetching your on-chain status. Please try again later.',
embeds: []
});
return;
}
// Determine validator state using existing logic
const validatorState = determineValidatorState(
statusResult.data,
registration.network
);
// Note: DM notifications are handled by cronJob.js - no need to send duplicate DMs for manual status checks
// Create dynamic status embed
const embed = createStatusEmbed(validatorState, registration);
await interaction.editReply({
embeds: [embed],
});
} catch (error) {
logger.error('Status check error:', error);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({
content: 'Error checking your status. Please try again.',
embeds: []
});
} else {
await interaction.reply({
content: 'Error checking your status. Please try again.',
flags: MessageFlags.Ephemeral
});
}
}
}
// Create Dynamic Status Embed
function createStatusEmbed(validatorState, registration) {
const { state, details } = validatorState;
const tokenName = getTokenName(registration.network.toLowerCase());
const balanceText = formatBalance(details.balance, tokenName);
let embedKey;
switch (state) {
case 'HEALTHY':
embedKey = 'statusCheck.report.healthy';
break;
case 'SLASHED':
embedKey = 'statusCheck.report.slashed';
break;
case 'INACTIVE':
embedKey = 'statusCheck.report.inactive';
break;
default:
embedKey = 'statusCheck.report.unknown';
}
return getEmbed(embedKey, {
balanceText: balanceText,
network: registration.network
});
}
// Format Balance Function
function formatBalance(balance, tokenName) {
// Handle undefined, null, or invalid balance values
if (balance === undefined || balance === null || typeof balance === 'undefined') {
logger.warn('Invalid balance value detected:', balance, 'tokenName:', tokenName);
return `0 ${tokenName}`;
}
// Convert BigInt to readable format
try {
const balanceStr = balance.toString();
const formatted = parseInt(balanceStr) / 1e18;
return `${formatted.toLocaleString()} ${tokenName}`;
} catch (error) {
logger.error('Error formatting balance:', error, 'balance:', balance);
return `0 ${tokenName}`;
}
}
// Create dynamic management dashboard based on user roles
function createManagementDashboard(member, hasTestnetRole, hasMainnetRole) {
let embed, components = [];
if (hasTestnetRole && !hasMainnetRole) {
// Scenario A: Testnet Only
embed = getEmbed('dashboard.testnet');
// Only Check Status button for testnet users
const checkStatusButton = new ButtonBuilder()
.setCustomId('check_status_testnet')
.setLabel('Check My Status')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📊');
const row = new ActionRowBuilder()
.addComponents(checkStatusButton);
components = [row];
} else if (!hasTestnetRole && hasMainnetRole) {
// Scenario B: Mainnet Only
embed = getEmbed('dashboard.mainnet');
// Check Status and Manage Colleagues buttons for mainnet users
const checkStatusButton = new ButtonBuilder()
.setCustomId('check_status_mainnet')
.setLabel('Check My Status')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📊');
const manageColleaguesButton = new ButtonBuilder()
.setCustomId('manage_colleagues')
.setLabel('Manage Colleagues')
.setStyle(ButtonStyle.Primary)
.setEmoji('👥');
const row = new ActionRowBuilder()
.addComponents(checkStatusButton, manageColleaguesButton, createRemoveColleaguesButton());
components = [row];
} else if (hasTestnetRole && hasMainnetRole) {
// Scenario C: Both Testnet and Mainnet (Power User UX)
embed = getEmbed('dashboard.mixed');
// Network selection dropdown and Manage Colleagues button
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('select_status_network')
.setPlaceholder('Select a network to check...');
for (const network of soloNetworks()) {
const name = network.charAt(0).toUpperCase() + network.slice(1);
selectMenu.addOptions({
label: `Check ${name} Status`,
value: network,
description: `Check your ${name} sequencer status`,
});
}
const manageColleaguesButton = new ButtonBuilder()
.setCustomId('manage_colleagues')
.setLabel('Manage Colleagues')
.setStyle(ButtonStyle.Primary)
.setEmoji('👥');
const secondRow = new ActionRowBuilder()
.addComponents(manageColleaguesButton, createRemoveColleaguesButton());
// Discord refuses a select menu without options.
components = selectMenu.options.length > 0
? [new ActionRowBuilder().addComponents(selectMenu), secondRow]
: [secondRow];
}
return { embed, components };
}
// Handle manage sequencer button interaction
async function handleManageSequencerButton(interaction) {
try {
// Determine user's role status
const hasTestnetRole = interaction.member.roles.cache.has(getDiscordRoleId('testnet'));
const hasMainnetRole = interaction.member.roles.cache.has(getDiscordRoleId('mainnet'));
// Create dynamic dashboard based on role status
const { embed, components } = createManagementDashboard(
interaction.member,
hasTestnetRole,
hasMainnetRole
);
await interaction.reply({
embeds: [embed],
components: components,
flags: MessageFlags.Ephemeral,
});
logger.info(`Dashboard shown to ${interaction.user.username} - Testnet: ${hasTestnetRole}, Mainnet: ${hasMainnetRole}`);
} catch (error) {
logger.error('❌ Error handling manage sequencer button:', error);
await interaction.followUp({
content: 'There was an error opening the management dashboard. Please try again.',
flags: MessageFlags.Ephemeral,
});
}
}
// Create Welcome Hub Embed
function createWelcomeHubEmbed() {
return getEmbed('registration.welcomeHub');
}
// Create Welcome Hub Action Buttons using the new Embed Manager
function createWelcomeHubButtons() {
// NEW: Use the Embed Manager for button context editing
// This demonstrates the new button context editing functionality
return getButtons('registration.welcomeHub');
}
// Alternative: Create Welcome Hub Action Rows using the new Embed Manager
function createWelcomeHubActionRows() {
// NEW: Get action rows with buttons automatically organized (max 5 per row)
return getActionRows('registration.welcomeHub');
}
// Example: Create dynamic buttons with variable substitution
function createDynamicButtons(network) {
// NEW: Use variables in button configurations
// This demonstrates dynamic button context editing
return getButtons('registration.networkSelection.submit', {
network: network.charAt(0).toUpperCase() + network.slice(1)
});
}
// Legacy function for backward compatibility
function createWelcomeHubButtonsLegacy() {
const registerButton = new ButtonBuilder()
.setCustomId('register_new_sequencer')
.setLabel('Register as a Solo Sequencer')
.setStyle(ButtonStyle.Primary)
.setEmoji('🚀');
const manageButton = new ButtonBuilder()
.setCustomId('manage_sequencer')
.setLabel('Manage My Sequencer')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⚙️');
const learnButton = new ButtonBuilder()
.setCustomId('what_is_sequencer')
.setLabel('What is a Sequencer?')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📚');
const helpButton = new ButtonBuilder()
.setCustomId('need_help')
.setLabel('Need Help?')
.setStyle(ButtonStyle.Secondary)
.setEmoji('❓');
return [registerButton, manageButton, learnButton, helpButton];
}
// Create Sequencer Management Dashboard Embed
function createSequencerManagementDashboard() {
return getEmbed('dashboard.general');
}
// Create Management Dashboard Buttons
function createManagementButtons() {
const checkStatusButton = new ButtonBuilder()
.setCustomId('check_status')
.setLabel('Check My Status')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📊');
const manageColleaguesButton = new ButtonBuilder()
.setCustomId('manage_colleagues')
.setLabel('Manage Colleagues')
.setStyle(ButtonStyle.Primary)
.setEmoji('👥');
return [checkStatusButton, manageColleaguesButton];
}
// Create Network Selection Embed
function createNetworkSelectionEmbed() {
return getEmbed('registration.networkSelection');
}
// Create Network Select Menu
function createNetworkSelectMenu(networks) {
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('network_select')
.setPlaceholder('Select a network...');
// Add network options
networks.forEach(network => {
selectMenu.addOptions({
label: network.charAt(0).toUpperCase() + network.slice(1),
value: network,
description: `Register as a sequencer on ${network}`,
});
});
return selectMenu;
}
// Create Cancel Button
function createCancelButton() {
return new ButtonBuilder()
.setCustomId('cancel_registration')
.setLabel('Cancel')
.setStyle(ButtonStyle.Danger);
}
// Create Input Embed for Step 2
function createInputEmbed(selectedNetwork) {
return getEmbed('registration.inputDetails', {
network: selectedNetwork.charAt(0).toUpperCase() + selectedNetwork.slice(1)
});
}
// Create Submit Details Button
function createSubmitDetailsButton(network) {
return new ButtonBuilder()
.setCustomId(`submit_details_${network}`)
.setLabel('Submit Details')
.setStyle(ButtonStyle.Primary);
}
// Create Registration Modal
function createRegistrationModal(network) {
return new ModalBuilder()
.setCustomId(`registration_modal_${network}`)
.setTitle(`Registration Details - ${network.charAt(0).toUpperCase() + network.slice(1)}`);
}
// Create Wallet Address Text Input
function createWalletAddressInput() {
return new TextInputBuilder()
.setCustomId('wallet_address')
.setLabel('Wallet Address')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Enter your wallet address')
.setRequired(true);
}
// Create Signature Embed (Step 9)
function createSignatureEmbed(messageToSign, recordId) {
return getEmbed('registration.signature', {
messageToSign: messageToSign
});
}
// Create Signature Modal (Step 10)
function createSignatureModal(recordId) {
return new ModalBuilder()
.setCustomId(`sig_sub_${recordId}`)
.setTitle('Submit Signature');
}
// Create Signature Text Input
function createSignatureInput() {
return new TextInputBuilder()
.setCustomId('signature')
.setLabel('Signature Hash')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('Enter the signature hash (0x...')
.setRequired(true);
}
// Create the button that opens the form to remove colleagues
function createRemoveColleaguesButton() {
return new ButtonBuilder()
.setCustomId('remove_colleagues')
.setLabel('Remove Colleagues')
.setStyle(ButtonStyle.Danger);
}
// Create the form to remove colleagues, with the same five ID fields
function createRemoveColleaguesModal() {
const modal = new ModalBuilder()
.setCustomId('remove_colleagues_modal')
.setTitle('Remove Sequencer Colleagues');
const inputs = [createDiscordIdInput1(), createDiscordIdInput2(), createDiscordIdInput3(), createDiscordIdInput4(), createDiscordIdInput5()];
modal.addComponents(...inputs.map((input) => new ActionRowBuilder().addComponents(input)));
return modal;
}
// Text for the status command: the registration state and the next action
function registrationStateText(registration) {
switch (registration.status) {
case 'completed':
return 'You have the role.';
case 'at_risk': {
if (!registration.at_risk_since) {
return 'Your attester is inactive. Your role is removed after seven days of inactivity.';
}
const removalTime = Math.floor(new Date(registration.at_risk_since).getTime() / 1000) + 7 * 24 * 60 * 60;
return `Your attester is inactive. Your role is removed on <t:${removalTime}:D> if the attester stays inactive.`;
}
case 'removed':
return 'Your role was removed. Register again to get it back.';
default:
return 'Your registration is not complete. Register to get the role.';
}
}
// Create Colleague Management Modal
function createColleagueManagementModal() {
return new ModalBuilder()
.setCustomId('manage_colleagues_modal')
.setTitle('Manage Your Sequencer Colleagues');
}
// Create Discord ID Input Fields
function createDiscordIdInput1() {
return new TextInputBuilder()
.setCustomId('discord_id_1')
.setLabel('User ID #1')
.setStyle(TextInputStyle.Short)
.setPlaceholder('123456789012345678')
.setRequired(false);
}
function createDiscordIdInput2() {
return new TextInputBuilder()
.setCustomId('discord_id_2')
.setLabel('User ID #2')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Optional')
.setRequired(false);
}
function createDiscordIdInput3() {
return new TextInputBuilder()
.setCustomId('discord_id_3')
.setLabel('User ID #3')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Optional')
.setRequired(false);
}
function createDiscordIdInput4() {
return new TextInputBuilder()
.setCustomId('discord_id_4')
.setLabel('User ID #4')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Optional')
.setRequired(false);
}
function createDiscordIdInput5() {
return new TextInputBuilder()
.setCustomId('discord_id_5')
.setLabel('User ID #5')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Optional')
.setRequired(false);
}
// Create Submit Signature Button
function createSubmitSignatureButton(recordId) {
return new ButtonBuilder()
.setCustomId(`submit_signature_${recordId}`)
.setLabel('Submit Signature')
.setStyle(ButtonStyle.Success);
}
// Give the network role of a solo registration. Returns null, or the error
// text when the role id is not configured or Discord refuses the grant.
// The failure goes to the admin channel.
async function grantSoloRole(interaction, network) {
const roleId = getDiscordRoleId(network.toLowerCase());
let grantError = roleId ? null : 'no role id configured';
if (roleId) {
try {
await interaction.member.roles.add(roleId);
} catch (error) {
grantError = error.message;
}
}
if (grantError) {
await logToAdminChannel(`⚠️ Role grant failed for registration: ${interaction.user.username} (${interaction.user.id}) - ${network} - ${grantError}`);
}
return grantError;
}
// A completed registration whose member has no role (for example after a
// failed grant) gets the role on the next attempt. Only 'completed': a
// registration at risk keeps a role it has, but does not get a removed role
// back. Returns true when it answered the interaction.
async function restoreCompletedRole(interaction, networkName, answer) {
const { rows } = await getPool().query(
`SELECT id FROM pending_registrations
WHERE discord_user_id = $1 AND network = $2 AND status = 'completed' LIMIT 1`,
[interaction.user.id, networkName]
);
const roleId = getDiscordRoleId(networkName.toLowerCase());
if (rows.length === 0 || (roleId && interaction.member.roles.cache.has(roleId))) return false;
if (await grantSoloRole(interaction, networkName)) {
await answer({ content: ROLE_GRANT_FAILED, embeds: [], components: [] });
return true;
}
await answer({ embeds: [getEmbed('registrationProcess.success')], components: [] });
await logToAdminChannel(`Role restored for ${interaction.user.username} (${interaction.user.id}) on ${networkName} - role assigned`);
return true;
}
// Only an operator or provider with an own completed mainnet registration
// manages colleagues. A colleague has the role too, but no registration.
async function managesColleagues(userId) {
return (await managerNetworks(getPool(), userId)).includes('Mainnet');
}
const MAX_COLLEAGUES = 10;
// Give a colleague each network role of the manager. Discord accepts a grant
// of a role that the colleague has already. Returns the number of those roles
// the colleague now holds and the networks whose grant failed ({ network, error }).
async function grantColleagueRoles(guild, managerId, colleagueId) {
const result = { held: 0, failed: [] };
const networks = await managerNetworks(getPool(), managerId);
for (const network of networks) {
const roleId = getDiscordRoleId(network.toLowerCase());
if (!roleId) {
result.failed.push({ network, error: 'no role id configured' });
continue;
}
try {
await memberRoles.grantRole(guild, colleagueId, roleId);
result.held += 1;
} catch (error) {
result.failed.push({ network, error: error.message });
}
}
return result;
}
// A Discord user id (snowflake), the same for the add and the remove form.
const DISCORD_ID_PATTERN = /^\d{17,20}$/;
// Log to Admin Channel
async function logToAdminChannel(message) {
try {
const adminChannelId = process.env.ADMIN_LOG_CHANNEL_ID;
if (!adminChannelId) {
logger.warn('ADMIN_LOG_CHANNEL_ID not set, skipping admin log');
return;
}
const channel = await client.channels.fetch(adminChannelId);
if (channel) {
const timestamp = new Date().toISOString();
await channel.send(`[${timestamp}] ${message}`);
}
} catch (error) {
logger.error('❌ Error logging to admin channel:', error);
}
}
// Handle slash command interactions
client.on('interactionCreate', async (interaction) => {
// Handle slash commands
if (interaction.isChatInputCommand()) {
if (interaction.commandName === 'register') {
try {
// Send Welcome Hub Embed with 4 Action Buttons
const embed = createWelcomeHubEmbed();
const buttons = createWelcomeHubButtons();
// Split buttons into two rows (max 5 buttons per row, we'll use 2 rows for 4 buttons)
const firstRow = new ActionRowBuilder()
.addComponents(buttons[0], buttons[1], buttons[2]);
const secondRow = new ActionRowBuilder()
.addComponents(buttons[3], buttons[4]);
await interaction.reply({
embeds: [embed],
components: [firstRow, secondRow]
});
console.log(`Welcome hub sent to ${interaction.user.username}`);
} catch (error) {
console.error('❌ Error handling register command:', error);
await interaction.reply({
content: 'There was an error starting the registration process. Please try again.',
flags: MessageFlags.Ephemeral,
});
}
} else if (interaction.commandName === 'check-status-now') {
try {
// Force immediate status check with DM notification
const userId = interaction.user.id;
const registration = await getUserRegistration(userId);
if (!registration) {
return await interaction.reply({
content: 'You are not registered as a sequencer.',
flags: MessageFlags.Ephemeral
});
}
if (!isNetworkConfigured(registration.network)) {
return await interaction.reply({ content: NETWORK_NOT_AVAILABLE, flags: MessageFlags.Ephemeral });
}
// Send initial response
const processingEmbed = getEmbed('statusCheck.forceInit');
await interaction.reply({
embeds: [processingEmbed],
flags: MessageFlags.Ephemeral,
});
// Perform immediate status check (without DM notifications - handled by cronJob.js)
const networkConfig = loadNetworkConfig(registration.network.toLowerCase());
const statusResult = await fetchOnChainStatus(registration.wallet_address, networkConfig);
if (!statusResult.success) {
await interaction.editReply({
content: 'Error fetching your on-chain status. Please try again later.',
embeds: []
});
return;
}
// Determine validator state using existing logic
const validatorState = determineValidatorState(statusResult.data, registration.network);
const resultEmbed = getEmbed('statusCheck.forceComplete', {
state: validatorState.state,
network: registration.network
});
resultEmbed.addFields({ name: 'Registration', value: registrationStateText(registration) });
await interaction.editReply({
embeds: [resultEmbed],
});
console.log(`⚡ Force status check completed for ${interaction.user.username} - State: ${validatorState.state}`);
} catch (error) {
console.error('❌ Error handling check-status-now command:', error);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({
content: 'There was an error performing the force status check. Please try again.',
embeds: [],
});
} else {
await interaction.reply({
content: 'There was an error performing the force status check. Please try again.',
flags: MessageFlags.Ephemeral,
});
}
}
}
}
// Handle message component interactions (dropdowns and buttons)
else if (interaction.isStringSelectMenu()) {
if (interaction.customId === 'network_select') {
const selectedNetwork = interaction.values[0];
try {
if (!soloNetworks().includes(selectedNetwork)) {
await interaction.update({ content: NETWORK_NOT_AVAILABLE, embeds: [], components: [] });
return;
}
// Check for existing registrations on this network
const pool = getPool();
const roleId = getDiscordRoleId(selectedNetwork);
// Check if user already has this specific network role
if (roleId && interaction.member.roles.cache.has(roleId)) {
const alreadyRegisteredEmbed = getEmbed('registration.alreadyRegistered', {
network: selectedNetwork.charAt(0).toUpperCase() + selectedNetwork.slice(1)
});
await interaction.update({
embeds: [alreadyRegisteredEmbed],
components: [],
});
console.log(`Existing ${selectedNetwork} sequencer detected for user ${interaction.user.username} (${interaction.user.id}) - redirected to management`);
return;
}
const networkName = selectedNetwork.charAt(0).toUpperCase() + selectedNetwork.slice(1);
if (await restoreCompletedRole(interaction, networkName, (payload) => interaction.update(payload))) return;
// Also check database for existing completed registration on this network
const existingRegistrationQuery = `
SELECT id, status FROM pending_registrations
WHERE discord_user_id = $1 AND network = $2 AND status = 'completed'
ORDER BY created_at DESC
LIMIT 1
`;
const existingResult = await pool.query(existingRegistrationQuery, [interaction.user.id, selectedNetwork.charAt(0).toUpperCase() + selectedNetwork.slice(1)]);
if (existingResult.rows.length > 0) {
const alreadyRegisteredEmbed = getEmbed('registration.alreadyRegisteredCompleted', {
network: selectedNetwork.charAt(0).toUpperCase() + selectedNetwork.slice(1)
});
await interaction.update({
embeds: [alreadyRegisteredEmbed],
components: [],
});
console.log(`Existing ${selectedNetwork} registration found in database for user ${interaction.user.username} (${interaction.user.id}) - redirected to management`);
return;
}
// Continue with normal flow if no existing registration found
const embed = createInputEmbed(selectedNetwork);
const submitButton = createSubmitDetailsButton(selectedNetwork);
const cancelButton = createCancelButton();
const row = new ActionRowBuilder()
.addComponents(submitButton, cancelButton);
await interaction.update({
embeds: [embed],
components: [row],
});
} catch (error) {
console.error('❌ Error handling network selection:', error);
await interaction.followUp({
content: 'There was an error processing your selection. Please try again.',
flags: MessageFlags.Ephemeral,
});
}
}
else if (interaction.customId === 'select_status_network') {
// Handle network selection for status checking
const selectedNetwork = interaction.values[0];
try {
await handleCheckStatusButton(interaction, selectedNetwork);
console.log(`Network-specific status check triggered for ${interaction.user.username} (${interaction.user.id}) - Network: ${selectedNetwork}`);
} catch (error) {
console.error('❌ Error handling network status selection:', error);
await interaction.followUp({
content: 'There was an error checking your status. Please try again.',
flags: MessageFlags.Ephemeral,
});
}
}
else if (interaction.customId === 'provider_network_select') {
try {
const network = interaction.values[0];
if (!providerNetworks().includes(network)) {
await interaction.reply({ content: PROVIDER_START_REPLIES.not_configured, flags: MessageFlags.Ephemeral });
return;
}
const modal = new ModalBuilder()
.setCustomId(`provider_details_modal_${network}`)
.setTitle(`Provider Registration - ${network === 'mainnet' ? 'Mainnet' : 'Testnet'}`)
.addComponents(
new ActionRowBuilder().addComponents(new TextInputBuilder()
.setCustomId('provider_id')
.setLabel('Provider ID (from the staking dashboard)')
.setStyle(TextInputStyle.Short)
.setPlaceholder('e.g. 1')
.setRequired(true)),
new ActionRowBuilder().addComponents(new TextInputBuilder()
.setCustomId('signer_address')
.setLabel('Admin or rewards recipient address')
.setStyle(TextInputStyle.Short)
.setPlaceholder('0x...')
.setRequired(true))
);
await interaction.showModal(modal);
} catch (error) {
console.error('❌ Error opening the provider details form:', error);
}
}
}
// Handle message component interactions (buttons)
else if (interaction.isButton()) {
if (interaction.customId === 'cancel_registration') {
try {
await interaction.update({
content: 'Registration cancelled.',
embeds: [],
components: [],
});
} catch (error) {
console.error('❌ Error handling cancel button:', error);
}
}
else if (interaction.customId === 'register_new_sequencer') {
try {
// Check for existing registrations
// We'll check for existing registrations after network selection
// For now, allow the flow to continue to network selection