diff --git a/examples/Groups/GroupChat.ts b/examples/Groups/GroupChat.ts index fed5ca5..7dec5c4 100644 --- a/examples/Groups/GroupChat.ts +++ b/examples/Groups/GroupChat.ts @@ -1,10 +1,12 @@ +import { GroupChatSessionJoinEvent, GroupChatEvent, UUID } from '../../lib'; +import { GroupChatClosedEvent } from '../../lib/events/GroupChatClosedEvent'; import { ExampleBot } from '../ExampleBot'; -import { UUID } from '../../lib/classes/UUID'; -import { GroupChatEvent } from '../../lib/events/GroupChatEvent'; class GroupChat extends ExampleBot { - private pings: {[key: string]: number} = {}; + private pings: { + [key: string]: number + } = {}; async onConnected(): Promise { @@ -15,9 +17,6 @@ class GroupChat extends ExampleBot // Start a group chat session - equivalent to opening a group chat but not sending a message await this.bot.clientCommands.comms.startGroupChatSession(groupID, ''); - // Send a group message - await this.bot.clientCommands.comms.sendGroupMessage(groupID, 'Test'); - const badGuyID = new UUID('1481561a-9113-46f8-9c02-9ac1bf005de7'); await this.bot.clientCommands.comms.moderateGroupChat(groupID, badGuyID, true, true); @@ -38,6 +37,23 @@ class GroupChat extends ExampleBot } }); + this.bot.clientEvents.onGroupChatSessionJoin.subscribe((event: GroupChatSessionJoinEvent) => + { + if (event.success) + { + console.log('We have joined a chat session! Group ID: ' + event.sessionID); + } + else + { + console.log('We have FAILED to join a chat session! Group ID: ' + event.sessionID); + } + }); + + this.bot.clientEvents.onGroupChatClosed.subscribe((event: GroupChatClosedEvent) => + { + console.log('Group chat session closed! Group ID: ' + event.groupID.toString()); + }); + // Actually, maybe we want to ban the chump. await this.bot.clientCommands.group.banMembers(groupID, [badGuyID]); await this.bot.clientCommands.group.ejectFromGroup(groupID, badGuyID); @@ -61,6 +77,15 @@ class GroupChat extends ExampleBot console.error(error); } } + else if (event.message === '!rejoin') + { + console.log('Leaving the session..'); + await this.bot.clientCommands.comms.endGroupChatSession(event.groupID); + console.log('Session terminated'); + console.log('Rejoining session'); + await this.bot.clientCommands.comms.startGroupChatSession(event.groupID, ''); + await this.bot.clientCommands.comms.sendGroupMessage(event.groupID, 'I am back!'); + } else if (event.from.toString() === this.bot.agentID().toString()) { if (event.message.substr(0, 5) === 'ping ') @@ -78,4 +103,10 @@ class GroupChat extends ExampleBot } } -new GroupChat().run().then(() => {}).catch((err: Error) => { console.error(err) }); +new GroupChat().run().then(() => +{ + +}).catch((err: Error) => +{ + console.error(err) +}); diff --git a/lib/classes/Agent.ts b/lib/classes/Agent.ts index 3f08f04..704a6c9 100644 --- a/lib/classes/Agent.ts +++ b/lib/classes/Agent.ts @@ -42,12 +42,13 @@ export class Agent regionAccess: string; agentAccess: string; currentRegion: Region; - chatSessions: { [key: string]: { - [key: string]: { - hasVoice: boolean, - isModerator: boolean - } - } } = {}; + chatSessions = new Map, + timeout?: Timer + }>(); controlFlags: ControlFlags = 0; openID: { 'token'?: string, @@ -96,6 +97,8 @@ export class Agent private clientEvents: ClientEvents; private animSubscription?: Subscription; + public onGroupChatExpired = new Subject(); + constructor(clientEvents: ClientEvents) { this.inventory = new Inventory(clientEvents, this); @@ -103,27 +106,51 @@ export class Agent this.clientEvents.onGroupChatAgentListUpdate.subscribe((event: GroupChatSessionAgentListEvent) => { const str = event.groupID.toString(); - if (this.chatSessions[str] === undefined) - { - this.chatSessions[str] = {}; - } const agent = event.agentID.toString(); + const session = this.chatSessions.get(str); + if (session === undefined) + { + return; + } + if (event.entered) { - this.chatSessions[str][agent] = { + if (session.agents === undefined) + { + session.agents = new Map(); + } + session.agents.set(agent, { hasVoice: event.canVoiceChat, isModerator: event.isModerator - } + }); } else { - delete this.chatSessions[str][agent]; + session.agents.delete(agent); } }); } + public updateLastMessage(groupID: UUID): void + { + const str = groupID.toString(); + const entry = this.chatSessions.get(str); + if (entry === undefined) + { + return; + } + if (entry.timeout !== undefined) + { + clearInterval(entry.timeout); + entry.timeout = setTimeout(this.groupChatExpired.bind(this, groupID), 900000); + } + } + setIsEstateManager(is: boolean): void { this.estateManager = is; @@ -132,29 +159,54 @@ export class Agent getSessionAgentCount(uuid: UUID): number { const str = uuid.toString(); - if (this.chatSessions[str] === undefined) + const session = this.chatSessions.get(str); + if (session === undefined) { return 0; } else { - return Object.keys(this.chatSessions[str]).length; + return Object.keys(session.agents).length; } } - addChatSession(uuid: UUID): void + addChatSession(uuid: UUID, timeout: boolean): boolean { const str = uuid.toString(); - if (this.chatSessions[str] === undefined) + if (this.chatSessions.has(str)) { - this.chatSessions[str] = {}; + return false; } + this.chatSessions.set(str, { + agents: new Map(), + timeout: timeout ? setTimeout(this.groupChatExpired.bind(this, uuid), 900000) : undefined + }); + return true; + } + + private groupChatExpired(groupID: UUID): void + { + this.onGroupChatExpired.next(groupID); } hasChatSession(uuid: UUID): boolean { const str = uuid.toString(); - return !(this.chatSessions[str] === undefined); + return this.chatSessions.has(str); + } + + deleteChatSession(uuid: UUID): boolean + { + const str = uuid.toString(); + if (!this.chatSessions.has(str)) + { + return false; + } + this.chatSessions.delete(str); + return true; } setCurrentRegion(region: Region): void diff --git a/lib/classes/ClientEvents.ts b/lib/classes/ClientEvents.ts index 980d4a7..06d0563 100644 --- a/lib/classes/ClientEvents.ts +++ b/lib/classes/ClientEvents.ts @@ -1,4 +1,5 @@ import { Subject } from 'rxjs'; +import { GroupChatClosedEvent } from '../events/GroupChatClosedEvent'; import { NewObjectEvent } from '../events/NewObjectEvent'; import { ObjectUpdatedEvent } from '../events/ObjectUpdatedEvent'; import { ObjectKilledEvent } from '../events/ObjectKilledEvent'; @@ -43,6 +44,7 @@ export class ClientEvents onDisconnected: Subject = new Subject(); onCircuitLatency: Subject = new Subject(); onGroupChat: Subject = new Subject(); + onGroupChatClosed: Subject = new Subject(); onGroupNotice: Subject = new Subject(); onGroupChatSessionJoin: Subject = new Subject(); onGroupChatAgentListUpdate: Subject = new Subject(); diff --git a/lib/classes/Comms.ts b/lib/classes/Comms.ts index 07e291a..933719e 100644 --- a/lib/classes/Comms.ts +++ b/lib/classes/Comms.ts @@ -1,3 +1,6 @@ +import { Subscription } from 'rxjs'; +import { GroupChatClosedEvent } from '../events/GroupChatClosedEvent'; +import { Agent } from './Agent'; import { Circuit } from './Circuit'; import { Packet } from './Packet'; import { Message } from '../enums/Message'; @@ -25,13 +28,11 @@ import { InventoryResponseEvent } from '../events/InventoryResponseEvent'; export class Comms { - private circuit: Circuit; - private clientEvents: ClientEvents; + private groupChatExpiredSub?: Subscription; - constructor(circuit: Circuit, clientEvents: ClientEvents) + constructor(public readonly circuit: Circuit, public readonly agent: Agent, public readonly clientEvents: ClientEvents) { - this.clientEvents = clientEvents; - this.circuit = circuit; + this.groupChatExpiredSub = this.agent.onGroupChatExpired.subscribe(this.groupChatExpired.bind(this)); this.circuit.subscribeToMessages([ Message.ImprovedInstantMessage, @@ -263,6 +264,14 @@ export class Comms this.clientEvents.onInstantMessage.next(imEvent); break; } + case InstantMessageDialog.SessionDrop: + { + const groupChatClosedEvent = new GroupChatClosedEvent(); + groupChatClosedEvent.groupID = im.MessageBlock.ID; + this.clientEvents.onGroupChatClosed.next(groupChatClosedEvent); + this.agent.deleteChatSession(groupChatClosedEvent.groupID); + break; + } case InstantMessageDialog.SessionSend: { const groupChatEvent = new GroupChatEvent(); @@ -270,6 +279,11 @@ export class Comms groupChatEvent.fromName = Utils.BufferToStringSimple(im.MessageBlock.FromAgentName); groupChatEvent.groupID = im.MessageBlock.ID; groupChatEvent.message = Utils.BufferToStringSimple(im.MessageBlock.Message); + if (!this.agent.hasChatSession(groupChatEvent.groupID)) + { + this.agent.addChatSession(groupChatEvent.groupID, true); + } + this.agent.updateLastMessage(groupChatEvent.groupID); this.clientEvents.onGroupChat.next(groupChatEvent); break; } @@ -335,6 +349,17 @@ export class Comms shutdown(): void { + if (this.groupChatExpiredSub !== undefined) + { + this.groupChatExpiredSub.unsubscribe(); + delete this.groupChatExpiredSub; + } + } + private async groupChatExpired(groupID: UUID): Promise + { + // Reconnect to group chat since it's been idle for 15 minutes + await this.agent.currentRegion.clientCommands.comms.endGroupChatSession(groupID, false); + await this.agent.currentRegion.clientCommands.comms.startGroupChatSession(groupID, ''); } } diff --git a/lib/classes/EventQueueClient.ts b/lib/classes/EventQueueClient.ts index ca89bbb..bf51f03 100644 --- a/lib/classes/EventQueueClient.ts +++ b/lib/classes/EventQueueClient.ts @@ -337,7 +337,11 @@ export class EventQueueClient if (gcsje.success) { gcsje.sessionID = new UUID(event['body']['session_id'].toString()); - this.agent.addChatSession(gcsje.sessionID); + const added = this.agent.addChatSession(gcsje.sessionID, true); + if (!added) + { + return; + } } this.clientEvents.onGroupChatSessionJoin.next(gcsje); } @@ -363,13 +367,13 @@ export class EventQueueClient }; this.caps.capsPostXML('ChatSessionRequest', requested).then((_ignore: unknown) => { - this.agent.addChatSession(groupChatEvent.groupID); - + this.agent.addChatSession(groupChatEvent.groupID, true); const gcsje = new GroupChatSessionJoinEvent(); gcsje.sessionID = groupChatEvent.groupID; gcsje.success = true; this.clientEvents.onGroupChatSessionJoin.next(gcsje); this.clientEvents.onGroupChat.next(groupChatEvent); + this.agent.updateLastMessage(groupChatEvent.groupID); }).catch((err) => { console.error(err); diff --git a/lib/classes/Region.ts b/lib/classes/Region.ts index 8b6d1ca..20e2b3a 100644 --- a/lib/classes/Region.ts +++ b/lib/classes/Region.ts @@ -338,7 +338,7 @@ export class Region { this.objects = new ObjectStoreFull(this.circuit, agent, clientEvents, options); } - this.comms = new Comms(this.circuit, clientEvents); + this.comms = new Comms(this.circuit, agent, clientEvents); this.parcelPropertiesSubscription = this.clientEvents.onParcelPropertiesEvent.subscribe(async(parcelProperties: ParcelPropertiesEvent) => { diff --git a/lib/classes/commands/CommunicationsCommands.ts b/lib/classes/commands/CommunicationsCommands.ts index 86448a9..05a4954 100644 --- a/lib/classes/commands/CommunicationsCommands.ts +++ b/lib/classes/commands/CommunicationsCommands.ts @@ -389,6 +389,48 @@ export class CommunicationsCommands extends CommandsBase }); } + public async endGroupChatSession(groupID: UUID | string, removeEntry = true): Promise + { + if (typeof groupID === 'string') + { + groupID = new UUID(groupID); + } + if (!this.agent.hasChatSession(groupID)) + { + throw new Error('Group session does not exist'); + } + 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.SessionDrop, + ID: groupID, + Timestamp: Math.floor(new Date().getTime() / 1000), + FromAgentName: Utils.StringToBuffer(agentName), + Message: Buffer.allocUnsafe(0), + BinaryBucket: Buffer.allocUnsafe(0) + }; + im.EstateBlock = { + EstateID: 0 + }; + if (removeEntry) + { + this.agent.deleteChatSession(groupID); + } + const sequenceNo = this.circuit.sendMessage(im, PacketFlags.Reliable); + return this.circuit.waitForAck(sequenceNo, 10000); + } + startGroupChatSession(groupID: UUID | string, message: string): Promise { return new Promise((resolve, reject) => @@ -397,54 +439,47 @@ export class CommunicationsCommands extends CommandsBase { groupID = new UUID(groupID); } - if (this.agent.hasChatSession(groupID)) - { - resolve(); - } - else - { - 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.SessionGroupStart, - 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 waitForJoin = this.currentRegion.clientEvents.onGroupChatSessionJoin.subscribe((event: GroupChatSessionJoinEvent) => - { - if (event.sessionID.toString() === groupID.toString()) - { - if (event.success) - { - waitForJoin.unsubscribe(); - resolve(); - } - else - { - reject(); - } + 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.SessionGroupStart, + 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 waitForJoin = this.currentRegion.clientEvents.onGroupChatSessionJoin.subscribe((event: GroupChatSessionJoinEvent) => + { + if (event.sessionID.toString() === groupID.toString()) + { + if (event.success) + { + waitForJoin.unsubscribe(); + resolve(); } - }); - circuit.sendMessage(im, PacketFlags.Reliable); - } + else + { + reject(); + } + } + }); + circuit.sendMessage(im, PacketFlags.Reliable); }); } diff --git a/lib/events/GroupChatClosedEvent.ts b/lib/events/GroupChatClosedEvent.ts new file mode 100644 index 0000000..c865428 --- /dev/null +++ b/lib/events/GroupChatClosedEvent.ts @@ -0,0 +1,6 @@ +import { UUID } from '../classes/UUID'; + +export class GroupChatClosedEvent +{ + groupID: UUID; +} diff --git a/package.json b/package.json index 51d77da..82d8f83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@caspertech/node-metaverse", - "version": "0.5.31", + "version": "0.5.32", "description": "A node.js interface for Second Life.", "main": "dist/lib/index.js", "types": "dist/lib/index.d.ts",