Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add FreeRolesAnomalyDetector.ts (not system-tested yet) #4

Open
wants to merge 2 commits into
base: gmdi-rebase
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/events/guildMemberAdd.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { GMDIExtension, Member } from "oceanic.js";
import { gmdiGuildID } from "../handler/Config";
import altPrevention from "../registry/altPrevention";
import FreeRolesAnomalyDetector from "../handler/FreeRolesAnomalyDetector";

export default async (client: GMDIExtension, member: Member) => {
if (member.guild.id !== gmdiGuildID || member.bot) return;

FreeRolesAnomalyDetector.startDetect(member);
return await altPrevention(client, member.guild, member);
};
6 changes: 6 additions & 0 deletions src/events/guildMemberRemove.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { GMDIExtension, Guild, Member, User } from "oceanic.js";
import { gmdiGuildID, firstGeneralTextChannelID } from "../handler/Config";
import { EmbedBuilder as RichEmbed } from "@oceanicjs/builders";
import FreeRolesAnomalyDetector from "../handler/FreeRolesAnomalyDetector";

export default async (client: GMDIExtension, member: User | Member, guild: Guild) => {
if (guild.id !== gmdiGuildID || member.bot) return;

// skip if member still on verification pending
if (member instanceof Member && member.pending) return;

// Anomaly detector
if (member instanceof Member) {
FreeRolesAnomalyDetector.stopDetect(member);
}

// Embed
const embed = new RichEmbed()
.setColor(0xC82427)
Expand Down
6 changes: 5 additions & 1 deletion src/events/guildMemberUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Member, GMDIExtension, JSONMember } from "oceanic.js";
import membershipScreenings from "../registry/membershipScreenings";
import boostNotification from "../registry/boostNotification";
import usernameModeration from "../registry/usernameModeration";
import FreeRolesAnomalyDetector from "../handler/FreeRolesAnomalyDetector";

export default async (client: GMDIExtension, member: Member, oldMember: JSONMember | null) => {
// passed Membership Screenings
Expand All @@ -13,4 +14,7 @@ export default async (client: GMDIExtension, member: Member, oldMember: JSONMemb

// guild nickname moderation
usernameModeration(client, member, oldMember);
};

// anomaly detector
FreeRolesAnomalyDetector.memberUpdated(member);
};
7 changes: 6 additions & 1 deletion src/handler/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ export const cooldownMessageCooling = [
"kata mamah jangan ribut"
];

export const evalPrefix = ".";
export const evalPrefix = ".";

export const freeRolesAnomalyDetectorConfig = {
maxAnomalyMs: 2 * 60 * 1000,
freeRoleIds: ["1234"] // TODO: Fill with the correct roles.
};
219 changes: 219 additions & 0 deletions src/handler/FreeRolesAnomalyDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { Member } from "oceanic.js";
import { freeRolesAnomalyDetectorConfig as detectorConfig } from "./Config";

type FreeRolesAnomalyDetectorInfo = {
status: boolean;
memberId: string;
detail: {
maxAnomalyMs: number;
startTimestamp: number;
stopTimestamp: number;
durationMs: number;
}
};

class FreeRolesAnomalyDetector {
private freeRoleIds: string[];
private maxAnomalyMs: number;
private detectCallback: (info: FreeRolesAnomalyDetectorInfo) => void;
private startTimeMap: {
[memberId: string]: number
} = {};

constructor(
config: {
freeRoleIds: string[],
maxAnomalyMs: number,
},
detectCallback: (info: FreeRolesAnomalyDetectorInfo) => void
) {
const { freeRoleIds, maxAnomalyMs } = config;
this.freeRoleIds = freeRoleIds;
this.maxAnomalyMs = maxAnomalyMs;
this.detectCallback = detectCallback;
};

startDetect(member: Member) {
/**
* Call this method to set a start reference for detecting anomaly.
*/
this.startTimeMap[member.id] = Date.now();
}

stopDetect(member: Member) {
/**
* Call this method if no need to check that member again.
*/
if (!(member.id in this.startTimeMap)) return; // Ignore.
delete this.startTimeMap[member.id];
}

memberUpdated(member: Member) {
/**
* Call this method every time a member is updated.
*/
if (!(member.id in this.startTimeMap)) return; // Ignore.

for (const roleId of this.freeRoleIds) {
if (!member.roles.includes(roleId)) {
return; // At least one free role hasn't been taken yet; ignore.
}
}
// All free roles have been taken.

const memberId = member.id;
const startTimestamp = this.startTimeMap[memberId];
delete this.startTimeMap[memberId];

const { durationMs, maxAnomalyMs, detail } = this.makeDetail(startTimestamp);
const status = (durationMs < maxAnomalyMs);
const info = { memberId, status, detail };
this.detectCallback(info);
}

private makeDetail(startTimestamp: number) {
const stopTimestamp = Date.now();
const durationMs = stopTimestamp - startTimestamp;
const maxAnomalyMs = this.maxAnomalyMs;

const detail = { startTimestamp, stopTimestamp, durationMs, maxAnomalyMs };
return { durationMs, maxAnomalyMs, detail };
}
};

// Testing: executed asynchronously to prevent blocking
Promise.all([
// Test 1: Anomaly path
new Promise((resolve, reject) => {
const config = {
freeRoleIds: ["Role1", "Role2"],
maxAnomalyMs: 5 * 1000
};

const dummyMember = { id: "Member1", roles: [] as string[] } as Member;
const frad = new FreeRolesAnomalyDetector(config, info => {
if (dummyMember.roles.length < config.freeRoleIds.length) {
reject("FreeRolesAnomalyDetector - Test 1 failed - unsufficient member roles");
} else if (info.memberId != "Member1") {
reject("FreeRolesAnomalyDetector - Test 1 failed - memberId should be from Member1");
} else if (info.status != true) {
reject("FreeRolesAnomalyDetector - Test 1 failed - status should be true");
} else if (info.detail.maxAnomalyMs != config.maxAnomalyMs) {
reject("FreeRolesAnomalyDetector - Test 1 failed - detail.durationMs should be same as given");
} else if (info.detail.durationMs >= info.detail.maxAnomalyMs) {
reject("FreeRolesAnomalyDetector - Test 1 failed - detail.durationMs should be smaller than detail.maxAnomalyMs");
} else {
resolve(true);
}
clearTimeout(waitCallTimeout);
});

frad.startDetect({ id: "OtherMemberA", roles: [] as string[] } as Member);
frad.startDetect(dummyMember);
frad.startDetect({ id: "OtherMemberB", roles: [] as string[] } as Member);
setTimeout(() => {
dummyMember.roles.push("Role1");
frad.memberUpdated(dummyMember);
setTimeout(() => {
dummyMember.roles.push("Role2");
frad.memberUpdated(dummyMember);
}, 100);
}, 100);

const waitCallTimeout = setTimeout(() => {
reject("FreeRolesAnomalyDetector - Test 1 failed - callback should be called after all roles have been taken");
}, 1000);
}),

// Test 2: Not-anomaly path
new Promise((resolve, reject) => {
const config = {
freeRoleIds: ["Role1", "Role2"],
maxAnomalyMs: 100
};

const frad = new FreeRolesAnomalyDetector(config, info => {
if (info.status != false) {
reject("FreeRolesAnomalyDetector - Test 2 failed - status should be false");
} else if (info.memberId != "Member1") {
reject("FreeRolesAnomalyDetector - Test 2 failed - memberId should be from Member1");
} else if (info.detail.maxAnomalyMs != config.maxAnomalyMs) {
reject("FreeRolesAnomalyDetector - Test 2 failed - detail.durationMs should be same as given");
} else if (info.detail.durationMs < info.detail.maxAnomalyMs) {
reject("FreeRolesAnomalyDetector - Test 2 failed - detail.durationMs should not be smaller than detail.maxAnomalyMs");
} else {
resolve(true);
}
clearTimeout(waitCallTimeout);
});

const roles: string[] = [];
const dummyMember = { id: "Member1", roles } as Member;
frad.startDetect(dummyMember);
setTimeout(() => {
roles.push("Role1");
frad.memberUpdated(dummyMember);

setTimeout(() => {
roles.push("Role2");
frad.memberUpdated(dummyMember);
}, 100);
}, 100);

const waitCallTimeout = setTimeout(() => {
reject("FreeRolesAnomalyDetector - Test 2 failed - callback should be called after all roles have been taken");
}, 1000);
}),

// Test 3: Old member path
new Promise((resolve, reject) => {
const config = {
freeRoleIds: ["Role1", "Role2"],
maxAnomalyMs: 100
};

const frad = new FreeRolesAnomalyDetector(config, () => {
reject("FreeRolesAnomalyDetector - Test 3 failed - callback should not be called");
clearTimeout(waitCallTimeout);
});

const dummyMember = { id: "Member1", roles: ["Role1", "Role2"] } as Member;
frad.memberUpdated(dummyMember); // No startDetect
const waitCallTimeout = setTimeout(() => {
resolve(true);
}, 500);
}),

// Test 4: Stop detect functionality
new Promise((resolve, reject) => {
const config = {
freeRoleIds: ["Role1", "Role2"],
maxAnomalyMs: 100
};

const frad = new FreeRolesAnomalyDetector(config, () => {
reject("FreeRolesAnomalyDetector - Test 4 failed - callback should not be called");
clearTimeout(waitCallTimeout);
});

const dummyMember = { id: "Member1", roles: ["Role1", "Role2"] } as Member;
frad.startDetect(dummyMember);
frad.stopDetect(dummyMember); // Here we don't check the roles.
frad.memberUpdated(dummyMember);
const waitCallTimeout = setTimeout(() => {
resolve(true);
}, 500);
})
])
.then(() => {
console.log("FreeRolesAnomalyDetector - All tests success");
})
.catch(reason => {
console.error(reason);
});

const { freeRoleIds, maxAnomalyMs } = detectorConfig;
export default new FreeRolesAnomalyDetector({ freeRoleIds, maxAnomalyMs }, x => {
// TODO: Call bot prevention manager
console.log(x); // Logging is important.
});