From 2fcbab9a3728351a1e66b4fe96eeb2c4833447be Mon Sep 17 00:00:00 2001 From: RaidMax Date: Sat, 3 Jun 2023 16:48:03 -0500 Subject: [PATCH] implement initial url request functionality for game interface --- Application/IW4MServer.cs | 1 - GameFiles/GameInterface/_integration_base.gsc | 127 +++++++++------- GameFiles/GameInterface/_integration_iw4x.gsc | 2 +- .../GameInterface/_integration_shared.gsc | 135 ++++++++++++++++++ IW4MAdmin.sln | 6 + Plugins/ScriptPlugins/GameInterface.js | 100 +++++++++++-- 6 files changed, 305 insertions(+), 66 deletions(-) diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs index 7c9b79dd3..bbde0c416 100644 --- a/Application/IW4MServer.cs +++ b/Application/IW4MServer.cs @@ -377,7 +377,6 @@ namespace IW4MAdmin if (E.Origin.State != ClientState.Connected) { E.Origin.State = ClientState.Connected; - E.Origin.LastConnection = DateTime.UtcNow; E.Origin.Connections += 1; ChatHistory.Add(new ChatInfo() diff --git a/GameFiles/GameInterface/_integration_base.gsc b/GameFiles/GameInterface/_integration_base.gsc index c170bf183..e918758ff 100644 --- a/GameFiles/GameInterface/_integration_base.gsc +++ b/GameFiles/GameInterface/_integration_base.gsc @@ -37,7 +37,7 @@ Setup() level.clientDataKey = "clientData"; level.eventTypes = spawnstruct(); - level.eventTypes.localClientEvent = "client_event"; + level.eventTypes.eventAvailable = "EventAvailable"; level.eventTypes.clientDataReceived = "ClientDataReceived"; level.eventTypes.clientDataRequested = "ClientDataRequested"; level.eventTypes.setClientDataRequested = "SetClientDataRequested"; @@ -70,6 +70,9 @@ Setup() _SetDvarIfUninitialized( level.eventBus.outVar, "" ); _SetDvarIfUninitialized( level.commonKeys.enabled, 1 ); _SetDvarIfUninitialized( "sv_iw4madmin_integration_debug", 0 ); + _SetDvarIfUninitialized( "GroupSeparatorChar", "" ); + _SetDvarIfUninitialized( "RecordSeparatorChar", "" ); + _SetDvarIfUninitialized( "UnitSeparatorChar", "" ); if ( GetDvarInt( level.commonKeys.enabled ) != 1 ) { @@ -77,35 +80,44 @@ Setup() } // start long running tasks - thread MonitorClientEvents(); + thread MonitorEvents(); thread MonitorBus(); } -////////////////////////////////// -// Client Methods -////////////////////////////////// - -MonitorClientEvents() +MonitorEvents() { level endon( level.eventTypes.gameEnd ); for ( ;; ) { - level waittill( level.eventTypes.localClientEvent, client ); + level waittill( level.eventTypes.eventAvailable, event ); - LogDebug( "Processing Event " + client.event.type + "-" + client.event.subtype ); + LogDebug( "Processing Event " + event.type + "-" + event.subtype ); - eventHandler = level.eventCallbacks[client.event.type]; + eventHandler = level.eventCallbacks[event.type]; if ( IsDefined( eventHandler ) ) { - client [[eventHandler]]( client.event ); - LogDebug( "notify client for " + client.event.type ); - client notify( level.eventTypes.localClientEvent, client.event ); - client notify( client.event.type, client.event ); + if ( IsDefined( event.entity ) ) + { + event.entity [[eventHandler]]( event ); + } + else + { + [[eventHandler]]( event ); + } + } + + if ( IsDefined( event.entity ) ) + { + LogDebug( "Notify client for " + event.type ); + event.entity notify( event.type, event ); + } + else + { + LogDebug( "Notify level for " + event.type ); + level notify( event.type, event ); } - - client.eventData = []; } } @@ -163,7 +175,7 @@ _Log( LogLevel, message ) { for( i = 0; i < level.logger._logger.size; i++ ) { - [[level.logger._logger[i]]]( LogLevel, message ); + [[level.logger._logger[i]]]( LogLevel, GetSubStr( message, 0, 1000 ) ); } } @@ -246,18 +258,20 @@ DecrementClientMeta( metaKey, decrementValue, clientId ) SetClientMeta( metaKey, metaValue, clientId, direction ) { - data = "key=" + metaKey + "|value=" + metaValue; + data = []; + data["key"] = metaKey; + data["value"] = metaValue; clientNumber = -1; if ( IsDefined ( clientId ) ) { - data = data + "|clientId=" + clientId; + data["clientId"] = clientId; clientNumber = -1; } if ( IsDefined( direction ) ) { - data = data + "|direction=" + direction; + data["direction"] = direction; } if ( IsPlayer( self ) ) @@ -292,9 +306,12 @@ BuildEventRequest( responseExpected, eventType, eventSubtype, entOrId, data ) { request = "1"; } - - request = request + ";" + eventType + ";" + eventSubtype + ";" + entOrId + ";" + data; - return request; + + data = BuildDataString( data ); + groupSeparator = GetSubStr( GetDvar( "GroupSeparatorChar" ), 0, 1 ); + request = request + groupSeparator + eventType + groupSeparator + eventSubtype + groupSeparator + entOrId + groupSeparator + data; + +eturn request; } MonitorBus() @@ -319,7 +336,8 @@ MonitorBus() } LogDebug( "-> " + eventString ); - NotifyClientEvent( strtok( eventString, ";" ) ); + groupSeparator = GetSubStr( GetDvar( "GroupSeparatorChar" ), 0, 1 ); + NotifyEvent( strtok( eventString, groupSeparator ) ); SetDvar( level.eventBus.outVar, "" ); } @@ -361,7 +379,7 @@ QueueEvent( request, eventType, notifyEntity ) return; } - LogDebug("<- " + request ); + LogDebug( "<- " + request ); SetDvar( level.eventBus.inVar, request ); } @@ -374,13 +392,13 @@ ParseDataString( data ) return []; } - dataParts = strtok( data, "|" ); + dataParts = strtok( data, GetSubStr( GetDvar( "RecordSeparatorChar" ), 0, 1 ) ); dict = []; for ( i = 0; i < dataParts.size; i++ ) { part = dataParts[i]; - splitPart = strtok( part, "=" ); + splitPart = strtok( part, GetSubStr( GetDvar( "UnitSeparatorChar" ), 0, 1 ) ); key = splitPart[0]; value = splitPart[1]; dict[key] = value; @@ -390,6 +408,26 @@ ParseDataString( data ) return dict; } +BuildDataString( data ) +{ + if ( IsString( data ) ) + { + return data; + } + + dataString = ""; + keys = GetArrayKeys( data ); + unitSeparator = GetSubStr( GetDvar( "UnitSeparatorChar" ), 0, 1 ); + recordSeparator = GetSubStr( GetDvar( "RecordSeparatorChar" ), 0, 1 ); + + for ( i = 0; i < keys.size; i++ ) + { + dataString = dataString + keys[i] + unitSeparator + data[keys[i]] + recordSeparator; + } + + return dataString; +} + NotifyClientEventTimeout( eventType ) { // todo: make this actual eventing @@ -399,7 +437,7 @@ NotifyClientEventTimeout( eventType ) } } -NotifyClientEvent( eventInfo ) +NotifyEvent( eventInfo ) { origin = [[level.overrideMethods[level.commonFunctions.getPlayerFromClientNum]]]( int( eventInfo[3] ) ); target = [[level.overrideMethods[level.commonFunctions.getPlayerFromClientNum]]]( int( eventInfo[4] ) ); @@ -407,15 +445,10 @@ NotifyClientEvent( eventInfo ) event = spawnstruct(); event.type = eventInfo[1]; event.subtype = eventInfo[2]; - event.data = eventInfo[5]; + event.data = ParseDataString( eventInfo[5] ); event.origin = origin; event.target = target; - if ( IsDefined( event.data ) ) - { - LogDebug( "NotifyClientEvent->" + event.data ); - } - if ( int( eventInfo[3] ) != -1 && !IsDefined( origin ) ) { LogDebug( "origin is null but the slot id is " + int( eventInfo[3] ) ); @@ -425,23 +458,15 @@ NotifyClientEvent( eventInfo ) LogDebug( "target is null but the slot id is " + int( eventInfo[4] ) ); } - if ( IsDefined( target ) ) + client = event.origin; + + if ( !IsDefined( client ) ) { client = event.target; } - else if ( IsDefined( origin ) ) - { - client = event.origin; - } - else - { - LogDebug( "Neither origin or target are set but we are a Client Event, aborting" ); - - return; - } - - client.event = event; - level notify( level.eventTypes.localClientEvent, client ); + + event.entity = client; + level notify( level.eventTypes.eventAvailable, event ); } AddClientCommand( commandName, shouldRunAsTarget, callback, shouldOverwrite ) @@ -461,7 +486,6 @@ AddClientCommand( commandName, shouldRunAsTarget, callback, shouldOverwrite ) OnClientDataReceived( event ) { - event.data = ParseDataString( event.data ); clientData = self.pers[level.clientDataKey]; if ( event.subtype == "Fail" ) @@ -497,7 +521,7 @@ OnClientDataReceived( event ) OnExecuteCommand( event ) { - data = ParseDataString( event.data ); + data = event.data; response = ""; command = level.clientCommandCallbacks[event.subtype]; @@ -527,8 +551,7 @@ OnExecuteCommand( event ) OnSetClientDataCompleted( event ) { - data = ParseDataString( event.data ); - LogDebug( "Set Client Data -> subtype = " + CoerceUndefined( event.subType ) + ", status = " + CoerceUndefined( data["status"] ) ); + LogDebug( "Set Client Data -> subtype = " + CoerceUndefined( event.subType ) + ", status = " + CoerceUndefined( event.data["status"] ) ); } CoerceUndefined( object ) diff --git a/GameFiles/GameInterface/_integration_iw4x.gsc b/GameFiles/GameInterface/_integration_iw4x.gsc index 71f9454ef..097b4ca72 100644 --- a/GameFiles/GameInterface/_integration_iw4x.gsc +++ b/GameFiles/GameInterface/_integration_iw4x.gsc @@ -85,7 +85,7 @@ WaitForClientEvents() for ( ;; ) { - self waittill( level.eventTypes.localClientEvent, event ); + self waittill( level.eventTypes.eventAvailable, event ); scripts\_integration_base::LogDebug( "Received client event " + event.type ); diff --git a/GameFiles/GameInterface/_integration_shared.gsc b/GameFiles/GameInterface/_integration_shared.gsc index 21d7b01f1..54f300c97 100644 --- a/GameFiles/GameInterface/_integration_shared.gsc +++ b/GameFiles/GameInterface/_integration_shared.gsc @@ -45,8 +45,14 @@ Setup() level.eventTypes.joinSpec = "joined_spectators"; level.eventTypes.spawned = "spawned_player"; level.eventTypes.gameEnd = "game_ended"; + + level.eventTypes.urlRequested = "UrlRequested"; + level.eventTypes.urlRequestCompleted = "UrlRequestCompleted"; + + level.eventCallbacks[level.eventTypes.urlRequestCompleted] = ::OnUrlRequestCompletedCallback; level.iw4madminIntegrationDefaultPerformance = 200; + level.notifyEntities = []; level notify( level.notifyTypes.sharedFunctionsInitialized ); level waittill( level.notifyTypes.gameFunctionsInitialized ); @@ -185,6 +191,135 @@ SaveTrackingMetrics() scripts\_integration_base::IncrementClientMeta( "TotalShotsFired", change, self.persistentClientId ); } +// #region web requests + +RequestUrlObject( request ) +{ + return RequestUrl( request.url, request.method, request.body, request.headers, request ); +} + +RequestUrl( url, method, body, headers, webNotify ) +{ + if ( !IsDefined( webNotify ) ) + { + webNotify = SpawnStruct(); + webNotify.url = url; + webNotify.method = method; + webNotify.body = body; + webNotify.headers = headers; + } + + webNotify.index = GetNextNotifyEntity(); + + scripts\_integration_base::LogDebug( "next notify index is " + webNotify.index ); + level.notifyEntities[webNotify.index] = webNotify; + + data = []; + data["url"] = webNotify.url; + data["entity"] = webNotify.index; + + if ( IsDefined( method ) ) + { + data["method"] = method; + } + + if ( IsDefined( body ) ) + { + data["body"] = body; + } + + if ( IsDefined( headers ) ) + { + headerString = ""; + + keys = GetArrayKeys( headers ); + for ( i = 0; i < keys.size; i++ ) + { + headerString = headerString + keys[i] + ":" + headers[keys[i]] + ","; + } + + data["headers"] = headerString; + } + + webNotifyEvent = scripts\_integration_base::BuildEventRequest( true, level.eventTypes.urlRequested, "", webNotify.index, data ); + thread scripts\_integration_base::QueueEvent( webNotifyEvent, level.eventTypes.urlRequested, webNotify ); + webNotify thread WaitForUrlRequestComplete(); + + return webNotify; +} + +WaitForUrlRequestComplete() +{ + level endon( level.eventTypes.gameEnd ); + + timeoutResult = self [[level.overrideMethods[level.commonFunctions.waitTillAnyTimeout]]]( 30, level.eventTypes.urlRequestCompleted ); + + if ( timeoutResult == level.eventBus.timeoutKey ) + { + scripts\_integration_base::LogWarning( "Request to " + self.url + " timed out" ); + self notify ( level.eventTypes.urlRequestCompleted, "error" ); + } + + scripts\_integration_base::LogDebug( "Request to " + self.url + " completed" ); + + //self delete(); + level.notifyEntities[self.index] = undefined; +} + +OnUrlRequestCompletedCallback( event ) +{ + if ( !IsDefined( event ) || !IsDefined( event.data ) ) + { + scripts\_integration_base::LogWarning( "Incomplete data for url request callback. [1]" ); + return; + } + + notifyEnt = event.data["entity"]; + response = event.data["response"]; + + if ( !IsDefined( notifyEnt ) || !IsDefined( response ) ) + { + scripts\_integration_base::LogWarning( "Incomplete data for url request callback. [2] " + scripts\_integration_base::CoerceUndefined( notifyEnt ) + " , " + scripts\_integration_base::CoerceUndefined( response ) ); + return; + } + + webNotify = level.notifyEntities[int( notifyEnt )]; + + if ( !IsDefined( webNotify.response ) ) + { + webNotify.response = response; + } + else + { + webNotify.response = webNotify.response + response; + } + + if ( int( event.data["remaining"] ) != 0 ) + { + scripts\_integration_base::LogDebug( "Additional data available for url request " + notifyEnt + " (" + event.data["remaining"] + " chunks remaining)" ); + return; + } + + scripts\_integration_base::LogDebug( "Notifying " + notifyEnt + " that url request completed" ); + webNotify notify( level.eventTypes.urlRequestCompleted, webNotify.response ); +} + +GetNextNotifyEntity() +{ + max = level.notifyEntities.size + 1; + + for ( i = 0; i < max; i++ ) + { + if ( !IsDefined( level.notifyEntities[i] ) ) + { + return i; + } + } +} + + +// #end region + // #region team balance OnPlayerDisconnect() diff --git a/IW4MAdmin.sln b/IW4MAdmin.sln index 7d0cae409..b16c20b2c 100644 --- a/IW4MAdmin.sln +++ b/IW4MAdmin.sln @@ -72,6 +72,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mute", "Plugins\Mute\Mute.csproj", "{259824F3-D860-4233-91D6-FF73D4DD8B18}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GameFiles", "GameFiles", "{6CBF412C-EFEE-45F7-80FD-AC402C22CDB9}" + ProjectSection(SolutionItems) = preProject + GameFiles\deploy.bat = GameFiles\deploy.bat + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GameInterface", "GameInterface", "{5C2BE2A8-EA1D-424F-88E1-7FC33EEC2E55}" ProjectSection(SolutionItems) = preProject @@ -80,6 +83,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GameInterface", "GameInterf GameFiles\GameInterface\_integration_iw5.gsc = GameFiles\GameInterface\_integration_iw5.gsc GameFiles\GameInterface\_integration_shared.gsc = GameFiles\GameInterface\_integration_shared.gsc GameFiles\GameInterface\_integration_t5.gsc = GameFiles\GameInterface\_integration_t5.gsc + GameFiles\GameInterface\_integration_t5zm.gsc = GameFiles\GameInterface\_integration_t5zm.gsc + GameFiles\GameInterface\_integration_t6.gsc = GameFiles\GameInterface\_integration_t6.gsc + GameFiles\GameInterface\_integration_t6zm_helper.gsc = GameFiles\GameInterface\_integration_t6zm_helper.gsc EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AntiCheat", "AntiCheat", "{AB83BAC0-C539-424A-BF00-78487C10753C}" diff --git a/Plugins/ScriptPlugins/GameInterface.js b/Plugins/ScriptPlugins/GameInterface.js index 2c56c0002..e3c758286 100644 --- a/Plugins/ScriptPlugins/GameInterface.js +++ b/Plugins/ScriptPlugins/GameInterface.js @@ -3,33 +3,38 @@ const inDvar = 'sv_iw4madmin_in'; const outDvar = 'sv_iw4madmin_out'; const integrationEnabledDvar = 'sv_iw4madmin_integration_enabled'; const pollingRate = 300; +const groupSeparatorChar = '\x1d'; +const recordSeparatorChar = '\x1e'; +const unitSeparatorChar = '\x1f'; -const init = (registerNotify, serviceResolver, config) => { +const init = (registerNotify, serviceResolver, config, scriptHelper) => { registerNotify('IManagementEventSubscriptions.ClientStateInitialized', (clientEvent, _) => plugin.onClientEnteredMatch(clientEvent)); registerNotify('IGameServerEventSubscriptions.ServerValueReceived', (serverValueEvent, _) => plugin.onServerValueReceived(serverValueEvent)); registerNotify('IGameServerEventSubscriptions.ServerValueSetCompleted', (serverValueEvent, _) => plugin.onServerValueSetCompleted(serverValueEvent)); registerNotify('IGameServerEventSubscriptions.MonitoringStarted', (monitorStartEvent, _) => plugin.onServerMonitoringStart(monitorStartEvent)); registerNotify('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => plugin.onPenalty(penaltyEvent)); - plugin.onLoad(serviceResolver, config); + plugin.onLoad(serviceResolver, config, scriptHelper); return plugin; }; const plugin = { author: 'RaidMax', - version: '2.0', + version: '2.1', name: 'Game Interface', serviceResolver: null, eventManager: null, logger: null, commands: null, + scriptHelper: null, - onLoad: function (serviceResolver, config) { + onLoad: function (serviceResolver, config, scriptHelper) { this.serviceResolver = serviceResolver; this.eventManager = serviceResolver.resolveService('IManager'); this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); this.commands = commands; this.config = config; + this.scriptHelper = scriptHelper; }, onClientEnteredMatch: function (clientEvent) { @@ -96,6 +101,10 @@ const plugin = { // loop restarts this.requestGetDvar(inDvar, serverValueEvent.server); }, + + onServerMonitoringStart: function (monitorStartEvent) { + this.initializeServer(monitorStartEvent.server); + }, initializeServer: function (server) { servers[server.id] = { @@ -287,17 +296,48 @@ const plugin = { } } + if (event.eventType === 'UrlRequested') { + const urlRequest = this.parseUrlRequest(event); + + this.logger.logDebug('Making gamescript web request {@Request}', urlRequest); + + this.scriptHelper.requestUrl(urlRequest, response => { + this.logger.logDebug('Got response for gamescript web request - {Response}', response); + + const max = 10; + this.logger.logDebug(`response length ${response.length}`); + let chunks = chunkString(response.replace(/"/gm, '\\"').replace(/[\n|\t]/gm, ''), 800); + if (chunks.length > max) + { + this.logger.logWarning(`Response chunks greater than max (${max}). Data truncated!`); + chunks = chunks.slice(0, max); + } + this.logger.logDebug(`chunk size ${chunks.length}`); + + for (let i = 0; i < chunks.length; i++) { + this.sendEventMessage(server, false, 'UrlRequestCompleted', null, null, + null, { entity: event.data.entity, remaining: chunks.length - (i + 1), response: chunks[i]}); + } + }); + } + tokenSource.dispose(); return messageQueued; }, sendEventMessage: function (server, responseExpected, event, subtype, origin, target, data) { let targetClientNumber = -1; + let originClientNumber = -1; + if (target != null) { - targetClientNumber = target.ClientNumber; + targetClientNumber = target.clientNumber; } - const output = `${responseExpected ? '1' : '0'};${event};${subtype};${origin.ClientNumber};${targetClientNumber};${buildDataString(data)}`; + if (origin != null) { + originClientNumber = origin.clientNumber + } + + const output = `${responseExpected ? '1' : '0'}${groupSeparatorChar}${event}${groupSeparatorChar}${subtype}${groupSeparatorChar}${originClientNumber}${groupSeparatorChar}${targetClientNumber}${groupSeparatorChar}${buildDataString(data)}`; this.logger.logDebug('Queuing output for server {output}', output); servers[server.id].commandQueue.push(output); @@ -365,8 +405,35 @@ const plugin = { } }, - onServerMonitoringStart: function (monitorStartEvent) { - this.initializeServer(monitorStartEvent.server); + parseUrlRequest: function(event) { + const url = event.data?.url; + + if (url === undefined) { + this.logger.logWarning('No url provided for gamescript web request - {Event}', event); + return; + } + + const body = event.data?.body; + const method = event.data?.method || 'GET'; + const contentType = event.data?.contentType || 'text/plain'; + const headers = event.data?.headers; + + const dictionary = System.Collections.Generic.Dictionary(System.String, System.String); + const headerDict = new dictionary(); + + if (headers) { + const eachHeader = headers.split(','); + + for (let eachKeyValue of eachHeader) { + const keyValueSplit = eachKeyValue.split(':'); + if (keyValueSplit.length === 2) { + headerDict.add(keyValueSplit[0], keyValueSplit[1]); + } + } + } + + const script = importNamespace('IW4MAdmin.Application.Plugin.Script'); + return new script.ScriptPluginWebRequest(url, body, method, contentType, headerDict); } }; @@ -634,7 +701,7 @@ const parseEvent = (input) => { return {}; } - const eventInfo = input.split(';'); + const eventInfo = input.split(groupSeparatorChar); return { eventType: eventInfo[1], @@ -652,7 +719,7 @@ const buildDataString = data => { let formattedData = ''; for (let [key, value] of Object.entries(data)) { - formattedData += `${key}=${value}|`; + formattedData += `${key}${unitSeparatorChar}${value}${recordSeparatorChar}`; } return formattedData.slice(0, -1); @@ -664,11 +731,11 @@ const parseDataString = data => { } const dict = {}; - const split = data.split('|'); + const split = data.split(recordSeparatorChar); for (let i = 0; i < split.length; i++) { const segment = split[i]; - const keyValue = segment.split('='); + const keyValue = segment.split(unitSeparatorChar); if (keyValue.length !== 2) { continue; } @@ -689,3 +756,12 @@ const validateEnabled = (server, origin) => { const isEmpty = (value) => { return value == null || false || value === '' || value === 'null'; }; + +const chunkString = (str, chunkSize) => { + const result = []; + for (let i = 0; i < str.length; i += chunkSize) { + result.push(str.slice(i, i + chunkSize)); + } + + return result; +}