From 7a00f2471d6cbaccd3ccff8606a45d2502102ac7 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 17 Feb 2021 21:09:19 +0100 Subject: [PATCH] Initial source upload --- src/ClientService.ts | 184 +++++++++++++++++++++++++++++++++++++++ src/Connection.ts | 203 +++++++++++++++++++++++++++++++++++++++++++ src/GeoLocation.ts | 164 ++++++++++++++++++++++++++++++++++ src/Logging.ts | 39 +++++++++ src/Messages.ts | 67 ++++++++++++++ src/index.ts | 3 + 6 files changed, 660 insertions(+) create mode 100644 src/ClientService.ts create mode 100644 src/Connection.ts create mode 100644 src/GeoLocation.ts create mode 100644 src/Logging.ts create mode 100644 src/Messages.ts create mode 100644 src/index.ts diff --git a/src/ClientService.ts b/src/ClientService.ts new file mode 100644 index 0000000..b94bf65 --- /dev/null +++ b/src/ClientService.ts @@ -0,0 +1,184 @@ +import { + ClientSessionType, + CommandSessionInitializeAgent, + CommandSessionUpdateLocale, + MessageCommand, + MessageCommandResult, + NotifyClientsOnline +} from "./Messages"; +import {geoLocationProvider} from "./GeoLocation"; +import {clientServiceLogger} from "./Logging"; +import {ClientServiceConnection} from "./Connection"; + +export type LocalAgent = { + clientVersion: string, + uiVersion: string, + + architecture: string, + platform: string, + platformVersion: string, +} + +export interface ClientServiceConfig { + getSelectedLocaleUrl() : string | null; + getSessionType() : ClientSessionType; + generateHostInfo() : LocalAgent; +} + +export class ClientServices { + readonly config: ClientServiceConfig; + private connection: ClientServiceConnection; + + private sessionInitialized: boolean; + private retryTimer: any; + + private initializeAgentId: number; + private initializeLocaleId: number; + + constructor(config: ClientServiceConfig) { + this.config = config; + this.initializeAgentId = 0; + this.initializeLocaleId = 0; + + this.sessionInitialized = false; + this.connection = new ClientServiceConnection(5000); + this.connection.events.on("notify_state_changed", event => { + if(event.newState !== "connected") { + this.sessionInitialized = false; + return; + } + + clientServiceLogger.logInfo("Connected successfully. Initializing session."); + this.executeCommandWithRetry({ type: "SessionInitialize", payload: { anonymize_ip: false }}, 2500).then(result => { + if(result.type !== "Success") { + if(result.type === "ConnectionClosed") { + return; + } + + clientServiceLogger.logError( "Failed to initialize session. Retrying in 120 seconds. Result: %o", result); + this.scheduleRetry(120 * 1000); + return; + } + + this.sendInitializeAgent().then(undefined); + this.sendLocaleUpdate().then(undefined); + }); + }); + + this.connection.events.on("notify_notify_received", event => { + switch (event.notify.type) { + case "NotifyClientsOnline": + this.handleNotifyClientsOnline(event.notify.payload); + break; + + default: + return; + } + }); + } + + start() { + this.connection.connect(); + } + + stop() { + this.connection.disconnect(); + clearTimeout(this.retryTimer); + + this.initializeAgentId++; + this.initializeLocaleId++; + } + + private scheduleRetry(time: number) { + this.stop(); + + this.retryTimer = setTimeout(() => this.connection.connect(), time); + } + + /** + * Returns as soon the result indicates that something else went wrong rather than transmitting. + * @param command + * @param retryInterval + */ + private async executeCommandWithRetry(command: MessageCommand, retryInterval: number) : Promise { + while(true) { + const result = await this.connection.executeCommand(command); + switch (result.type) { + case "ServerInternalError": + case "CommandEnqueueError": + case "ClientSessionUninitialized": + const shouldRetry = await new Promise(resolve => { + const timeout = setTimeout(() => { + listener(); + resolve(true); + }, 2500); + + const listener = this.connection.events.on("notify_state_changed", event => { + if(event.newState !== "connected") { + resolve(false); + clearTimeout(timeout); + } + }) + }); + + if(shouldRetry) { + continue; + } else { + return result; + } + + default: + return result; + } + } + } + + private async sendInitializeAgent() { + const taskId = ++this.initializeAgentId; + + const hostInfo = this.config.generateHostInfo(); + const payload: CommandSessionInitializeAgent = { + session_type: this.config.getSessionType(), + architecture: hostInfo.architecture, + platform_version: hostInfo.platformVersion, + platform: hostInfo.platform, + client_version: hostInfo.clientVersion, + ui_version: hostInfo.uiVersion + }; + + if(this.initializeAgentId !== taskId) { + /* We don't want to send that stuff any more */ + return; + } + + this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500).then(result => { + clientServiceLogger.logTrace("Agent initialize result: %o", result); + }); + } + + private async sendLocaleUpdate() { + const taskId = ++this.initializeLocaleId; + + const payload: CommandSessionUpdateLocale = { + ip_country: null, + selected_locale: null, + local_timestamp: Date.now() + }; + + const geoInfo = await geoLocationProvider.queryInfo(2500); + payload.ip_country = geoInfo?.country?.toLowerCase() || null; + payload.selected_locale = this.config.getSelectedLocaleUrl(); + + if(this.initializeLocaleId !== taskId) { + return; + } + + this.connection.executeCommand({ type: "SessionUpdateLocale", payload }).then(result => { + clientServiceLogger.logTrace("Agent local update result: %o", result); + }); + } + + private handleNotifyClientsOnline(notify: NotifyClientsOnline) { + clientServiceLogger.logInfo("Received user count update: %o", notify); + } +} \ No newline at end of file diff --git a/src/Connection.ts b/src/Connection.ts new file mode 100644 index 0000000..58f69aa --- /dev/null +++ b/src/Connection.ts @@ -0,0 +1,203 @@ +import {clientServiceLogger} from "./Logging"; +import {Message, MessageCommand, MessageCommandResult, MessageNotify} from "./Messages"; +import {Registry} from "tc-events"; + +const kApiVersion = 1; + +type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnect-pending"; +type PendingCommand = { + resolve: (result: MessageCommandResult) => void, + timeout: any +}; + +interface ClientServiceConnectionEvents { + notify_state_changed: { oldState: ConnectionState, newState: ConnectionState }, + notify_notify_received: { notify: MessageNotify } +} + +let tokenIndex = 0; +export class ClientServiceConnection { + readonly events: Registry; + readonly reconnectInterval: number; + + private reconnectTimeout: any; + private connectionState: ConnectionState; + private connection: WebSocket; + + private pendingCommands: {[key: string]: PendingCommand} = {}; + + constructor(reconnectInterval: number) { + this.events = new Registry(); + this.reconnectInterval = reconnectInterval; + } + + destroy() { + this.disconnect(); + this.events.destroy(); + } + + getState() : ConnectionState { + return this.connectionState; + } + + private setState(newState: ConnectionState) { + if(this.connectionState === newState) { + return; + } + + const oldState = this.connectionState; + this.connectionState = newState; + this.events.fire("notify_state_changed", { oldState, newState }) + } + + connect() { + this.disconnect(); + + this.setState("connecting"); + + let address; + address = "client-services.teaspeak.de:27791"; + address = "localhost:1244"; + //address = "192.168.40.135:1244"; + + this.connection = new WebSocket(`wss://${address}/ws-api/v${kApiVersion}`); + this.connection.onclose = event => { + clientServiceLogger.logTrace("Lost connection to statistics server (Connection closed). Reason: %s", event.reason ? `${event.reason} (${event.code})` : event.code); + this.handleConnectionLost(); + }; + + this.connection.onopen = () => { + clientServiceLogger.logTrace("Connection established."); + this.setState("connected"); + } + + this.connection.onerror = () => { + if(this.connectionState === "connecting") { + clientServiceLogger.logTrace("Failed to connect to target server."); + this.handleConnectFail(); + } else { + clientServiceLogger.logTrace("Received web socket error which indicates that the connection has been closed."); + this.handleConnectionLost(); + } + }; + + this.connection.onmessage = event => { + if(typeof event.data !== "string") { + clientServiceLogger.logTrace("Receved non text message: %o", event.data); + return; + } + + this.handleServerMessage(event.data); + }; + } + + disconnect() { + if(this.connection) { + this.connection.onclose = undefined; + this.connection.onopen = undefined; + this.connection.onmessage = undefined; + this.connection.onerror = undefined; + + this.connection.close(); + this.connection = undefined; + } + + for(const command of Object.values(this.pendingCommands)) { + command.resolve({ type: "ConnectionClosed" }); + } + this.pendingCommands = {}; + + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = undefined; + + this.setState("disconnected"); + } + + cancelReconnect() { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = undefined; + + if(this.connectionState === "reconnect-pending") { + this.setState("disconnected"); + } + } + + async executeCommand(command: MessageCommand) : Promise { + if(this.connectionState !== "connected") { + return { type: "ConnectionClosed" }; + } + + const token = "tk-" + ++tokenIndex; + try { + this.connection.send(JSON.stringify({ + type: "Command", + token: token, + command: command + } as Message)); + } catch (error) { + clientServiceLogger.logTrace("Failed to send command: %o", error); + return { type: "GenericError", error: "Failed to send command" }; + } + + return await new Promise(resolve => { + const proxiedResolve = (result: MessageCommandResult) => { + clearTimeout(this.pendingCommands[token]?.timeout); + delete this.pendingCommands[token]; + resolve(result); + }; + + this.pendingCommands[token] = { + resolve: proxiedResolve, + timeout: setTimeout(() => proxiedResolve({ type: "ConnectionTimeout" }), 5000) + }; + }); + } + + private handleConnectFail() { + this.disconnect(); + this.executeReconnect(); + } + + private handleConnectionLost() { + this.disconnect(); + this.executeReconnect(); + } + + private executeReconnect() { + if(!this.reconnectInterval) { + return; + } + + clientServiceLogger.logTrace("Scheduling reconnect in %dms", this.reconnectInterval); + this.reconnectTimeout = setTimeout(() => this.connect(), this.reconnectInterval); + this.setState("reconnect-pending"); + } + + private handleServerMessage(message: string) { + let data: Message; + try { + data = JSON.parse(message); + } catch (_error) { + clientServiceLogger.logTrace("Received message which isn't parsable as JSON."); + return; + } + + if(data.type === "Command") { + clientServiceLogger.logTrace("Received message of type command. The server should not send these. Message: %o", data); + /* Well this is odd. We should never receive such */ + } else if(data.type === "CommandResult") { + if(data.token === null) { + clientServiceLogger.logTrace("Received general error: %o", data.result); + } else if(this.pendingCommands[data.token]) { + /* The entry itself will be cleaned up by the resolve callback */ + this.pendingCommands[data.token].resolve(data.result); + } else { + clientServiceLogger.logWarn("Received command result for unknown token: %o", data.token); + } + } else if(data.type === "Notify") { + this.events.fire("notify_notify_received", { notify: data.notify }); + } else { + clientServiceLogger.logWarn("Received message with invalid type: %o", (data as any).type); + } + } +} diff --git a/src/GeoLocation.ts b/src/GeoLocation.ts new file mode 100644 index 0000000..82251b0 --- /dev/null +++ b/src/GeoLocation.ts @@ -0,0 +1,164 @@ +import jsonp from 'simple-jsonp-promise'; +import {clientServiceLogger} from "./Logging"; + +interface GeoLocationInfo { + /* The country code */ + country: string, + + city?: string, + region?: string, + timezone?: string +} + +interface GeoLocationResolver { + name() : string; + resolve() : Promise; +} + +const kLocalCacheKey = "geo-info"; +type GeoLocationCache = { + version: 1, + + timestamp: number, + info: GeoLocationInfo, +} + +class GeoLocationProvider { + private readonly resolver: GeoLocationResolver[]; + private currentResolverIndex: number; + + private cachedInfo: GeoLocationInfo | undefined; + private lookupPromise: Promise; + + constructor() { + this.resolver = [ + new GeoResolverIpInfo(), + new GeoResolverIpData() + ]; + this.currentResolverIndex = 0; + } + + loadCache() { + this.doLoadCache(); + if(!this.cachedInfo) { + this.lookupPromise = this.doQueryInfo(); + } + } + + private doLoadCache() : GeoLocationInfo { + try { + const rawItem = localStorage.getItem(kLocalCacheKey); + if(!rawItem) { + return undefined; + } + + const info: GeoLocationCache = JSON.parse(rawItem); + if(info.version !== 1) { + throw tr("invalid version number"); + } + + if(info.timestamp + 2 * 24 * 60 * 60 * 1000 < Date.now()) { + throw tr("cache is too old"); + } + + if(info.timestamp + 12 * 60 * 60 * 1000 > Date.now()) { + clientServiceLogger.logTrace(tr("Geo cache is less than 12hrs old. Don't updating.")); + this.lookupPromise = Promise.resolve(info.info); + } else { + this.lookupPromise = this.doQueryInfo(); + } + + this.cachedInfo = info.info; + } catch (error) { + clientServiceLogger.logTrace(tr("Failed to load geo resolve cache: %o"), error); + } + } + + async queryInfo(timeout: number) : Promise { + return await new Promise(resolve => { + if(!this.lookupPromise) { + resolve(this.cachedInfo); + return; + } + + const timeoutId: any = typeof timeout === "number" ? setTimeout(() => resolve(this.cachedInfo), timeout) : -1; + this.lookupPromise.then(result => { + clearTimeout(timeoutId); + resolve(result); + }); + }); + } + + + private async doQueryInfo() : Promise { + while(this.currentResolverIndex < this.resolver.length) { + const resolver = this.resolver[this.currentResolverIndex++]; + try { + const info = await resolver.resolve(); + clientServiceLogger.logTrace(tr("Successfully resolved geo info from %s: %o"), resolver.name(), info); + + localStorage.setItem(kLocalCacheKey, JSON.stringify({ + version: 1, + timestamp: Date.now(), + info: info + } as GeoLocationCache)); + return info; + } catch (error) { + clientServiceLogger.logTrace(tr("Geo resolver %s failed: %o. Trying next one."), resolver.name(), error); + } + } + + clientServiceLogger.logTrace(tr("All geo resolver failed.")); + return undefined; + } +} + +class GeoResolverIpData implements GeoLocationResolver { + name(): string { + return "ipdata.co"; + } + + async resolve(): Promise { + const response = await fetch("https://api.ipdata.co/?api-key=test"); + const json = await response.json(); + + if(!("country_code" in json)) { + throw tr("missing country code"); + } + + return { + country: json["country_code"], + region: json["region"], + city: json["city"], + timezone: json["time_zone"]["name"] + } + } + +} + +class GeoResolverIpInfo implements GeoLocationResolver { + name(): string { + return "ipinfo.io"; + } + + async resolve(): Promise { + const response = await jsonp("https://ipinfo.io"); + if(!("country" in response)) { + throw tr("missing country"); + } + + return { + country: response["country"], + + city: response["city"], + region: response["region"], + timezone: response["timezone"] + } + } + +} + +export let geoLocationProvider: GeoLocationProvider; + +geoLocationProvider = new GeoLocationProvider(); +geoLocationProvider.loadCache(); \ No newline at end of file diff --git a/src/Logging.ts b/src/Logging.ts new file mode 100644 index 0000000..3c320ac --- /dev/null +++ b/src/Logging.ts @@ -0,0 +1,39 @@ +export interface ClientServiceLogger { + logTrace(message: string, ...args: any[]); + logDebug(message: string, ...args: any[]); + logInfo(message: string, ...args: any[]); + logWarn(message: string, ...args: any[]); + logError(message: string, ...args: any[]); + logCritical(message: string, ...args: any[]); +} + +export let clientServiceLogger: ClientServiceLogger; +clientServiceLogger = new class implements ClientServiceLogger { + logCritical(message: string, ...args: any[]) { + console.error("[Critical] " + message, ...args); + } + + logError(message: string, ...args: any[]) { + console.error("[Error] " + message, ...args); + } + + logWarn(message: string, ...args: any[]) { + console.warn("[Warn ] " + message, ...args); + } + + logInfo(message: string, ...args: any[]) { + console.info("[Info ] " + message, ...args); + } + + logDebug(message: string, ...args: any[]) { + console.debug("[Debug] " + message, ...args); + } + + logTrace(message: string, ...args: any[]) { + console.debug("[Trace] " + message, ...args); + } +}; + +export function setClientServiceLogger(logger: ClientServiceLogger) { + clientServiceLogger = logger; +} \ No newline at end of file diff --git a/src/Messages.ts b/src/Messages.ts new file mode 100644 index 0000000..d372b36 --- /dev/null +++ b/src/Messages.ts @@ -0,0 +1,67 @@ +/* Basic message declarations */ +export type Message = + | { type: "Command"; token: string; command: MessageCommand } + | { type: "CommandResult"; token: string | null; result: MessageCommandResult } + | { type: "Notify"; notify: MessageNotify }; + +export type MessageCommand = + | { type: "SessionInitialize"; payload: CommandSessionInitialize } + | { type: "SessionInitializeAgent"; payload: CommandSessionInitializeAgent } + | { type: "SessionUpdateLocale"; payload: CommandSessionUpdateLocale } + | { type: "InviteQueryInfo"; payload: CommandInviteQueryInfo } + | { type: "InviteLogAction"; payload: CommandInviteLogAction } + | { type: "InviteCreate"; payload: CommandInviteCreate }; + +export type MessageCommandResult = + | { type: "Success" } + | { type: "GenericError"; error: string } + | { type: "ConnectionTimeout" } + | { type: "ConnectionClosed" } + | { type: "ClientSessionUninitialized" } + | { type: "ServerInternalError" } + | { type: "ParameterInvalid"; parameter: string } + | { type: "CommandParseError"; error: string } + | { type: "CommandEnqueueError"; fields: string } + | { type: "CommandNotFound" } + | { type: "CommandNotImplemented" } + | { type: "SessionAlreadyInitialized" } + | { type: "SessionAgentAlreadyInitialized" } + | { type: "SessionNotInitialized" } + | { type: "SessionAgentNotInitialized" } + | { type: "SessionInvalidType" } + | { type: "InviteSessionNotInitialized" } + | { type: "InviteSessionAlreadyInitialized" } + | { type: "InviteKeyInvalid"; fields: string } + | { type: "InviteKeyNotFound" }; + +export type MessageNotify = + | { type: "NotifyClientsOnline"; payload: NotifyClientsOnline } + | { type: "NotifyInviteCreated"; payload: NotifyInviteCreated } + | { type: "NotifyInviteInfo"; payload: NotifyInviteInfo }; + +/* Some command data payload */ +export enum ClientSessionType { + WebClient = 0, + TeaClient = 1, + InviteWebSite = 16, +} + +/* All commands */ +export type CommandSessionInitialize = { anonymize_ip: boolean }; + +export type CommandSessionInitializeAgent = { session_type: ClientSessionType; platform: string | null; platform_version: string | null; architecture: string | null; client_version: string | null; ui_version: string | null }; + +export type CommandSessionUpdateLocale = { ip_country: string | null; selected_locale: string | null; local_timestamp: number }; + +export type CommandInviteQueryInfo = { link_id: string }; + +export type CommandInviteLogAction = { click_type: number }; + +export type CommandInviteCreate = { new_link: boolean; properties_connect: { [key: string]: string }; properties_info: { [key: string]: string } }; + +/* Notifies */ +export type NotifyClientsOnline = { users_online: { [key: number]: number }; unique_users_online: { [key: number]: number }; total_users_online: number; total_unique_users_online: number }; + +export type NotifyInviteCreated = { link_id: string; admin_token: string | null }; + +export type NotifyInviteInfo = { link_id: string; timestamp_created: number; timestamp_deleted: number; amount_viewed: number; amount_clicked: number; properties_connect: { [key: string]: string }; properties_info: { [key: string]: string } }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dfad1b9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export { ClientServiceConfig, ClientServices, LocalAgent } from "./ClientService"; +export { ClientServiceLogger, setClientServiceLogger } from "./Logging"; +export { ClientSessionType } from "./Messages"; \ No newline at end of file