1
0
Fork 0

Initial source upload

master
WolverinDEV 2021-02-17 21:09:19 +01:00
commit 7a00f2471d
6 changed files with 660 additions and 0 deletions

184
src/ClientService.ts Normal file
View File

@ -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<MessageCommandResult> {
while(true) {
const result = await this.connection.executeCommand(command);
switch (result.type) {
case "ServerInternalError":
case "CommandEnqueueError":
case "ClientSessionUninitialized":
const shouldRetry = await new Promise<boolean>(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);
}
}

203
src/Connection.ts Normal file
View File

@ -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<ClientServiceConnectionEvents>;
readonly reconnectInterval: number;
private reconnectTimeout: any;
private connectionState: ConnectionState;
private connection: WebSocket;
private pendingCommands: {[key: string]: PendingCommand} = {};
constructor(reconnectInterval: number) {
this.events = new Registry<ClientServiceConnectionEvents>();
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<MessageCommandResult> {
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);
}
}
}

164
src/GeoLocation.ts Normal file
View File

@ -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<GeoLocationInfo>;
}
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<GeoLocationInfo>;
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<GeoLocationInfo | undefined> {
return await new Promise<GeoLocationInfo>(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<GeoLocationInfo | undefined> {
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<GeoLocationInfo> {
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<GeoLocationInfo> {
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();

39
src/Logging.ts Normal file
View File

@ -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;
}

67
src/Messages.ts Normal file
View File

@ -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 } };

3
src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { ClientServiceConfig, ClientServices, LocalAgent } from "./ClientService";
export { ClientServiceLogger, setClientServiceLogger } from "./Logging";
export { ClientSessionType } from "./Messages";