From cc12380e484cc58622545be92f5121b0eb5b7368 Mon Sep 17 00:00:00 2001 From: devlikepro Date: Thu, 20 Aug 2026 11:30:40 +0700 Subject: [PATCH] feat(groups): membership approval - settings, pending requests API and group.v2.participants.join-request event Closes #2200 Co-authored-by: icecubex300 <83597812+icecubex300@users.noreply.github.com> --- src/api/groups.controller.ts | 85 ++++++ src/apps/mcp/tools/groups.tools.ts | 103 +++++++ src/apps/mcp/tools/groups.zod.ts | 8 + src/core/abc/session.abc.ts | 34 +++ src/core/engines/gows/groups.gows.ts | 76 +++++ src/core/engines/gows/grpc/gows.ts | 182 +++++++++++ src/core/engines/gows/grpc/gows_grpc_pb.js | 44 +++ src/core/engines/gows/grpc/gows_pb.js | 298 +++++++++++++++++++ src/core/engines/gows/session.gows.core.ts | 93 ++++++ src/core/engines/gows/types.group.ts | 22 ++ src/core/engines/noweb/groups.noweb.ts | 88 ++++++ src/core/engines/noweb/session.noweb.core.ts | 70 +++++ src/core/engines/webjs/groups.webjs.ts | 65 +++- src/core/engines/webjs/session.webjs.core.ts | 110 +++++++ src/core/engines/wpp/events.wpp.ts | 23 ++ src/core/engines/wpp/session.wpp.core.ts | 93 ++++++ src/structures/enums.dto.ts | 1 + src/structures/groups.dto.ts | 96 ++++++ src/structures/groups.events.dto.ts | 51 ++++ src/structures/groups.webhooks.dto.ts | 10 + src/structures/webhooks.ts | 2 + 21 files changed, 1553 insertions(+), 1 deletion(-) diff --git a/src/api/groups.controller.ts b/src/api/groups.controller.ts index 1ec5b140..c30070e2 100644 --- a/src/api/groups.controller.ts +++ b/src/api/groups.controller.ts @@ -32,6 +32,8 @@ import { CreateGroupRequest, DescriptionRequest, GroupField, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupsListFields, GroupsPaginationParams, @@ -39,6 +41,7 @@ import { JoinGroupResponse, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, SubjectRequest, } from '../structures/groups.dto'; @@ -343,6 +346,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/participants/join-requests') + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Read, FromParam('session'))) + @ApiOperation({ + summary: 'Get pending requests to join the group', + }) + getGroupJoinRequests( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + ): Promise { + return session.getGroupJoinRequests(id); + } + + @Post(':id/participants/join-requests/approve') + @HttpCode(HttpStatus.OK) + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Send, FromParam('session'))) + @ApiOperation({ + summary: 'Approve pending requests to join the group', + }) + approveGroupJoinRequests( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + @Body() request: ParticipantsRequest, + ): Promise { + return session.approveGroupJoinRequests(id, request); + } + + @Post(':id/participants/join-requests/reject') + @HttpCode(HttpStatus.OK) + @SessionApiParam + @GroupIdApiParam + @CheckPolicies(CanSession(Action.Send, FromParam('session'))) + @ApiOperation({ + summary: 'Reject pending requests to join the group', + }) + rejectGroupJoinRequests( + @WorkingSessionParam session: WhatsappSession, + @Param('id') id: string, + @Body() request: ParticipantsRequest, + ): Promise { + return session.rejectGroupJoinRequests(id, request); + } + @Get(':id/invite-code') @SessionApiParam @GroupIdApiParam diff --git a/src/apps/mcp/tools/groups.tools.ts b/src/apps/mcp/tools/groups.tools.ts index 519478e8..2754337d 100644 --- a/src/apps/mcp/tools/groups.tools.ts +++ b/src/apps/mcp/tools/groups.tools.ts @@ -8,6 +8,7 @@ import { GroupDescriptionInput, GroupIdInput, GroupJoinInput, + GroupMembershipApprovalInput, GroupParticipantsInput, GroupPictureInput, GroupsListInput, @@ -363,6 +364,108 @@ export class GroupTools extends McpController { }); } + @Tool('groups-get-membership-approval', { + title: 'Get membership approval setting', + description: + 'Get whether admin approval is required for users requesting to join the group', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getMembershipApproval({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/settings/security/membership-approval`, + }); + } + + @Tool('groups-set-membership-approval', { + title: 'Set membership approval setting', + description: + 'Enable or disable admin approval for users requesting to join the group', + inputSchema: GroupMembershipApprovalInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + }) + async setMembershipApproval({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'PUT', + url: `/api/${session}/groups/${id}/settings/security/membership-approval`, + data: body, + }); + } + + @Tool('groups-get-join-requests', { + title: 'Get pending requests to join the group', + description: 'Get pending requests to join the group', + inputSchema: GroupIdInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }) + async getJoinRequests({ session, id }: z.infer) { + return this.textRequest({ + method: 'GET', + url: `/api/${session}/groups/${id}/participants/join-requests`, + }); + } + + @Tool('groups-approve-join-requests', { + title: 'Approve pending requests to join the group', + description: 'Approve pending requests to join the group', + inputSchema: GroupParticipantsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async approveJoinRequests({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/participants/join-requests/approve`, + data: body, + }); + } + + @Tool('groups-reject-join-requests', { + title: 'Reject pending requests to join the group', + description: 'Reject pending requests to join the group', + inputSchema: GroupParticipantsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, + }) + async rejectJoinRequests({ + session, + id, + ...body + }: z.infer) { + return this.textRequest({ + method: 'POST', + url: `/api/${session}/groups/${id}/participants/join-requests/reject`, + data: body, + }); + } + @Tool('groups-get-invite-code', { title: 'Get group invite code', description: 'Get the invite code for a group', diff --git a/src/apps/mcp/tools/groups.zod.ts b/src/apps/mcp/tools/groups.zod.ts index 686c9c20..c1a1cbb5 100644 --- a/src/apps/mcp/tools/groups.zod.ts +++ b/src/apps/mcp/tools/groups.zod.ts @@ -7,6 +7,7 @@ import { GroupsPaginationParams, JoinGroupRequest, ParticipantsRequest, + SettingsMembershipApproval, SettingsSecurityChangeInfo, SubjectRequest, } from '@waha/structures/groups.dto'; @@ -64,3 +65,10 @@ export const GroupParticipantsInput = DtoToZod(ParticipantsRequest).extend({ session: SessionField, id: GroupIdField, }); + +export const GroupMembershipApprovalInput = DtoToZod( + SettingsMembershipApproval, +).extend({ + session: SessionField, + id: GroupIdField, +}); diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index b513f9f4..770e0b2f 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -106,10 +106,13 @@ import { EventMessageRequest } from '../../structures/events.dto'; import { CreateGroupRequest, GroupField, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupsListFields, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '../../structures/groups.dto'; import { WAHAChatPresences } from '../../structures/presence.dto'; @@ -1069,6 +1072,37 @@ 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 getGroupJoinRequests(id: string): Promise { + throw new NotImplementedByEngineError(); + } + + public approveGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + throw new NotImplementedByEngineError(); + } + + public rejectGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + throw new NotImplementedByEngineError(); + } + public deleteGroup(id) { throw new NotImplementedByEngineError(); } diff --git a/src/core/engines/gows/groups.gows.ts b/src/core/engines/gows/groups.gows.ts index ca7e20f4..990ff44f 100644 --- a/src/core/engines/gows/groups.gows.ts +++ b/src/core/engines/gows/groups.gows.ts @@ -1,20 +1,28 @@ import { + GOWSGroupJoinRequest, GOWSGroupParticipant, + GOWSGroupParticipantRequest, GroupInfoEvent, GroupInfoFull, + GroupJoinRequestEvent, JoinedGroupEvent, } from '@waha/core/engines/gows/types.group'; import { isPnUser, toCusFormat, toJID } from '@waha/core/utils/jids'; import { GroupId, GroupInfo, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupParticipantRole, + NormalizeJoinRequestMethod, } from '@waha/structures/groups.dto'; import { + GroupParticipantsJoinRequestAction, GroupParticipantType, GroupV2JoinEvent, GroupV2LeaveEvent, + GroupV2ParticipantsJoinRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; @@ -171,6 +179,74 @@ function ToGroupInfoPartial(group: GroupInfoEvent): GroupInfo | null { return null; } +export function ToGroupJoinRequest( + request: GOWSGroupParticipantRequest, +): GroupJoinRequest { + const requestedAt = Date.parse(request.RequestedAt); + return { + requesterId: toCusFormat(request.JID), + addedById: null, + parentGroupId: null, + requestMethod: null, + timestamp: requestedAt ? Math.floor(requestedAt / 1000) : 0, + }; +} + +export function ToGroupJoinRequestResult( + participant: GOWSGroupParticipant, +): GroupJoinRequestResult { + return { + requesterId: toCusFormat(participant.JID), + success: !participant.Error, + error: participant.Error || undefined, + }; +} + +function ToGroupParticipantsJoinRequestAction( + event: GroupJoinRequestEvent, + request: GOWSGroupJoinRequest, +): GroupParticipantsJoinRequestAction | null { + if (event.Action === 'created') { + return GroupParticipantsJoinRequestAction.CREATED; + } + if (event.Action !== 'revoked') { + return null; + } + // The wire has one 'revoked' change for both cases - tell them apart by who sent it: + // the requester cancelled their own request, anyone else (an admin) rejected it + const self = + event.Sender === request.JID || + (!!event.SenderPN && event.SenderPN === request.PhoneNumber); + if (self) { + return GroupParticipantsJoinRequestAction.REVOKED; + } + return GroupParticipantsJoinRequestAction.REJECTED; +} + +export function ToGroupV2ParticipantsJoinRequestEvents( + event: GroupJoinRequestEvent, +): GroupV2ParticipantsJoinRequestEvent[] { + const events: GroupV2ParticipantsJoinRequestEvent[] = []; + for (const request of event.Requests || []) { + const action = ToGroupParticipantsJoinRequestAction(event, request); + if (!action) { + continue; + } + events.push({ + group: { + id: event.JID, + }, + action: action, + requesterId: toCusFormat(request.JID), + requesterPn: toCusFormat(request.PhoneNumber) || null, + requestMethod: NormalizeJoinRequestMethod(request.RequestMethod), + timestamp: Date.now(), + _data: event, + }); + } + return events; +} + export function ToGroupParticipants( participants: GOWSGroupParticipant[], ): GroupParticipant[] { diff --git a/src/core/engines/gows/grpc/gows.ts b/src/core/engines/gows/grpc/gows.ts index d7aec2d9..cae0a7e7 100644 --- a/src/core/engines/gows/grpc/gows.ts +++ b/src/core/engines/gows/grpc/gows.ts @@ -19,6 +19,10 @@ export namespace messages { PROMOTE = 2, DEMOTE = 3 } + export enum ParticipantRequestAction { + APPROVE = 0, + REJECT = 1 + } export enum MediaType { IMAGE = 0, AUDIO = 1, @@ -2978,6 +2982,145 @@ export namespace messages { return UpdateParticipantsRequest.deserialize(bytes); } } + export class UpdateRequestParticipantsRequest extends pb_1.Message { + #one_of_decls: number[][] = []; + constructor(data?: any[] | { + session?: Session; + jid?: string; + participants?: string[]; + action?: ParticipantRequestAction; + }) { + super(); + pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [3], this.#one_of_decls); + if (!Array.isArray(data) && typeof data == "object") { + if ("session" in data && data.session != undefined) { + this.session = data.session; + } + if ("jid" in data && data.jid != undefined) { + this.jid = data.jid; + } + if ("participants" in data && data.participants != undefined) { + this.participants = data.participants; + } + if ("action" in data && data.action != undefined) { + this.action = data.action; + } + } + } + get session() { + return pb_1.Message.getWrapperField(this, Session, 1) as Session; + } + set session(value: Session) { + pb_1.Message.setWrapperField(this, 1, value); + } + get has_session() { + return pb_1.Message.getField(this, 1) != null; + } + get jid() { + return pb_1.Message.getFieldWithDefault(this, 2, "") as string; + } + set jid(value: string) { + pb_1.Message.setField(this, 2, value); + } + get participants() { + return pb_1.Message.getFieldWithDefault(this, 3, []) as string[]; + } + set participants(value: string[]) { + pb_1.Message.setField(this, 3, value); + } + get action() { + return pb_1.Message.getFieldWithDefault(this, 4, ParticipantRequestAction.APPROVE) as ParticipantRequestAction; + } + set action(value: ParticipantRequestAction) { + pb_1.Message.setField(this, 4, value); + } + static fromObject(data: { + session?: ReturnType; + jid?: string; + participants?: string[]; + action?: ParticipantRequestAction; + }): UpdateRequestParticipantsRequest { + const message = new UpdateRequestParticipantsRequest({}); + if (data.session != null) { + message.session = Session.fromObject(data.session); + } + if (data.jid != null) { + message.jid = data.jid; + } + if (data.participants != null) { + message.participants = data.participants; + } + if (data.action != null) { + message.action = data.action; + } + return message; + } + toObject() { + const data: { + session?: ReturnType; + jid?: string; + participants?: string[]; + action?: ParticipantRequestAction; + } = {}; + if (this.session != null) { + data.session = this.session.toObject(); + } + if (this.jid != null) { + data.jid = this.jid; + } + if (this.participants != null) { + data.participants = this.participants; + } + if (this.action != null) { + data.action = this.action; + } + return data; + } + serialize(): Uint8Array; + serialize(w: pb_1.BinaryWriter): void; + serialize(w?: pb_1.BinaryWriter): Uint8Array | void { + const writer = w || new pb_1.BinaryWriter(); + if (this.has_session) + writer.writeMessage(1, this.session, () => this.session.serialize(writer)); + if (this.jid.length) + writer.writeString(2, this.jid); + if (this.participants.length) + writer.writeRepeatedString(3, this.participants); + if (this.action != ParticipantRequestAction.APPROVE) + writer.writeEnum(4, this.action); + if (!w) + return writer.getResultBuffer(); + } + static deserialize(bytes: Uint8Array | pb_1.BinaryReader): UpdateRequestParticipantsRequest { + const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new UpdateRequestParticipantsRequest(); + while (reader.nextField()) { + if (reader.isEndGroup()) + break; + switch (reader.getFieldNumber()) { + case 1: + reader.readMessage(message.session, () => message.session = Session.deserialize(reader)); + break; + case 2: + message.jid = reader.readString(); + break; + case 3: + pb_1.Message.addToRepeatedField(message, 3, reader.readString()); + break; + case 4: + message.action = reader.readEnum(); + break; + default: reader.skipField(); + } + } + return message; + } + serializeBinary(): Uint8Array { + return this.serialize(); + } + static deserializeBinary(bytes: Uint8Array): UpdateRequestParticipantsRequest { + return UpdateRequestParticipantsRequest.deserialize(bytes); + } + } export class GroupCodeRequest extends pb_1.Message { #one_of_decls: number[][] = []; constructor(data?: any[] | { @@ -11475,6 +11618,33 @@ export namespace messages { responseSerialize: (message: JsonList) => Buffer.from(message.serialize()), responseDeserialize: (bytes: Buffer) => JsonList.deserialize(new Uint8Array(bytes)) }, + SetGroupJoinApprovalMode: { + path: "/messages.MessageService/SetGroupJoinApprovalMode", + requestStream: false, + responseStream: false, + requestSerialize: (message: JidBoolRequest) => Buffer.from(message.serialize()), + requestDeserialize: (bytes: Buffer) => JidBoolRequest.deserialize(new Uint8Array(bytes)), + responseSerialize: (message: Empty) => Buffer.from(message.serialize()), + responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes)) + }, + GetGroupRequestParticipants: { + path: "/messages.MessageService/GetGroupRequestParticipants", + requestStream: false, + responseStream: false, + requestSerialize: (message: JidRequest) => Buffer.from(message.serialize()), + requestDeserialize: (bytes: Buffer) => JidRequest.deserialize(new Uint8Array(bytes)), + responseSerialize: (message: JsonList) => Buffer.from(message.serialize()), + responseDeserialize: (bytes: Buffer) => JsonList.deserialize(new Uint8Array(bytes)) + }, + UpdateGroupRequestParticipants: { + path: "/messages.MessageService/UpdateGroupRequestParticipants", + requestStream: false, + responseStream: false, + requestSerialize: (message: UpdateRequestParticipantsRequest) => Buffer.from(message.serialize()), + requestDeserialize: (bytes: Buffer) => UpdateRequestParticipantsRequest.deserialize(new Uint8Array(bytes)), + responseSerialize: (message: JsonList) => Buffer.from(message.serialize()), + responseDeserialize: (bytes: Buffer) => JsonList.deserialize(new Uint8Array(bytes)) + }, GetProfilePicture: { path: "/messages.MessageService/GetProfilePicture", requestStream: false, @@ -11858,6 +12028,9 @@ export namespace messages { abstract SetGroupAnnounce(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract SetGroupMemberAddMode(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract UpdateGroupParticipants(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; + abstract SetGroupJoinApprovalMode(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; + abstract GetGroupRequestParticipants(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; + abstract UpdateGroupRequestParticipants(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract GetProfilePicture(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract SendPresence(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; abstract SendChatPresence(call: grpc_1.ServerUnaryCall, callback: grpc_1.sendUnaryData): void; @@ -11992,6 +12165,15 @@ export namespace messages { UpdateGroupParticipants: GrpcUnaryServiceInterface = (message: UpdateParticipantsRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { return super.UpdateGroupParticipants(message, metadata, options, callback); }; + SetGroupJoinApprovalMode: GrpcUnaryServiceInterface = (message: JidBoolRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { + return super.SetGroupJoinApprovalMode(message, metadata, options, callback); + }; + GetGroupRequestParticipants: GrpcUnaryServiceInterface = (message: JidRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { + return super.GetGroupRequestParticipants(message, metadata, options, callback); + }; + UpdateGroupRequestParticipants: GrpcUnaryServiceInterface = (message: UpdateRequestParticipantsRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { + return super.UpdateGroupRequestParticipants(message, metadata, options, callback); + }; GetProfilePicture: GrpcUnaryServiceInterface = (message: ProfilePictureRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback, options?: grpc_1.CallOptions | grpc_1.requestCallback, callback?: grpc_1.requestCallback): grpc_1.ClientUnaryCall => { return super.GetProfilePicture(message, metadata, options, callback); }; diff --git a/src/core/engines/gows/grpc/gows_grpc_pb.js b/src/core/engines/gows/grpc/gows_grpc_pb.js index 024c8714..1db6b701 100644 --- a/src/core/engines/gows/grpc/gows_grpc_pb.js +++ b/src/core/engines/gows/grpc/gows_grpc_pb.js @@ -697,6 +697,17 @@ function deserialize_messages_UpdateParticipantsRequest(buffer_arg) { return gows_pb.UpdateParticipantsRequest.deserializeBinary(new Uint8Array(buffer_arg)); } +function serialize_messages_UpdateRequestParticipantsRequest(arg) { + if (!(arg instanceof gows_pb.UpdateRequestParticipantsRequest)) { + throw new Error('Expected argument of type messages.UpdateRequestParticipantsRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_messages_UpdateRequestParticipantsRequest(buffer_arg) { + return gows_pb.UpdateRequestParticipantsRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + function serialize_messages_UpsertLabelRequest(arg) { if (!(arg instanceof gows_pb.UpsertLabelRequest)) { throw new Error('Expected argument of type messages.UpsertLabelRequest'); @@ -1073,6 +1084,39 @@ updateGroupParticipants: { responseSerialize: serialize_messages_JsonList, responseDeserialize: deserialize_messages_JsonList, }, + setGroupJoinApprovalMode: { + path: '/messages.MessageService/SetGroupJoinApprovalMode', + requestStream: false, + responseStream: false, + requestType: gows_pb.JidBoolRequest, + responseType: gows_pb.Empty, + requestSerialize: serialize_messages_JidBoolRequest, + requestDeserialize: deserialize_messages_JidBoolRequest, + responseSerialize: serialize_messages_Empty, + responseDeserialize: deserialize_messages_Empty, + }, + getGroupRequestParticipants: { + path: '/messages.MessageService/GetGroupRequestParticipants', + requestStream: false, + responseStream: false, + requestType: gows_pb.JidRequest, + responseType: gows_pb.JsonList, + requestSerialize: serialize_messages_JidRequest, + requestDeserialize: deserialize_messages_JidRequest, + responseSerialize: serialize_messages_JsonList, + responseDeserialize: deserialize_messages_JsonList, + }, + updateGroupRequestParticipants: { + path: '/messages.MessageService/UpdateGroupRequestParticipants', + requestStream: false, + responseStream: false, + requestType: gows_pb.UpdateRequestParticipantsRequest, + responseType: gows_pb.JsonList, + requestSerialize: serialize_messages_UpdateRequestParticipantsRequest, + requestDeserialize: deserialize_messages_UpdateRequestParticipantsRequest, + responseSerialize: serialize_messages_JsonList, + responseDeserialize: deserialize_messages_JsonList, + }, // // Actions // diff --git a/src/core/engines/gows/grpc/gows_pb.js b/src/core/engines/gows/grpc/gows_pb.js index cbb2da97..0f2edd90 100644 --- a/src/core/engines/gows/grpc/gows_pb.js +++ b/src/core/engines/gows/grpc/gows_pb.js @@ -84,6 +84,7 @@ goog.exportSymbol('proto.messages.Pagination', null, global); goog.exportSymbol('proto.messages.PairCodeRequest', null, global); goog.exportSymbol('proto.messages.PairCodeResponse', null, global); goog.exportSymbol('proto.messages.ParticipantAction', null, global); +goog.exportSymbol('proto.messages.ParticipantRequestAction', null, global); goog.exportSymbol('proto.messages.PasskeyResponseRequest', null, global); goog.exportSymbol('proto.messages.PhoneInfo', null, global); goog.exportSymbol('proto.messages.PollMessage', null, global); @@ -120,6 +121,7 @@ goog.exportSymbol('proto.messages.StreamEventsRequest', null, global); goog.exportSymbol('proto.messages.SubscribePresenceRequest', null, global); goog.exportSymbol('proto.messages.UpdateContactRequest', null, global); goog.exportSymbol('proto.messages.UpdateParticipantsRequest', null, global); +goog.exportSymbol('proto.messages.UpdateRequestParticipantsRequest', null, global); goog.exportSymbol('proto.messages.UpsertLabelRequest', null, global); goog.exportSymbol('proto.messages.VideoInfo', null, global); goog.exportSymbol('proto.messages.vCardContact', null, global); @@ -753,6 +755,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.messages.UpdateParticipantsRequest.displayName = 'proto.messages.UpdateParticipantsRequest'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.messages.UpdateRequestParticipantsRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.messages.UpdateRequestParticipantsRequest.repeatedFields_, null); +}; +goog.inherits(proto.messages.UpdateRequestParticipantsRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.messages.UpdateRequestParticipantsRequest.displayName = 'proto.messages.UpdateRequestParticipantsRequest'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -7652,6 +7675,273 @@ proto.messages.UpdateParticipantsRequest.prototype.setAction = function(value) { +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.messages.UpdateRequestParticipantsRequest.repeatedFields_ = [3]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.toObject = function(opt_includeInstance) { + return proto.messages.UpdateRequestParticipantsRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.messages.UpdateRequestParticipantsRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.messages.UpdateRequestParticipantsRequest.toObject = function(includeInstance, msg) { + var f, obj = { + session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f), + jid: jspb.Message.getFieldWithDefault(msg, 2, ""), + participantsList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f, + action: jspb.Message.getFieldWithDefault(msg, 4, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.messages.UpdateRequestParticipantsRequest} + */ +proto.messages.UpdateRequestParticipantsRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.messages.UpdateRequestParticipantsRequest; + return proto.messages.UpdateRequestParticipantsRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.messages.UpdateRequestParticipantsRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.messages.UpdateRequestParticipantsRequest} + */ +proto.messages.UpdateRequestParticipantsRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = new proto.messages.Session; + reader.readMessage(value,proto.messages.Session.deserializeBinaryFromReader); + msg.setSession(value); + break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setJid(value); + break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.addParticipants(value); + break; + case 4: + var value = /** @type {!proto.messages.ParticipantRequestAction} */ (reader.readEnum()); + msg.setAction(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.messages.UpdateRequestParticipantsRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.messages.UpdateRequestParticipantsRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.messages.UpdateRequestParticipantsRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getSession(); + if (f != null) { + writer.writeMessage( + 1, + f, + proto.messages.Session.serializeBinaryToWriter + ); + } + f = message.getJid(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } + f = message.getParticipantsList(); + if (f.length > 0) { + writer.writeRepeatedString( + 3, + f + ); + } + f = message.getAction(); + if (f !== 0.0) { + writer.writeEnum( + 4, + f + ); + } +}; + + +/** + * optional Session session = 1; + * @return {?proto.messages.Session} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.getSession = function() { + return /** @type{?proto.messages.Session} */ ( + jspb.Message.getWrapperField(this, proto.messages.Session, 1)); +}; + + +/** + * @param {?proto.messages.Session|undefined} value + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this +*/ +proto.messages.UpdateRequestParticipantsRequest.prototype.setSession = function(value) { + return jspb.Message.setWrapperField(this, 1, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.clearSession = function() { + return this.setSession(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.hasSession = function() { + return jspb.Message.getField(this, 1) != null; +}; + + +/** + * optional string jid = 2; + * @return {string} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.getJid = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.setJid = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + +/** + * repeated string participants = 3; + * @return {!Array} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.getParticipantsList = function() { + return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 3)); +}; + + +/** + * @param {!Array} value + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.setParticipantsList = function(value) { + return jspb.Message.setField(this, 3, value || []); +}; + + +/** + * @param {string} value + * @param {number=} opt_index + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.addParticipants = function(value, opt_index) { + return jspb.Message.addToRepeatedField(this, 3, value, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.clearParticipantsList = function() { + return this.setParticipantsList([]); +}; + + +/** + * optional ParticipantRequestAction action = 4; + * @return {!proto.messages.ParticipantRequestAction} + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.getAction = function() { + return /** @type {!proto.messages.ParticipantRequestAction} */ (jspb.Message.getFieldWithDefault(this, 4, 0)); +}; + + +/** + * @param {!proto.messages.ParticipantRequestAction} value + * @return {!proto.messages.UpdateRequestParticipantsRequest} returns this + */ +proto.messages.UpdateRequestParticipantsRequest.prototype.setAction = function(value) { + return jspb.Message.setProto3EnumField(this, 4, value); +}; + + + if (jspb.Message.GENERATE_TO_OBJECT) { @@ -22845,6 +23135,14 @@ proto.messages.ParticipantAction = { DEMOTE: 3 }; +/** + * @enum {number} + */ +proto.messages.ParticipantRequestAction = { + APPROVE: 0, + REJECT: 1 +}; + /** * @enum {number} */ diff --git a/src/core/engines/gows/session.gows.core.ts b/src/core/engines/gows/session.gows.core.ts index d27cc037..af308df4 100644 --- a/src/core/engines/gows/session.gows.core.ts +++ b/src/core/engines/gows/session.gows.core.ts @@ -15,12 +15,19 @@ import { Jid } from '@waha/core/engines/const'; import { EventsFromObservable } from '@waha/core/engines/gows/EventsFromObservable'; import { GowsEventStreamObservable } from '@waha/core/engines/gows/GowsEventStreamObservable'; import { + ToGroupJoinRequest, + ToGroupJoinRequestResult, ToGroupParticipants, ToGroupV2JoinEvent, ToGroupV2LeaveEvent, + ToGroupV2ParticipantsJoinRequestEvents, ToGroupV2ParticipantsEvents, ToGroupV2UpdateEvent, } from '@waha/core/engines/gows/groups.gows'; +import { + GOWSGroupParticipant, + GOWSGroupParticipantRequest, +} from '@waha/core/engines/gows/types.group'; import { messages } from '@waha/core/engines/gows/grpc/gows'; import { optional, @@ -116,11 +123,14 @@ import { import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; import { CreateGroupRequest, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupSortField, Participant, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '@waha/structures/groups.dto'; import { ReplyToMessage } from '@waha/structures/message.dto'; @@ -247,6 +257,7 @@ enum WhatsMeowEvent { // Groups GROUP_INFO = 'events.GroupInfo', JOINED_GROUP = 'events.JoinedGroup', + GROUP_JOIN_REQUEST = 'gows.GroupJoinRequestEvent', // Labels LABEL_EDIT = 'events.LabelEdit', LABEL_ASSOCIATION_CHAT = 'events.LabelAssociationChat', @@ -753,6 +764,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { ); this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$); + const groupV2ParticipantsJoinRequest$ = all$.pipe( + onlyEvent(WhatsMeowEvent.GROUP_JOIN_REQUEST), + map(ToGroupV2ParticipantsJoinRequestEvents), + mergeMap((events) => events), + ); + this.events2 + .get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST) + .switch(groupV2ParticipantsJoinRequest$); + // Label Events // First, create streams for the raw label events const labelEditEvents$ = all$.pipe( @@ -1683,6 +1703,79 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { return; } + public async getMembershipApprovalMode( + id: string, + ): Promise { + const group = await this.getGroup(id); + return { + newMembersApprovalRequired: !!group.IsJoinApprovalRequired, + }; + } + + @Activity() + public async setMembershipApprovalMode( + id: string, + value: boolean, + ): Promise { + const req = new messages.JidBoolRequest({ + session: this.session, + jid: id, + value: value, + }); + await promisify(this.client.SetGroupJoinApprovalMode)(req); + return true; + } + + @Activity() + public async getGroupJoinRequests(id: string): Promise { + const req = new messages.JidRequest({ + session: this.session, + jid: id, + }); + const response = await promisify(this.client.GetGroupRequestParticipants)( + req, + ); + const requests: GOWSGroupParticipantRequest[] = parseJsonList(response); + return requests.map(ToGroupJoinRequest); + } + + @Activity() + public approveGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + const action = messages.ParticipantRequestAction.APPROVE; + return this.updateRequestParticipants(id, request.participants, action); + } + + @Activity() + public rejectGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + const action = messages.ParticipantRequestAction.REJECT; + return this.updateRequestParticipants(id, request.participants, action); + } + + private async updateRequestParticipants( + id: string, + participants: Array, + action: messages.ParticipantRequestAction, + ): Promise { + const jids = participants.map((p) => normalizeJid(toJID(p.id))); + const req = new messages.UpdateRequestParticipantsRequest({ + session: this.session, + jid: id, + participants: jids, + action: action, + }); + const response = await promisify( + this.client.UpdateGroupRequestParticipants, + )(req); + const results: GOWSGroupParticipant[] = parseJsonList(response); + return results.map(ToGroupJoinRequestResult); + } + public deleteGroup(id) { throw new NotImplementedByEngineError(); } diff --git a/src/core/engines/gows/types.group.ts b/src/core/engines/gows/types.group.ts index 995af55b..77542335 100644 --- a/src/core/engines/gows/types.group.ts +++ b/src/core/engines/gows/types.group.ts @@ -89,6 +89,7 @@ export interface GOWSGroupParticipant { PhoneNumber: JID; IsAdmin: boolean; IsSuperAdmin: boolean; + Error?: number; // set when the participant action failed } export interface GroupInfoFull { @@ -107,3 +108,24 @@ export interface JoinedGroupEvent extends GroupInfoFull { Reason: string; Type: string; } + +export interface GOWSGroupJoinRequest { + JID: JID; + PhoneNumber: JID; // empty string if not provided + RequestMethod: string; // invite_link | linked_group_join | non_admin_add +} + +export interface GroupJoinRequestEvent { + JID: JID; + Sender?: JID; + SenderPN?: JID; + Timestamp: string; + Action: string; // created | revoked + Requests: GOWSGroupJoinRequest[]; +} + +// GetGroupRequestParticipants RPC result item +export interface GOWSGroupParticipantRequest { + JID: JID; + RequestedAt: string; +} diff --git a/src/core/engines/noweb/groups.noweb.ts b/src/core/engines/noweb/groups.noweb.ts index 1f8bafa6..68b5ae81 100644 --- a/src/core/engines/noweb/groups.noweb.ts +++ b/src/core/engines/noweb/groups.noweb.ts @@ -7,13 +7,18 @@ import type { import { getGroupInviteLink } from '@waha/core/abc/session.abc'; import { GroupInfo, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupParticipantRole, + NormalizeJoinRequestMethod, } from '@waha/structures/groups.dto'; import { + GroupParticipantsJoinRequestAction, GroupParticipantType, GroupV2JoinEvent, GroupV2LeaveEvent, + GroupV2ParticipantsJoinRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; @@ -164,6 +169,89 @@ export function ToGroupV2UpdateEvent( }; } +// Raw node attrs from groupRequestParticipantsList +interface MembershipApprovalRequestAttrs { + jid?: string; + phone_number?: string; + request_method?: string; + request_time?: string; + t?: string; +} + +export function ToGroupJoinRequest( + attrs: MembershipApprovalRequestAttrs, +): GroupJoinRequest { + return { + requesterId: toCusFormat(attrs.jid), + requesterPn: toCusFormat(attrs.phone_number) || null, + addedById: null, + parentGroupId: null, + requestMethod: NormalizeJoinRequestMethod(attrs.request_method), + timestamp: Number(attrs.request_time || attrs.t) || 0, + }; +} + +export function ToGroupJoinRequestResult(result: { + status: string; + jid: string | undefined; +}): GroupJoinRequestResult { + const success = result.status === '200'; + let error: number | undefined; + if (!success) { + error = Number(result.status) || undefined; + } + return { + requesterId: toCusFormat(result.jid) || null, + success: success, + error: error, + }; +} + +interface GroupJoinRequestUpdate { + id: string; + author: string; + authorPn?: string; + participant: string; + participantPn?: string; + action: string; + method?: string; +} + +function ToGroupParticipantsJoinRequestAction( + action: string, +): GroupParticipantsJoinRequestAction | null { + switch (action) { + case 'created': + return GroupParticipantsJoinRequestAction.CREATED; + case 'rejected': + return GroupParticipantsJoinRequestAction.REJECTED; + case 'revoked': + return GroupParticipantsJoinRequestAction.REVOKED; + default: + return null; + } +} + +export function ToGroupV2ParticipantsJoinRequestEvent( + update: GroupJoinRequestUpdate, +): GroupV2ParticipantsJoinRequestEvent | null { + const action = ToGroupParticipantsJoinRequestAction(update.action); + if (!action) { + return null; + } + return { + group: { + id: toCusFormat(update.id), + }, + action: action, + requesterId: toCusFormat(update.participant), + requesterPn: toCusFormat(update.participantPn) || null, + requestMethod: NormalizeJoinRequestMethod(update.method), + timestamp: Date.now(), + _data: update, + }; +} + export function ToGroupV2LeaveEvent( me: Contact, update: GroupParticipantUpdate, diff --git a/src/core/engines/noweb/session.noweb.core.ts b/src/core/engines/noweb/session.noweb.core.ts index 1ebe41bd..7428ae7d 100644 --- a/src/core/engines/noweb/session.noweb.core.ts +++ b/src/core/engines/noweb/session.noweb.core.ts @@ -51,9 +51,12 @@ import { WhatsappSession, } from '@waha/core/abc/session.abc'; import { + ToGroupJoinRequest, + ToGroupJoinRequestResult, ToGroupParticipant, ToGroupV2JoinEvent, ToGroupV2LeaveEvent, + ToGroupV2ParticipantsJoinRequestEvent, ToGroupV2Participants, ToGroupV2UpdateEvent, } from '@waha/core/engines/noweb/groups.noweb'; @@ -154,9 +157,12 @@ import { import { BinaryFile, FileType, RemoteFile } from '@waha/structures/files.dto'; import { CreateGroupRequest, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '@waha/structures/groups.dto'; import { @@ -2086,6 +2092,59 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { return await this.sock.groupMemberAddMode(id, mode); } + public async getMembershipApprovalMode( + id: string, + ): Promise { + const group = await this.getGroup(id); + return { newMembersApprovalRequired: !!group.joinApprovalMode }; + } + + @Activity() + public async setMembershipApprovalMode( + id: string, + value: boolean, + ): Promise { + const mode = value ? 'on' : 'off'; + await this.sock.groupJoinApprovalMode(id, mode); + return true; + } + + @Activity() + public async getGroupJoinRequests(id: string): Promise { + const requests = await this.sock.groupRequestParticipantsList(id); + return requests.map(ToGroupJoinRequest); + } + + @Activity() + public async approveGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + return await this.updateGroupJoinRequests(id, request, 'approve'); + } + + @Activity() + public async rejectGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + return await this.updateGroupJoinRequests(id, request, 'reject'); + } + + private async updateGroupJoinRequests( + id: string, + request: ParticipantsRequest, + action: 'approve' | 'reject', + ): Promise { + const participants = request.participants.map(getId); + const results = await this.sock.groupRequestParticipantsUpdate( + id, + participants, + action, + ); + return results.map(ToGroupJoinRequestResult); + } + @Activity() public async leaveGroup(id) { return this.sock.groupLeave(id); @@ -2781,6 +2840,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { ); this.events2.get(WAHAEvents.GROUP_V2_LEAVE).switch(groupV2Leave$); + const groupV2ParticipantsJoinRequest$: Observable = fromEvent( + this.sock.ev, + 'group.join-request', + ).pipe( + map((event: any) => ToGroupV2ParticipantsJoinRequestEvent(event)), + filter(Boolean), + ); + this.events2 + .get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST) + .switch(groupV2ParticipantsJoinRequest$); + this.events2.get(WAHAEvents.PRESENCE_UPDATE).switch( fromEvent(this.sock.ev, 'presence.update').pipe( filter((presence: any) => this.jids.include(presence.id)), diff --git a/src/core/engines/webjs/groups.webjs.ts b/src/core/engines/webjs/groups.webjs.ts index 4152b64b..1f7d5054 100644 --- a/src/core/engines/webjs/groups.webjs.ts +++ b/src/core/engines/webjs/groups.webjs.ts @@ -2,21 +2,28 @@ import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore'; import { GroupId, GroupInfo, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupParticipantRole, + NormalizeJoinRequestMethod, } from '@waha/structures/groups.dto'; import { + GroupParticipantsJoinRequestAction, GroupParticipantType, GroupV2JoinEvent, GroupV2LeaveEvent, + GroupV2ParticipantsJoinRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; import { GroupChat, + GroupMembershipRequest as WEBJSGroupJoinRequest, 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 +43,68 @@ 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 ToGroupJoinRequest( + request: WEBJSGroupJoinRequest, +): GroupJoinRequest { + 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: NormalizeJoinRequestMethod(request.requestMethod), + timestamp: request.t, + }; +} + +export function ToGroupJoinRequestResults( + result: WEBJSMembershipRequestActionResult, +): GroupJoinRequestResult[] { + // whatsapp-web.js may report one result for multiple requesters - flatten to one entry per requester + let requesterIds: (string | null)[]; + if (Array.isArray(result.requesterId)) { + requesterIds = result.requesterId; + } else { + requesterIds = [result.requesterId ?? null]; + } + return requesterIds.map((requesterId) => ({ + requesterId: requesterId, + success: !result.error, + error: result.error, + })); +} + +export function ToGroupV2ParticipantsJoinRequestEvent( + notification: GroupNotification, +): GroupV2ParticipantsJoinRequestEvent | null { + if (!notification.chatId || !notification.author) { + return null; + } + return { + group: { + id: notification.chatId, + }, + action: GroupParticipantsJoinRequestAction.CREATED, + 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 1715015a..b743c114 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, + ToGroupJoinRequest, + ToGroupJoinRequestResults, ToGroupV2JoinEvent, ToGroupV2LeaveEvent, + ToGroupV2ParticipantsJoinRequestEvent, ToGroupV2ParticipantsEvent, ToGroupV2UpdateEvent, } from '@waha/core/engines/webjs/groups.webjs'; @@ -106,10 +110,13 @@ import { import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; import { CreateGroupRequest, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupSortField, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '@waha/structures/groups.dto'; import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto'; @@ -1691,6 +1698,101 @@ 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 getGroupJoinRequests(id: string): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const requests = await groupChat.getGroupMembershipRequests(); + return requests.map(ToGroupJoinRequest); + } + + @Activity() + public async approveGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const results = await groupChat.approveGroupMembershipRequests({ + requesterIds: request.participants.map((participant) => participant.id), + sleep: [250, 500], + }); + return results.flatMap(ToGroupJoinRequestResults); + } + + @Activity() + public async rejectGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat; + const results = await groupChat.rejectGroupMembershipRequests({ + requesterIds: request.participants.map((participant) => participant.id), + sleep: [250, 500], + }); + return results.flatMap(ToGroupJoinRequestResults); + } + public async getGroups(pagination: PaginationParams) { const chats = await this.whatsapp.getChats(); const groups = lodash.filter(chats, (chat) => chat.isGroup); @@ -2373,6 +2475,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { .get(WAHAEvents.GROUP_V2_PARTICIPANTS) .switch(groupV2Participants); + const groupV2ParticipantsJoinRequest$ = fromEvent( + this.whatsapp, + Events.GROUP_MEMBERSHIP_REQUEST, + ).pipe(map(ToGroupV2ParticipantsJoinRequestEvent), filter(Boolean)); + this.events2 + .get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST) + .switch(groupV2ParticipantsJoinRequest$); + const groupUpdate$ = fromEvent( this.whatsapp, Events.GROUP_UPDATE, diff --git a/src/core/engines/wpp/events.wpp.ts b/src/core/engines/wpp/events.wpp.ts index de2eaf93..0cbe7617 100644 --- a/src/core/engines/wpp/events.wpp.ts +++ b/src/core/engines/wpp/events.wpp.ts @@ -8,8 +8,10 @@ import { GroupParticipantRole, } from '@waha/structures/groups.dto'; import { + GroupParticipantsJoinRequestAction, GroupParticipantType, GroupV2LeaveEvent, + GroupV2ParticipantsJoinRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; @@ -203,6 +205,27 @@ export function WppGp2ToGroupV2Update(msg: any): GroupV2UpdateEvent { }; } +export function WppGp2ToGroupV2ParticipantsJoinRequest( + msg: any, +): GroupV2ParticipantsJoinRequestEvent | null { + // Same subtype whatsapp-web.js emits 'group_membership_request' for + if (msg.subtype !== 'membership_approval_request') { + return null; + } + if (!msg.chatId || !msg.author) { + return null; + } + return { + group: { + id: toCusFormat(msg.chatId), + }, + action: GroupParticipantsJoinRequestAction.CREATED, + requesterId: toCusFormat(msg.author), + timestamp: msg.timestamp, + _data: msg, + }; +} + export function WppUpdateLabelToAssociations( data: WppUpdateLabelEvent, ): LabelChatAssociation[] { diff --git a/src/core/engines/wpp/session.wpp.core.ts b/src/core/engines/wpp/session.wpp.core.ts index 1dab6ce3..7d1b0866 100644 --- a/src/core/engines/wpp/session.wpp.core.ts +++ b/src/core/engines/wpp/session.wpp.core.ts @@ -70,6 +70,7 @@ import { CallData } from '@waha/structures/calls.dto'; import { GroupV2JoinEvent, GroupV2LeaveEvent, + GroupV2ParticipantsJoinRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; @@ -110,6 +111,7 @@ import { } from '@wppconnect-team/wppconnect'; import type { Logger as WinstonLogger } from 'winston'; import { + WppGp2ToGroupV2ParticipantsJoinRequest, WppGp2ToGroupV2Update, WppParticipantsIsMyJoin, WppParticipantsIsMyLeave, @@ -138,6 +140,7 @@ import { getSessionNamespace } from '@waha/config'; import { killProcessesByPatterns } from '@waha/core/utils/processes'; import { DistinctAck } from '@waha/core/utils/reactive'; import { isJidGroup, toCusFormat } from '@waha/core/utils/jids'; +import { GetSerialized } from '@waha/core/utils/serialized'; import { WAMedia } from '@waha/structures/media.dto'; import * as lodash from 'lodash'; import { @@ -161,11 +164,15 @@ import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; import { CreateGroupRequest, GroupInfo, + GroupJoinRequest, + GroupJoinRequestResult, GroupParticipant, GroupParticipantRole, GroupSortField, + NormalizeJoinRequestMethod, ParticipantsRequest, SettingsMemberAddMode, + SettingsMembershipApproval, SettingsSecurityChangeInfo, } from '@waha/structures/groups.dto'; import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto'; @@ -1433,6 +1440,67 @@ export class WhatsappSessionWPPCore extends WhatsappSession { ); } + public async getMembershipApprovalMode( + id: string, + ): Promise { + const group = await this.wpp!.getChatById(this.ensureSuffix(id)); + return { + newMembersApprovalRequired: + !!group?.groupMetadata?.membershipApprovalMode, + }; + } + + @Activity() + public setMembershipApprovalMode( + id: string, + value: boolean, + ): Promise { + // Not in the GroupProperty enum, but wa-js passes the property through + return this.wpp!.setGroupProperty( + this.ensureSuffix(id), + 'membership_approval_mode' as GroupProperty, + value, + ); + } + + @Activity() + public async getGroupJoinRequests(id: string): Promise { + const requests = await this.wpp!.getGroupMembershipRequests( + this.ensureSuffix(id), + ); + return requests.map((request) => ({ + requesterId: GetSerialized(request.id), + addedById: GetSerialized(request.addedBy), + parentGroupId: GetSerialized(request.parentGroupId), + requestMethod: NormalizeJoinRequestMethod(request.requestMethod), + timestamp: request.t, + })); + } + + @Activity() + public async approveGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + const results = await this.wpp!.approveGroupMembershipRequest( + this.ensureSuffix(id), + request.participants.map((participant) => participant.id), + ); + return results.map(WppToGroupJoinRequestResult); + } + + @Activity() + public async rejectGroupJoinRequests( + id: string, + request: ParticipantsRequest, + ): Promise { + const results = await this.wpp!.rejectGroupMembershipRequest( + this.ensureSuffix(id), + request.participants.map((participant) => participant.id), + ); + return results.map(WppToGroupJoinRequestResult); + } + @Activity() public deleteGroup(id: string): Promise { return this.wpp!.deleteChat(this.ensureSuffix(id)); @@ -1927,6 +1995,20 @@ export class WhatsappSessionWPPCore extends WhatsappSession { ); this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$); + const groupV2ParticipantsJoinRequest$ = streams.onAnyMessage.pipe( + map((p) => p.data), + filter((msg) => msg?.type === MessageType.GP2), + distinct((msg) => msg?.id, interval(60_000)), + map( + (msg): GroupV2ParticipantsJoinRequestEvent => + WppGp2ToGroupV2ParticipantsJoinRequest(msg), + ), + filter(Boolean), + ); + this.events2 + .get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST) + .switch(groupV2ParticipantsJoinRequest$); + // // Calls // @@ -2738,6 +2820,17 @@ export class WhatsappSessionWPPCore extends WhatsappSession { } } +function WppToGroupJoinRequestResult(result: { + error: any; + wid: any; +}): GroupJoinRequestResult { + return { + requesterId: GetSerialized(result.wid), + success: !result.error, + error: Number(result.error) || undefined, + }; +} + function extractWppMessageId(response: unknown): string | null { if (!response || typeof response !== 'object') { return null; diff --git a/src/structures/enums.dto.ts b/src/structures/enums.dto.ts index 0d8287a8..495661bd 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_PARTICIPANTS_JOIN_REQUEST = 'group.v2.participants.join-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..c504c4d1 100644 --- a/src/structures/groups.dto.ts +++ b/src/structures/groups.dto.ts @@ -30,6 +30,102 @@ export class SettingsMemberAddMode { membersCanAddNewMember: boolean = true; } +export class SettingsMembershipApproval { + @IsBoolean() + newMembersApprovalRequired: boolean = false; +} + +export enum GroupJoinRequestMethod { + INVITE_LINK = 'invite_link', + LINKED_GROUP_JOIN = 'linked_group_join', + NON_ADMIN_ADD = 'non_admin_add', +} + +export function NormalizeJoinRequestMethod( + value: string | null | undefined, +): GroupJoinRequestMethod | null { + // Engines report the method in different formats + // NOWEB/GOWS use 'invite_link' + // WEBJS/WPP use 'InviteLink' + switch (value) { + case 'InviteLink': + case GroupJoinRequestMethod.INVITE_LINK: + return GroupJoinRequestMethod.INVITE_LINK; + case 'LinkedGroupJoin': + case GroupJoinRequestMethod.LINKED_GROUP_JOIN: + return GroupJoinRequestMethod.LINKED_GROUP_JOIN; + case 'NonAdminAdd': + case GroupJoinRequestMethod.NON_ADMIN_ADD: + return GroupJoinRequestMethod.NON_ADMIN_ADD; + default: + return null; + } +} + +export class GroupJoinRequest { + @ApiProperty({ + description: 'ID of the user requesting to join the group', + example: '123456789@c.us', + }) + requesterId: string; + + @ApiProperty({ + description: 'Phone number ID of the requester, if known', + example: '123456789@c.us', + nullable: true, + required: false, + }) + requesterPn?: string | null; + + @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 non_admin_add, invite_link, or linked_group_join', + example: 'invite_link', + nullable: true, + }) + requestMethod: string | null; + + @ApiProperty({ + description: 'Unix timestamp when the request was created', + example: 1666943582, + }) + timestamp: number; +} + +export class GroupJoinRequestResult { + @ApiProperty({ + description: 'ID of the participant the action was applied to', + example: '123456789@c.us', + nullable: true, + }) + requesterId: string | null; + + @ApiProperty({ + example: true, + }) + success: boolean; + + @ApiProperty({ + required: false, + example: 404, + }) + error?: number; +} + /** * Queries */ diff --git a/src/structures/groups.events.dto.ts b/src/structures/groups.events.dto.ts index 68db1fce..065ac947 100644 --- a/src/structures/groups.events.dto.ts +++ b/src/structures/groups.events.dto.ts @@ -69,3 +69,54 @@ export class GroupV2ParticipantsEvent { _data: any; } + +export enum GroupParticipantsJoinRequestAction { + // A user requested to join the group + CREATED = 'created', + // An admin rejected the request + REJECTED = 'rejected', + // The requester cancelled their own request + REVOKED = 'revoked', +} + +export class GroupV2ParticipantsJoinRequestEvent { + group: GroupId; + + @ApiProperty({ + description: 'What happened to the request to join the group', + enum: GroupParticipantsJoinRequestAction, + example: GroupParticipantsJoinRequestAction.CREATED, + }) + action: GroupParticipantsJoinRequestAction; + + @ApiProperty({ + description: 'ID of the user requesting to join the group', + example: '123456789@lid', + }) + requesterId: string; + + @ApiProperty({ + description: 'Phone number ID of the requester, if known', + example: '123456789@c.us', + nullable: true, + required: false, + }) + requesterPn?: string | null; + + @ApiProperty({ + description: + 'How the request was created, for example non_admin_add, invite_link, or linked_group_join', + example: 'invite_link', + nullable: true, + required: false, + }) + requestMethod?: string | null; + + @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..18ff8161 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, + GroupV2ParticipantsJoinRequestEvent, GroupV2ParticipantsEvent, GroupV2UpdateEvent, } from '@waha/structures/groups.events.dto'; @@ -43,3 +44,12 @@ export class WebhookGroupV2Participants extends WAHAWebhook { payload: GroupV2ParticipantsEvent; } + +export class WebhookGroupV2ParticipantsJoinRequest extends WAHAWebhook { + @ApiProperty({ + description: 'When a user requests to join a group', + }) + event = WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST; + + payload: GroupV2ParticipantsJoinRequestEvent; +} diff --git a/src/structures/webhooks.ts b/src/structures/webhooks.ts index 42673fbf..f71f0f4b 100644 --- a/src/structures/webhooks.ts +++ b/src/structures/webhooks.ts @@ -1,6 +1,7 @@ import { WebhookGroupV2Join, WebhookGroupV2Leave, + WebhookGroupV2ParticipantsJoinRequest, WebhookGroupV2Participants, WebhookGroupV2Update, } from '@waha/structures/groups.webhooks.dto'; @@ -45,6 +46,7 @@ const WAHA_WEBHOOKS = [ WebhookGroupV2Leave, WebhookGroupV2Update, WebhookGroupV2Participants, + WebhookGroupV2ParticipantsJoinRequest, WAHAWebhookPresenceUpdate, WAHAWebhookPollVote, WAHAWebhookPollVoteFailed,