feat: Channel (#2620)

* window.injectToFunction clarifications

* added createChannel method

* initial Channel class structure

* added getChannelById and getChannels methods

* added 4 methods to change channel metadata

* added deleteChannel method

* added subscribe to/unsubscribe from channel methods

* ecmaVersion updated to 2022

* added searchChannels method

* refactoring of: getChat, getChannel, getChatModel, getChannelModel

* removed Channel.canSend

* sendMessage updated to support channels [p1]

* createChannel: returns string if an error occupied

* added getSubscribers

* added mute/unmute methods

* searchChannels: minor update

* sendMessage updated to support channels [p2]

- added support to send image, sticker, gif and video

* [REFAC] refactored chat retrieving in getChatOrChannel

* [FEAT] added getChannelByInviteCode method

* [CHG] added check if channel creation enabled

* [FEAT] added sendChannelAdminInvitation method

* [WIP] preparations for sending polls [p1]

* [BUG] fixed some internal functions

* [BUG] fixed poll creation

* [COPY] renamed some internal functions

- window.WWebJS.getChatOrChannel renamed back to window.WWebJS.getChat
-window.WWebJS.getChatOrChannelModel renamed back to window.WWebJS.getChatModel

* fix: typo

* fix: send docs without caption

* fix: fixed some internal function

* fix: channel retrieving

* fix: fixed internal getChannelMetadata function

* fix: fixed channel admin can unsubscribe from channel

* feat: channel admin functionality

* feat: added fetchMessages method

Co-authored-by: tuke307 <58882912+tuke307@users.noreply.github.com>

* refactor: revert removing support for Buttons and Lists

Note: buttons and lists are deprecated

* refactor: revert parameter name from 'chatOrChannel' to 'chat'

* fix: support Node v12

* refactor: return 'undefined' in a case of falsy value

* refactor: use already created variable

* docs: fix 'countryCodes' property type

* fix: 'searchChannels' method fixed

* fix: add check in 'setReactionSetting' method

* sendNewsletterMessageJob requires a proper Msg Object in 2.24xx (#10)

Co-authored-by: Sven Neumann <s.neumann@websix.de>

* style: fix broken link in readme file

* fix: only directPath determines whether hasMedia or not

fixes #2952

* fix: get rid of unnecessary 'await'

* feat: add option to send polls in channels

* refactor: get channel instances with 'getChatById'

* chore: fix eslint

* fix: fatal error

* refactor: delete local models by default when unsubscribing

* fix: prevent TypeError if no channel models are returned

* fix: update 'searchChannels' function for WWeb > 2.3000.0

* fix: fix for 'getChannelByInviteCode' function

* fix: fix for 'sendChannelAdminInvite' function

* fix: update 'fetchMessages' for channels

* refactor: update 'setReactionSetting' function

* fix: update 'sendMessage' for channels

* feat: add 'transferChannelOwnership' function

* refactor: add 'deleteChannel' function to 'Client'

* style: fix project code style

* chore: eslint fix

* feat: add 'sendSeen' function

* fix: fix the function restoration after injection

* refactor: remove duplicate module

* fix: get isNewsletter properly

* fix: fix channels media - add mediaHandle

Co-authored-by: devlikepro <155617407+devlikepro@users.noreply.github.com>

---------

Co-authored-by: tuke307 <58882912+tuke307@users.noreply.github.com>
Co-authored-by: Sven <sven2neumann@gmail.com>
Co-authored-by: Sven Neumann <s.neumann@websix.de>
Co-authored-by: devlikepro <155617407+devlikepro@users.noreply.github.com>
This commit is contained in:
alechkos 2025-05-31 21:36:19 +03:00 • committed by GitHub
commit d0642b761c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1241 additions and 234 deletions

View file

@ -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 { ClientInfo, Message, MessageMedia, Contact, Location, Poll, PollVote, GroupNotification, Label, Call, Buttons, List, Reaction, Broadcast} = require('./structures');
const { Broadcast, Buttons, Call, ClientInfo, Contact, GroupNotification, Label, List, Location, Message, MessageMedia, Poll, PollVote, Reaction } = require('./structures');
const NoAuth = require('./authStrategies/NoAuth');
const {exposeFunctionIfAbsent} = require('./util/Puppeteer');
@ -820,11 +820,9 @@ class Client extends EventEmitter {
*
*/
async sendSeen(chatId) {
const result = await this.pupPage.evaluate(async (chatId) => {
return await this.pupPage.evaluate(async (chatId) => {
return window.WWebJS.sendSeen(chatId);
}, chatId);
return result;
}
/**
@ -854,6 +852,7 @@ class Client extends EventEmitter {
* @property {string} [stickerName=undefined] - Sets the name of the sticker, (if sendMediaAsSticker is true).
* @property {string[]} [stickerCategories=undefined] - Sets the categories of the sticker, (if sendMediaAsSticker is true). Provide emoji char array, can be null.
* @property {MessageMedia} [media] - Media to be sent
* @property {any} [extra] - Extra options
*/
/**
@ -865,6 +864,19 @@ class Client extends EventEmitter {
* @returns {Promise<Message>} Message that was just sent
*/
async sendMessage(chatId, content, options = {}) {
const isChannel = /@\w*newsletter\b/.test(chatId);
if (isChannel && [
options.sendMediaAsDocument, options.quotedMessageId,
options.parseVCards, options.isViewOnce,
content instanceof Location, content instanceof Contact,
content instanceof Buttons, content instanceof List,
Array.isArray(content) && content.length > 0 && content[0] instanceof Contact
].includes(true)) {
console.warn('The message type is currently not supported for sending in channels,\nthe supported message types are: text, image, sticker, gif, video, voice and poll.');
return null;
}
if (options.mentions) {
!Array.isArray(options.mentions) && (options.mentions = [options.mentions]);
if (options.mentions.some((possiblyContact) => possiblyContact instanceof Contact)) {
@ -874,7 +886,7 @@ class Client extends EventEmitter {
}
options.groupMentions && !Array.isArray(options.groupMentions) && (options.groupMentions = [options.groupMentions]);
let internalOptions = {
linkPreview: options.linkPreview === false ? undefined : true,
sendAudioAsVoice: options.sendAudioAsVoice,
@ -890,14 +902,14 @@ class Client extends EventEmitter {
extraOptions: options.extra
};
const sendSeen = typeof options.sendSeen === 'undefined' ? true : options.sendSeen;
const sendSeen = options.sendSeen !== false;
if (content instanceof MessageMedia) {
internalOptions.attachment = content;
internalOptions.media = content;
internalOptions.isViewOnce = options.isViewOnce,
content = '';
} else if (options.media instanceof MessageMedia) {
internalOptions.attachment = options.media;
internalOptions.media = options.media;
internalOptions.caption = content;
internalOptions.isViewOnce = options.isViewOnce,
content = '';
@ -914,17 +926,19 @@ class Client extends EventEmitter {
internalOptions.contactCardList = content.map(contact => contact.id._serialized);
content = '';
} else if (content instanceof Buttons) {
console.warn('Buttons are now deprecated. See more at https://www.youtube.com/watch?v=hv1R1rLeVVE.');
if (content.type !== 'chat') { internalOptions.attachment = content.body; }
internalOptions.buttons = content;
content = '';
} else if (content instanceof List) {
console.warn('Lists are now deprecated. See more at https://www.youtube.com/watch?v=hv1R1rLeVVE.');
internalOptions.list = content;
content = '';
}
if (internalOptions.sendMediaAsSticker && internalOptions.attachment) {
internalOptions.attachment = await Util.formatToWebpSticker(
internalOptions.attachment, {
if (internalOptions.sendMediaAsSticker && internalOptions.media) {
internalOptions.media = await Util.formatToWebpSticker(
internalOptions.media, {
name: options.stickerName,
author: options.stickerAuthor,
categories: options.stickerCategories
@ -932,20 +946,60 @@ class Client extends EventEmitter {
);
}
const newMessage = await this.pupPage.evaluate(async (chatId, message, options, sendSeen) => {
const chatWid = window.Store.WidFactory.createWid(chatId);
const chat = await window.Store.Chat.find(chatWid);
const sentMsg = await this.pupPage.evaluate(async (chatId, content, options, sendSeen) => {
const chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
if (!chat) return null;
if (sendSeen) {
await window.WWebJS.sendSeen(chatId);
}
const msg = await window.WWebJS.sendMessage(chat, message, options, sendSeen);
return window.WWebJS.getMessageModel(msg);
const msg = await window.WWebJS.sendMessage(chat, content, options);
return msg
? window.WWebJS.getMessageModel(msg)
: undefined;
}, chatId, content, internalOptions, sendSeen);
return new Message(this, newMessage);
return sentMsg
? new Message(this, sentMsg)
: undefined;
}
/**
* @typedef {Object} SendChannelAdminInviteOptions
* @property {?string} comment The comment to be added to an invitation
*/
/**
* Sends a channel admin invitation to a user, allowing them to become an admin of the channel
* @param {string} chatId The ID of a user to send the channel admin invitation to
* @param {string} channelId The ID of a channel for which the invitation is being sent
* @param {SendChannelAdminInviteOptions} options
* @returns {Promise<boolean>} Returns true if an invitation was sent successfully, false otherwise
*/
async sendChannelAdminInvite(chatId, channelId, options = {}) {
const response = await this.pupPage.evaluate(async (chatId, channelId, options) => {
const channelWid = window.Store.WidFactory.createWid(channelId);
const chatWid = window.Store.WidFactory.createWid(chatId);
const chat = window.Store.Chat.get(chatWid) || (await window.Store.Chat.find(chatWid));
if (!chatWid.isUser()) {
return false;
}
return await window.Store.SendChannelMessage.sendNewsletterAdminInviteMessage(
chat,
{
newsletterWid: channelWid,
invitee: chatWid,
inviteMessage: options.comment,
base64Thumb: await window.WWebJS.getProfilePicThumbToBase64(channelWid)
}
);
}, chatId, channelId, options);
return response.messageSendResult === 'OK';
}
/**
@ -971,7 +1025,7 @@ class Client extends EventEmitter {
* @returns {Promise<Array<Chat>>}
*/
async getChats() {
let chats = await this.pupPage.evaluate(async () => {
const chats = await this.pupPage.evaluate(async () => {
return await window.WWebJS.getChats();
});
@ -979,16 +1033,51 @@ class Client extends EventEmitter {
}
/**
* Get chat instance by ID
* Gets all cached {@link Channel} instance
* @returns {Promise<Array<Channel>>}
*/
async getChannels() {
const channels = await this.pupPage.evaluate(async () => {
return await window.WWebJS.getChannels();
});
return channels.map((channel) => ChatFactory.create(this, channel));
}
/**
* Gets chat or channel instance by ID
* @param {string} chatId
* @returns {Promise<Chat>}
* @returns {Promise<Chat|Channel>}
*/
async getChatById(chatId) {
let chat = await this.pupPage.evaluate(async chatId => {
const chat = await this.pupPage.evaluate(async chatId => {
return await window.WWebJS.getChat(chatId);
}, chatId);
return chat
? ChatFactory.create(this, chat)
: undefined;
}
return ChatFactory.create(this, chat);
/**
* Gets a {@link Channel} instance by invite code
* @param {string} inviteCode The code that comes after the 'https://whatsapp.com/channel/'
* @returns {Promise<Channel>}
*/
async getChannelByInviteCode(inviteCode) {
const channel = await this.pupPage.evaluate(async (inviteCode) => {
let channelMetadata;
try {
channelMetadata = await window.WWebJS.getChannelMetadata(inviteCode);
} catch (err) {
if (err.name === 'ServerStatusCodeError') return null;
throw err;
}
return await window.WWebJS.getChat(channelMetadata.id);
}, inviteCode);
return channel
? ChatFactory.create(this, channel)
: undefined;
}
/**
@ -1058,6 +1147,61 @@ class Client extends EventEmitter {
return res.gid._serialized;
}
/**
* Accepts a channel admin invitation and promotes the current user to a channel admin
* @param {string} channelId The channel ID to accept the admin invitation from
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async acceptChannelAdminInvite(channelId) {
return await this.pupPage.evaluate(async (channelId) => {
try {
await window.Store.ChannelUtils.acceptNewsletterAdminInvite(channelId);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
}, channelId);
}
/**
* Revokes a channel admin invitation sent to a user by a channel owner
* @param {string} channelId The channel ID an invitation belongs to
* @param {string} userId The user ID the invitation was sent to
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async revokeChannelAdminInvite(channelId, userId) {
return await this.pupPage.evaluate(async (channelId, userId) => {
try {
const userWid = window.Store.WidFactory.createWid(userId);
await window.Store.ChannelUtils.revokeNewsletterAdminInvite(channelId, userWid);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
}, channelId, userId);
}
/**
* Demotes a channel admin to a regular subscriber (can be used also for self-demotion)
* @param {string} channelId The channel ID to demote an admin in
* @param {string} userId The user ID to demote
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async demoteChannelAdmin(channelId, userId) {
return await this.pupPage.evaluate(async (channelId, userId) => {
try {
const userWid = window.Store.WidFactory.createWid(userId);
await window.Store.ChannelUtils.demoteNewsletterAdmin(channelId, userWid);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
}, channelId, userId);
}
/**
* Accepts a private invitation to join a group
* @param {object} inviteInfo Invite V4 Info
@ -1134,7 +1278,7 @@ class Client extends EventEmitter {
*/
async archiveChat(chatId) {
return await this.pupPage.evaluate(async chatId => {
let chat = await window.Store.Chat.get(chatId);
let chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
await window.Store.Cmd.archiveChat(chat, true);
return true;
}, chatId);
@ -1146,7 +1290,7 @@ class Client extends EventEmitter {
*/
async unarchiveChat(chatId) {
return await this.pupPage.evaluate(async chatId => {
let chat = await window.Store.Chat.get(chatId);
let chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
await window.Store.Cmd.archiveChat(chat, false);
return false;
}, chatId);
@ -1158,7 +1302,7 @@ class Client extends EventEmitter {
*/
async pinChat(chatId) {
return this.pupPage.evaluate(async chatId => {
let chat = window.Store.Chat.get(chatId);
let chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
if (chat.pin) {
return true;
}
@ -1181,7 +1325,7 @@ class Client extends EventEmitter {
*/
async unpinChat(chatId) {
return this.pupPage.evaluate(async chatId => {
let chat = window.Store.Chat.get(chatId);
let chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
if (!chat.pin) {
return false;
}
@ -1233,7 +1377,7 @@ class Client extends EventEmitter {
*/
async markChatUnread(chatId) {
await this.pupPage.evaluate(async chatId => {
let chat = await window.Store.Chat.get(chatId);
let chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
await window.Store.Cmd.markChatUnread(chat, true);
}, chatId);
}
@ -1435,7 +1579,7 @@ class Client extends EventEmitter {
for (const participant of createGroupResult.participants) {
let isInviteV4Sent = false;
const participantId = participant.wid._serialized;
const statusCode = participant.error ?? 200;
const statusCode = participant.error || 200;
if (autoSendInviteV4 && statusCode === 403) {
window.Store.Contact.gadd(participant.wid, { silent: true });
@ -1474,6 +1618,219 @@ class Client extends EventEmitter {
}, title, participants, options);
}
/**
* An object that handles the result for {@link createChannel} method
* @typedef {Object} CreateChannelResult
* @property {string} title A channel title
* @property {ChatId} nid An object that handels the newly created channel ID
* @property {string} nid.server 'newsletter'
* @property {string} nid.user 'XXXXXXXXXX'
* @property {string} nid._serialized 'XXXXXXXXXX@newsletter'
* @property {string} inviteLink The channel invite link, starts with 'https://whatsapp.com/channel/'
* @property {number} createdAtTs The timestamp the channel was created at
*/
/**
* Options for the channel creation
* @typedef {Object} CreateChannelOptions
* @property {?string} description The channel description
* @property {?MessageMedia} picture The channel profile picture
*/
/**
* Creates a new channel
* @param {string} title The channel name
* @param {CreateChannelOptions} options
* @returns {Promise<CreateChannelResult|string>} Returns an object that handles the result for the channel creation or an error message as a string
*/
async createChannel(title, options = {}) {
return await this.pupPage.evaluate(async (title, options) => {
let response, { description = null, picture = null } = options;
if (!window.Store.ChannelUtils.isNewsletterCreationEnabled()) {
return 'CreateChannelError: A channel creation is not enabled';
}
if (picture) {
picture = await window.WWebJS.cropAndResizeImage(picture, {
asDataUrl: true,
mimetype: 'image/jpeg',
size: 640,
quality: 1
});
}
try {
response = await window.Store.ChannelUtils.createNewsletterQuery({
name: title,
description: description,
picture: picture,
});
} catch (err) {
if (err.name === 'ServerStatusCodeError') {
return 'CreateChannelError: An error occupied while creating a channel';
}
throw err;
}
return {
title: title,
nid: window.Store.JidToWid.newsletterJidToWid(response.idJid),
inviteLink: `https://whatsapp.com/channel/${response.newsletterInviteLinkMetadataMixin.inviteCode}`,
createdAtTs: response.newsletterCreationTimeMetadataMixin.creationTimeValue
};
}, title, options);
}
/**
* Subscribe to channel
* @param {string} channelId The channel ID
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async subscribeToChannel(channelId) {
return await this.pupPage.evaluate(async (channelId) => {
return await window.WWebJS.subscribeToUnsubscribeFromChannel(channelId, 'Subscribe');
}, channelId);
}
/**
* Options for unsubscribe from a channel
* @typedef {Object} UnsubscribeOptions
* @property {boolean} [deleteLocalModels = false] If true, after an unsubscription, it will completely remove a channel from the channel collection making it seem like the current user have never interacted with it. Otherwise it will only remove a channel from the list of channels the current user is subscribed to and will set the membership type for that channel to GUEST
*/
/**
* Unsubscribe from channel
* @param {string} channelId The channel ID
* @param {UnsubscribeOptions} options
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async unsubscribeFromChannel(channelId, options) {
return await this.pupPage.evaluate(async (channelId, options) => {
return await window.WWebJS.subscribeToUnsubscribeFromChannel(channelId, 'Unsubscribe', options);
}, channelId, options);
}
/**
* Options for transferring a channel ownership to another user
* @typedef {Object} TransferChannelOwnershipOptions
* @property {boolean} [shouldDismissSelfAsAdmin = false] If true, after the channel ownership is being transferred to another user, the current user will be dismissed as a channel admin and will become to a channel subscriber.
*/
/**
* Transfers a channel ownership to another user.
* Note: the user you are transferring the channel ownership to must be a channel admin.
* @param {string} channelId
* @param {string} newOwnerId
* @param {TransferChannelOwnershipOptions} options
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async transferChannelOwnership(channelId, newOwnerId, options = {}) {
return await this.pupPage.evaluate(async (channelId, newOwnerId, options) => {
const channel = await window.WWebJS.getChat(channelId, { getAsModel: false });
const newOwner = window.Store.Contact.get(newOwnerId) || (await window.Store.Contact.find(newOwnerId));
if (!channel.newsletterMetadata) {
await window.Store.NewsletterMetadataCollection.update(channel.id);
}
try {
await window.Store.ChannelUtils.changeNewsletterOwnerAction(channel, newOwner);
if (options.shouldDismissSelfAsAdmin) {
const meContact = window.Store.ContactCollection.getMeContact();
meContact && (await window.Store.ChannelUtils.demoteNewsletterAdminAction(channel, meContact));
}
} catch (error) {
return false;
}
return true;
}, channelId, newOwnerId, options);
}
/**
* Searches for channels based on search criteria, there are some notes:
* 1. The method finds only channels you are not subscribed to currently
* 2. If you have never been subscribed to a found channel
* or you have unsubscribed from it with {@link UnsubscribeOptions.deleteLocalModels} set to 'true',
* the lastMessage property of a found channel will be 'null'
*
* @param {Object} searchOptions Search options
* @param {string} [searchOptions.searchText = ''] Text to search
* @param {Array<string>} [searchOptions.countryCodes = [your local region]] Array of country codes in 'ISO 3166-1 alpha-2' standart (@see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) to search for channels created in these countries
* @param {boolean} [searchOptions.skipSubscribedNewsletters = false] If true, channels that user is subscribed to won't appear in found channels
* @param {number} [searchOptions.view = 0] View type, makes sense only when the searchText is empty. Valid values to provide are:
* 0 for RECOMMENDED channels
* 1 for TRENDING channels
* 2 for POPULAR channels
* 3 for NEW channels
* @param {number} [searchOptions.limit = 50] The limit of found channels to be appear in the returnig result
* @returns {Promise<Array<Channel>|[]>} Returns an array of Channel objects or an empty array if no channels were found
*/
async searchChannels(searchOptions = {}) {
return await this.pupPage.evaluate(async ({
searchText = '',
countryCodes = [window.Store.ChannelUtils.currentRegion],
skipSubscribedNewsletters = false,
view = 0,
limit = 50
}) => {
searchText = searchText.trim();
const currentRegion = window.Store.ChannelUtils.currentRegion;
if (![0, 1, 2, 3].includes(view)) view = 0;
countryCodes = countryCodes.length === 1 && countryCodes[0] === currentRegion
? countryCodes
: countryCodes.filter((code) => Object.keys(window.Store.ChannelUtils.countryCodesIso).includes(code));
const viewTypeMapping = {
0: 'RECOMMENDED',
1: 'TRENDING',
2: 'POPULAR',
3: 'NEW'
};
searchOptions = {
searchText: searchText,
countryCodes: countryCodes,
skipSubscribedNewsletters: skipSubscribedNewsletters,
view: viewTypeMapping[view],
categories: [],
cursorToken: ''
};
const originalFunction = window.Store.ChannelUtils.getNewsletterDirectoryPageSize;
limit !== 50 && (window.Store.ChannelUtils.getNewsletterDirectoryPageSize = () => limit);
const channels = (await window.Store.ChannelUtils.fetchNewsletterDirectories(searchOptions)).newsletters;
limit !== 50 && (window.Store.ChannelUtils.getNewsletterDirectoryPageSize = originalFunction);
return channels
? await Promise.all(channels.map((channel) => window.WWebJS.getChatModel(channel, { isChannel: true })))
: [];
}, searchOptions);
}
/**
* Deletes the channel you created
* @param {string} channelId The ID of a channel to delete
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async deleteChannel(channelId) {
return await this.client.pupPage.evaluate(async (channelId) => {
const channel = await window.WWebJS.getChat(channelId, { getAsModel: false });
if (!channel) return false;
try {
await window.Store.ChannelUtils.deleteNewsletterAction(channel);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
}, channelId);
}
/**
* Get all current Labels
* @returns {Promise<Array<Label>>}