From cbafbf06134e728deccc06a6afc1a4a9a530b0dc Mon Sep 17 00:00:00 2001 From: Casper Warden <216465704+casperwardensl@users.noreply.github.com> Date: Sun, 5 Jan 2020 19:05:52 +0000 Subject: [PATCH] [Closes #11] Add moderateGroupChat function. Add ability to retrieve group ban list. --- example/testBot.js | 57 ++++++++- lib/classes/Agent.ts | 2 +- lib/classes/Caps.ts | 121 +++++++++++------- lib/classes/EventQueueClient.ts | 12 +- lib/classes/GroupBan.ts | 9 ++ lib/classes/Inventory.ts | 2 +- lib/classes/InventoryFolder.ts | 2 +- lib/classes/commands/AssetCommands.ts | 10 +- .../commands/CommunicationsCommands.ts | 100 +++++++++------ lib/classes/commands/GroupCommands.ts | 105 ++++++++------- lib/classes/commands/RegionCommands.ts | 4 +- package-lock.json | 8 +- package.json | 4 +- 13 files changed, 280 insertions(+), 156 deletions(-) create mode 100644 lib/classes/GroupBan.ts diff --git a/example/testBot.js b/example/testBot.js index 948ddb7..88c322d 100644 --- a/example/testBot.js +++ b/example/testBot.js @@ -309,7 +309,62 @@ async function connect() new nmv.Vector3([-1.0, 0, 0]), new nmv.Vector3([0.0, 1.0, 0])); - + + // Get group member list + try + { + const memberList = await bot.clientCommands.group.getMemberList('f0466f71-abf4-1559-db93-50352d13ae74'); + } + catch (error) + { + // Probably access denied + console.error(error); + } + + // Get group ban list + try + { + const banList = await bot.clientCommands.group.getBanList('f0466f71-abf4-1559-db93-50352d13ae74'); + } + catch (error) + { + // Probably access denied + console.error(error); + } + + + // Moderate group member + try + { + const groupKey = '4b35083d-b51a-a148-c400-6f1038a5589e'; + const avatarKey = '4300b952-d20e-4aa5-b3d6-d2e4d675880d'; + + // Note this will start a new group chat session if one does not already exist + await bot.clientCommands.comms.moderateGroupChat(groupKey, avatarKey, true, true); + + // Now, the group mute stuff is often pretty useless because an avatar can just leave the session and re-join. + // Let's enforce it a little better. + const groupChatSubscriber = bot.clientEvents.onGroupChatAgentListUpdate.subscribe((event) => + { + if (event.groupID.toString() === groupKey && event.agentID.toString() === avatarKey && event.entered) + { + bot.clientCommands.comms.moderateGroupChat(groupKey, avatarKey, true, true).then(() => + { + console.log('Re-enforced mute on ' + avatarKey); + }).catch((err) => + { + console.error(err); + }); + } + }); + + // Send a group message + await bot.clientCommands.comms.sendGroupMessage(groupKey, 'Test'); + } + catch (error) + { + console.error(error); + } //await bot.clientCommands.friends.grantFriendRights('d1cd5b71-6209-4595-9bf0-771bf689ce00', nmv.RightsFlags.CanModifyObjects | nmv.RightsFlags.CanSeeOnline | nmv.RightsFlags.CanSeeOnMap ); diff --git a/lib/classes/Agent.ts b/lib/classes/Agent.ts index 08218e6..2cea5ad 100644 --- a/lib/classes/Agent.ts +++ b/lib/classes/Agent.ts @@ -261,7 +261,7 @@ export class Agent requestFolder ] }; - this.currentRegion.caps.capsRequestXML('FetchInventoryDescendents2', requestedFolders).then((folderContents: any) => + this.currentRegion.caps.capsPostXML('FetchInventoryDescendents2', requestedFolders).then((folderContents: any) => { const currentOutfitFolderContents = folderContents['folders'][0]['items']; const wornObjects = this.currentRegion.objects.getObjectsByParent(this.localID); diff --git a/lib/classes/Caps.ts b/lib/classes/Caps.ts index 49a6c70..b70fc3d 100644 --- a/lib/classes/Caps.ts +++ b/lib/classes/Caps.ts @@ -301,47 +301,6 @@ export class Caps }); } - capsGetXML(capability: string): Promise - { - return new Promise((resolve, reject) => - { - this.getCapability(capability).then((capURL) => - { - this.requestGet(capURL).then((resp: ICapResponse) => - { - let result: any = null; - try - { - result = LLSD.LLSD.parseXML(resp.body); - } - catch (err) - { - if (resp.status === 201) - { - resolve({}); - } - else if (resp.status === 403) - { - reject(new Error('Access Denied')); - } - else - { - reject(err); - } - } - resolve(result); - }).catch((err) => - { - console.error(err); - reject(err); - }); - }).catch((err) => - { - reject(err); - }); - }); - } - private waitForCapTimeout(capName: string): Promise { return new Promise((resolve, reject) => @@ -376,7 +335,7 @@ export class Caps }); } - capsPerformXMLRequest(capURL: string, data: any): Promise + capsPerformXMLPost(capURL: string, data: any): Promise { return new Promise(async (resolve, reject) => { @@ -412,13 +371,43 @@ export class Caps }); } - async capsRequestXML(capability: string | [string, {[key: string]: string}], data: any, debug = false): Promise + capsPerformXMLGet(capURL: string): Promise { - if (debug) + return new Promise(async (resolve, reject) => { - console.log(data); - } + this.requestGet(capURL).then((resp: ICapResponse) => + { + let result: any = null; + try + { + result = LLSD.LLSD.parseXML(resp.body); + resolve(result); + } + catch (err) + { + if (resp.status === 201) + { + resolve({}); + } + else if (resp.status === 403) + { + reject(new Error('Access Denied')); + } + else + { + reject(err); + } + } + }).catch((err) => + { + console.error(err); + reject(err); + }); + }); + } + async capsGetXML(capability: string | [string, {[key: string]: string}]): Promise + { let capName = ''; let queryParams: {[key: string]: string} = {}; if (typeof capability === 'string') @@ -445,7 +434,45 @@ export class Caps } try { - return await this.capsPerformXMLRequest(capURL, data); + return await this.capsPerformXMLGet(capURL); + } + catch (error) + { + console.log('Error with cap ' + capName); + console.log(error); + throw error; + } + } + + async capsPostXML(capability: string | [string, {[key: string]: string}], data: any): Promise + { + let capName = ''; + let queryParams: {[key: string]: string} = {}; + if (typeof capability === 'string') + { + capName = capability; + } + else + { + capName = capability[0]; + queryParams = capability[1]; + } + + await this.waitForCapTimeout(capName); + + let capURL = await this.getCapability(capName); + if (Object.keys(queryParams).length > 0) + { + const parsedURL = url.parse(capURL, true); + for (const key of Object.keys(queryParams)) + { + parsedURL.query[key] = queryParams[key]; + } + capURL = url.format(parsedURL); + } + try + { + return await this.capsPerformXMLPost(capURL, data); } catch (error) { diff --git a/lib/classes/EventQueueClient.ts b/lib/classes/EventQueueClient.ts index 23b9b09..14ccad5 100644 --- a/lib/classes/EventQueueClient.ts +++ b/lib/classes/EventQueueClient.ts @@ -46,7 +46,7 @@ export class EventQueueClient 'ack': this.ack, 'done': true }; - this.capsRequestXML('EventQueueGet', req).then((data) => + this.capsPostXML('EventQueueGet', req).then((data) => { const state = new EventQueueStateChangeEvent(); state.active = false; @@ -60,7 +60,7 @@ export class EventQueueClient 'done': this.done }; const startTime = new Date().getTime(); - this.capsRequestXML('EventQueueGet', req).then((data) => + this.capsPostXML('EventQueueGet', req).then((data) => { if (data['id']) { @@ -278,11 +278,11 @@ export class EventQueueClient groupChatEvent.groupID = new UUID(messageParams['id'].toString()); groupChatEvent.message = messageParams['message']; - const requestedFolders = { + const requested = { 'method': 'accept invitation', 'session-id': imSessionID }; - this.caps.capsRequestXML('ChatSessionRequest', requestedFolders).then((ignore: any) => + this.caps.capsPostXML('ChatSessionRequest', requested).then((ignore: any) => { this.agent.addChatSession(groupChatEvent.groupID); @@ -456,7 +456,7 @@ export class EventQueueClient }); } - capsRequestXML(capability: string, data: any, attempt: number = 0): Promise + capsPostXML(capability: string, data: any, attempt: number = 0): Promise { return new Promise((resolve, reject) => { @@ -477,7 +477,7 @@ export class EventQueueClient // Retry caps request three times before giving up if (attempt < 3 && capability !== 'EventQueueGet') { - return this.capsRequestXML(capability, data, ++attempt); + return this.capsPostXML(capability, data, ++attempt); } else { diff --git a/lib/classes/GroupBan.ts b/lib/classes/GroupBan.ts new file mode 100644 index 0000000..4d00726 --- /dev/null +++ b/lib/classes/GroupBan.ts @@ -0,0 +1,9 @@ +import {UUID} from './UUID'; + +export class GroupBan +{ + constructor(public AgentID: UUID, public BanDate: Date) + { + + } +} diff --git a/lib/classes/Inventory.ts b/lib/classes/Inventory.ts index cb51d04..df5eaed 100644 --- a/lib/classes/Inventory.ts +++ b/lib/classes/Inventory.ts @@ -88,7 +88,7 @@ export class Inventory } ] }; - const response = await this.agent.currentRegion.caps.capsRequestXML('FetchInventory2', params); + const response = await this.agent.currentRegion.caps.capsPostXML('FetchInventory2', params); for (const receivedItem of response['items']) { const invItem = new InventoryItem(); diff --git a/lib/classes/InventoryFolder.ts b/lib/classes/InventoryFolder.ts index c500a2a..4fcd83d 100644 --- a/lib/classes/InventoryFolder.ts +++ b/lib/classes/InventoryFolder.ts @@ -187,7 +187,7 @@ export class InventoryFolder requestFolder ] }; - this.agent.currentRegion.caps.capsRequestXML('FetchInventoryDescendents2', requestedFolders).then((folderContents: any) => + this.agent.currentRegion.caps.capsPostXML('FetchInventoryDescendents2', requestedFolders).then((folderContents: any) => { if (folderContents['folders'] && folderContents['folders'][0] && folderContents['folders'][0]['items']) { diff --git a/lib/classes/commands/AssetCommands.ts b/lib/classes/commands/AssetCommands.ts index be70240..ba5a932 100644 --- a/lib/classes/commands/AssetCommands.ts +++ b/lib/classes/commands/AssetCommands.ts @@ -190,9 +190,9 @@ export class AssetCommands extends CommandsBase reject(error); return; } - const result = await this.currentRegion.caps.capsRequestXML('RenderMaterials', { + const result = await this.currentRegion.caps.capsPostXML('RenderMaterials', { 'Zipped': new LLSD.LLSD.asBinary(res.toString('base64')) - }, false); + }); const resultZipped = Buffer.from(result['Zipped'].octets); zlib.inflate(resultZipped, async (err: Error | null, reslt: Buffer) => @@ -359,14 +359,14 @@ export class AssetCommands extends CommandsBase 'group_mask': PermissionMask.All, 'next_owner_mask': PermissionMask.All }; - const result = await this.currentRegion.caps.capsRequestXML('NewFileAgentInventory', uploadMap); + const result = await this.currentRegion.caps.capsPostXML('NewFileAgentInventory', uploadMap); if (result['state'] === 'upload' && result['upload_price']) { const cost = result['upload_price']; if (await confirmCostCallback(cost)) { const uploader = result['uploader']; - const uploadResult = await this.currentRegion.caps.capsPerformXMLRequest(uploader, assetResources); + const uploadResult = await this.currentRegion.caps.capsPerformXMLPost(uploader, assetResources); if (uploadResult['new_inventory_item'] && uploadResult['new_asset']) { const inventoryItem = new UUID(uploadResult['new_inventory_item'].toString()); @@ -399,7 +399,7 @@ export class AssetCommands extends CommandsBase { if (this.agent && this.agent.inventory && this.agent.inventory.main && this.agent.inventory.main.root) { - this.currentRegion.caps.capsRequestXML('NewFileAgentInventory', { + this.currentRegion.caps.capsPostXML('NewFileAgentInventory', { 'folder_id': new LLSD.UUID(this.agent.inventory.main.root.toString()), 'asset_type': type, 'inventory_type': Utils.HTTPAssetTypeToInventoryType(type), diff --git a/lib/classes/commands/CommunicationsCommands.ts b/lib/classes/commands/CommunicationsCommands.ts index de92e72..639ea06 100644 --- a/lib/classes/commands/CommunicationsCommands.ts +++ b/lib/classes/commands/CommunicationsCommands.ts @@ -9,6 +9,7 @@ import {InstantMessageDialog} from '../../enums/InstantMessageDialog'; import Timer = NodeJS.Timer; import {GroupChatSessionJoinEvent, PacketFlags, ScriptDialogEvent} from '../..'; import {ScriptDialogReplyMessage} from '../messages/ScriptDialogReply'; +import * as LLSD from "@caspertech/llsd"; export class CommunicationsCommands extends CommandsBase { @@ -358,50 +359,67 @@ export class CommunicationsCommands extends CommandsBase }); } - sendGroupMessage(groupID: UUID | string, message: string): Promise + async moderateGroupChat(groupID: UUID | string, memberID: UUID | string, muteText: boolean, muteVoice: boolean) { - return new Promise((resolve, reject) => + if (typeof groupID === 'object') { - this.startGroupChatSession(groupID, message).then(() => - { - if (typeof groupID === 'string') - { - groupID = new UUID(groupID); + groupID = groupID.toString(); + } + if (typeof memberID === 'object') + { + memberID = memberID.toString(); + } + await this.startGroupChatSession(groupID, ''); + const requested = { + 'method': 'mute update', + 'params': { + 'agent_id': new LLSD.UUID(memberID), + 'mute_info': { + 'voice': muteVoice, + 'text': muteText } - const circuit = this.circuit; - const agentName = this.agent.firstName + ' ' + this.agent.lastName; - const im: ImprovedInstantMessageMessage = new ImprovedInstantMessageMessage(); - im.AgentData = { - AgentID: this.agent.agentID, - SessionID: circuit.sessionID - }; - im.MessageBlock = { - FromGroup: false, - ToAgentID: groupID, - ParentEstateID: 0, - RegionID: UUID.zero(), - Position: Vector3.getZero(), - Offline: 0, - Dialog: InstantMessageDialog.SessionSend, - ID: groupID, - Timestamp: Math.floor(new Date().getTime() / 1000), - FromAgentName: Utils.StringToBuffer(agentName), - Message: Utils.StringToBuffer(message), - BinaryBucket: Utils.StringToBuffer('') - }; - im.EstateBlock = { - EstateID: 0 - }; - const sequenceNo = circuit.sendMessage(im, PacketFlags.Reliable); - return this.circuit.waitForAck(sequenceNo, 10000); - }).then(() => - { - resolve(this.bot.clientCommands.group.getSessionAgentCount(groupID)) - }).catch((err) => - { - reject(err); - }); - }); + }, + 'session-id': new LLSD.UUID(groupID), + }; + return this.currentRegion.caps.capsPostXML('ChatSessionRequest', requested); + } + + async sendGroupMessage(groupID: UUID | string, message: string): Promise + { + await this.startGroupChatSession(groupID, message); + + if (typeof groupID === 'string') + { + groupID = new UUID(groupID); + } + const circuit = this.circuit; + const agentName = this.agent.firstName + ' ' + this.agent.lastName; + const im: ImprovedInstantMessageMessage = new ImprovedInstantMessageMessage(); + im.AgentData = { + AgentID: this.agent.agentID, + SessionID: circuit.sessionID + }; + im.MessageBlock = { + FromGroup: false, + ToAgentID: groupID, + ParentEstateID: 0, + RegionID: UUID.zero(), + Position: Vector3.getZero(), + Offline: 0, + Dialog: InstantMessageDialog.SessionSend, + ID: groupID, + Timestamp: Math.floor(new Date().getTime() / 1000), + FromAgentName: Utils.StringToBuffer(agentName), + Message: Utils.StringToBuffer(message), + BinaryBucket: Utils.StringToBuffer('') + }; + im.EstateBlock = { + EstateID: 0 + }; + const sequenceNo = circuit.sendMessage(im, PacketFlags.Reliable); + await this.circuit.waitForAck(sequenceNo, 10000); + + return this.bot.clientCommands.group.getSessionAgentCount(groupID); } respondToScriptDialog(event: ScriptDialogEvent, buttonIndex: number): Promise diff --git a/lib/classes/commands/GroupCommands.ts b/lib/classes/commands/GroupCommands.ts index a21032f..0e23648 100644 --- a/lib/classes/commands/GroupCommands.ts +++ b/lib/classes/commands/GroupCommands.ts @@ -18,6 +18,7 @@ import { EjectGroupMemberRequestMessage } from '../messages/EjectGroupMemberRequ import { GroupProfileRequestMessage } from '../messages/GroupProfileRequest'; import { GroupProfileReplyMessage } from '../messages/GroupProfileReply'; import { GroupBanAction } from '../../enums/GroupBanAction'; +import { GroupBan } from '../GroupBan'; export class GroupCommands extends CommandsBase { @@ -183,7 +184,10 @@ export class GroupCommands extends CommandsBase async banMembers(groupID: UUID | string, avatars: UUID | string | string[] | UUID[], groupAction: GroupBanAction = GroupBanAction.Ban) { const listOfIDs: string[] = []; - + if (typeof groupID === 'string') + { + groupID = new UUID(groupID); + } if (Array.isArray(avatars)) { for (const av of avatars) @@ -216,59 +220,70 @@ export class GroupCommands extends CommandsBase requestData.ban_ids.push(new LLSD.UUID(id)); } - await this.currentRegion.caps.capsRequestXML(['GroupAPIv1', {'group_id': groupID.toString()}], requestData); + await this.currentRegion.caps.capsPostXML(['GroupAPIv1', {'group_id': groupID.toString()}], requestData); } - getMemberList(groupID: UUID | string): Promise + async getBanList(groupID: UUID | string): Promise { - return new Promise((resolve, reject) => + if (typeof groupID === 'string') { - if (typeof groupID === 'string') + groupID = new UUID(groupID); + } + const result = await this.currentRegion.caps.capsGetXML(['GroupAPIv1', {'group_id': groupID.toString()}]); + const bans: GroupBan[] = []; + if (result.ban_list !== undefined) + { + for (const k of Object.keys(result.ban_list)) { - groupID = new UUID(groupID); + bans.push(new GroupBan(new UUID(k), result.ban_list[k].ban_date)); } - const result: GroupMember[] = []; - const requestData = { - 'group_id': new LLSD.UUID(groupID.toString()) - }; - this.currentRegion.caps.capsRequestXML('GroupMemberData', requestData).then((response: any) => - { - if (response['members']) - { - Object.keys(response['members']).forEach((uuid) => - { - const member = new GroupMember(); - const data = response['members'][uuid]; - member.AgentID = new UUID(uuid); - member.OnlineStatus = data['last_login']; - let powers = response['defaults']['default_powers']; - if (data['powers']) - { - powers = data['powers']; - } - member.IsOwner = data['owner'] === 'Y'; + } + return bans; + } - let titleIndex = 0; - if (data['title']) - { - titleIndex = data['title']; - } - member.Title = response['titles'][titleIndex]; - member.AgentPowers = Utils.HexToLong(powers); + async getMemberList(groupID: UUID | string): Promise + { + if (typeof groupID === 'string') + { + groupID = new UUID(groupID); + } + const result: GroupMember[] = []; + const requestData = { + 'group_id': new LLSD.UUID(groupID.toString()) + }; - result.push(member); - }); - resolve(result); - } - else - { - reject(new Error('Bad response')); - } - }).catch((err) => + const response: any = await this.currentRegion.caps.capsPostXML('GroupMemberData', requestData); + if (response['members']) + { + for (const uuid of Object.keys(response['members'])) { - reject(err); - }); - }); + const member = new GroupMember(); + const data = response['members'][uuid]; + member.AgentID = new UUID(uuid); + member.OnlineStatus = data['last_login']; + let powers = response['defaults']['default_powers']; + if (data['powers']) + { + powers = data['powers']; + } + member.IsOwner = data['owner'] === 'Y'; + + let titleIndex = 0; + if (data['title']) + { + titleIndex = data['title']; + } + member.Title = response['titles'][titleIndex]; + member.AgentPowers = Utils.HexToLong(powers); + + result.push(member); + } + return result; + } + else + { + throw new Error('Bad response'); + } } getGroupRoles(groupID: UUID | string): Promise diff --git a/lib/classes/commands/RegionCommands.ts b/lib/classes/commands/RegionCommands.ts index a3c96e1..1e90ab1 100644 --- a/lib/classes/commands/RegionCommands.ts +++ b/lib/classes/commands/RegionCommands.ts @@ -724,7 +724,7 @@ export class RegionCommands extends CommandsBase const that = this; const getCosts = async function(objIDs: UUID[]) { - const result = await that.currentRegion.caps.capsRequestXML('GetObjectCost', { + const result = await that.currentRegion.caps.capsPostXML('GetObjectCost', { 'object_ids': objIDs }); const uuids = Object.keys(result); @@ -1229,7 +1229,7 @@ export class RegionCommands extends CommandsBase { objRef[obj.FullID.toString()] = obj; } - const result = await this.currentRegion.caps.capsRequestXML('GetObjectCost', { + const result = await this.currentRegion.caps.capsPostXML('GetObjectCost', { 'object_ids': uuidList }); for (const u of Object.keys(result)) diff --git a/package-lock.json b/package-lock.json index 172a52d..06ad98c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "@caspertech/node-metaverse", - "version": "0.5.11", + "version": "0.5.12", "lockfileVersion": 1, "requires": true, "dependencies": { "@caspertech/llsd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@caspertech/llsd/-/llsd-1.0.1.tgz", - "integrity": "sha512-/t8fwyir/Us6QlW7UP4qCWruMtQ0acPBWrueA3WTidiXpg3Vy/USwctt4ZXrRXmDF3XP1gF6s3hL844pq37OOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@caspertech/llsd/-/llsd-1.0.2.tgz", + "integrity": "sha512-sVgsfk3x6cp/lXG9wdvQqIxKNYI2YqicQNw1TamfghRKwZV50w7pfZlG8pCJLKPKhYwSxSr0e32zn6H0L15k8g==", "requires": { "abab": "^1.0.4", "xmldom": "^0.1.27" diff --git a/package.json b/package.json index dab7064..1c611ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@caspertech/node-metaverse", - "version": "0.5.11", + "version": "0.5.12", "description": "A node.js interface for Second Life.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -31,7 +31,7 @@ "typescript": "^3.6.3" }, "dependencies": { - "@caspertech/llsd": "^1.0.1", + "@caspertech/llsd": "^1.0.2", "@types/long": "^4.0.0", "@types/micromatch": "^3.1.0", "@types/mocha": "^5.2.5",