diff --git a/digletbot.env.example b/digletbot.env.example index 9e75bfed..ec326f7a 100644 --- a/digletbot.env.example +++ b/digletbot.env.example @@ -25,6 +25,11 @@ ROLE_ALBION_US_REGISTERED=12345 ROLE_ALBION_EU_REGISTERED=12345 ROLE_ALBION_US_ANNOUNCEMENTS=12345 ROLE_ALBION_EU_ANNOUNCEMENTS=12345 +# Comma separated IDs of the pings embeds in CHANNEL_ALBION_EU_ROLES (content pings and guild +# pings). Their ALB/ role names drive the sweep; leave unset to disable the sweep entirely. +MESSAGE_ALBION_PINGS=12345,67890 +# Comma separated role IDs that keep ping roles without an Albion registration (alliance). +ALBION_PING_ROLE_EXEMPT_ROLES=12345,67890 # PS2 Channels & Roles CHANNEL_PS2_VERIFY=1234567 diff --git a/src/albion/albion.module.ts b/src/albion/albion.module.ts index cd87811b..5b230196 100644 --- a/src/albion/albion.module.ts +++ b/src/albion/albion.module.ts @@ -7,6 +7,7 @@ import { AlbionRegistrationService } from './services/albion.registration.servic import { DiscordModule } from '../discord/discord.module'; import { AlbionScanningService } from './services/albion.scanning.service'; import { AlbionScanCommand } from './commands/scan.command'; +import { AlbionPingRoleService } from './services/albion.ping.role.service'; import { AlbionCronService } from './services/albion.cron.service'; import { AlbionUtilities } from './utilities/albion.utilities'; import { AlbionLogCommand } from './commands/log.command'; @@ -29,6 +30,7 @@ import { GeneralModule } from '../general/general.module'; imports: [ConfigModule, DatabaseModule, DiscordModule, GeneralModule], providers: [ AlbionApiService, + AlbionPingRoleService, AlbionCronService, AlbionDeregisterCommand, AlbionDeregistrationService, diff --git a/src/albion/services/albion.deregistration.service.spec.ts b/src/albion/services/albion.deregistration.service.spec.ts index 3a8120d6..df57e3e0 100644 --- a/src/albion/services/albion.deregistration.service.spec.ts +++ b/src/albion/services/albion.deregistration.service.spec.ts @@ -9,6 +9,7 @@ import { EntityRepository } from '@mikro-orm/core'; import { AlbionDeregistrationService } from './albion.deregistration.service'; import { DiscordService } from '../../discord/discord.service'; import { AlbionDeregisterDto } from '../dto/albion.deregister.dto'; +import { AlbionPingRoleService } from './albion.ping.role.service'; import { Role } from 'discord.js'; let mockAlbionRegistrationsRepository: jest.Mocked>; @@ -22,6 +23,7 @@ describe('AlbionDeregistrationService', () => { let service: AlbionDeregistrationService; let discordService: jest.Mocked; let configService: jest.Mocked; + let albionPingRoleService: jest.Mocked; beforeEach(async () => { mockCharacter = TestBootstrapper.getMockAlbionCharacter(); @@ -59,6 +61,12 @@ describe('AlbionDeregistrationService', () => { getRoleViaMember: jest.fn(), }, }, + { + provide: AlbionPingRoleService, + useValue: { + stripForDeregistration: jest.fn(), + }, + }, { provide: getRepositoryToken(AlbionRegistrationsEntity), useValue: mockAlbionRegistrationsRepository, @@ -71,6 +79,7 @@ describe('AlbionDeregistrationService', () => { service = moduleRef.get(AlbionDeregistrationService); discordService = moduleRef.get(DiscordService) as any; configService = moduleRef.get(ConfigService) as any; + albionPingRoleService = moduleRef.get(AlbionPingRoleService) as any; // Ensure albion.roleMap + devUserId available for role stripping tests (configService.get as jest.Mock).mockImplementation((key: string) => { @@ -174,6 +183,40 @@ describe('AlbionDeregistrationService', () => { expect(stripRolesSpy).not.toHaveBeenCalled(); }); + it('should strip ping roles and reactions for the member', async () => { + const dto: AlbionDeregisterDto = { discordMember: mockDiscordMember.user }; + + await service.deregister(mockChannel, dto); + + expect(albionPingRoleService.stripForDeregistration).toHaveBeenCalledWith( + mockRegistration.discordId, + mockDiscordMember, + mockChannel, + ); + }); + + it('should still clear ping reactions when the member has left the server', async () => { + const dto: AlbionDeregisterDto = { discordMember: mockDiscordMember.user }; + discordService.getGuildMember.mockRejectedValueOnce(new Error('Gone')); + + await service.deregister(mockChannel, dto); + + expect(albionPingRoleService.stripForDeregistration).toHaveBeenCalledWith( + mockRegistration.discordId, + null, + mockChannel, + ); + }); + + it('should not strip ping roles when there was no registration to remove', async () => { + const dto: AlbionDeregisterDto = { discordMember: mockDiscordMember.user }; + mockAlbionRegistrationsRepository.findOne.mockResolvedValueOnce(null); + + await service.deregister(mockChannel, dto); + + expect(albionPingRoleService.stripForDeregistration).not.toHaveBeenCalled(); + }); + it('should skip role stripping if member fetch fails (character path)', async () => { const dto: AlbionDeregisterDto = { character: mockRegistration.characterName }; mockAlbionRegistrationsRepository.findOne.mockResolvedValueOnce(mockRegistration); diff --git a/src/albion/services/albion.deregistration.service.ts b/src/albion/services/albion.deregistration.service.ts index e6d6ae59..0bed5974 100644 --- a/src/albion/services/albion.deregistration.service.ts +++ b/src/albion/services/albion.deregistration.service.ts @@ -7,6 +7,7 @@ import { GuildMember, GuildTextBasedChannel } from 'discord.js'; import { AlbionRoleMapInterface } from '../../config/albion.app.config'; import { DiscordService } from '../../discord/discord.service'; import { AlbionDeregisterDto } from '../dto/albion.deregister.dto'; +import { AlbionPingRoleService } from './albion.ping.role.service'; @Injectable() export class AlbionDeregistrationService { @@ -15,6 +16,7 @@ export class AlbionDeregistrationService { constructor( private readonly config: ConfigService, private readonly discordService: DiscordService, + private readonly albionPingRoleService: AlbionPingRoleService, @InjectRepository(AlbionRegistrationsEntity) private readonly albionRegistrationsRepository: EntityRepository, ) { } @@ -67,6 +69,14 @@ export class AlbionDeregistrationService { await this.stripRegistration(registration, responseChannel); + // Runs whether or not they're still on the server: their reactions on the content pings + // message outlive them, and Discord never prunes reactions of people who have left. + await this.albionPingRoleService.stripForDeregistration( + registration.discordId, + discordMember, + responseChannel, + ); + if (!discordMember) { const error = `Discord Member with ID "${registration.discordId}" not found in Discord! They have likely left the server, so no roles can be stripped.`; this.logger.warn(error); diff --git a/src/albion/services/albion.ping.role.service.spec.ts b/src/albion/services/albion.ping.role.service.spec.ts new file mode 100644 index 00000000..ee08fc8f --- /dev/null +++ b/src/albion/services/albion.ping.role.service.spec.ts @@ -0,0 +1,822 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@mikro-orm/nestjs'; +import { Collection } from 'discord.js'; +import { + AlbionPingRoleService, + MAX_REACTION_REMOVALS_PER_RUN, + PROGRESS_EDIT_INTERVAL_MS, +} from './albion.ping.role.service'; +import { AlbionRegistrationsEntity } from '../../database/entities/albion.registrations.entity'; +import { DiscordService } from '../../discord/discord.service'; +import { TestBootstrapper } from '../../test.bootstrapper'; + +const pingRoleIds = TestBootstrapper.mockPingRoleIds; +const exemptRoleId = TestBootstrapper.mockConfig.albion.pingRoleExemptRoles[0]; +const rankRoleId = TestBootstrapper.mockConfig.albion.roleMap[0].discordRoleId; +const albionGuildId = TestBootstrapper.mockConfig.albion.guildId; +const devUserId = TestBootstrapper.mockConfig.discord.devUserId; + +// Every role on the server, not just the ping ones: the service has to pick the ping roles +// out of a list that also holds rank roles sharing the same ALB/ prefix. +const allGuildRoles = () => TestBootstrapper.getMockPingRoleCollection() + .set(rankRoleId, { id: rankRoleId, name: 'ALB/Archmage' } as any) + .set(exemptRoleId, { id: exemptRoleId, name: 'ALB/Alliance' } as any); + +describe('AlbionPingRoleService', () => { + let service: AlbionPingRoleService; + let discordService: jest.Mocked; + let mockRepository: any; + let mockChannel: any; + let mockPingsMessage: any; + let mockGuildPingsMessage: any; + + const setupModule = async (configOverride?: any) => { + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + AlbionPingRoleService, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + { + provide: DiscordService, + useValue: { + getTextChannel: jest.fn(), + getAllRolesFromGuild: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AlbionRegistrationsEntity), + useValue: mockRepository, + }, + ], + }).compile(); + + TestBootstrapper.setupConfig(moduleRef, configOverride); + + service = moduleRef.get(AlbionPingRoleService); + discordService = moduleRef.get(DiscordService) as any; + + discordService.getTextChannel.mockResolvedValue(mockChannel); + discordService.getAllRolesFromGuild.mockResolvedValue(allGuildRoles()); + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockRepository = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + }; + + // Production has two pings embeds, so the fetch answers by ID rather than handing the same + // message back for both - otherwise every reaction would be counted twice. + mockPingsMessage = TestBootstrapper.getMockContentPingsMessage(); + mockGuildPingsMessage = TestBootstrapper.getMockGuildPingsMessage(); + mockChannel = { + messages: { + fetch: jest.fn().mockImplementation(async (messageId: string) => { + const message = [mockPingsMessage, mockGuildPingsMessage].find((candidate) => candidate.id === messageId); + + if (!message) { + throw new Error('Unknown Message'); + } + + return message; + }), + }, + }; + + await setupModule(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getPingsMessages', () => { + it('should fetch every configured message from the configured channel', async () => { + const result = await service.getPingsMessages(); + + expect(discordService.getTextChannel).toHaveBeenCalledWith(TestBootstrapper.mockConfig.discord.channels.albionRoles); + expect(mockChannel.messages.fetch).toHaveBeenCalledTimes(2); + expect(result.messages).toEqual([mockPingsMessage, mockGuildPingsMessage]); + expect(result.failed).toEqual([]); + }); + + it.each([[[]], [undefined]])('should return nothing when no message IDs are configured (%p)', async (pingsMessageIds) => { + await setupModule({ + albion: { ...TestBootstrapper.mockConfig.albion, pingsMessageIds }, + discord: TestBootstrapper.mockConfig.discord, + }); + + const result = await service.getPingsMessages(); + + expect(result.messages).toEqual([]); + expect(result.failed).toEqual([]); + expect(discordService.getTextChannel).not.toHaveBeenCalled(); + }); + + it('should keep the messages it could read and report the ones it could not', async () => { + mockChannel.messages.fetch.mockRejectedValueOnce(new Error('Unknown Message')); + + const result = await service.getPingsMessages(); + + expect(result.messages).toEqual([mockGuildPingsMessage]); + expect(result.failed).toEqual([TestBootstrapper.mockConfig.albion.pingsMessageIds[0]]); + }); + + it('should report every message when the channel cannot be fetched', async () => { + discordService.getTextChannel.mockRejectedValue(new Error('No channel')); + + const result = await service.getPingsMessages(); + + expect(result.messages).toEqual([]); + expect(result.failed).toEqual(TestBootstrapper.mockConfig.albion.pingsMessageIds); + }); + }); + + describe('extractText', () => { + it('should read content, embed title, description and fields', () => { + const text = service.extractText({ + content: 'top level', + embeds: [ + { + title: 'A title', + description: 'A description', + fields: [{ name: 'field name', value: 'field value' }], + }, + ], + } as any); + + expect(text).toContain('top level'); + expect(text).toContain('A title'); + expect(text).toContain('A description'); + expect(text).toContain('field name'); + expect(text).toContain('field value'); + }); + + it('should cope with a message that has no embeds or content', () => { + expect(service.extractText({} as any)).toBe(''); + }); + + it('should cope with an embed with no description or fields', () => { + expect(service.extractText({ content: '', embeds: [{ title: 'Only a title' }] } as any)) + .toContain('Only a title'); + }); + + it('should cope with an embed with no title', () => { + expect(service.extractText({ embeds: [{ description: 'Only a description' }] } as any)) + .toContain('Only a description'); + }); + + it('should cope with a field with no name or value', () => { + expect(service.extractText({ embeds: [{ fields: [{}] }] } as any).trim()).toBe(''); + }); + }); + + describe('getPingRoles', () => { + it('should resolve every ping role named in the embed', async () => { + const result = await service.getPingRoles({} as any, [mockPingsMessage]); + + expect(result.roles.size).toBe(3); + expect(result.roles.map((role) => role.name).sort()).toEqual([ + 'ALB/Arena', + 'ALB/FactionWarfare', + 'ALB/Mist', + ]); + expect(result.unresolved).toEqual([]); + }); + + it('should union the roles named across every pings message', async () => { + const result = await service.getPingRoles({} as any, [mockPingsMessage, mockGuildPingsMessage]); + + expect(result.roles.map((role) => role.name).sort()).toEqual([ + 'ALB/Arena', + 'ALB/CTA', + 'ALB/FactionWarfare', + 'ALB/Gatherer', + 'ALB/Mist', + ]); + expect(result.unresolved).toEqual([]); + }); + + it('should de-duplicate a role named in two different messages', async () => { + mockGuildPingsMessage.embeds[0].description += '\nโ˜๏ธ ALB/Mist - named in both by mistake'; + + const result = await service.getPingRoles({} as any, [mockPingsMessage, mockGuildPingsMessage]); + + expect(result.roles.size).toBe(5); + }); + + it('should never treat a rank role as a ping role, even when the embed names one', async () => { + mockPingsMessage.embeds[0].description += '\n๐Ÿ‘‘ ALB/Archmage - the guild leader'; + + const result = await service.getPingRoles({} as any, [mockPingsMessage]); + + expect(result.roles.has(rankRoleId)).toBe(false); + expect(result.roles.size).toBe(3); + expect(result.unresolved).toEqual([]); + }); + + it('should report role names in the embed that do not exist on the server', async () => { + mockPingsMessage.embeds[0].description += '\n๐Ÿพ ALB/Tracking - if you want to go hunting'; + + const result = await service.getPingRoles({} as any, [mockPingsMessage]); + + expect(result.unresolved).toEqual(['ALB/Tracking']); + expect(result.roles.size).toBe(3); + }); + + it('should de-duplicate a role named twice in the embed', async () => { + mockPingsMessage.embeds[0].description += '\nโ˜๏ธ ALB/Mist - mentioned again by mistake'; + + const result = await service.getPingRoles({} as any, [mockPingsMessage]); + + expect(result.roles.size).toBe(3); + }); + + it('should return null when there are no messages to read', async () => { + expect(await service.getPingRoles({} as any, [])).toBeNull(); + expect(discordService.getAllRolesFromGuild).not.toHaveBeenCalled(); + }); + + it('should return null when the embed names no ALB/ roles at all', async () => { + mockPingsMessage.embeds[0].description = 'Someone has emptied the message'; + + expect(await service.getPingRoles({} as any, [mockPingsMessage])).toBeNull(); + }); + + it('should return null when none of the named roles resolve', async () => { + discordService.getAllRolesFromGuild.mockResolvedValue(new Collection() as any); + + expect(await service.getPingRoles({} as any, [mockPingsMessage])).toBeNull(); + }); + + it('should return null when the embed only names rank roles', async () => { + mockPingsMessage.embeds[0].description = '๐Ÿ‘‘ ALB/Archmage - the guild leader'; + + expect(await service.getPingRoles({} as any, [mockPingsMessage])).toBeNull(); + }); + }); + + describe('fetchReactors', () => { + it('should map each user to the reactions they placed', async () => { + const message = TestBootstrapper.getMockContentPingsMessage([ + TestBootstrapper.getMockPingReaction('โš”๏ธ', ['1', '2']), + TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['2']), + ]); + + const reactors = await service.fetchReactors([message]); + + expect(reactors.size).toBe(2); + expect(reactors.get('1')).toHaveLength(1); + expect(reactors.get('2')).toHaveLength(2); + }); + + it('should gather a user\'s reactions from every pings message', async () => { + const content = TestBootstrapper.getMockContentPingsMessage([ + TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['1']), + ]); + const guild = TestBootstrapper.getMockGuildPingsMessage([ + TestBootstrapper.getMockPingReaction('๐Ÿ“ฏ', ['1', '2']), + ]); + + const reactors = await service.fetchReactors([content, guild]); + + expect(reactors.size).toBe(2); + expect(reactors.get('1')).toHaveLength(2); + expect(reactors.get('2')).toHaveLength(1); + }); + + it('should page past the first 100 users', async () => { + const userIds = Array.from({ length: 250 }, (_, index) => `user-${index}`); + const reaction = TestBootstrapper.getMockPingReaction('โš”๏ธ', userIds); + const message = TestBootstrapper.getMockContentPingsMessage([reaction]); + + const reactors = await service.fetchReactors([message]); + + expect(reactors.size).toBe(250); + expect(reaction.users.fetch).toHaveBeenCalledTimes(3); + expect(reaction.users.fetch).toHaveBeenNthCalledWith(2, { limit: 100, after: 'user-99' }); + }); + + it('should stop paging on an exact multiple of the page size', async () => { + const userIds = Array.from({ length: 100 }, (_, index) => `user-${index}`); + const reaction = TestBootstrapper.getMockPingReaction('โš”๏ธ', userIds); + + const reactors = await service.fetchReactors([TestBootstrapper.getMockContentPingsMessage([reaction])]); + + expect(reactors.size).toBe(100); + // One full page, then one empty page proving there is no more + expect(reaction.users.fetch).toHaveBeenCalledTimes(2); + }); + + it('should ignore bots', async () => { + const reaction = TestBootstrapper.getMockPingReaction('โš”๏ธ', ['1'], ['bot-1']); + + const reactors = await service.fetchReactors([TestBootstrapper.getMockContentPingsMessage([reaction])]); + + expect(reactors.size).toBe(1); + expect(reactors.has('bot-1')).toBe(false); + }); + + it('should return nothing for a message with no reactions', async () => { + const reactors = await service.fetchReactors([TestBootstrapper.getMockContentPingsMessage()]); + + expect(reactors.size).toBe(0); + }); + }); + + describe('isExempt', () => { + it('should be false for a member who has left the server', () => { + expect(service.isExempt(null)).toBe(false); + }); + + it('should be true for a member holding an exempt role', () => { + expect(service.isExempt(TestBootstrapper.getMockGuildMemberWithRoles('1', [exemptRoleId]))).toBe(true); + }); + + it('should be false for a member holding only ping roles', () => { + expect(service.isExempt(TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist]))).toBe(false); + }); + + it('should be false when no exempt roles are configured', async () => { + await setupModule({ + albion: { ...TestBootstrapper.mockConfig.albion, pingRoleExemptRoles: undefined }, + discord: TestBootstrapper.mockConfig.discord, + }); + + expect(service.isExempt(TestBootstrapper.getMockGuildMemberWithRoles('1', [exemptRoleId]))).toBe(false); + }); + }); + + describe('removePingRoles', () => { + it('should remove only the ping roles the member actually holds', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist]); + + const result = await service.removePingRoles(member, TestBootstrapper.getMockPingRoleCollection(), false); + + expect(result.removed).toEqual(['ALB/Mist']); + expect(member.roles.remove).toHaveBeenCalledTimes(1); + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.mist); + }); + + it('should report but not perform removals on a dry run', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist, pingRoleIds.arena]); + + const result = await service.removePingRoles(member, TestBootstrapper.getMockPingRoleCollection(), true); + + expect(result.removed).toHaveLength(2); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); + + it('should capture an error without abandoning the remaining roles', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.factionWarfare, pingRoleIds.mist]); + member.roles.remove.mockRejectedValueOnce(new Error('Discord says no')); + + const result = await service.removePingRoles(member, TestBootstrapper.getMockPingRoleCollection(), false); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Discord says no'); + expect(member.roles.remove).toHaveBeenCalledTimes(2); + }); + + it('should do nothing for a member with no ping roles', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', []); + + const result = await service.removePingRoles(member, TestBootstrapper.getMockPingRoleCollection(), false); + + expect(result.removed).toEqual([]); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); + }); + + describe('removeReactions', () => { + it('should remove the user from each reaction', async () => { + const first = TestBootstrapper.getMockPingReaction('โš”๏ธ', ['1']); + const second = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['1']); + + const result = await service.removeReactions([first, second], '1', false); + + expect(result.removed).toBe(2); + expect(first.users.remove).toHaveBeenCalledWith('1'); + expect(second.users.remove).toHaveBeenCalledWith('1'); + }); + + it('should report but not perform removals on a dry run', async () => { + const reaction = TestBootstrapper.getMockPingReaction('โš”๏ธ', ['1']); + + const result = await service.removeReactions([reaction], '1', true); + + expect(result.removed).toBe(1); + expect(reaction.users.remove).not.toHaveBeenCalled(); + }); + + it('should not count a failed removal as removed', async () => { + const reaction = TestBootstrapper.getMockPingReaction('โš”๏ธ', ['1']); + reaction.users.remove.mockRejectedValueOnce(new Error('Missing Permissions')); + + const result = await service.removeReactions([reaction], '1', false); + + expect(result.removed).toBe(0); + expect(result.errors[0]).toContain('Missing Permissions'); + }); + }); + + describe('stripForDeregistration', () => { + let responseChannel: any; + + beforeEach(() => { + responseChannel = { send: jest.fn() }; + mockPingsMessage.reactions.cache = new Collection([ + ['0', TestBootstrapper.getMockPingReaction('โš”๏ธ', ['1'])], + ]); + }); + + it('should strip roles and reactions and report what it did', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist]); + + const result = await service.stripForDeregistration('1', member, responseChannel); + + expect(result.rolesRemoved).toEqual(['ALB/Mist']); + expect(result.reactionsRemoved).toBe(1); + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.mist); + expect(responseChannel.send).toHaveBeenCalledWith( + expect.stringContaining('Removed 1 ping role(s) and 1 ping reaction(s) from <@1>'), + ); + }); + + it('should still clear reactions for someone who has left the server', async () => { + const result = await service.stripForDeregistration('1', null, responseChannel); + + expect(result.rolesRemoved).toEqual([]); + expect(result.reactionsRemoved).toBe(1); + expect(discordService.getAllRolesFromGuild).not.toHaveBeenCalled(); + }); + + it('should leave an exempt member entirely alone', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [exemptRoleId, pingRoleIds.mist]); + + const result = await service.stripForDeregistration('1', member, responseChannel); + + expect(result).toBeNull(); + expect(member.roles.remove).not.toHaveBeenCalled(); + expect(mockChannel.messages.fetch).not.toHaveBeenCalled(); + }); + + it('should do nothing when no pings message can be read', async () => { + mockChannel.messages.fetch.mockRejectedValue(new Error('Unknown Message')); + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist]); + + expect(await service.stripForDeregistration('1', member, responseChannel)).toBeNull(); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); + + it('should clear reactions from every pings message', async () => { + const guildPingsReaction = TestBootstrapper.getMockPingReaction('๐Ÿ“ฏ', ['1']); + mockGuildPingsMessage.reactions.cache = new Collection([['0', guildPingsReaction]]); + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.cta]); + + const result = await service.stripForDeregistration('1', member, responseChannel); + + expect(result.reactionsRemoved).toBe(2); + expect(guildPingsReaction.users.remove).toHaveBeenCalledWith('1'); + // A role that only the guild pings embed names still has to come off + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.cta); + }); + + it('should still clear reactions when the ping roles cannot be resolved', async () => { + discordService.getAllRolesFromGuild.mockResolvedValue(new Collection() as any); + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist]); + + const result = await service.stripForDeregistration('1', member, responseChannel); + + expect(result.rolesRemoved).toEqual([]); + expect(result.reactionsRemoved).toBe(1); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); + + it('should stay quiet when there was nothing to remove', async () => { + mockPingsMessage.reactions.cache = new Collection(); + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', []); + + const result = await service.stripForDeregistration('1', member, responseChannel); + + expect(result.rolesRemoved).toEqual([]); + expect(result.reactionsRemoved).toBe(0); + expect(responseChannel.send).not.toHaveBeenCalled(); + }); + + it('should report an error and ping the dev', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('1', [pingRoleIds.mist]); + member.roles.remove.mockRejectedValueOnce(new Error('Discord says no')); + + await service.stripForDeregistration('1', member, responseChannel); + + expect(responseChannel.send).toHaveBeenCalledWith( + expect.stringContaining(`Discord says no Pinging <@${devUserId}>!`), + ); + }); + + it('should attempt every reaction rather than paging the reactor lists', async () => { + const first = TestBootstrapper.getMockPingReaction('โš”๏ธ', []); + const second = TestBootstrapper.getMockPingReaction('โ˜๏ธ', []); + mockPingsMessage.reactions.cache = new Collection([['0', first], ['1', second]]); + + await service.stripForDeregistration('1', null, responseChannel); + + expect(first.users.fetch).not.toHaveBeenCalled(); + expect(first.users.remove).toHaveBeenCalledWith('1'); + expect(second.users.remove).toHaveBeenCalledWith('1'); + }); + }); + + describe('reconcile', () => { + let scanMessage: any; + let members: Collection; + + // Per-member lines are sent as a placeholder and then edited in, so the report is only + // legible to a test that watches both calls. + let edits: string[]; + + const buildScanMessage = () => { + const guild = { + id: TestBootstrapper.mockConfig.discord.guildId, + members: { fetch: jest.fn().mockImplementation(async () => members) }, + }; + return { + id: 'scan-message', + guild, + edit: jest.fn(), + delete: jest.fn(), + channel: { + guild, + send: jest.fn().mockImplementation(async () => ({ + edit: jest.fn().mockImplementation(async (content: string) => edits.push(content)), + })), + }, + } as any; + }; + + const sentMessages = () => [ + ...(scanMessage.channel.send as jest.Mock).mock.calls.map((call) => call[0]), + ...edits, + ]; + + beforeEach(() => { + members = new Collection(); + edits = []; + scanMessage = buildScanMessage(); + }); + + it('should skip and say so when no pings message can be read', async () => { + mockChannel.messages.fetch.mockRejectedValue(new Error('Unknown Message')); + + expect(await service.reconcile(scanMessage)).toBe(false); + expect(sentMessages().join()).toContain('no pings messages could be read'); + expect(scanMessage.guild.members.fetch).not.toHaveBeenCalled(); + }); + + it('should carry on but shout when only one pings message can be read', async () => { + mockChannel.messages.fetch.mockRejectedValueOnce(new Error('Unknown Message')); + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', [pingRoleIds.cta]); + members.set('leaver', member); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(true); + expect(sentMessages().join()).toContain('1 configured pings message(s) could not be read'); + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.cta); + }); + + it('should strip a role that only the guild pings message names', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', [pingRoleIds.gatherer]); + members.set('leaver', member); + const reaction = TestBootstrapper.getMockPingReaction('๐Ÿ‘จโ€๐ŸŒพ', ['leaver']); + mockGuildPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(true); + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.gatherer); + expect(reaction.users.remove).toHaveBeenCalledWith('leaver'); + }); + + it('should skip and say so when no ping roles resolve', async () => { + discordService.getAllRolesFromGuild.mockResolvedValue(new Collection() as any); + + expect(await service.reconcile(scanMessage)).toBe(false); + expect(sentMessages().join()).toContain('no ping roles could be resolved'); + expect(scanMessage.guild.members.fetch).not.toHaveBeenCalled(); + }); + + it('should warn about role names in the embed that do not exist', async () => { + mockPingsMessage.embeds[0].description += '\n๐Ÿพ ALB/Tracking - if you want to go hunting'; + + await service.reconcile(scanMessage); + + expect(sentMessages().join()).toContain('**ALB/Tracking**'); + }); + + it('should strip an unregistered member of their ping roles and reactions', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', [pingRoleIds.mist]); + members.set('leaver', member); + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['leaver']); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(true); + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.mist); + expect(reaction.users.remove).toHaveBeenCalledWith('leaver'); + expect(sentMessages().join()).toContain('1 member(s) stripped of ping roles'); + }); + + it('should leave a registered member alone', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('keeper', [pingRoleIds.mist]); + members.set('keeper', member); + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['keeper']); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + mockRepository.find.mockResolvedValue([{ discordId: 'keeper' } as AlbionRegistrationsEntity]); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(false); + expect(member.roles.remove).not.toHaveBeenCalled(); + expect(reaction.users.remove).not.toHaveBeenCalled(); + expect(mockRepository.find).toHaveBeenCalledWith({ guildId: albionGuildId }); + }); + + it('should leave an unregistered alliance member alone', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('ally', [exemptRoleId, pingRoleIds.mist]); + members.set('ally', member); + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['ally']); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(false); + expect(member.roles.remove).not.toHaveBeenCalled(); + expect(reaction.users.remove).not.toHaveBeenCalled(); + }); + + it('should ignore bots holding a ping role', async () => { + const bot = TestBootstrapper.getMockGuildMemberWithRoles('bot', [pingRoleIds.mist], true); + members.set('bot', bot); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(false); + expect(bot.roles.remove).not.toHaveBeenCalled(); + }); + + it('should clear the reactions of someone who has left the Discord server', async () => { + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['ghost']); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(true); + expect(reaction.users.remove).toHaveBeenCalledWith('ghost'); + expect(sentMessages().join()).toContain('has left the Discord server'); + }); + + it('should never strip a rank role', async () => { + mockPingsMessage.embeds[0].description += '\n๐Ÿ‘‘ ALB/Archmage - the guild leader'; + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', [rankRoleId, pingRoleIds.mist]); + members.set('leaver', member); + + await service.reconcile(scanMessage); + + expect(member.roles.remove).toHaveBeenCalledWith(pingRoleIds.mist); + expect(member.roles.remove).not.toHaveBeenCalledWith(rankRoleId); + }); + + it('should change nothing on a dry run but still report', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', [pingRoleIds.mist]); + members.set('leaver', member); + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['leaver']); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + const actionRequired = await service.reconcile(scanMessage, true); + + expect(actionRequired).toBe(true); + expect(member.roles.remove).not.toHaveBeenCalled(); + expect(reaction.users.remove).not.toHaveBeenCalled(); + expect(sentMessages().join()).toContain('(DRY RUN)'); + }); + + it('should say so when nothing needs doing', async () => { + expect(await service.reconcile(scanMessage)).toBe(false); + expect(sentMessages().join()).toContain('No ping role inconsistencies were detected'); + }); + + it('should report a live membership count for every ping role', async () => { + members.set('a', TestBootstrapper.getMockGuildMemberWithRoles('a', [pingRoleIds.mist])); + members.set('b', TestBootstrapper.getMockGuildMemberWithRoles('b', [pingRoleIds.mist])); + mockRepository.find.mockResolvedValue([ + { discordId: 'a' } as AlbionRegistrationsEntity, + { discordId: 'b' } as AlbionRegistrationsEntity, + ]); + + await service.reconcile(scanMessage); + + const report = sentMessages().join(); + expect(report).toContain('**ALB/Mist**: 2'); + expect(report).toContain('**ALB/Arena**: 0'); + }); + + it('should count only what survives the sweep', async () => { + members.set('a', TestBootstrapper.getMockGuildMemberWithRoles('a', [pingRoleIds.mist])); + const leaver = TestBootstrapper.getMockGuildMemberWithRoles('b', [pingRoleIds.mist]); + // The sweep removes the role, so the live count must not include them any more + leaver.roles.remove.mockImplementation(async () => leaver.roles.cache.has.mockReturnValue(false)); + members.set('b', leaver); + mockRepository.find.mockResolvedValue([{ discordId: 'a' } as AlbionRegistrationsEntity]); + + await service.reconcile(scanMessage); + + expect(sentMessages().join()).toContain('**ALB/Mist**: 1'); + }); + + it('should defer reactions beyond the per-run cap to the next run', async () => { + const ghostIds = Array.from({ length: MAX_REACTION_REMOVALS_PER_RUN + 10 }, (_, index) => `ghost-${index}`); + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ghostIds); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + await service.reconcile(scanMessage); + + expect(reaction.users.remove).toHaveBeenCalledTimes(MAX_REACTION_REMOVALS_PER_RUN); + expect(sentMessages().join()).toContain('10 reaction(s) were left for the next run'); + }); + + it('should report removal errors and ping the dev', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', [pingRoleIds.mist]); + member.roles.remove.mockRejectedValueOnce(new Error('Discord says no')); + members.set('leaver', member); + + await service.reconcile(scanMessage); + + expect(sentMessages().join()).toContain(`Discord says no Pinging <@${devUserId}>!`); + }); + + describe('progress reporting', () => { + const addLeavers = (count: number) => { + for (let index = 0; index < count; index++) { + members.set(`leaver-${index}`, TestBootstrapper.getMockGuildMemberWithRoles(`leaver-${index}`, [pingRoleIds.mist])); + } + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should not edit the scan message when the sweep is quick', async () => { + addLeavers(3); + + await service.reconcile(scanMessage); + + expect(scanMessage.edit).not.toHaveBeenCalled(); + }); + + it('should report progress against the heading it was given once the interval passes', async () => { + addLeavers(3); + // Every check of the clock reads two seconds later, so each member trips the throttle + let clock = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => (clock += PROGRESS_EDIT_INTERVAL_MS)); + + await service.reconcile(scanMessage, false, '# Sweeping ping roles...'); + + const progressEdits = (scanMessage.edit as jest.Mock).mock.calls.map((call) => call[0]); + expect(progressEdits[0]).toContain('# Sweeping ping roles... [0/3] (0%)'); + // The last edit is the finishing one, so it always reads as complete + expect(progressEdits[progressEdits.length - 1]).toContain('[3/3] (100%)'); + }); + + it('should carry on when a progress edit fails', async () => { + addLeavers(3); + let clock = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => (clock += PROGRESS_EDIT_INTERVAL_MS)); + scanMessage.edit.mockRejectedValue(new Error('Unknown Message')); + + expect(await service.reconcile(scanMessage)).toBe(true); + }); + }); + + it('should sweep a member who reacted but holds no ping role', async () => { + const member = TestBootstrapper.getMockGuildMemberWithRoles('leaver', []); + members.set('leaver', member); + const reaction = TestBootstrapper.getMockPingReaction('โ˜๏ธ', ['leaver']); + mockPingsMessage.reactions.cache = new Collection([['0', reaction]]); + + const actionRequired = await service.reconcile(scanMessage); + + expect(actionRequired).toBe(true); + expect(reaction.users.remove).toHaveBeenCalledWith('leaver'); + expect(sentMessages().join()).toContain('is not registered'); + }); + }); +}); diff --git a/src/albion/services/albion.ping.role.service.ts b/src/albion/services/albion.ping.role.service.ts new file mode 100644 index 00000000..bb9d9478 --- /dev/null +++ b/src/albion/services/albion.ping.role.service.ts @@ -0,0 +1,526 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { EntityRepository } from '@mikro-orm/core'; +import { + Collection, + Guild, + GuildMember, + GuildTextBasedChannel, + Message, + MessageReaction, + Role, + Snowflake, +} from 'discord.js'; +import { AlbionRegistrationsEntity } from '../../database/entities/albion.registrations.entity'; +import { AlbionRoleMapInterface } from '../../config/albion.app.config'; +import { ALBION_GUILD_EMOJI } from '../interfaces/albion.api.interfaces'; +import { DiscordService } from '../../discord/discord.service'; +import { getChannel } from '../../discord/discord.hacks'; + +// Every line of the pings embed reads " ALB/Name - description". Only the role token is +// load bearing: anyone who shouldn't be there loses every ping role and every reaction they +// hold, so nothing needs to know which emoji belongs to which role. +export const PING_ROLE_TOKEN = /\bALB\/[A-Za-z0-9_-]+/g; + +// reaction.users.fetch() returns 100 at most, so a popular ping needs paging or everyone past +// the first page is invisible to the sweep. The page cap is a runaway guard, nothing more. +const REACTION_PAGE_SIZE = 100; +const MAX_REACTION_PAGES = 50; + +// A safety valve for the first live run, which has years of drift to clear. Whatever is left +// over is picked up by the next run rather than hammering the API in one go. +export const MAX_REACTION_REMOVALS_PER_RUN = 500; + +// Progress edits cost an API call of their own, on the tightest bucket in the sweep +export const PROGRESS_EDIT_INTERVAL_MS = 2000; + +export interface PingRoleSet { + roles: Collection; + // Role names in the embed that match no Discord role. Reported rather than dropped, since + // it means the embed and the server have drifted apart. + unresolved: string[]; +} + +export interface PingsMessageSet { + messages: Message[]; + // IDs that are configured but could not be fetched. Reported rather than fatal: a message the + // sweep can't see means less gets cleaned up, never that the wrong person gets stripped. + failed: string[]; +} + +export interface PingRoleStripResult { + discordId: string; + rolesRemoved: string[]; + reactionsRemoved: number; + errors: string[]; +} + +@Injectable() +export class AlbionPingRoleService { + private readonly logger = new Logger(AlbionPingRoleService.name); + + constructor( + private readonly config: ConfigService, + private readonly discordService: DiscordService, + @InjectRepository(AlbionRegistrationsEntity) private readonly albionRegistrationsRepository: EntityRepository, + ) {} + + // Null means "ping roles could not be established". Callers must treat it as do-nothing: + // a failed fetch reading as an empty role list would strip the entire server. + async getPingRoles(guild: Guild, messages: Message[]): Promise { + if (messages.length === 0) { + return null; + } + + const text = messages.map((message) => this.extractText(message)).join('\n'); + const tokens = [...new Set(text.match(PING_ROLE_TOKEN) ?? [])]; + + if (tokens.length === 0) { + this.logger.error('Ping Roles: No ALB/ role names found in the pings messages!'); + return null; + } + + const guildRoles = await this.discordService.getAllRolesFromGuild(guild); + const rankRoleIds = new Set( + (this.config.get('albion.roleMap') as AlbionRoleMapInterface[]).map((role) => role.discordRoleId), + ); + + const roles = new Collection(); + const unresolved: string[] = []; + + for (const token of tokens) { + const role = guildRoles.find((guildRole) => guildRole.name === token); + + if (!role) { + unresolved.push(token); + continue; + } + + // Rank roles share the ALB/ prefix. Excluded by ID whatever the embed happens to say, + // so a stray mention of a rank in the text can never strip someone of their rank. + if (rankRoleIds.has(role.id)) { + this.logger.warn(`Ping Roles: Ignoring rank role "${role.name}" found in the pings message.`); + continue; + } + + roles.set(role.id, role); + } + + if (roles.size === 0) { + this.logger.error('Ping Roles: None of the role names in the pings messages resolved to a Discord role!'); + return null; + } + + return { roles, unresolved }; + } + + // Fetched fresh every time. The embeds are edited by hand whenever a ping role is added, and + // a cached copy would keep the sweep working off the old list. + async getPingsMessages(): Promise { + const messageIds: string[] = this.config.get('albion.pingsMessageIds') ?? []; + const channelId = this.config.get('discord.channels.albionRoles'); + const result: PingsMessageSet = { messages: [], failed: [] }; + + if (messageIds.length === 0) { + this.logger.warn('Ping Roles: No pings message IDs are configured, skipping.'); + return result; + } + + for (const messageId of messageIds) { + try { + const channel = await this.discordService.getTextChannel(channelId); + result.messages.push(await channel.messages.fetch(messageId)); + } + catch (err) { + result.failed.push(messageId); + this.logger.error(`Ping Roles: Failed to fetch pings message ${messageId}! Err: ${err.message}`); + } + } + + return result; + } + + extractText(message: Message): string { + const parts: string[] = [message.content ?? '']; + + for (const embed of message.embeds ?? []) { + parts.push(embed.title ?? '', embed.description ?? ''); + + for (const field of embed.fields ?? []) { + parts.push(field.name ?? '', field.value ?? ''); + } + } + + return parts.join('\n'); + } + + // Who has reacted to the pings messages, by Discord ID. Users who have left the server still + // appear here - Discord never prunes reactions - which is exactly the drift being cleaned up. + async fetchReactors(messages: Message[]): Promise> { + const reactors = new Map(); + + for (const reaction of this.allReactions(messages)) { + let after: Snowflake | undefined; + + for (let page = 0; page < MAX_REACTION_PAGES; page++) { + const users = await reaction.users.fetch({ limit: REACTION_PAGE_SIZE, after }); + + if (users.size === 0) { + break; + } + + for (const [discordId, user] of users) { + if (user.bot) { + continue; + } + reactors.set(discordId, [...(reactors.get(discordId) ?? []), reaction]); + } + + if (users.size < REACTION_PAGE_SIZE) { + break; + } + + after = users.lastKey(); + } + } + + return reactors; + } + + // Every reaction across every pings message, flattened. The same emoji on two messages is two + // distinct reactions, so they can't be deduplicated by emoji. + allReactions(messages: Message[]): MessageReaction[] { + return messages.flatMap((message) => [...message.reactions.cache.values()]); + } + + isExempt(member: GuildMember | null): boolean { + if (!member) { + return false; + } + + const exemptRoles: string[] = this.config.get('albion.pingRoleExemptRoles') ?? []; + + return exemptRoles.some((roleId) => member.roles.cache.has(roleId)); + } + + async removePingRoles( + member: GuildMember, + pingRoles: Collection, + dryRun: boolean, + ): Promise<{ removed: string[], errors: string[] }> { + const removed: string[] = []; + const errors: string[] = []; + + for (const role of pingRoles.values()) { + if (!member.roles.cache.has(role.id)) { + continue; + } + + removed.push(role.name); + + if (dryRun) { + continue; + } + + try { + await member.roles.remove(role.id); + } + catch (err) { + errors.push(`Failed to remove role "${role.name}" from <@${member.id}>: ${err.message}`); + this.logger.error(`Ping Roles: Failed to remove role ${role.name} from ${member.id}! Err: ${err.message}`); + } + } + + return { removed, errors }; + } + + // Reactions can only be removed, never added on someone's behalf, so clearing them is what + // lets a returning member opt back in by clicking the emoji again. + async removeReactions( + reactions: MessageReaction[], + discordId: Snowflake, + dryRun: boolean, + ): Promise<{ removed: number, errors: string[] }> { + const errors: string[] = []; + let removed = 0; + + for (const reaction of reactions) { + removed++; + + if (dryRun) { + continue; + } + + try { + await reaction.users.remove(discordId); + } + catch (err) { + removed--; + errors.push(`Failed to remove reaction ${reaction.emoji.name} from <@${discordId}>: ${err.message}`); + this.logger.error(`Ping Roles: Failed to remove reaction from ${discordId}! Err: ${err.message}`); + } + } + + return { removed, errors }; + } + + // Called from deregistration, so it runs for every leaver the scan finds and for every manual + // deregistration. discordMember is null when they have already left the server, in which case + // there are no roles left to strip but their reactions outlive them. + async stripForDeregistration( + discordId: Snowflake, + discordMember: GuildMember | null, + responseChannel: GuildTextBasedChannel, + ): Promise { + // Alliance members hold ping roles without ever registering. Exempt uniformly rather + // than only in the sweep, so leaving the guild but staying in the alliance keeps the pings. + if (this.isExempt(discordMember)) { + this.logger.log(`Ping Roles: ${discordId} holds an exempt role, leaving their ping roles alone.`); + return null; + } + + const { messages } = await this.getPingsMessages(); + + if (messages.length === 0) { + return null; + } + + const result: PingRoleStripResult = { + discordId, + rolesRemoved: [], + reactionsRemoved: 0, + errors: [], + }; + + if (discordMember) { + const pingRoles = await this.getPingRoles(discordMember.guild, messages); + + if (pingRoles) { + const roleResult = await this.removePingRoles(discordMember, pingRoles.roles, false); + result.rolesRemoved = roleResult.removed; + result.errors.push(...roleResult.errors); + } + } + + // Every reaction on every message is attempted rather than paging the reactor lists first: + // deregistrations are rare and one-by-one, and the delete is a no-op if they never reacted. + const reactionResult = await this.removeReactions( + this.allReactions(messages), + discordId, + false, + ); + result.reactionsRemoved = reactionResult.removed; + result.errors.push(...reactionResult.errors); + + if (result.rolesRemoved.length > 0 || result.reactionsRemoved > 0) { + await responseChannel.send( + `${ALBION_GUILD_EMOJI} ๐Ÿงน Removed ${result.rolesRemoved.length} ping role(s) and ${result.reactionsRemoved} ping reaction(s) from <@${discordId}>.`, + ); + } + + for (const error of result.errors) { + await responseChannel.send(`โš ๏ธ ${error} Pinging <@${this.config.get('discord.devUserId')}>!`); + } + + return result; + } + + // The reconciliation sweep. Clears historic drift the deregistration hook can't reach: people + // who left before the hook existed, and people who left the Discord server outright. + async reconcile( + scanMessage: Message, + dryRun = false, + heading = `# ${ALBION_GUILD_EMOJI} Sweeping ping roles...`, + ): Promise { + const emoji = ALBION_GUILD_EMOJI; + const guild = scanMessage.guild; + const { messages, failed } = await this.getPingsMessages(); + + if (messages.length === 0) { + await getChannel(scanMessage).send(`${emoji} โš ๏ธ Ping role sweep skipped: no pings messages could be read.`); + return false; + } + + // One unreadable message out of several only means less gets swept, so the run continues - + // but silently sweeping half the pings is how this stops being noticed, hence the shout. + if (failed.length > 0) { + await getChannel(scanMessage).send( + `${emoji} โš ๏ธ ${failed.length} configured pings message(s) could not be read, so their roles and reactions were skipped: **${failed.join('**, **')}**.`, + ); + } + + const pingRoleSet = await this.getPingRoles(guild, messages); + + if (!pingRoleSet) { + await getChannel(scanMessage).send(`${emoji} โš ๏ธ Ping role sweep skipped: no ping roles could be resolved from the pings messages.`); + return false; + } + + const { roles: pingRoles, unresolved } = pingRoleSet; + + if (unresolved.length > 0) { + await getChannel(scanMessage).send( + `${emoji} โš ๏ธ The pings messages name ${unresolved.length} role(s) that don't exist on the server: **${unresolved.join('**, **')}**. Fix the message or the role names.`, + ); + } + + // Full member list rather than role.members, which only reflects whatever happens to be + // cached and quietly undercounts. + const members = await guild.members.fetch(); + const reactors = await this.fetchReactors(messages); + + const registeredIds = new Set( + (await this.albionRegistrationsRepository.find({ guildId: this.config.get('albion.guildId') })) + .map((registration) => registration.discordId), + ); + + const candidates = new Set(reactors.keys()); + + // Bots are dropped in the candidate loop below, which is the one place both sources meet + for (const [discordId, member] of members) { + if (pingRoles.some((role) => member.roles.cache.has(role.id))) { + candidates.add(discordId); + } + } + + const dryRunText = dryRun ? ' (DRY RUN)' : ''; + const changes: string[] = []; + const errors: string[] = []; + let reactionsRemoved = 0; + let capped = 0; + let processed = 0; + + const progress = this.progressReporter(scanMessage, heading, candidates.size); + + for (const discordId of candidates) { + const member = members.get(discordId) ?? null; + + // Reports what is finished, before starting this one - so the counts never run ahead + await progress.update(processed, reactionsRemoved); + processed++; + + if (member?.user.bot) { + continue; + } + if (registeredIds.has(discordId)) { + continue; + } + if (this.isExempt(member)) { + continue; + } + + const rolesRemoved = member + ? await this.removePingRoles(member, pingRoles, dryRun) + : { removed: [], errors: [] }; + errors.push(...rolesRemoved.errors); + + const memberReactions = reactors.get(discordId) ?? []; + let reactionCount = 0; + + if (reactionsRemoved + memberReactions.length > MAX_REACTION_REMOVALS_PER_RUN) { + capped += memberReactions.length; + } + else { + const reactionResult = await this.removeReactions(memberReactions, discordId, dryRun); + reactionCount = reactionResult.removed; + reactionsRemoved += reactionCount; + errors.push(...reactionResult.errors); + } + + if (rolesRemoved.removed.length === 0 && reactionCount === 0) { + continue; + } + + const status = member ? 'is not registered' : 'has left the Discord server'; + changes.push( + `- ${emoji} ๐Ÿงน${dryRunText} <@${discordId}> ${status}: removed ${rolesRemoved.removed.length} ping role(s) and ${reactionCount} reaction(s).`, + ); + } + + await progress.finish(processed, reactionsRemoved); + + await this.report(scanMessage, changes, errors, capped, pingRoles, members, dryRun); + + return changes.length > 0; + } + + // Editing the scan message as it goes, so a sweep clearing hundreds of reactions doesn't look + // hung. Throttled, and the clock starts before the first member, so a short sweep edits nothing. + private progressReporter(scanMessage: Message, heading: string, total: number) { + let lastEditAt = Date.now(); + let emitted = false; + + const edit = async (processed: number, reactionsRemoved: number): Promise => { + const percent = total > 0 ? Math.floor((processed / total) * 100) : 100; + + try { + await scanMessage.edit(`${heading} [${processed}/${total}] (${percent}%) โ€” ${reactionsRemoved} reaction(s) removed`); + } + catch (err) { + // A failed progress edit must never take the sweep down with it + this.logger.warn(`Ping Roles: Failed to update sweep progress. Err: ${err.message}`); + } + }; + + return { + update: async (processed: number, reactionsRemoved: number): Promise => { + if (Date.now() - lastEditAt < PROGRESS_EDIT_INTERVAL_MS) { + return; + } + + lastEditAt = Date.now(); + emitted = true; + await edit(processed, reactionsRemoved); + }, + // Only when progress was reported at all, otherwise the heading is still accurate + finish: async (processed: number, reactionsRemoved: number): Promise => { + if (emitted) { + await edit(processed, reactionsRemoved); + } + }, + }; + } + + private async report( + scanMessage: Message, + changes: string[], + errors: string[], + capped: number, + pingRoles: Collection, + members: Collection, + dryRun: boolean, + ): Promise { + const emoji = ALBION_GUILD_EMOJI; + const dryRunText = dryRun ? ' (DRY RUN)' : ''; + + if (changes.length === 0) { + await getChannel(scanMessage).send(`${emoji} โœ… No ping role inconsistencies were detected.`); + } + else { + await getChannel(scanMessage).send(`## ${emoji} ๐Ÿงน${dryRunText} ${changes.length} member(s) stripped of ping roles!`); + + for (const change of changes) { + const lineMessage = await getChannel(scanMessage).send('.'); + await lineMessage.edit(change); + } + } + + if (capped > 0) { + await getChannel(scanMessage).send( + `${emoji} โ„น๏ธ ${capped} reaction(s) were left for the next run to avoid hammering Discord's API.`, + ); + } + + for (const error of errors) { + await getChannel(scanMessage).send(`โš ๏ธ ${error} Pinging <@${this.config.get('discord.devUserId')}>!`); + } + + // The whole point of the sweep: the counts on the roles are now a live membership count. + const counts = pingRoles + .map((role) => `- **${role.name}**: ${members.filter((member) => member.roles.cache.has(role.id)).size}`) + .join('\n'); + + await getChannel(scanMessage).send(`### ${emoji} Ping role membership\n${counts}`); + } +} diff --git a/src/albion/services/albion.scanning.service.spec.ts b/src/albion/services/albion.scanning.service.spec.ts index 5fca947a..81387695 100644 --- a/src/albion/services/albion.scanning.service.spec.ts +++ b/src/albion/services/albion.scanning.service.spec.ts @@ -11,6 +11,7 @@ import { AlbionApiService } from './albion.api.service'; import { AlbionUtilities } from '../utilities/albion.utilities'; import { TestBootstrapper } from '../../test.bootstrapper'; import { AlbionDeregistrationService } from './albion.deregistration.service'; +import { AlbionPingRoleService } from './albion.ping.role.service'; const mockDevUserId = TestBootstrapper.mockConfig.discord.devUserId; const mockScanUserId = '1337'; @@ -33,6 +34,7 @@ describe('AlbionScanningService', () => { let service: AlbionScanningService; let albionApiService: AlbionApiService; let albionDeregistrationService: AlbionDeregistrationService; + let albionPingRoleService: jest.Mocked; let mockDiscordUser: any; let mockDiscordMessage: any; @@ -83,6 +85,12 @@ describe('AlbionScanningService', () => { deregister: jest.fn(), }, }, + { + provide: AlbionPingRoleService, + useValue: { + reconcile: jest.fn().mockResolvedValue(false), + }, + }, { provide: getRepositoryToken(AlbionRegistrationsEntity), useValue: mockAlbionRegistrationsRepository, @@ -93,6 +101,7 @@ describe('AlbionScanningService', () => { service = moduleRef.get(AlbionScanningService); albionApiService = moduleRef.get(AlbionApiService); albionDeregistrationService = moduleRef.get(AlbionDeregistrationService); + albionPingRoleService = moduleRef.get(AlbionPingRoleService) as any; const albionMerged = { ...TestBootstrapper.mockConfig.albion, @@ -220,10 +229,11 @@ describe('AlbionScanningService', () => { await service.startScan(mockDiscordMessage, false); expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Starting scan...'); - expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [1/4] Gathering 1 characters from the ALB API...'); - expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [2/4] Checking 1 characters for membership status...'); - expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [3/4] Performing reverse role scan...'); - expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [4/4] Checking for role inconsistencies...'); + expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [1/5] Gathering 1 characters from the ALB API...'); + expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [2/5] Checking 1 characters for membership status...'); + expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [3/5] Performing reverse role scan...'); + expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [4/5] Checking for role inconsistencies...'); + expect(mockDiscordMessage.edit).toHaveBeenCalledWith('# ๐Ÿ‡ช๐Ÿ‡บ Task: [5/5] Sweeping ping roles...'); expect(mockDiscordMessage.channel.send).toHaveBeenCalledWith('## ๐Ÿ‡ช๐Ÿ‡บ Scan complete!'); expect(mockDiscordMessage.channel.send).toHaveBeenCalledWith('------------------------------------------'); expect(mockDiscordMessage.delete).toHaveBeenCalled(); @@ -243,6 +253,23 @@ describe('AlbionScanningService', () => { expect(service.removeLeavers).toHaveBeenCalledTimes(1); expect(service.reverseRoleScan).toHaveBeenCalledTimes(1); expect(service.roleInconsistencies).toHaveBeenCalledTimes(1); + expect(albionPingRoleService.reconcile).toHaveBeenCalledTimes(1); + }); + + it('should pass the dry run flag through to the ping role sweep', async () => { + mockAlbionRegistrationsRepository.find = jest.fn().mockResolvedValueOnce([mockRegisteredMember]); + service.gatherCharacters = jest.fn().mockResolvedValueOnce([mockCharacter]); + service.removeLeavers = jest.fn().mockResolvedValueOnce([]); + service.reverseRoleScan = jest.fn().mockResolvedValueOnce([]); + service.roleInconsistencies = jest.fn().mockResolvedValueOnce([]); + + await service.startScan(mockDiscordMessage, true); + + expect(albionPingRoleService.reconcile).toHaveBeenCalledWith( + mockDiscordMessage, + true, + expect.stringContaining('Sweeping ping roles'), + ); }); it('should properly ping the correct leaders when action is required', async () => { diff --git a/src/albion/services/albion.scanning.service.ts b/src/albion/services/albion.scanning.service.ts index 50eeef56..7a91525b 100644 --- a/src/albion/services/albion.scanning.service.ts +++ b/src/albion/services/albion.scanning.service.ts @@ -10,6 +10,7 @@ import { AlbionRoleMapInterface } from '../../config/albion.app.config'; import { AlbionUtilities } from '../utilities/albion.utilities'; import { getChannel } from '../../discord/discord.hacks'; import { AlbionDeregistrationService } from './albion.deregistration.service'; +import { AlbionPingRoleService } from './albion.ping.role.service'; export interface RoleInconsistencyResult { id: string, @@ -27,6 +28,7 @@ export class AlbionScanningService { private readonly albionApiService: AlbionApiService, private readonly config: ConfigService, private readonly albionDeregistrationService: AlbionDeregistrationService, + private readonly albionPingRoleService: AlbionPingRoleService, private readonly albionUtilities: AlbionUtilities, @InjectRepository(AlbionRegistrationsEntity) private readonly albionRegistrationsRepository: EntityRepository, ) {} @@ -53,7 +55,7 @@ export class AlbionScanningService { let actionRequired = false; try { - await message.edit(`# ${emoji} Task: [1/4] Gathering ${length} characters from the ALB API...`); + await message.edit(`# ${emoji} Task: [1/5] Gathering ${length} characters from the ALB API...`); characters = await this.gatherCharacters(guildMembers, message, 0); } catch (err) { @@ -68,19 +70,24 @@ export class AlbionScanningService { } try { - await message.edit(`# ${emoji} Task: [2/4] Checking ${length} characters for membership status...`); + await message.edit(`# ${emoji} Task: [2/5] Checking ${length} characters for membership status...`); if (await this.removeLeavers(characters, message, dryRun)) { actionRequired = true; } // Check if members have roles they shouldn't have - await message.edit(`# ${emoji} Task: [3/4] Performing reverse role scan...`); + await message.edit(`# ${emoji} Task: [3/5] Performing reverse role scan...`); await this.reverseRoleScan(message, dryRun); - await message.edit(`# ${emoji} Task: [4/4] Checking for role inconsistencies...`); + await message.edit(`# ${emoji} Task: [4/5] Checking for role inconsistencies...`); if (await this.roleInconsistencies(message, dryRun)) { actionRequired = true; } + + // Runs after the leaver pass so it sees the registrations that pass has just removed + const sweepHeading = `# ${emoji} Task: [5/5] Sweeping ping roles...`; + await message.edit(sweepHeading); + await this.albionPingRoleService.reconcile(message, dryRun, sweepHeading); } catch (err) { await message.edit(`## ${emoji} โŒ An error occurred while scanning!`); diff --git a/src/config/albion.app.config.ts b/src/config/albion.app.config.ts index 66fa8f43..e392e531 100644 --- a/src/config/albion.app.config.ts +++ b/src/config/albion.app.config.ts @@ -119,6 +119,20 @@ const leadershipPing = isProduction // Filtered on priority rather than name because production spells tier 3 "EldritchMager". const ELECTOR_MAX_PRIORITY = 3; +// Roles that keep their ping roles without a registration, e.g. the alliance role. Alliance +// members use the pings but never register with DIG, so the sweep would otherwise strip them. +const pingRoleExemptRoles = (process.env.ALBION_PING_ROLE_EXEMPT_ROLES ?? '') + .split(',') + .map((roleId) => roleId.trim()) + .filter((roleId) => roleId.length > 0); + +// There is one embed per ping category โ€” content pings and guild pings โ€” and more can be added +// without a deploy, so this is a list rather than a single ID. +const pingsMessageIds = (process.env.MESSAGE_ALBION_PINGS ?? '') + .split(',') + .map((messageId) => messageId.trim()) + .filter((messageId) => messageId.length > 0); + export default () => ({ guildId: '0_zTfLfASD2Wtw6Tc-yckA', roleMap, @@ -129,6 +143,10 @@ export default () => ({ // soft-leadership, so a human makes that call even after a successful vote. autoAssignRanks: ['@ALB/Graduate'], gameActivityName: 'Albion Online', // As Discord reports it in presence data + // The pings embeds are the source of truth for which ALB/ roles are ping roles. + // Rank roles share the prefix, so the name alone can't tell the two apart. + pingsMessageIds, + pingRoleExemptRoles, scanExcludedUsers: [], // Discord IDs guildLeaderRole: findRole('@ALB/Archmage'), guildOfficerRole: findRole('@ALB/Magister'), diff --git a/src/test.bootstrapper.ts b/src/test.bootstrapper.ts index 405d2dcf..57bacb39 100644 --- a/src/test.bootstrapper.ts +++ b/src/test.bootstrapper.ts @@ -310,6 +310,124 @@ export class TestBootstrapper { } as any; } + // Ping role IDs and the embeds that name them. The pings messages are the only source of + // truth for which ALB/ roles are ping roles, so most ping role tests start here. Two embeds, + // as in production: content pings and guild pings. + static readonly mockPingRoleIds = { + factionWarfare: '9000000000000001', + mist: '9000000000000002', + arena: '9000000000000003', + cta: '9000000000000004', + gatherer: '9000000000000005', + }; + + static getMockPingRoleCollection() { + return new Collection() + .set(this.mockPingRoleIds.factionWarfare, { id: this.mockPingRoleIds.factionWarfare, name: 'ALB/FactionWarfare' } as Role) + .set(this.mockPingRoleIds.mist, { id: this.mockPingRoleIds.mist, name: 'ALB/Mist' } as Role) + .set(this.mockPingRoleIds.arena, { id: this.mockPingRoleIds.arena, name: 'ALB/Arena' } as Role) + .set(this.mockPingRoleIds.cta, { id: this.mockPingRoleIds.cta, name: 'ALB/CTA' } as Role) + .set(this.mockPingRoleIds.gatherer, { id: this.mockPingRoleIds.gatherer, name: 'ALB/Gatherer' } as Role); + } + + static getMockContentPingsEmbedDescription() { + return [ + 'These Pings can be used by guildmembers to look for a group to play with.', + '**Content List**', + 'โš”๏ธ ALB/FactionWarfare - for all Faction Warfare related things', + 'โ˜๏ธ ALB/Mist - to seek for a partner for Duo Mists', + '๐ŸŸ๏ธ ALB/Arena - to find people for the arenas', + ].join('\n'); + } + + static getMockGuildPingsEmbedDescription() { + return [ + 'Proclamations, Event and the CTA ping can only be used by leadership members.', + '**Ping List**', + '๐Ÿ“ฏ ALB/CTA - Call To Arms for instant action', + '๐Ÿ‘จโ€๐ŸŒพ ALB/Gatherer - to be pinged for gathering requests', + ].join('\n'); + } + + // A reaction whose users.fetch() honours Discord's paging contract, so a test can prove the + // sweep pages past the first 100 users. + static getMockPingReaction(emojiName: string, userIds: string[], botIds: string[] = []) { + const allIds = [...userIds, ...botIds]; + return { + emoji: { name: emojiName, id: null }, + count: allIds.length, + users: { + fetch: jest.fn().mockImplementation(async ({ limit = 100, after } = {} as any) => { + const start = after ? allIds.indexOf(after) + 1 : 0; + const page = allIds.slice(start, start + limit); + return new Collection( + page.map((id) => [id, { id, bot: botIds.includes(id) }]), + ); + }), + remove: jest.fn().mockResolvedValue(true), + }, + } as any; + } + + static getMockContentPingsMessage(reactions: any[] = []) { + return this.getMockPingsMessage( + this.mockConfig.albion.pingsMessageIds[0], + 'Albion Content Pings', + this.getMockContentPingsEmbedDescription(), + reactions, + ); + } + + static getMockGuildPingsMessage(reactions: any[] = []) { + return this.getMockPingsMessage( + this.mockConfig.albion.pingsMessageIds[1], + 'Albion Guild Pings', + this.getMockGuildPingsEmbedDescription(), + reactions, + ); + } + + private static getMockPingsMessage(id: string, title: string, description: string, reactions: any[]) { + return { + id, + content: '', + embeds: [ + { + title, + description, + fields: [], + }, + ], + reactions: { + cache: new Collection(reactions.map((reaction, index) => [String(index), reaction])), + }, + } as any; + } + + // A member whose role cache answers honestly rather than with a blanket jest.fn(), which + // every role sweep depends on to tell holders from non-holders. + static getMockGuildMemberWithRoles( + id: string, + roleIds: string[] = [], + isBot = false, + guild: any = undefined, + ) { + const roles = new Set(roleIds); + return { + id, + displayName: `member-${id}`, + user: { id, username: `member-${id}`, bot: isBot }, + guild: guild ?? this.getMockGuild('123456789'), + roles: { + add: jest.fn(), + remove: jest.fn().mockResolvedValue(true), + cache: { + has: jest.fn().mockImplementation((roleId: string) => roles.has(roleId)), + }, + }, + } as any; + } + static getMockDiscordVoiceState(member, channel) { return { member: member, // The GuildMember object, mocked separately @@ -448,6 +566,8 @@ export class TestBootstrapper { electorMaxPriority: 3, autoAssignRanks: ['@ALB/Graduate'], gameActivityName: 'Albion Online', + pingsMessageIds: ['1401401401401401401', '1402402402402402402'], + pingRoleExemptRoles: ['7070707070707070'], }, discord: { guildId: '657687978899',