diff --git a/README.md b/README.md index 693348f..3552b6c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/facade/sender.facade.ts b/src/facade/sender.facade.ts index 81c640f..3bdf642 100644 --- a/src/facade/sender.facade.ts +++ b/src/facade/sender.facade.ts @@ -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: @@ -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 { + public async send(options: SendOptionsInterface): Promise { let executed = false; logger.info("Starting the core application process..."); @@ -36,31 +43,26 @@ 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"); @@ -68,4 +70,51 @@ export default class SenderFacade { 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 { + // 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 { + // 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; + } } diff --git a/src/inputs/http/controllers/sender.controller.ts b/src/inputs/http/controllers/sender.controller.ts index 923128b..00b8141 100644 --- a/src/inputs/http/controllers/sender.controller.ts +++ b/src/inputs/http/controllers/sender.controller.ts @@ -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(); @@ -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" }); @@ -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); } }); diff --git a/src/interfaces/send.interface.ts b/src/interfaces/send.interface.ts new file mode 100644 index 0000000..8e56600 --- /dev/null +++ b/src/interfaces/send.interface.ts @@ -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; +} diff --git a/src/interfaces/webhook.interface.ts b/src/interfaces/webhook.interface.ts new file mode 100644 index 0000000..c21336c --- /dev/null +++ b/src/interfaces/webhook.interface.ts @@ -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; +} diff --git a/src/outputs/discord/webhook.output.ts b/src/outputs/discord/webhook.output.ts new file mode 100644 index 0000000..a2030f8 --- /dev/null +++ b/src/outputs/discord/webhook.output.ts @@ -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 { + let responses: Promise[] = []; + + 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); + } +} diff --git a/src/services/data.service.ts b/src/services/data.service.ts index ef7639a..46dc8f0 100644 --- a/src/services/data.service.ts +++ b/src/services/data.service.ts @@ -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"; @@ -19,6 +20,9 @@ export class DataService { cache: { name: "cache.json", }, + channel: { + name: "channel.json", + }, }; private constructor() { @@ -94,6 +98,16 @@ export class DataService { } } + public async getChannels(): Promise { + 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()); } diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts new file mode 100644 index 0000000..cdd4106 --- /dev/null +++ b/src/services/webhook.service.ts @@ -0,0 +1,90 @@ +import { logger } from "../config/logger.config"; +import { GameInterface } from "../interfaces/game.interface"; +import { ChannelInterface, EmbedObject } from "../interfaces/webhook.interface"; +import { WebhookOutput } from "../outputs/discord/webhook.output"; + +export class WebhookService { + private webhook = new WebhookOutput(); + + /** + * Send games to Discord channels. + * @param channels Discord channels + * @param games Games to send + */ + public async send(channels: ChannelInterface[], games: GameInterface[]): Promise { + const embeds: EmbedObject[] = []; + + const today = new Date(); + logger.debug(`Today is [${today.toLocaleDateString("fr-FR")}]`); + + games.forEach((game) => { + // check if the promotion date is inferior to today + // and if the promotion date is superior to today + if ( + new Date(game.promotion.startDate).getTime() < today.getTime() && + new Date(game.promotion.endDate).getTime() > today.getTime() + ) { + logger.debug( + `[${game.title}] with promotion start date (${new Date(game.promotion.startDate).toLocaleDateString( + "fr-FR" + )}) is lower than today (${today.toLocaleDateString("fr-FR")})` + ); + + embeds.push({ + color: 0x113c55, // blue color + title: game.title, + url: game.urlSlug, + author: { + name: "Epic Games Store", + icon_url: "https://static-00.iconduck.com/assets.00/epic-games-icon-512x512-7qpmojcd.png", + url: "https://store.epicgames.com/fr/", + }, + description: `Description : ${game.description}`, + thumbnail: { + url: game.imageUrl, + }, + fields: [ + { + name: "🏁 Le contenu est disponible depuis le :", + value: `🗓️ ${new Date(game.promotion.startDate).toLocaleDateString("fr-FR", { + dateStyle: "full", + })}`, + }, + { + name: "⚠️ Le contenu ne sera plus disponible après le :", + value: `🗓️ ${new Date(game.promotion.endDate).toLocaleDateString("fr-FR", { + dateStyle: "full", + })}`, + }, + ], + image: { + url: game.imageUrl, + }, + // timestamp: new Date(), + footer: { + text: `Message envoyé le ${today.toLocaleDateString("fr-FR", { + dateStyle: "full", + })} à ${today.toLocaleTimeString("fr-FR", { timeZone: "Europe/Paris" })}`, + icon_url: "https://cdn-icons-png.flaticon.com/512/1134/1134154.png", + }, + }); + } + }); + + if (embeds != undefined && embeds.length > 0) { + try { + return await this.webhook.send( + channels, + "Hey ! De nouveaux jeux sont disponibles sur l'Epic Games Store !\nClique sur le titre du jeu pour ouvrir directement l'offre sur le site et récupérer son contenu ! 🎮 🔥", + embeds + ); + } catch (error) { + logger.error(error); + return false; + } + } else { + logger.info("No free games available for now, webhook not sent"); + return false; + } + } +} diff --git a/test/data/channel.json b/test/data/channel.json new file mode 100644 index 0000000..8259d18 --- /dev/null +++ b/test/data/channel.json @@ -0,0 +1,14 @@ +[ + { + "server": "Size Up® Organization", + "name": "webhook-testing", + "id": "1", + "token": "XXX" + }, + { + "server": "BarberCrew® Community", + "name": "free-game", + "id": "2", + "token": "XXX" + } +] diff --git a/test/outputs/webhook.output.test.ts b/test/outputs/webhook.output.test.ts new file mode 100644 index 0000000..8942035 --- /dev/null +++ b/test/outputs/webhook.output.test.ts @@ -0,0 +1,82 @@ +import axios from "axios"; +import { logger } from "../../src/config/logger.config"; + +import { WebhookOutput } from "../../src/outputs/discord/webhook.output"; + +import channelJson from "../data/channel.json"; + +jest.mock("axios"); + +beforeAll(async () => { + /** + * Silence the logger to avoid unnecessary output. + */ + logger.silent = true; +}); + +afterEach(async () => { + jest.clearAllMocks(); +}); + +describe("WebhookOutput", () => { + describe("send()", () => { + test(`given axios response webhook status with 404 then 204, + when send content to webhook, + then response is false with a specific log message`, async () => { + // given + logger.error = jest.fn(); + + const mockedAxios = axios as jest.Mocked; + mockedAxios.post.mockResolvedValueOnce({ status: 404 }); + mockedAxios.post.mockResolvedValueOnce({ status: 204 }); + + // when + const webhookOutput = new WebhookOutput(); + const response: boolean = await webhookOutput.send(channelJson); + + // then + // because we have 2 channels in the channel.json file + // and we send 2 messages to each channel (game + contact message) + // but the contact message isn't sent every time + expect(mockedAxios.post.mock.calls.length).toBeGreaterThanOrEqual(2); + + expect(logger.error).toHaveBeenCalledWith( + "Error while sending message to Discord server: [Size Up® Organization], channel: [webhook-testing] with status code: 404" + ); + + expect(response).toBe(false); + }); + + test(`given axios response webhook status with 204, + when send content to webhook, + then response is false with a specific log message`, async () => { + // given + logger.info = jest.fn(); + + const mockedAxios = axios as jest.Mocked; + mockedAxios.post.mockResolvedValue({ status: 204 }); + + // when + const webhookOutput = new WebhookOutput(); + const response: boolean = await webhookOutput.send(channelJson); + + // then + // because we have 2 channels in the channel.json file + // and we send 2 messages to each channel (game + contact message) + // but the contact message isn't sent every time + expect(mockedAxios.post.mock.calls.length).toBeGreaterThanOrEqual(2); + + expect(logger.info).toHaveBeenCalledWith( + "Sending message to Discord webhook server: [Size Up® Organization], channel: [webhook-testing] ..." + ); + expect(logger.info).toHaveBeenCalledWith( + "Message sent to Discord server: [Size Up® Organization], channel: [webhook-testing]" + ); + expect(logger.info).toHaveBeenCalledWith( + "Sending message to Discord webhook server: [BarberCrew® Community], channel: [free-game] ..." + ); + + expect(response).toBe(true); + }); + }); +}); diff --git a/test/services/data.service.test.ts b/test/services/data.service.test.ts index 807aeca..0fe0ea3 100644 --- a/test/services/data.service.test.ts +++ b/test/services/data.service.test.ts @@ -2,9 +2,11 @@ import { logger } from "../../src/config/logger.config"; import { GameInterface } from "../../src/interfaces/game.interface"; import { ReceiverInterface } from "../../src/interfaces/receiver.interface"; +import { ChannelInterface } from "../../src/interfaces/webhook.interface"; import { DriveOutput } from "../../src/outputs/google/drive.output"; import { DataService } from "../../src/services/data.service"; +import channelJson from "../data/channel.json"; import receiverJson from "../data/receiver.json"; jest.mock("../../src/outputs/google/drive.output"); @@ -219,4 +221,93 @@ describe("DataService", () => { expect(JSON.parse(spyUpdateDocument.mock.calls[0][1])).toHaveLength(5); // verify that we have now 5 GameInterface objects because we have added 2 new ones to the 3 existing ones }); }); + + describe("getChannels()", () => { + test(`given output that return null, + when calling getChannels() from data service, + then retrieve an empty array`, async () => { + // given + /** + * Mock the private constructor and the `getInstance()` method of the DriveOutput class. + */ + const mockedDriveOutput = new (DriveOutput as any)() as jest.Mocked; // jest.MockedObject is working too + + const mockGetDocument = mockedDriveOutput.getDocument.mockResolvedValue(null); + + const mockDriveOutputInstance = jest.spyOn(DriveOutput, "getInstance").mockReturnValue(mockedDriveOutput); + + // when + const dataService: DataService = new (DataService as any)(); // even if the constructor is private, we can still instantiate it, it's to avoid retrieve the instance from the singleton + const result = await dataService.getChannels(); + + // then + + expect(mockGetDocument).toHaveBeenCalledWith("channel.json"); + expect(mockDriveOutputInstance).toHaveBeenCalledTimes(1); + + expect(result).toBeNull(); + }); + + test(`given output that return empty channel, + when calling getChannels() from data service, + then retrieve an empty array`, async () => { + // given + /** + * Mock the private constructor and the `getInstance()` method of the DriveOutput class. + */ + const mockedDriveOutput = new (DriveOutput as any)() as jest.Mocked; // jest.MockedObject is working too + + const mockGetDocument = mockedDriveOutput.getDocument.mockResolvedValue([]); + + const mockDriveOutputInstance = jest.spyOn(DriveOutput, "getInstance").mockReturnValue(mockedDriveOutput); + + // when + const dataService: DataService = new (DataService as any)(); // even if the constructor is private, we can still instantiate it, it's to avoid retrieve the instance from the singleton + const result = await dataService.getChannels(); + + // then + expect(mockGetDocument).toHaveBeenCalledWith("channel.json"); + expect(mockDriveOutputInstance).toHaveBeenCalledTimes(1); + + expect(result).toHaveLength(0); + }); + + test(`given output that return two channels, + when calling getChannels() from data service, + then retrieve two channels`, async () => { + // given + /** + * Mock the private constructor and the `getInstance()` method of the DriveOutput class. + */ + const mockedDriveOutput = new (DriveOutput as any)() as jest.Mocked; // jest.MockedObject is working too + + const channels: ChannelInterface[] = channelJson; + + const mockGetDocument = mockedDriveOutput.getDocument.mockResolvedValue(channels); + + const mockDriveOutputInstance = jest.spyOn(DriveOutput, "getInstance").mockReturnValue(mockedDriveOutput); + + // when + const dataService: DataService = new (DataService as any)(); // even if the constructor is private, we can still instantiate it, it's to avoid retrieve the instance from the singleton + const result = await dataService.getChannels(); + + // then + expect(mockGetDocument).toHaveBeenCalledWith("channel.json"); + expect(mockDriveOutputInstance).toHaveBeenCalledTimes(1); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(2); + + expect(result?.[0].server).toBe("Size Up® Organization"); + expect(result?.[0].name).toBe("webhook-testing"); + expect(result?.[0].id).toBe("1"); + expect(result?.[0].token).toBe("XXX"); + + expect(result?.[1].server).toBe("BarberCrew® Community"); + expect(result?.[1].name).toBe("free-game"); + expect(result?.[1].id).toBe("2"); + expect(result?.[1].token).toBe("XXX"); + }); + }); }); diff --git a/test/services/webhook.service.test.ts b/test/services/webhook.service.test.ts new file mode 100644 index 0000000..ca4f629 --- /dev/null +++ b/test/services/webhook.service.test.ts @@ -0,0 +1,95 @@ +import { logger } from "../../src/config/logger.config"; + +import { ChannelInterface } from "../../src/interfaces/webhook.interface"; +import { WebhookOutput } from "../../src/outputs/discord/webhook.output"; +import { WebhookService } from "../../src/services/webhook.service"; + +jest.mock("../../src/outputs/discord/webhook.output"); + +beforeAll(() => { + /** + * Silence the logger to avoid unnecessary output. + */ + logger.silent = true; +}); + +describe("WebhookService", () => { + describe("send()", () => { + test("should have promotion start date inferior to end date", async () => { + const channels: ChannelInterface[] = [ + { + id: "1", + token: "XXX", + name: "first", + server: "first", + }, + { + id: "2", + token: "XXX", + name: "second", + server: "second", + }, + ]; + + const today = new Date(); + + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + const yesterdayToISOString = yesterday.toISOString(); + + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const tomorrowToISOString = tomorrow.toISOString(); + + const games = [ + { + title: "my-game-1", + urlSlug: "test", + description: "my-game-description-1", + imageUrl: "test", + promotion: { + startDate: yesterdayToISOString, + endDate: tomorrowToISOString, + }, + }, + { + title: "my-game-2", + urlSlug: "test", + description: "my-game-description-2", + imageUrl: "test", + promotion: { + startDate: yesterdayToISOString, + endDate: tomorrowToISOString, + }, + }, + ]; + + const output = jest.spyOn(WebhookOutput.prototype, "send").mockResolvedValue(true); + const webhookService = new WebhookService(); + + const isDone: boolean = await webhookService.send(channels, games); + + expect(isDone).toBe(true); + + expect(output).toHaveBeenCalledTimes(1); + expect(output.mock.calls[0][2]?.length).toBe(2); + + const yesterdayToLocaleDateString = yesterday.toLocaleDateString("fr-FR", { dateStyle: "full" }); + const tomorrowToLocaleDateString = tomorrow.toLocaleDateString("fr-FR", { dateStyle: "full" }); + + const fields = output.mock.calls[0][2]?.map((embed) => embed.fields); + expect(fields?.[0]?.[0].name).toEqual("🏁 Le contenu est disponible depuis le :"); + expect(fields?.[0]?.[0].value).toEqual(`🗓️ ${yesterdayToLocaleDateString}`); + + expect(fields?.[0]?.[1].name).toEqual("⚠️ Le contenu ne sera plus disponible après le :"); + expect(fields?.[0]?.[1].value).toEqual(`🗓️ ${tomorrowToLocaleDateString}`); + + const footer = output.mock.calls[0][2]?.map((embed) => embed.footer); + expect(footer?.[0]?.text).toEqual( + `Message envoyé le ${today.toLocaleDateString("fr-FR", { + dateStyle: "full", + })} à ${today.toLocaleTimeString("fr-FR", { timeZone: "Europe/Paris" })}` + ); + }); + }); +});