Add Discord Fetch class and Discord types (#1703)

Adds a Fetcher class that takes fetch options and fetches against
the correct endpoints, returning re-formatted request data
and pagination info.

Adds a models.js file that defines Discord types.

Test Plan:

Unit tests added for fetcher class and helper functions in
models.js file.

Paired with @Beanow
This commit is contained in:
Brian Litwin 2020-03-27 16:23:37 -04:00 committed by Robin
parent eda3d19772
commit eb205e8680
4 changed files with 602 additions and 0 deletions

View File

@ -0,0 +1,141 @@
// @flow
import * as Model from "./models";
import {type Snowflake} from "./models";
type FetchEndpoint = (endpoint: string) => Promise<any>;
/**
* Provide the Guild ID to fetch against, and the 'limit'
* parameter when fetching GuildMembers, Messages, and Reactions.
*
*/
type FetchOptions = {|
membersLimit: number,
messagesLimit: number,
reactionsLimit: number,
|};
export type PageInfo = {|
+hasNextPage: boolean,
+endCursor: Snowflake | null,
|};
export type ResultPage<T> = {|
+pageInfo: PageInfo,
+results: $ReadOnlyArray<T>,
|};
/**
* Fetcher is responsible for:
* - Returning the correct endpoint to fetch against for Guilds, Channels,
* Members, and Reactions.
* - Formatting the returned results into the correct Typed objects
* - Returning pagination info in a PageInfo object, containing hasNextPage
* and endCursor properties.
* The endCursor property is calculated as the Id of the last object
* returned in the response results. We are assuming Discord provides
* consistent, ordered results.
* The hasNextPage property is a boolean calculated as whether the number of
* results recieved is equal to the `limit` property provided in the
* fetch request.
*
* Note that Discord doesn't support pagination for Channels, so we're
* returning an array of Channel objects in the corresponding method.
* See: https://discordapp.com/developers/docs/resources/guild#get-guild-channels
*
*/
export class Fetcher {
+_fetch: FetchEndpoint;
+_options: FetchOptions;
constructor(fetchEndpoint: FetchEndpoint, options: FetchOptions) {
this._fetch = fetchEndpoint;
this._options = options;
}
async guild(guildId: Snowflake): Promise<Model.Guild> {
const {id, name, permissions} = await this._fetch(`guilds/${guildId}`);
return {id: id, name: name, permissions: permissions};
}
async channels(guildId: Snowflake): Promise<$ReadOnlyArray<Model.Channel>> {
const response = await this._fetch(`/guilds/${guildId}/channels`);
return response.map((x) => ({
id: x.id,
name: x.name,
type: Model.channelTypeFromId(x.type),
}));
}
async members(
guildId: Snowflake,
after: Snowflake
): Promise<ResultPage<Model.GuildMember>> {
const {membersLimit} = this._options;
const endpoint = `/guilds/${guildId}/members?after=${after}&limit=${membersLimit}`;
const response = await this._fetch(endpoint);
const results = response.map((x) => ({
user: {
id: x.user.id,
username: x.user.username,
discriminator: x.user.discriminator,
bot: x.user.bot || x.user.system || false,
},
nick: x.nick || null,
roles: x.roles,
}));
const hasNextPage = results.length === membersLimit;
const endCursor =
response.length > 0 ? response[response.length - 1].user.id : null;
const pageInfo = {hasNextPage, endCursor};
return {results, pageInfo};
}
async messages(
channel: Snowflake,
after: Snowflake
): Promise<ResultPage<Model.Message>> {
const {messagesLimit} = this._options;
const endpoint = `/channels/${channel}/messages?after=${after}&limit=${messagesLimit}`;
const response = await this._fetch(endpoint);
const results = response.map((x) => ({
id: x.id,
channelId: channel,
authorId: x.author.id,
timestampMs: Date.parse(x.timestamp),
content: x.content,
reactionEmoji: (x.reactions || []).map((r) => r.emoji),
nonUserAuthor: Model.isAuthoredByNonUser(x),
mentions: (x.mentions || []).map((user) => user.id),
}));
const hasNextPage = results.length === messagesLimit;
const endCursor =
response.length > 0 ? response[response.length - 1].id : null;
const pageInfo = {hasNextPage, endCursor};
return {results, pageInfo};
}
async reactions(
channel: Snowflake,
message: Snowflake,
emoji: Model.Emoji,
after: Snowflake
): Promise<ResultPage<Model.Reaction>> {
const {reactionsLimit} = this._options;
const emojiRef = Model.emojiToRef(emoji);
const endpoint = `/channels/${channel}/messages/${message}/reactions/${emojiRef}?after=${after}&limit=${reactionsLimit}`;
const response = await this._fetch(endpoint);
const results = response.map((x) => ({
emoji: x.emoji,
channelId: channel,
messageId: message,
authorId: x.id,
}));
const hasNextPage = results.length === reactionsLimit;
const endCursor =
response.length > 0 ? response[response.length - 1].id : null;
const pageInfo = {hasNextPage, endCursor};
return {results, pageInfo};
}
}

View File

@ -0,0 +1,299 @@
// @flow
import {Fetcher} from "./fetcher";
import {
type Channel,
type Guild,
type Reaction,
type Message,
type GuildMember,
type Snowflake,
type Emoji,
} from "./models";
/**
* Note: the 'any' type signature is assigned to server response objects
* to make explicit that they are untyped prior to transformation by the
* appropriate handlers
*/
describe("plugins/discord/fetcher", () => {
const defaultOptions = () => ({
membersLimit: 100,
messagesLimit: 100,
reactionsLimit: 100,
});
describe("fetch guild", () => {
it("passes correct endpoint", async () => {
const fetch = jest.fn(() => Promise.resolve([]));
const fetcher = new Fetcher(fetch, defaultOptions());
await fetcher.guild("1");
expect(fetch.mock.calls[0]).toEqual(["guilds/1"]);
});
it("handles response", async () => {
const expected: Guild = {id: "1", name: "guildname", permissions: 0};
const fetch = jest.fn(() => Promise.resolve(expected));
const fetcher = new Fetcher(fetch, defaultOptions());
const guild = await fetcher.guild("1");
expect(guild).toEqual(expected);
});
});
describe("fetch channels", () => {
it("passes correct endpoint", async () => {
const fetch = jest.fn(() => Promise.resolve([]));
const fetcher = new Fetcher(fetch, defaultOptions());
await fetcher.channels("1");
expect(fetch.mock.calls[0]).toEqual(["/guilds/1/channels"]);
});
it("handles response", async () => {
const testChannel = (id: string): any => ({
id: id,
name: "testChannelName",
type: 0,
});
const testChannelObj = (id: Snowflake): Channel => ({
id: id,
name: "testChannelName",
type: "GUILD_TEXT",
});
const response: any[] = [testChannel("1")];
const expected: Channel[] = [testChannelObj("1")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, defaultOptions());
const data = await fetcher.channels("0");
expect(data).toEqual(expected);
});
});
describe("fetch members", () => {
const testMember = (userId: string): any => ({
user: {
id: userId,
username: "username",
discriminator: "disc",
bot: true,
},
nick: "nickname",
roles: ["test role"],
});
const options = () => ({
membersLimit: 2,
messagesLimit: 100,
reactionsLimit: 100,
});
it("passes correct endpoint", async () => {
const fetch = jest.fn(() => Promise.resolve([]));
const fetcher = new Fetcher(fetch, options());
await fetcher.members("1", "0");
expect(fetch.mock.calls[0]).toEqual([
"/guilds/1/members?after=0&limit=2",
]);
});
it("handles response", async () => {
const response: GuildMember[] = [testMember("1")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, options());
const {results} = await fetcher.members("1", "0");
expect(results).toEqual(response);
});
it("returns correct endCursor", async () => {
const response: any[] = [testMember("1"), testMember("2")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, options());
const {endCursor} = (await fetcher.members("1", "0")).pageInfo;
expect(endCursor).toEqual("2");
});
describe("next page", () => {
it("next page = true", async () => {
const response: any[] = [testMember("1")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, {
membersLimit: 1,
messagesLimit: 100,
reactionsLimit: 100,
});
const {hasNextPage} = (await fetcher.members("1", "0")).pageInfo;
expect(hasNextPage).toBe(true);
});
it("next page = false", async () => {
const response: any[] = [testMember("1")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, {
membersLimit: 2,
messagesLimit: 100,
reactionsLimit: 100,
});
const {hasNextPage} = (await fetcher.members("1", "0")).pageInfo;
expect(hasNextPage).toBe(false);
});
});
});
describe("fetch reactions", () => {
const emoji: Emoji = {id: "1", name: "emojiname"};
const options = () => ({
membersLimit: 100,
messagesLimit: 100,
reactionsLimit: 2,
});
it("passes correct endpoint", async () => {
const fetch = jest.fn(() => Promise.resolve([]));
const fetcher = new Fetcher(fetch, options());
await fetcher.reactions("1", "2", emoji, "0");
expect(fetch.mock.calls[0]).toEqual([
`/channels/1/messages/2/reactions/emojiname:1?after=0&limit=2`,
]);
});
it("handles response", async () => {
// response object from server, prior to type transformation
const response: any[] = [{id: "123", emoji}];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, options());
const {results} = await fetcher.reactions("3", "2", emoji, "0");
const expected: Reaction[] = [
{emoji, channelId: "3", messageId: "2", authorId: "123"},
];
expect(results).toEqual(expected);
});
it("returns correct endCursor", async () => {
const response: any[] = [
{id: "1", emoji},
{id: "2", emoji},
];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, options());
const {endCursor} = (
await fetcher.reactions("1", "2", emoji, "0")
).pageInfo;
expect(endCursor).toBe("2");
});
describe("next page", () => {
it("next page = true", async () => {
const response: any[] = [{id: 1, emoji}];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, {
membersLimit: 100,
messagesLimit: 100,
reactionsLimit: 1,
});
const {hasNextPage} = (
await fetcher.reactions("1", "2", emoji, "0")
).pageInfo;
expect(hasNextPage).toBe(true);
});
it("next page = false", async () => {
const response: any[] = [{id: 1, emoji}];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, {
membersLimit: 100,
messagesLimit: 100,
reactionsLimit: 2,
});
const {hasNextPage} = (
await fetcher.reactions("1", "2", emoji, "0")
).pageInfo;
expect(hasNextPage).toBe(false);
});
});
});
describe("fetch messages", () => {
const testMessage = (id: string): any => ({
id: id,
author: {id: "2"},
timestamp: "2020-03-03T23:35:10.615000+00:00",
content: "Just going to drop this here",
reactions: [{emoji: {id: "1", name: "testemoji"}}],
mentions: [{id: "4", username: "testuser"}],
});
const options = () => ({
membersLimit: 100,
messagesLimit: 2,
reactionsLimit: 100,
});
it("passes correct endpoint", async () => {
const fetch = jest.fn(() => Promise.resolve([]));
const fetcher = new Fetcher(fetch, options());
await fetcher.messages("1", "0");
expect(fetch.mock.calls[0]).toEqual([
"/channels/1/messages?after=0&limit=2",
]);
});
it("handles response", async () => {
const response: any[] = [testMessage("1")];
const expected: Message[] = [
{
id: "1",
channelId: "123",
authorId: "2",
timestampMs: Date.parse("2020-03-03T23:35:10.615000+00:00"),
content: "Just going to drop this here",
reactionEmoji: [{id: "1", name: "testemoji"}],
nonUserAuthor: false,
mentions: ["4"],
},
];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, options());
const {results} = await fetcher.messages("123", "0");
expect(results).toEqual(expected);
});
it("returns correct endCursor", async () => {
const response: any[] = [testMessage("1"), testMessage("2")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, options());
const {endCursor} = (await fetcher.messages("123", "0")).pageInfo;
expect(endCursor).toBe("2");
});
describe("next page", () => {
it("next page = true", async () => {
const response: any[] = [testMessage("1")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, {
membersLimit: 100,
messagesLimit: 1,
reactionsLimit: 2,
});
const {hasNextPage} = (await fetcher.messages("123", "0")).pageInfo;
expect(hasNextPage).toBe(true);
});
it("next page = false", async () => {
const response: any[] = [testMessage("1")];
const fetch = jest.fn(() => Promise.resolve(response));
const fetcher = new Fetcher(fetch, {
membersLimit: 100,
messagesLimit: 2,
reactionsLimit: 2,
});
const {hasNextPage} = (await fetcher.messages("123", "0")).pageInfo;
expect(hasNextPage).toBe(false);
});
});
});
});

View File

@ -0,0 +1,134 @@
// @flow
// https://discordapp.com/developers/docs/reference#snowflakes
export type Snowflake = string;
export const ZeroSnowflake: Snowflake = "0";
export type BotToken = string;
/**
* Discord Channels can be of various types, defined below.
* See: https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
*
*/
export type ChannelType =
| "GUILD_TEXT"
| "DM"
| "GUILD_VOICE"
| "GROUP_DM"
| "GUILD_CATEGORY"
| "GUILD_NEWS"
| "GUILD_STORE";
/**
* The Discord server returns an Id field of type number to represent
* the type of Channel. Here we convert that to a text representation,
* also based on: https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
*
*/
export function channelTypeFromId(id: number): ChannelType {
switch (id) {
case 0:
return "GUILD_TEXT";
case 1:
return "DM";
case 2:
return "GUILD_VOICE";
case 3:
return "GROUP_DM";
case 4:
return "GUILD_CATEGORY";
case 5:
return "GUILD_NEWS";
case 6:
return "GUILD_STORE";
default: {
throw new Error(`Unknown channel type ID: ${id}`);
}
}
}
// https://discordapp.com/developers/docs/resources/guild#guild-object
export type Guild = {|
+id: Snowflake,
+name: string,
+permissions: number,
|};
// https://discordapp.com/developers/docs/resources/channel#channel-object
export type Channel = {|
+id: Snowflake,
+type: ChannelType,
+name: string,
|};
// https://discordapp.com/developers/docs/resources/user#user-object
export type User = {|
+id: Snowflake,
+username: string,
+discriminator: string,
+bot: boolean,
|};
// https://discordapp.com/developers/docs/resources/guild#guild-member-object
export type GuildMember = {|
+user: User,
+nick: string | null,
+roles: $ReadOnlyArray<Snowflake>,
|};
// From the Discord docs: "emoji takes the form of name:id for
// custom guild emoji, or Unicode characters."
// https://discordapp.com/developers/docs/resources/channel#create-message
export type Emoji = {|
+id: ?Snowflake,
+name: string,
|};
export type EmojiRef = string;
/**
* Custom emoji returned from the Discord server are defined
* by an `id` and `name` property. Generic emoji are defined by
* a unicode name property only, with a null id property.
* This function returns the appropriate reference based on this
* generic vs custom distinction.
*/
export function emojiToRef({id, name}: Emoji): EmojiRef {
// Built-in emoji, unicode names.
if (!id) return name;
// Custom emoji.
return `${name}:${id}`;
}
// Determines whether the message was created by a webhook or a Discord User
export function isAuthoredByNonUser(rawMessage: {
+webhook_id?: Snowflake,
}): boolean {
return rawMessage.webhook_id != null || false;
}
// https://discordapp.com/developers/docs/resources/channel#message-object
export type Message = {|
+id: Snowflake,
+channelId: Snowflake,
+authorId: Snowflake,
// Could be a message from a webhook, meaning the authorId isn't a user.
+nonUserAuthor: boolean,
+timestampMs: number,
+content: string,
// Normally includes reaction counters, but we don't care about counters.
// We could filter based on which types of emoji have been added though.
+reactionEmoji: $ReadOnlyArray<Emoji>,
// Snowflake of user IDs.
+mentions: $ReadOnlyArray<Snowflake>,
|};
// https://discordapp.com/developers/docs/resources/channel#get-reactions
export type Reaction = {|
+channelId: Snowflake,
+messageId: Snowflake,
+authorId: Snowflake,
+emoji: Emoji,
|};

View File

@ -0,0 +1,28 @@
// @flow
import {emojiToRef, isAuthoredByNonUser} from "./models";
describe("plugins/discord/models", () => {
describe("model helper functions", () => {
describe("emojiToRef", () => {
it("returns name if id is null", () => {
expect(emojiToRef({id: null, name: "testEmojiName"})).toBe(
"testEmojiName"
);
});
it("returns name and id if id is not null", () => {
expect(emojiToRef({id: "testEmojiId", name: "testEmojiName"})).toBe(
"testEmojiName:testEmojiId"
);
});
});
describe("isAuthoredByNonUser", () => {
it("returns true if webhook_id property is provided in message", () => {
expect(isAuthoredByNonUser({webhook_id: "0"})).toBe(true);
});
it("returns false if webhook_id property is not provided in message", () => {
expect(isAuthoredByNonUser({})).toBe(false);
});
});
});
});