1
0
Fork 0

Added basic invite link functionality

master
WolverinDEV 2021-02-17 22:39:10 +01:00
parent 7a00f2471d
commit 41876e273c
6 changed files with 275 additions and 27 deletions

34
src/Action.ts Normal file
View File

@ -0,0 +1,34 @@
import {MessageCommandErrorResult} from "./Messages";
import {clientServiceLogger} from "./Logging";
export type ActionResult<T> = {
unwrap() : T;
} & ({
status: "success",
result: T
} | {
status: "error",
result: MessageCommandErrorResult
});
export function createErrorResult<T>(result: MessageCommandErrorResult) : ActionResult<T> {
return {
status: "error",
result: result,
unwrap(): T {
clientServiceLogger.logError("Tried to unwrap an action which failed: %o", result);
throw "action failed with " + result.type;
}
}
}
export function createResult<T>(result: T) : ActionResult<T> {
return {
status: "success",
result: result,
unwrap(): T {
return result;
}
}
}

View File

@ -9,6 +9,7 @@ import {
import {geoLocationProvider} from "./GeoLocation"; import {geoLocationProvider} from "./GeoLocation";
import {clientServiceLogger} from "./Logging"; import {clientServiceLogger} from "./Logging";
import {ClientServiceConnection} from "./Connection"; import {ClientServiceConnection} from "./Connection";
import {Registry} from "tc-events";
export type LocalAgent = { export type LocalAgent = {
clientVersion: string, clientVersion: string,
@ -25,9 +26,17 @@ export interface ClientServiceConfig {
generateHostInfo() : LocalAgent; generateHostInfo() : LocalAgent;
} }
export interface ClientServiceEvents {
/** Client service session has successfully be initialized */
notify_session_initialized: {},
/** The current active client service session has been closed */
notify_session_closed: {}
}
export class ClientServices { export class ClientServices {
readonly config: ClientServiceConfig; readonly config: ClientServiceConfig;
private connection: ClientServiceConnection; readonly events: Registry<ClientServiceEvents>;
private readonly connection: ClientServiceConnection;
private sessionInitialized: boolean; private sessionInitialized: boolean;
private retryTimer: any; private retryTimer: any;
@ -36,6 +45,7 @@ export class ClientServices {
private initializeLocaleId: number; private initializeLocaleId: number;
constructor(config: ClientServiceConfig) { constructor(config: ClientServiceConfig) {
this.events = new Registry<ClientServiceEvents>();
this.config = config; this.config = config;
this.initializeAgentId = 0; this.initializeAgentId = 0;
this.initializeLocaleId = 0; this.initializeLocaleId = 0;
@ -44,6 +54,9 @@ export class ClientServices {
this.connection = new ClientServiceConnection(5000); this.connection = new ClientServiceConnection(5000);
this.connection.events.on("notify_state_changed", event => { this.connection.events.on("notify_state_changed", event => {
if(event.newState !== "connected") { if(event.newState !== "connected") {
if(this.sessionInitialized) {
this.events.fire("notify_session_closed");
}
this.sessionInitialized = false; this.sessionInitialized = false;
return; return;
} }
@ -60,20 +73,30 @@ export class ClientServices {
return; return;
} }
this.sendInitializeAgent().then(undefined); this.sendInitializeAgent().then(status => {
switch (status) {
case "aborted":
return;
case "error":
clientServiceLogger.logError("Failed to initialize session. Closing it and trying again in 60 seconds.");
this.scheduleRetry(60 * 1000);
return;
case "success":
this.sessionInitialized = true;
this.events.fire("notify_session_initialized");
return;
}
});
/* The locale does not really matter for the session so just run it async */
this.sendLocaleUpdate().then(undefined); this.sendLocaleUpdate().then(undefined);
}); });
}); });
this.connection.events.on("notify_notify_received", event => { this.connection.registerNotifyHandler("NotifyClientsOnline", notify => {
switch (event.notify.type) { clientServiceLogger.logInfo("Received user count update: %o", notify);
case "NotifyClientsOnline":
this.handleNotifyClientsOnline(event.notify.payload);
break;
default:
return;
}
}); });
} }
@ -89,6 +112,10 @@ export class ClientServices {
this.initializeLocaleId++; this.initializeLocaleId++;
} }
getConnection() : ClientServiceConnection {
return this.connection;
}
private scheduleRetry(time: number) { private scheduleRetry(time: number) {
this.stop(); this.stop();
@ -97,12 +124,13 @@ export class ClientServices {
/** /**
* Returns as soon the result indicates that something else went wrong rather than transmitting. * Returns as soon the result indicates that something else went wrong rather than transmitting.
* Note: This will not throw an exception!
* @param command * @param command
* @param retryInterval * @param retryInterval
*/ */
private async executeCommandWithRetry(command: MessageCommand, retryInterval: number) : Promise<MessageCommandResult> { private async executeCommandWithRetry(command: MessageCommand, retryInterval: number) : Promise<MessageCommandResult> {
while(true) { while(true) {
const result = await this.connection.executeCommand(command); const result = await this.connection.executeMessageCommand(command);
switch (result.type) { switch (result.type) {
case "ServerInternalError": case "ServerInternalError":
case "CommandEnqueueError": case "CommandEnqueueError":
@ -133,7 +161,10 @@ export class ClientServices {
} }
} }
private async sendInitializeAgent() { /**
* @returns `true` if the session agent has been successfully initialized.
*/
private async sendInitializeAgent() : Promise<"success" | "aborted" | "error"> {
const taskId = ++this.initializeAgentId; const taskId = ++this.initializeAgentId;
const hostInfo = this.config.generateHostInfo(); const hostInfo = this.config.generateHostInfo();
@ -148,12 +179,22 @@ export class ClientServices {
if(this.initializeAgentId !== taskId) { if(this.initializeAgentId !== taskId) {
/* We don't want to send that stuff any more */ /* We don't want to send that stuff any more */
return; return "aborted";
} }
this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500).then(result => { const result = await this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500);
if(this.initializeAgentId !== taskId) {
/* We don't want to send that stuff any more */
return "aborted";
}
if(result.type === "Success") {
clientServiceLogger.logTrace("Agent initialized", result);
return "success";
} else {
clientServiceLogger.logTrace("Agent initialize result: %o", result); clientServiceLogger.logTrace("Agent initialize result: %o", result);
}); return "error";
}
} }
private async sendLocaleUpdate() { private async sendLocaleUpdate() {
@ -173,12 +214,11 @@ export class ClientServices {
return; return;
} }
this.connection.executeCommand({ type: "SessionUpdateLocale", payload }).then(result => { const result = await this.connection.executeCommand("SessionUpdateLocale", payload);
clientServiceLogger.logTrace("Agent local update result: %o", result); if(this.initializeLocaleId !== taskId) {
}); return;
} }
private handleNotifyClientsOnline(notify: NotifyClientsOnline) { clientServiceLogger.logTrace("Agent local update result: %o", result);
clientServiceLogger.logInfo("Received user count update: %o", notify);
} }
} }

View File

@ -0,0 +1,80 @@
import {ClientServices} from "./ClientService";
import {ActionResult, createErrorResult, createResult} from "./Action";
export type InviteLinkInfo = {
linkId: string,
timestampCreated: number,
timestampDeleted: number,
amountViewed: number,
amountClicked: number,
propertiesConnect: {[key: string]: string},
propertiesInfo: {[key: string]: string},
};
export class ClientServiceInvite {
private readonly handle: ClientServices;
constructor(handle: ClientServices) {
this.handle = handle;
}
async createInviteLink(connectProperties: {[key: string]: string}, infoProperties: {[key: string]: string}, createNew: boolean) : Promise<ActionResult<{ linkId: string, adminToken: string }>> {
const connection = this.handle.getConnection();
const notify = connection.catchNotify("NotifyInviteCreated");
const result = await connection.executeCommand("InviteCreate", {
new_link: createNew,
properties_connect: connectProperties,
properties_info: infoProperties
});
const notifyResult = notify();
if(result.type !== "Success") {
return createErrorResult(result);
}
if(notifyResult.status === "fail") {
return createErrorResult({ type: "GenericError", error: "failed to receive notify" });
}
return createResult({
adminToken: notifyResult.value.admin_token,
linkId: notifyResult.value.link_id
});
}
async queryInviteLink(linkId: string, registerView: boolean) : Promise<ActionResult<InviteLinkInfo>> {
const connection = this.handle.getConnection();
const notify = connection.catchNotify("NotifyInviteInfo", notify => notify.link_id === linkId);
const result = await connection.executeCommand("InviteQueryInfo", {
link_id: linkId,
register_view: registerView
});
const notifyResult = notify();
if(result.type !== "Success") {
return createErrorResult(result);
}
if(notifyResult.status === "fail") {
return createErrorResult({ type: "GenericError", error: "failed to receive notify" });
}
return createResult({
linkId: notifyResult.value.link_id,
amountClicked: notifyResult.value.amount_clicked,
amountViewed: notifyResult.value.amount_viewed,
timestampCreated: notifyResult.value.timestamp_created,
timestampDeleted: notifyResult.value.timestamp_deleted,
propertiesConnect: notifyResult.value.properties_connect,
propertiesInfo: notifyResult.value.properties_info,
});
}
}

View File

@ -12,9 +12,11 @@ type PendingCommand = {
interface ClientServiceConnectionEvents { interface ClientServiceConnectionEvents {
notify_state_changed: { oldState: ConnectionState, newState: ConnectionState }, notify_state_changed: { oldState: ConnectionState, newState: ConnectionState },
notify_notify_received: { notify: MessageNotify }
} }
type NotifyPayloadType<K extends MessageNotify["type"]> = Extract<MessageNotify, { type: K }>["payload"];
type CommandPayloadType<K extends MessageCommand["type"]> = Extract<MessageCommand, { type: K }>["payload"];
let tokenIndex = 0; let tokenIndex = 0;
export class ClientServiceConnection { export class ClientServiceConnection {
readonly events: Registry<ClientServiceConnectionEvents>; readonly events: Registry<ClientServiceConnectionEvents>;
@ -25,6 +27,7 @@ export class ClientServiceConnection {
private connection: WebSocket; private connection: WebSocket;
private pendingCommands: {[key: string]: PendingCommand} = {}; private pendingCommands: {[key: string]: PendingCommand} = {};
private notifyHandler: {[key: string]: ((event) => void)[]} = {};
constructor(reconnectInterval: number) { constructor(reconnectInterval: number) {
this.events = new Registry<ClientServiceConnectionEvents>(); this.events = new Registry<ClientServiceConnectionEvents>();
@ -34,6 +37,7 @@ export class ClientServiceConnection {
destroy() { destroy() {
this.disconnect(); this.disconnect();
this.events.destroy(); this.events.destroy();
this.notifyHandler = {};
} }
getState() : ConnectionState { getState() : ConnectionState {
@ -122,7 +126,7 @@ export class ClientServiceConnection {
} }
} }
async executeCommand(command: MessageCommand) : Promise<MessageCommandResult> { async executeMessageCommand(command: MessageCommand) : Promise<MessageCommandResult> {
if(this.connectionState !== "connected") { if(this.connectionState !== "connected") {
return { type: "ConnectionClosed" }; return { type: "ConnectionClosed" };
} }
@ -153,6 +157,83 @@ export class ClientServiceConnection {
}); });
} }
async executeCommand<K extends MessageCommand["type"]>(command: K, payload: CommandPayloadType<K>) : Promise<MessageCommandResult> {
return await this.executeMessageCommand({ type: command as any, payload: payload as any });
}
registerNotifyHandler<K extends MessageNotify["type"]>(notify: K, callback: (notify: NotifyPayloadType<K>) => void) : () => void {
const handler = this.notifyHandler[notify] || (this.notifyHandler[notify] = []);
handler.push(callback);
return () => this.unregisterNotifyHandler(notify, callback as any);
}
unregisterNotifyHandler<K extends MessageNotify["type"]>(callback: (notify: NotifyPayloadType<K>) => void);
unregisterNotifyHandler<K extends MessageNotify["type"]>(notify: K, callback: (notify: NotifyPayloadType<K>) => void);
unregisterNotifyHandler(notifyOrCallback, callback?) {
if(typeof notifyOrCallback === "string") {
const handler = this.notifyHandler[notifyOrCallback];
if(!handler) {
return;
}
const index = handler.indexOf(callback);
if(index === -1) {
return;
}
handler.splice(index);
if(handler.length === 0) {
delete this.notifyHandler[notifyOrCallback];
}
} else {
for(const key of Object.keys(this.notifyHandler)) {
this.unregisterNotifyHandler(key as any, notifyOrCallback);
}
}
}
catchNotify<K extends MessageNotify["type"]>(notify: K, filter?: (value: NotifyPayloadType<K>) => boolean) : () => ({ status: "success", value: NotifyPayloadType<K> } | { status: "fail" }) {
/*
* Note:
* The current implementation allows the user to forget about the callback without causing any memory leaks.
* The memory might still leak if the registered notify never triggered.
*/
const handlers = this.notifyHandler[notify] || (this.notifyHandler[notify] = []);
const resultContainer = { result: null };
const handler = notify => {
if(filter && !filter(notify)) {
return;
}
resultContainer.result = notify;
unregisterHandler();
};
const unregisterHandler = () => {
const index = handlers.indexOf(handler);
if(index !== -1) {
handlers.remove(handler);
}
}
handlers.push(handler);
return () => {
unregisterHandler();
if(resultContainer.result === null) {
return {
status: "fail"
};
} else {
return {
status: "success",
value: resultContainer.result
};
}
}
}
private handleConnectFail() { private handleConnectFail() {
this.disconnect(); this.disconnect();
this.executeReconnect(); this.executeReconnect();
@ -195,7 +276,16 @@ export class ClientServiceConnection {
clientServiceLogger.logWarn("Received command result for unknown token: %o", data.token); clientServiceLogger.logWarn("Received command result for unknown token: %o", data.token);
} }
} else if(data.type === "Notify") { } else if(data.type === "Notify") {
this.events.fire("notify_notify_received", { notify: data.notify }); const handlers = this.notifyHandler[data.notify.type];
if(typeof handlers !== "undefined") {
for(const handler of [...handlers]) {
try {
handler(data.notify.payload);
} catch (error) {
clientServiceLogger.logError("Failed to invoke notify handler for %s: %o", data.notify, error);
}
}
}
} else { } else {
clientServiceLogger.logWarn("Received message with invalid type: %o", (data as any).type); clientServiceLogger.logWarn("Received message with invalid type: %o", (data as any).type);
} }

View File

@ -34,6 +34,8 @@ export type MessageCommandResult =
| { type: "InviteKeyInvalid"; fields: string } | { type: "InviteKeyInvalid"; fields: string }
| { type: "InviteKeyNotFound" }; | { type: "InviteKeyNotFound" };
export type MessageCommandErrorResult = Exclude<MessageCommandResult, { type: "Success" }>;
export type MessageNotify = export type MessageNotify =
| { type: "NotifyClientsOnline"; payload: NotifyClientsOnline } | { type: "NotifyClientsOnline"; payload: NotifyClientsOnline }
| { type: "NotifyInviteCreated"; payload: NotifyInviteCreated } | { type: "NotifyInviteCreated"; payload: NotifyInviteCreated }
@ -53,7 +55,7 @@ export type CommandSessionInitializeAgent = { session_type: ClientSessionType; p
export type CommandSessionUpdateLocale = { ip_country: string | null; selected_locale: string | null; local_timestamp: number }; export type CommandSessionUpdateLocale = { ip_country: string | null; selected_locale: string | null; local_timestamp: number };
export type CommandInviteQueryInfo = { link_id: string }; export type CommandInviteQueryInfo = { link_id: string, register_view: boolean };
export type CommandInviteLogAction = { click_type: number }; export type CommandInviteLogAction = { click_type: number };

View File

@ -1,3 +1,5 @@
export { ClientServiceConfig, ClientServices, LocalAgent } from "./ClientService"; export { ClientServiceConfig, ClientServices, LocalAgent } from "./ClientService";
export { ClientServiceLogger, setClientServiceLogger } from "./Logging"; export { ClientServiceLogger, setClientServiceLogger } from "./Logging";
export { ClientSessionType } from "./Messages"; export { ClientSessionType } from "./Messages";
export { ClientServiceInvite, InviteActionResult, InviteLinkInfo, InviteCreateResult } from "./ClientServiceInvite";