diff --git a/index.d.ts b/index.d.ts index db5ebcc..f977403 100644 --- a/index.d.ts +++ b/index.d.ts @@ -261,6 +261,16 @@ declare namespace WAWebJS { /** Deletes the current user's profile picture */ deleteProfilePicture(): Promise + /** Generates a WhatsApp call link (video call or voice call) */ + createCallLink(startTime: Date, callType: string): Promise + + /** + * Sends a response to the scheduled event message, indicating whether a user is going to attend the event or not + * @param response The response code to the event message. Valid values are: `0` for NONE response (removes a previous response) | `1` for GOING | `2` for NOT GOING | `3` for MAYBE going + * @param eventMessageId The event message ID + */ + sendResponseToScheduledEvent(response: number, eventMessageId: string): Promise + /** Gets an array of membership requests */ getGroupMembershipRequests(groupId: string): Promise> @@ -902,6 +912,7 @@ declare namespace WAWebJS { REACTION = 'reaction', TEMPLATE_BUTTON_REPLY = 'template_button_reply', POLL_CREATION = 'poll_creation', + SCHEDULED_EVENT_CREATION = 'scheduled_event_creation', } /** Client status */ @@ -1071,6 +1082,24 @@ declare namespace WAWebJS { pollOptions: string[], /** False for a single choice poll, true for a multiple choice poll */ allowMultipleAnswers: boolean, + /** The start time of the event in timestamp (10 digits) */ + eventStartTime: number, + /** The end time of the event in timestamp (10 digits) */ + eventEndTime?: number, + /** The event description */ + eventDescription?: string, + /** The location of the event */ + eventLocation?: { + degreesLatitude: number; + degreesLongitude: number; + name: string; + }, + /** WhatsApp call link (video call or voice call) */ + eventJoinLink?: string, + /** Indicates if an event should be sent as an already canceled */ + isEventCaneled: boolean, + /** The custom message secret, can be used as an event ID */ + messageSecret?: Array, /* * Reloads this Message object's data in-place with the latest values from WhatsApp Web. * Note that the Message must still be in the web app cache for this to work, otherwise will return null. @@ -1128,6 +1157,11 @@ declare namespace WAWebJS { getReactions: () => Promise, /** Edits the current message */ edit: (content: MessageContent, options?: MessageEditOptions) => Promise, + /** + * Edits the current ScheduledEvent message. + * Once the event is canceled, it can not be edited. + */ + editScheduledEvent: (editedEventObject: Event) => Promise, } /** ID that represents a message */ @@ -1183,6 +1217,44 @@ declare namespace WAWebJS { constructor(pollName: string, pollOptions: Array, options?: PollSendOptions) } + /** ScheduledEvent send options */ + export interface ScheduledEventSendOptions { + /** The scheduled event description */ + description?: string, + /** The end time of the event */ + endTime?: Date, + /** The location of the event */ + location?: string, + /** The type of a WhatsApp call link to generate, valid values are: `video` | `voice` */ + callType?: string, + /** + * Indicates if a scheduled event should be sent as an already canceled + * @default false + */ + isEventCanceled?: boolean + /** + * The custom message secret, can be used as an event ID + * @note It has to be a unique vector with a length of 32 + */ + messageSecret: Array|undefined + } + + /** Represents a ScheduledEvent on WhatsApp */ + export class ScheduledEvent { + name: string + startTimeTs: number + eventSendOptions: { + description?: string; + endTimeTs?: number; + location?: string; + callType?: string; + isEventCanceled?: boolean; + messageSecret?: string; + }; + + constructor(name: string, startTime: Date, options?: EventSendOptions) + } + /** Represents a Poll Vote on WhatsApp */ export interface PollVote { /** The person who voted */ @@ -1338,7 +1410,7 @@ declare namespace WAWebJS { static fromUrl: (url: string, options?: MediaFromURLOptions) => Promise } - export type MessageContent = string | MessageMedia | Location | Poll | Contact | Contact[] | List | Buttons + export type MessageContent = string | MessageMedia | Location | Poll | Contact | Contact[] | List | Buttons | ScheduledEvent /** * Represents a Contact on WhatsApp diff --git a/index.js b/index.js index b4cb36f..d3373d0 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,7 @@ module.exports = { ClientInfo: require('./src/structures/ClientInfo'), Location: require('./src/structures/Location'), Poll: require('./src/structures/Poll'), + ScheduledEvent: require('./src/structures/ScheduledEvent'), ProductMetadata: require('./src/structures/ProductMetadata'), List: require('./src/structures/List'), Buttons: require('./src/structures/Buttons'), diff --git a/src/Client.js b/src/Client.js index 1b4e4bf..90e7cfe 100644 --- a/src/Client.js +++ b/src/Client.js @@ -15,7 +15,7 @@ const { LoadUtils } = require('./util/Injected/Utils'); const ChatFactory = require('./factories/ChatFactory'); const ContactFactory = require('./factories/ContactFactory'); const WebCacheFactory = require('./webCache/WebCacheFactory'); -const { Broadcast, Buttons, Call, ClientInfo, Contact, GroupNotification, Label, List, Location, Message, MessageMedia, Poll, PollVote, Reaction } = require('./structures'); +const { ClientInfo, Message, MessageMedia, Contact, Location, Poll, PollVote, GroupNotification, Label, Call, Buttons, List, Reaction, Broadcast, ScheduledEvent } = require('./structures'); const NoAuth = require('./authStrategies/NoAuth'); const {exposeFunctionIfAbsent} = require('./util/Puppeteer'); @@ -939,6 +939,9 @@ class Client extends EventEmitter { } else if (content instanceof Poll) { internalOptions.poll = content; content = ''; + } else if (content instanceof ScheduledEvent) { + internalOptions.event = content; + content = ''; } else if (content instanceof Contact) { internalOptions.contactCard = content.id._serialized; content = ''; @@ -2196,6 +2199,45 @@ class Client extends EventEmitter { }, chatId); } + /** + * Generates a WhatsApp call link (video call or voice call) + * @param {Date} startTime The start time of the call + * @param {string} callType The type of a WhatsApp call link to generate, valid values are: `video` | `voice` + * @returns {Promise} The WhatsApp call link (https://call.whatsapp.com/video/XxXxXxXxXxXxXx) or an empty string if a generation failed. + */ + async createCallLink(startTime, callType) { + if (!['video', 'voice'].includes(callType)) { + throw new class CreateCallLinkError extends Error { + constructor(m) { super(m); } + }('Invalid \'callType\' parameter value is provided. Valid values are: \'voice\' | \'video\'.'); + } + + startTime = Math.floor(startTime.getTime() / 1000); + + return await this.pupPage.evaluate(async (startTimeTs, callType) => { + const response = await window.Store.ScheduledEventMsgUtils.createEventCallLink(startTimeTs, callType); + return response ?? ''; + }, startTime, callType); + } + + /** + * Sends a response to the scheduled event message, indicating whether a user is going to attend the event or not + * @param {number} response The response code to the scheduled event message. Valid values are: `0` for NONE response (removes a previous response) | `1` for GOING | `2` for NOT GOING | `3` for MAYBE going + * @param {string} eventMessageId The scheduled event message ID + * @returns {Promise} + */ + async sendResponseToScheduledEvent(response, eventMessageId) { + if (![0, 1, 2, 3].includes(response)) return false; + + return await this.pupPage.evaluate(async (response, msgId) => { + const eventMsg = window.Store.Msg.get(msgId) || (await window.Store.Msg.getMessagesById([msgId]))?.messages?.[0]; + if (!eventMsg) return false; + + await window.Store.ScheduledEventMsgUtils.sendEventResponseMsg(response, eventMsg); + return true; + }, response, eventMessageId); + } + /** * Save new contact to user's addressbook or edit the existing one * @param {string} phoneNumber The contact's phone number in a format "17182222222", where "1" is a country code diff --git a/src/structures/Message.js b/src/structures/Message.js index cb1ad39..ca54edc 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -7,6 +7,7 @@ const Order = require('./Order'); const Payment = require('./Payment'); const Reaction = require('./Reaction'); const Contact = require('./Contact'); +const ScheduledEvent = require('./ScheduledEvent'); // eslint-disable-line no-unused-vars const { MessageTypes } = require('../util/Constants'); /** @@ -51,7 +52,7 @@ class Message extends Base { * Message content * @type {string} */ - this.body = this.hasMedia ? data.caption || '' : data.body || data.pollName || ''; + this.body = this.hasMedia ? data.caption || '' : data.body || data.pollName || data.eventName || ''; /** * Message type @@ -699,6 +700,40 @@ class Message extends Base { } return null; } + + /** + * Edits the current ScheduledEvent message. + * Once the scheduled event is canceled, it can not be edited. + * @param {ScheduledEvent} editedEventObject + * @returns {Promise} + */ + async editScheduledEvent(editedEventObject) { + if (!this.fromMe) { + return null; + } + + const edittedEventMsg = await this.client.pupPage.evaluate(async (msgId, editedEventObject) => { + const msg = window.Store.Msg.get(msgId) || (await window.Store.Msg.getMessagesById([msgId]))?.messages?.[0]; + if (!msg) return null; + + const { name, startTimeTs, eventSendOptions } = editedEventObject; + const eventOptions = { + name: name, + description: eventSendOptions.description, + startTime: startTimeTs, + endTime: eventSendOptions.endTimeTs, + location: eventSendOptions.location, + callType: eventSendOptions.callType, + isEventCanceled: eventSendOptions.isEventCanceled, + }; + + await window.Store.ScheduledEventMsgUtils.sendEventEditMessage(eventOptions, msg); + const editedMsg = window.Store.Msg.get(msg.id._serialized); + return editedMsg?.serialize(); + }, this.id._serialized, editedEventObject); + + return edittedEventMsg && new Message(this.client, edittedEventMsg); + } } module.exports = Message; diff --git a/src/structures/ScheduledEvent.js b/src/structures/ScheduledEvent.js new file mode 100644 index 0000000..de77cc5 --- /dev/null +++ b/src/structures/ScheduledEvent.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * ScheduledEvent send options + * @typedef {Object} ScheduledEventSendOptions + * @property {?string} description The scheduled event description + * @property {?Date} endTime The end time of the event + * @property {?string} location The location of the event + * @property {?string} callType The type of a WhatsApp call link to generate, valid values are: `video` | `voice` + * @property {boolean} [isEventCanceled = false] Indicates if a scheduled event should be sent as an already canceled + * @property {?Array} messageSecret The custom message secret, can be used as an event ID. NOTE: it has to be a unique vector with a length of 32 + */ + +/** Represents a ScheduledEvent on WhatsApp */ +class ScheduledEvent { + /** + * @param {string} name + * @param {Date} startTime + * @param {ScheduledEventSendOptions} options + */ + constructor(name, startTime, options = {}) { + /** + * The name of the event + * @type {string} + */ + this.name = this._validateInputs('name', name).trim(); + + /** + * The start time of the event + * @type {number} + */ + this.startTimeTs = Math.floor(startTime.getTime() / 1000); + + /** + * The send options for the event + * @type {Object} + */ + this.eventSendOptions = { + description: options.description?.trim(), + endTimeTs: options.endTime ? Math.floor(options.endTime.getTime() / 1000) : null, + location: options.location?.trim(), + callType: this._validateInputs('callType', options.callType), + isEventCanceled: options.isEventCanceled ?? false, + messageSecret: options.messageSecret + }; + } + + /** + * Inner function to validate input values + * @param {string} propName The property name to validate the value of + * @param {string | number} propValue The property value to validate + * @returns {string | number} The property value if a validation succeeded + */ + _validateInputs(propName, propValue) { + if (propName === 'name' && !propValue) { + throw new class CreateScheduledEventError extends Error { + constructor(m) { super(m); } + }(`Empty '${propName}' parameter value is provided.`); + } + + if (propName === 'callType' && propValue && !['video', 'voice'].includes(propValue)) { + throw new class CreateScheduledEventError extends Error { + constructor(m) { super(m); } + }(`Invalid '${propName}' parameter value is provided. Valid values are: 'voice' | 'video'.`); + } + + return propValue; + } +} + +module.exports = ScheduledEvent; diff --git a/src/structures/index.js b/src/structures/index.js index 3c354b7..ec7b816 100644 --- a/src/structures/index.js +++ b/src/structures/index.js @@ -22,5 +22,6 @@ module.exports = { Reaction: require('./Reaction'), Poll: require('./Poll'), PollVote: require('./PollVote'), - Broadcast: require('./Broadcast') + Broadcast: require('./Broadcast'), + ScheduledEvent: require('./ScheduledEvent'), }; diff --git a/src/util/Constants.js b/src/util/Constants.js index d7badc5..5231220 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -111,6 +111,7 @@ exports.MessageTypes = { REACTION: 'reaction', TEMPLATE_BUTTON_REPLY: 'template_button_reply', POLL_CREATION: 'poll_creation', + SCHEDULED_EVENT_CREATION: 'scheduled_event_creation', }; /** diff --git a/src/util/Injected/Store.js b/src/util/Injected/Store.js index 2aaf6f1..ba4043b 100644 --- a/src/util/Injected/Store.js +++ b/src/util/Injected/Store.js @@ -114,6 +114,11 @@ exports.ExposeStore = () => { window.Store.ForwardUtils = { ...window.require('WAWebChatForwardMessage') }; + window.Store.ScheduledEventMsgUtils = { + ...window.require('WAWebGenerateEventCallLink'), + ...window.require('WAWebSendEventEditMsgAction'), + ...window.require('WAWebSendEventResponseMsgAction') + }; window.Store.VCard = { ...window.require('WAWebFrontendVcardUtils'), ...window.require('WAWebVcardParsingUtils'), diff --git a/src/util/Injected/Utils.js b/src/util/Injected/Utils.js index 8418277..39a0e42 100644 --- a/src/util/Injected/Utils.js +++ b/src/util/Injected/Utils.js @@ -116,6 +116,34 @@ exports.LoadUtils = () => { delete options.poll; } + let eventOptions = {}; + if (options.event) { + const { name, startTimeTs, eventSendOptions } = options.event; + const { messageSecret } = eventSendOptions; + eventOptions = { + type: 'event_creation', + eventName: name, + eventDescription: eventSendOptions.description, + eventStartTime: startTimeTs, + eventEndTime: eventSendOptions.endTimeTs, + eventLocation: eventSendOptions.location && { + degreesLatitude: 0, + degreesLongitude: 0, + name: eventSendOptions.location + }, + eventJoinLink: await window.Store.ScheduledEventMsgUtils.createEventCallLink( + startTimeTs, + eventSendOptions.callType + ), + isEventCanceled: eventSendOptions.isEventCanceled, + messageSecret: + Array.isArray(messageSecret) && messageSecret.length === 32 + ? new Uint8Array(messageSecret) + : window.crypto.getRandomValues(new Uint8Array(32)), + }; + delete options.event; + } + let vcardOptions = {}; if (options.contactCard) { let contact = window.Store.Contact.get(options.contactCard); @@ -255,6 +283,7 @@ exports.LoadUtils = () => { ...quotedMsgOptions, ...locationOptions, ..._pollOptions, + ...eventOptions, ...vcardOptions, ...buttonOptions, ...listOptions,