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:
parent
eda3d19772
commit
eb205e8680
|
@ -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};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue