diff --git a/src/api/groups.controller.ts b/src/api/groups.controller.ts index 1ec5b140..b6dd0efb 100644 --- a/src/api/groups.controller.ts +++ b/src/api/groups.controller.ts @@ -32,6 +32,9 @@ import { CreateGroupRequest, DescriptionRequest, GroupField, + GroupMembershipRequest, + GroupMembershipRequestActionRequest, + GroupMembershipRequestActionResult, GroupParticipant, GroupsListFields, GroupsPaginationParams, @@ -39,6 +42,7 @@ import { JoinGroupResponse, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, SubjectRequest, } from '../structures/groups.dto'; @@ -343,6 +347,88 @@ export class GroupsController { return session.getMemberAddMode(id); } + @Put(':id/settings/security/membership-approval') + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Send, FromParam('session'))) + @ApiOperation({ + summary: 'Update settings - approve new members', + description: + 'Enables or disables admin approval for users requesting to join the group.', + }) + setMembershipApprovalMode( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + @Body() request: SettingsMembershipApproval, + ): Promise { + return session.setMembershipApprovalMode( + id, + request.newMembersApprovalRequired, + ); + } + + @Get(':id/settings/security/membership-approval') + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Read, FromParam('session'))) + @ApiOperation({ + summary: 'Get settings - approve new members', + description: + 'Returns whether admin approval is required for users requesting to join the group.', + }) + getMembershipApprovalMode( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + ): Promise { + return session.getMembershipApprovalMode(id); + } + + @Get(':id/membership-requests') + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Read, FromParam('session'))) + @ApiOperation({ + summary: 'Get pending group membership requests', + }) + getGroupMembershipRequests( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + ): Promise { + return session.getGroupMembershipRequests(id); + } + + @Post(':id/membership-requests/approve') + @HttpCode(HttpStatus.OK) + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Send, FromParam('session'))) + @ApiOperation({ + summary: 'Approve pending group membership requests', + }) + approveGroupMembershipRequests( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + @Body() request: GroupMembershipRequestActionRequest, + ): Promise { + return session.approveGroupMembershipRequests(id, request); + } + + @Post(':id/membership-requests/reject') + @HttpCode(HttpStatus.OK) + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Send, FromParam('session'))) + @ApiOperation({ + summary: 'Reject pending group membership requests', + }) + rejectGroupMembershipRequests( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + @Body() request: GroupMembershipRequestActionRequest, + ): Promise { + return session.rejectGroupMembershipRequests(id, request); + } + @Get(':id/invite-code') @SessionApiParam @GroupIdApiParam diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index f122c7e0..32a9acf8 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -106,10 +106,14 @@ import { EventMessageRequest } from '../../structures/events.dto'; import { CreateGroupRequest, GroupField, + GroupMembershipRequest, + GroupMembershipRequestActionRequest, + GroupMembershipRequestActionResult, GroupParticipant, GroupsListFields, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '../../structures/groups.dto'; import { WAHAChatPresences } from '../../structures/presence.dto'; @@ -1064,6 +1068,39 @@ export abstract class WhatsappSession { throw new NotImplementedByEngineError(); } + public getMembershipApprovalMode( + id: string, + ): Promise { + throw new NotImplementedByEngineError(); + } + + public setMembershipApprovalMode( + id: string, + value: boolean, + ): Promise { + throw new NotImplementedByEngineError(); + } + + public getGroupMembershipRequests( + id: string, + ): Promise { + throw new NotImplementedByEngineError(); + } + + public approveGroupMembershipRequests( + id: string, + request: GroupMembershipRequestActionRequest, + ): Promise { + throw new NotImplementedByEngineError(); + } + + public rejectGroupMembershipRequests( + id: string, + request: GroupMembershipRequestActionRequest, + ): Promise { + throw new NotImplementedByEngineError(); + } + public deleteGroup(id) { throw new NotImplementedByEngineError(); } diff --git a/src/core/engines/webjs/groups.webjs.ts b/src/core/engines/webjs/groups.webjs.ts index 4152b64b..06e05c20 100644 --- a/src/core/engines/webjs/groups.webjs.ts +++ b/src/core/engines/webjs/groups.webjs.ts @@ -2,6 +2,8 @@ import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore'; import { GroupId, GroupInfo, + GroupMembershipRequest, + GroupMembershipRequestActionResult, GroupParticipant, GroupParticipantRole, } from '@waha/structures/groups.dto'; @@ -9,14 +11,17 @@ import { GroupParticipantType, GroupV2JoinEvent, GroupV2LeaveEvent, + GroupV2MembershipRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; import { GroupChat, + GroupMembershipRequest as WEBJSGroupMembershipRequest, GroupNotification, GroupNotificationTypes, GroupParticipant as WEBJSGroupParticipant, + MembershipRequestActionResult as WEBJSMembershipRequestActionResult, } from 'whatsapp-web.js'; import { isPnUser } from '@waha/core/utils/jids'; import { GetSerialized } from '@waha/core/utils/serialized'; @@ -36,12 +41,60 @@ function ToGroupInfo( invite: invite, membersCanAddNewMember: groupMetadata.memberAddMode === 'all_member_add', membersCanSendMessages: groupMetadata.announce, - newMembersApprovalRequired: groupMetadata.membershipApprovalMode, + newMembersApprovalRequired: ToGroupMembershipApprovalRequired( + groupMetadata.membershipApprovalMode, + ), participants: participants, }; return info; } +export function ToGroupMembershipApprovalRequired(value: unknown): boolean { + return value === true || value === 1; +} + +export function ToGroupMembershipRequest( + request: WEBJSGroupMembershipRequest, +): GroupMembershipRequest { + const requesterId = GetSerialized(request.id); + if (!requesterId) { + throw new Error('Unable to serialize group membership requester ID'); + } + return { + requesterId: requesterId, + addedById: GetSerialized(request.addedBy), + parentGroupId: GetSerialized(request.parentGroupId), + requestMethod: request.requestMethod ?? null, + timestamp: request.t, + }; +} + +export function ToGroupMembershipRequestActionResult( + result: WEBJSMembershipRequestActionResult, +): GroupMembershipRequestActionResult { + return { + requesterId: result.requesterId, + error: result.error, + message: result.message, + }; +} + +export function ToGroupV2MembershipRequestEvent( + notification: GroupNotification, +): GroupV2MembershipRequestEvent | null { + if (!notification.chatId || !notification.author) { + return null; + } + return { + group: { + id: notification.chatId, + }, + requesterId: notification.author, + timestamp: notification.timestamp, + _data: notification, + }; +} + export async function ToGroupV2JoinEvent( client: WebjsClientCore, me: string, diff --git a/src/core/engines/webjs/session.webjs.core.ts b/src/core/engines/webjs/session.webjs.core.ts index f4cdc998..c1f33310 100644 --- a/src/core/engines/webjs/session.webjs.core.ts +++ b/src/core/engines/webjs/session.webjs.core.ts @@ -15,8 +15,12 @@ import { } from '@waha/core/engines/webjs/ack.webjs'; import { getParticipants, + ToGroupMembershipApprovalRequired, + ToGroupMembershipRequest, + ToGroupMembershipRequestActionResult, ToGroupV2JoinEvent, ToGroupV2LeaveEvent, + ToGroupV2MembershipRequestEvent, ToGroupV2ParticipantsEvent, ToGroupV2UpdateEvent, } from '@waha/core/engines/webjs/groups.webjs'; @@ -106,10 +110,14 @@ import { import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; import { CreateGroupRequest, + GroupMembershipRequest, + GroupMembershipRequestActionRequest, + GroupMembershipRequestActionResult, GroupParticipant, GroupSortField, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '@waha/structures/groups.dto'; import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto'; @@ -1626,6 +1634,105 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { return groupChat.setAddMembersAdminsOnly(!value); } + @Activity() + public async getMembershipApprovalMode( + id: string, + ): Promise { + const membershipApprovalMode = await this.whatsapp.pupPage.evaluate( + async (groupId) => { + await window + .require('WAWebGroupQueryJob') + .queryAndUpdateGroupMetadataById({ id: groupId }); + const groupWid = window + .require('WAWebWidFactory') + .createWid(groupId); + const group = await window + .require('WAWebCollections') + .Chat.find(groupWid); + return group.groupMetadata.membershipApprovalMode; + }, + id, + ); + return { + newMembersApprovalRequired: ToGroupMembershipApprovalRequired( + membershipApprovalMode, + ), + }; + } + + @Activity() + public async setMembershipApprovalMode( + id: string, + value: boolean, + ): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const success = await this.whatsapp.pupPage.evaluate( + async (groupId, enabled) => { + // @ts-ignore + const chat = await window.WWebJS.getChat(groupId, { + getAsModel: false, + }); + try { + await window + .require('WAWebSetPropertyGroupAction') + .setGroupProperty( + chat, + 'membership_approval_mode', + enabled ? 1 : 0, + ); + return true; + } catch (error) { + if (error.name === 'ServerStatusCodeError') { + return false; + } + throw error; + } + }, + id, + value, + ); + + if (success) { + (groupChat as any).groupMetadata.membershipApprovalMode = value; + } + return success; + } + + @Activity() + public async getGroupMembershipRequests( + id: string, + ): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const requests = await groupChat.getGroupMembershipRequests(); + return requests.map(ToGroupMembershipRequest); + } + + @Activity() + public async approveGroupMembershipRequests( + id: string, + request: GroupMembershipRequestActionRequest, + ): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const results = await groupChat.approveGroupMembershipRequests({ + requesterIds: request.requesterIds, + sleep: [250, 500], + }); + return results.map(ToGroupMembershipRequestActionResult); + } + + @Activity() + public async rejectGroupMembershipRequests( + id: string, + request: GroupMembershipRequestActionRequest, + ): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const results = await groupChat.rejectGroupMembershipRequests({ + requesterIds: request.requesterIds, + sleep: [250, 500], + }); + return results.map(ToGroupMembershipRequestActionResult); + } + public async getGroups(pagination: PaginationParams) { const chats = await this.whatsapp.getChats(); const groups = lodash.filter(chats, (chat) => chat.isGroup); @@ -2308,6 +2415,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { .get(WAHAEvents.GROUP_V2_PARTICIPANTS) .switch(groupV2Participants); + const groupMembershipRequest$ = fromEvent( + this.whatsapp, + Events.GROUP_MEMBERSHIP_REQUEST, + ).pipe(map(ToGroupV2MembershipRequestEvent), filter(Boolean)); + this.events2 + .get(WAHAEvents.GROUP_V2_MEMBERSHIP_REQUEST) + .switch(groupMembershipRequest$); + const groupUpdate$ = fromEvent( this.whatsapp, Events.GROUP_UPDATE, diff --git a/src/structures/enums.dto.ts b/src/structures/enums.dto.ts index 0d8287a8..ebbc4e8d 100644 --- a/src/structures/enums.dto.ts +++ b/src/structures/enums.dto.ts @@ -17,6 +17,7 @@ export enum WAHAEvents { GROUP_V2_LEAVE = 'group.v2.leave', GROUP_V2_UPDATE = 'group.v2.update', GROUP_V2_PARTICIPANTS = 'group.v2.participants', + GROUP_V2_MEMBERSHIP_REQUEST = 'group.v2.membership-request', PRESENCE_UPDATE = 'presence.update', POLL_VOTE = 'poll.vote', POLL_VOTE_FAILED = 'poll.vote.failed', diff --git a/src/structures/groups.dto.ts b/src/structures/groups.dto.ts index f94f08db..7b58b095 100644 --- a/src/structures/groups.dto.ts +++ b/src/structures/groups.dto.ts @@ -2,9 +2,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { BooleanString } from '@waha/nestjs/validation/BooleanString'; import { Transform } from 'class-transformer'; import { + ArrayNotEmpty, + ArrayUnique, IsArray, IsBoolean, IsEnum, + IsNotEmpty, IsOptional, IsString, } from 'class-validator'; @@ -30,6 +33,84 @@ export class SettingsMemberAddMode { membersCanAddNewMember: boolean = true; } +export class SettingsMembershipApproval { + @IsBoolean() + newMembersApprovalRequired: boolean = false; +} + +export class GroupMembershipRequest { + @ApiProperty({ + description: 'ID of the user requesting to join the group', + example: '123456789@c.us', + }) + requesterId: string; + + @ApiProperty({ + description: 'ID of the user who created the request', + example: '123456789@c.us', + nullable: true, + }) + addedById: string | null; + + @ApiProperty({ + description: 'ID of the parent community group, if present', + example: '123456789@g.us', + nullable: true, + }) + parentGroupId: string | null; + + @ApiProperty({ + description: + 'How the request was created, for example NonAdminAdd, InviteLink, or LinkedGroupJoin', + example: 'InviteLink', + nullable: true, + }) + requestMethod: string | null; + + @ApiProperty({ + description: 'Unix timestamp when the request was created', + example: 1666943582, + }) + timestamp: number; +} + +export class GroupMembershipRequestActionRequest { + @ApiProperty({ + description: + 'IDs of the users whose membership requests should be approved or rejected', + example: ['123456789@c.us'], + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + requesterIds: string[]; +} + +export class GroupMembershipRequestActionResult { + @ApiProperty({ + example: '123456789@c.us', + nullable: true, + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }) + requesterId: string[] | string | null; + + @ApiProperty({ + required: false, + example: 404, + }) + error?: number; + + @ApiProperty({ + example: 'Approved successfully', + }) + message: string; +} + /** * Queries */ diff --git a/src/structures/groups.events.dto.ts b/src/structures/groups.events.dto.ts index 68db1fce..880794a8 100644 --- a/src/structures/groups.events.dto.ts +++ b/src/structures/groups.events.dto.ts @@ -69,3 +69,21 @@ export class GroupV2ParticipantsEvent { _data: any; } + +export class GroupV2MembershipRequestEvent { + group: GroupId; + + @ApiProperty({ + description: 'ID of the user requesting to join the group', + example: '123456789@c.us', + }) + requesterId: string; + + @ApiProperty({ + description: 'Unix timestamp', + example: 1666943582, + }) + timestamp: number; + + _data: any; +} diff --git a/src/structures/groups.webhooks.dto.ts b/src/structures/groups.webhooks.dto.ts index dc3d8904..a6de8fb7 100644 --- a/src/structures/groups.webhooks.dto.ts +++ b/src/structures/groups.webhooks.dto.ts @@ -3,6 +3,7 @@ import { WAHAEvents } from '@waha/structures/enums.dto'; import { GroupV2JoinEvent, GroupV2LeaveEvent, + GroupV2MembershipRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; @@ -43,3 +44,12 @@ export class WebhookGroupV2Participants extends WAHAWebhook { payload: GroupV2ParticipantsEvent; } + +export class WebhookGroupV2MembershipRequest extends WAHAWebhook { + @ApiProperty({ + description: 'When a user requests to join a group', + }) + event = WAHAEvents.GROUP_V2_MEMBERSHIP_REQUEST; + + payload: GroupV2MembershipRequestEvent; +} diff --git a/src/structures/webhooks.ts b/src/structures/webhooks.ts index 42673fbf..d4e92a20 100644 --- a/src/structures/webhooks.ts +++ b/src/structures/webhooks.ts @@ -1,6 +1,7 @@ import { WebhookGroupV2Join, WebhookGroupV2Leave, + WebhookGroupV2MembershipRequest, WebhookGroupV2Participants, WebhookGroupV2Update, } from '@waha/structures/groups.webhooks.dto'; @@ -45,6 +46,7 @@ const WAHA_WEBHOOKS = [ WebhookGroupV2Leave, WebhookGroupV2Update, WebhookGroupV2Participants, + WebhookGroupV2MembershipRequest, WAHAWebhookPresenceUpdate, WAHAWebhookPollVote, WAHAWebhookPollVoteFailed,