Skip to content

Commit

Permalink
✨ Discord webhook message sender
Browse files Browse the repository at this point in the history
  • Loading branch information
anthonypillot committed May 7, 2023
1 parent 3d41889 commit b8c8c31
Show file tree
Hide file tree
Showing 12 changed files with 634 additions and 27 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,15 @@ This command generates static content into the `build` directory and can be serv
yarn run start
```

# Extra
# Documentation

## Discord webhook

Free Games Catcher is using Discord webhook to send notifications.
To use the webhook, you need to create a webhook in your Discord server, then a `POST` to the webhook URL will send a message to the Discord channel.

- [Webhook documentation](https://discord.com/developers/docs/resources/webhook#execute-webhook).
- [Embed object documentation](https://discord.com/developers/docs/resources/channel#embed-object) (the `embeds` field of the webhook request body, to create some embedded rich message content).

## Application versionning

Expand Down
99 changes: 74 additions & 25 deletions src/facade/sender.facade.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { logger } from "../config/logger.config";

import { GameInterface } from "../interfaces/game.interface";
import { ReceiverInterface } from "../interfaces/receiver.interface";
import { SendOptionsInterface } from "../interfaces/send.interface";
import { ChannelInterface } from "../interfaces/webhook.interface";
import { DataService } from "../services/data.service";
import { GameService } from "../services/game.service";
import { EmailService } from "../services/email.service";
import { GameInterface } from "../interfaces/game.interface";
import { GameService } from "../services/game.service";
import { WebhookService } from "../services/webhook.service";

/**
* This facade class is responsible for:
Expand All @@ -15,19 +20,21 @@ export default class SenderFacade {
private client: GameService;
private data: DataService;
private email: EmailService;
private webhook: WebhookService;

constructor() {
this.client = new GameService();
this.data = DataService.getInstance();
this.email = new EmailService();
this.webhook = new WebhookService();
}

/**
* This method is responsible to do all the process of the application.
* It will get the games data from the API, filter it and send it to the receivers.
* It will get the games data from the API, filter it and send it to the receivers list.
* @returns If the process was successful or not.
*/
public async send(): Promise<Boolean> {
public async send(options: SendOptionsInterface): Promise<boolean> {
let executed = false;

logger.info("Starting the core application process...");
Expand All @@ -36,36 +43,78 @@ export default class SenderFacade {
const games: GameInterface[] = await this.client.getEpicGamesData();

if (games) {
// Update games list in the cache.
// Update games list in the cache asynchronously.
this.data.updateCache(games);

// Retrieve receivers list from the cache.
const receivers = await this.data.getReceivers();

// Send game list to receivers.
if (receivers) {
try {
await this.email.sendEmails(
"Les nouveaux jeux de la semaine sur l'Epic Games Store",
receivers,
games
);

logger.info("Core application process finished successfully");
executed = true;
} catch (error) {
logger.error(error);
executed = false;
}
if (options.email) {
logger.info("Sending emails to receivers...");
executed = await this.sendEmails(games, executed);
} else {
executed = false;
logger.error("No receivers found, core application process is aborted");
logger.info("Emails sending is disabled");
executed = true;
}

if (options.webhook) {
logger.info("Sending webhooks to channels...");
executed = await this.sendWebhooks(games, executed);
} else {
logger.info("Webhooks sending is disabled");
executed = true;
}

logger.info("Core application process finished successfully");
} else {
executed = false;
logger.error("No games found, core application process is aborted");
}

return executed;
}

/**
* This method is responsible to send the games list to the email receivers list.
* @param games Games list to send.
* @param executed If the process was successful or not.
* @returns If the process was successful or not.
*/
private async sendEmails(games: GameInterface[], executed: boolean): Promise<boolean> {
// Retrieve receivers list from the cache.
const receivers: ReceiverInterface[] | null = await this.data.getReceivers();

// Send game list to receivers.
if (receivers) {
try {
await this.email.sendEmails("Les nouveaux jeux de la semaine sur l'Epic Games Store", receivers, games);
executed = true;
} catch (error) {
logger.error(error);
executed = false;
}
} else {
executed = false;
logger.error("No receivers found, core application process is aborted");
}
return executed;
}

/**
* This method is responsible to send the games list to the webhook channel list.
* @param games Games list to send.
* @param executed If the process was successful or not.
* @returns If the process was successful or not.
*/
private async sendWebhooks(games: GameInterface[], executed: boolean): Promise<boolean> {
// Retrieve channels list from the cache.
const channels: ChannelInterface[] | null = await this.data.getChannels();

if (channels) {
// Post game list to channels.
executed = await this.webhook.send(channels, games);
} else {
executed = false;
logger.error("No channels found, core application process is aborted");
}

return executed;
}
}
24 changes: 23 additions & 1 deletion src/inputs/http/controllers/sender.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextFunction, Request, Response, Router } from "express";
import { logger } from "../../../config/logger.config";
import SenderFacade from "../../../facade/sender.facade";
import { SendOptionsInterface } from "../../../interfaces/send.interface";

export default class SenderController {
private router = Router();
Expand All @@ -14,9 +15,28 @@ export default class SenderController {
* Execute the full application process.
*/
this.router.get("/", async (request: Request, response: Response, next: NextFunction) => {
if (request.query.email || request.query.webhook) {
if (
(request.query.email && request.query.email !== "true" && request.query.email !== "false") ||
(request.query.webhook && request.query.webhook !== "true" && request.query.webhook !== "false")
) {
response.status(400).json({ status: "Bad query parameter value, must be true or false" });
return;
}
}

/**
* Options to send to the sender facade.
* By default, both email and webhook are enabled.
*/
const options: SendOptionsInterface = {
email: request.query.email !== "false",
webhook: request.query.webhook !== "false",
};

try {
const sender = new SenderFacade();
const isOk = await sender.send();
const isOk = await sender.send(options);

if (isOk) {
response.status(200).json({ status: "Core application process well executed" });
Expand All @@ -26,6 +46,8 @@ export default class SenderController {
} catch (error) {
const message = "Core application process failed";
logger.error(message, error);
response.status(500).json({ status: message });

next(error);
}
});
Expand Down
8 changes: 8 additions & 0 deletions src/interfaces/send.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* This interface is used to define the send process options.
* i.e. if the process have to send an email or a webhook.
*/
export interface SendOptionsInterface {
email: boolean;
webhook: boolean;
}
47 changes: 47 additions & 0 deletions src/interfaces/webhook.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export interface Author {
name: string;
icon_url: string;
url: string;
}

export interface Thumbnail {
url: string;
}

export interface Field {
name: string;
value: string;
inline?: boolean;
}

export interface Image {
url: string;
}

export interface Footer {
text: string;
icon_url: string;
}

export interface EmbedObject {
color?: number;
title: string;
url?: string;
author?: Author;
description?: string;
thumbnail?: Thumbnail;
fields?: Field[];
image?: Image;
timestamp?: Date;
footer?: Footer;
}

/**
* Discord channel interface.
*/
export interface ChannelInterface {
server: string;
name: string;
id: string;
token: string;
}
87 changes: 87 additions & 0 deletions src/outputs/discord/webhook.output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import axios from "axios";
import packageJson from "../../../package.json";
import { logger } from "../../config/logger.config";
import { ChannelInterface, EmbedObject } from "../../interfaces/webhook.interface";

/**
* Discord Webhook Output class.
*
* This class is used to send messages to a Discord channel using a webhook.
*/
export class WebhookOutput {
/**
* Webhook Discord API configuration.
*/
private config = {
url: "https://discord.com/api/webhooks",
};

/**
* Send content to one of multiple Webhook Discord channel(s).
* @param channels List of Discord channel(s).
* @param content Content to send.
* @param embeds Embed array to send.
* @param username Username to erase the default one.
* @returns If the process was successful or not.
* @throws Error if the process failed.
*/
public async send(
channels: ChannelInterface[],
content?: string | null,
embeds?: EmbedObject[],
username?: string
): Promise<boolean> {
let responses: Promise<boolean>[] = [];

const avatarUrl =
"https://raw.githubusercontent.com/size-up/freegamescatcher-core/main/src/assets/freegamescatcher_logo.png";

if (channels && channels.length > 0) {
responses = channels.map(async (channel) => {
const channelInformations = `server: [${channel.server}], channel: [${channel.name}]`;

logger.info(`Sending message to Discord webhook ${channelInformations} ...`);

try {
const response = await axios.post(`${this.config.url}/${channel.id}/${channel.token}`, {
username: username || packageJson.displayName,
content: content || null,
embeds: embeds || null,
avatar_url: avatarUrl,
});

/**
* Send contact message to Discord channel randomly
* with a 25% chance to send it.
*/
if (Math.random() < 0.25) {
await axios.post(`${this.config.url}/${channel.id}/${channel.token}`, {
username: username || packageJson.displayName,
content:
"Ces messages sont envoyés automatiquement.\nSi vous détectez quelque chose qui vous semble incorrect (un lien mort 💀 ou un bug 🐛) vous pouvez contacter RAIIIIIN#2304 ou Bediver#5058 sur Discord.",
avatar_url: avatarUrl,
});
}

if (response.status === 204) {
logger.info(`Message sent to Discord ${channelInformations}`);
return true;
} else {
logger.error(
`Error while sending message to Discord ${channelInformations} with status code: ${response.status}`
);
return false;
}
} catch (error) {
logger.error("Error while sending message to Discord channels", error);
return false;
}
});
} else {
logger.error("No Discord channel found, no message sent");
return false;
}

return !(await Promise.all(responses)).includes(false);
}
}
14 changes: 14 additions & 0 deletions src/services/data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DriveOutput } from "../outputs/google/drive.output";

import { GameInterface } from "../interfaces/game.interface";
import { ReceiverInterface } from "../interfaces/receiver.interface";
import { ChannelInterface } from "../interfaces/webhook.interface";

import { logger } from "../config/logger.config";

Expand All @@ -19,6 +20,9 @@ export class DataService {
cache: {
name: "cache.json",
},
channel: {
name: "channel.json",
},
};

private constructor() {
Expand Down Expand Up @@ -94,6 +98,16 @@ export class DataService {
}
}

public async getChannels(): Promise<ChannelInterface[] | null> {
try {
const channels: ChannelInterface[] = await Object(this.drive.getDocument(this.file.channel.name));
return channels;
} catch (error) {
logger.error(error);
return null;
}
}

public static getInstance(): DataService {
return this.instance || (this.instance = new this());
}
Expand Down
Loading

0 comments on commit b8c8c31

Please sign in to comment.