diff --git a/src/core/webSocketHandler.ts b/src/core/webSocketHandler.ts new file mode 100644 index 0000000..30616cf --- /dev/null +++ b/src/core/webSocketHandler.ts @@ -0,0 +1,86 @@ +import { + WSListenerPipe, + WSConnIdPayload, + WSMakeTokenReq, + WSMakeTokenResp, + WSMessagePayload +} from "../domain/websocket.schema"; +import {getClient} from "./http"; +import {CreateNetworkReq, Network} from "../domain/networkService.schema"; +import {isAxiosError} from "axios"; +import {GenericErrorBody} from "../domain/http.schema"; +import {environment} from "./environment"; + +export class WebSocketHandler { + private static instance: WebSocketHandler; + private listeners: Set = new Set(); + private connection: WebSocket | null = null; + private connectionId: string | null = null; + + public static getInstance(): WebSocketHandler { + if (!WebSocketHandler.instance) { + WebSocketHandler.instance = new WebSocketHandler(); + } + return WebSocketHandler.instance; + } + + /** + * Connects to Chatenium WS. The ConnectionId is applied automatically in every service. The onMessage pipe is accessible in every service where needed + * @param userid + * @param token Session token + */ + public async connect(userid: string, token: string): Promise { + const client = getClient(false).create({ + headers: { + "Authorization": token + } + }) + + try { + const resp = await client.post("v2/ws/makeToken", { + userid: userid, + }); + this.connection = new WebSocket(`${environment.get().apiUrl}/v2/ws?userid=${userid}&access_token=${resp.data.token}`) + console.log("Connected to websocket successfully") + this.startListening() + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + public get connId(): string | null { + return this.connectionId; + } + + private startListening() { + if (this.connection) { + this.connection.onmessage = (event) => { + const payl: WSMessagePayload = JSON.parse(event.data); + if (payl.action == "connectionId") { + console.log("ConnectionID received") + const data: WSConnIdPayload = JSON.parse(payl.data); + this.listeners.forEach(listener => { + listener.onNewConnId(data.connId) + this.connectionId = data.connId; + }) + } else { + this.listeners.forEach(listener => { + listener.onNewMessage(payl.action, payl.data) + }) + } + } + } + } + + public registerService(service: WSListenerPipe) { + this.listeners.add(service); + } + + public unregisterService(service: WSListenerPipe) { + this.listeners.delete(service); + } +} \ No newline at end of file diff --git a/src/domain/chatService.schema.ts b/src/domain/chatService.schema.ts new file mode 100644 index 0000000..15d03b7 --- /dev/null +++ b/src/domain/chatService.schema.ts @@ -0,0 +1,43 @@ +// Request schemas +export interface GetChatsReq { + userid: string +} + +export interface StartNewReq { + userid: string + peerUsername: string +} + +export interface ToggleChatMuteReq { + userid: string + chatid: string +} + +export interface GetAvailabilityReq { + targetId: string + connId: string +} + +export interface GetAvailabilityResp { + available: boolean +} + +// Types +export interface Chat { + userid: string + chatid: string + username: string + displayName: string + pfp: string + status: 0 | 1 + type: "outgoing" | "incoming" + notifications: number + muted: boolean + latestMessage: LatestMessage +} + +export interface LatestMessage { + message: string + isAuthor: boolean + msgid: string +} \ No newline at end of file diff --git a/src/domain/websocket.schema.ts b/src/domain/websocket.schema.ts new file mode 100644 index 0000000..6918fb2 --- /dev/null +++ b/src/domain/websocket.schema.ts @@ -0,0 +1,28 @@ +export interface WSListenerPipe { + identifier: string; // Any identifier that is unique to the service (chatid, networkId, etc...) + onNewConnId: (newConnId: string) => void; + onNewMessage: MessageListener; +} + +export type MessageListener = (action: string, data: string) => void + +export interface WSMakeTokenReq { + userid: string +} + +export interface WSMakeTokenResp { + token: string +} + +export interface WSConnIdPayload { + connId: string +} + +export interface WSConnIdPayload { + connId: string +} + +export interface WSMessagePayload { + action: string, + data: string +} \ No newline at end of file diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 9f45c2d..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {beforeAll, describe, expect, it} from "vitest"; -import {jezus} from "./index"; -import {KeyringAPI} from "./storage/keyring"; -import {KeyringMock} from "./mocks/storage/keyring"; - -describe("Testing", () => { - it("should be defined", () => { - let keyringMock = new KeyringMock(); - jezus(keyringMock) - - expect(keyringMock.get("keyring")).toBe("apad"); - }) -}) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index be6045a..e69de29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +0,0 @@ -import {KeyringAPI} from "./storage/keyring"; - -export function jezus(keyring: KeyringAPI) { - keyring.set("keyring", "apad"); -} \ No newline at end of file diff --git a/src/mocks/handlers/chat.http.ts b/src/mocks/handlers/chat.http.ts new file mode 100644 index 0000000..b5cc286 --- /dev/null +++ b/src/mocks/handlers/chat.http.ts @@ -0,0 +1,23 @@ +import {http, HttpResponse} from "msw"; +import {Chat, GetAvailabilityResp, StartNewReq} from "../../domain/chatService.schema"; + +export const chatHandlers = [ + http.get('*/chat/get', () => { + return HttpResponse.json([{ + userid: "chatPartnerId" + }]) + }), + + http.get('*/chat/availability', () => { + return HttpResponse.json({ + available: true + }) + }), + + http.post('*/chat/startNew', async ({request}) => { + const body = await request.json() as StartNewReq + return HttpResponse.json({ + username: body.peerUsername + }) + }), +] \ No newline at end of file diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 9048c96..060ff83 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -3,11 +3,13 @@ import {authHandlers} from "./handlers/network.http"; import {pictureHandlers} from "./handlers/picture.http"; import {callHandlers} from "./handlers/call.http"; import {fileUploadHandlers} from "./handlers/fUpl.http"; +import {chatHandlers} from "./handlers/chat.http"; export const allHandlers = [ ...authHandlers, ...networkHandlers, ...pictureHandlers, ...callHandlers, - ...fileUploadHandlers + ...fileUploadHandlers, + ...chatHandlers ] \ No newline at end of file diff --git a/src/services/chatService.test.ts b/src/services/chatService.test.ts new file mode 100644 index 0000000..fcfdc38 --- /dev/null +++ b/src/services/chatService.test.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from "vitest"; +import {ChatService} from "./chatService"; +import {DatabaseMock} from "../mocks/storage/database"; +import {faker} from "@faker-js/faker/locale/en"; + +describe("ChatService", () => { + const service = new ChatService("", "", new DatabaseMock(), (action, data) => {}) + + it('should get chats', async () => { + const chats = await service.get() + expect(chats[0].userid).toBe("chatPartnerId") + }) + + it('should get availability', async () => { + const available = await service.getUserAvailability("") + expect(available).toBeTruthy() + }); + + it('should create a new chat', async () => { + const chatPartnerName = faker.internet.displayName() + const newChat = await service.startNew(chatPartnerName) + expect(newChat.username).toBe(chatPartnerName) + }); +}) \ No newline at end of file diff --git a/src/services/chatService.ts b/src/services/chatService.ts new file mode 100644 index 0000000..0a121b8 --- /dev/null +++ b/src/services/chatService.ts @@ -0,0 +1,116 @@ +import {DatabaseAPI} from "../storage/database"; +import {MessageListener} from "../domain/websocket.schema"; +import {getClient} from "../core/http"; +import {WebSocketHandler} from "../core/webSocketHandler"; +import {AxiosInstance, isAxiosError} from "axios"; +import {NetworkInvite} from "../domain/networkService.schema"; +import {GenericErrorBody} from "../domain/http.schema"; +import { + Chat, + GetAvailabilityReq, + GetAvailabilityResp, + StartNewReq, + ToggleChatMuteReq +} from "../domain/chatService.schema"; + +/** + * ChatService is an exception because it's one instance for all chats because it's unnecessary to create a new instance for each chat + */ +export class ChatService { + userid: string; + database: DatabaseAPI; + client: AxiosInstance; + + constructor(userid: string, token: string, database: DatabaseAPI, wsMessageListener: MessageListener) { + this.userid = userid; + this.database = database; + this.client = getClient(false).create({ + headers: { + "Authorization": token, + } + }) + WebSocketHandler.getInstance().registerService({ + identifier: userid, + onNewConnId: this.onNewConnId, + onNewMessage: wsMessageListener, + }) + } + + private onNewConnId(newConnId: string) { + console.log("ChatService: New connection id") + this.client.defaults.headers["X-WS-ID"] = newConnId; + } + + /** + * Fetches all chat partners + */ + async get(): Promise { + try { + const resp = await this.client.get(`chat/get?userid=${this.userid}`); + return resp.data + } catch (e) { + console.log(e) + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Gets the availability of the specified user + * @param userid + */ + async getUserAvailability(userid: string): Promise { + try { + const resp = await this.client.get(`chat/availability?target=${this.userid}&connId=${WebSocketHandler.getInstance().connId}`); + return resp.data.available + } catch (e) { + console.log(e) + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * (un)mutes the specified chat + * @param chatid + */ + async toggleMute(chatid: string): Promise { + try { + const resp = await this.client.post("v2/chat/toggleMute", { + userid: this.userid, + chatid: chatid, + }); + return + } catch (e) { + console.log(e) + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + /** + * Starts a new chat with the specified user + * @param peerUsername + */ + async startNew(peerUsername: string): Promise { + try { + const resp = await this.client.post("chat/startNew", { + userid: this.userid, + peerUsername: peerUsername, + }); + return resp.data + } catch (e) { + console.log(e) + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } +} \ No newline at end of file diff --git a/src/services/fileUploadService.test.ts b/src/services/fileUploadService.test.ts index 9d71017..c30c2e8 100644 --- a/src/services/fileUploadService.test.ts +++ b/src/services/fileUploadService.test.ts @@ -3,7 +3,7 @@ import {FileUploadService} from "./fileUploadService"; describe("fileUploadService", () => { it('should upload files', async () => { - const service = new FileUploadService("", null, null); + const service = new FileUploadService(""); const uploadId = await service.uploadFiles("", "", []) expect(uploadId).toBe("MockUploadId") }); diff --git a/src/services/fileUploadService.ts b/src/services/fileUploadService.ts index 4153a13..c650512 100644 --- a/src/services/fileUploadService.ts +++ b/src/services/fileUploadService.ts @@ -15,13 +15,13 @@ export class FileUploadService { client: AxiosInstance; cdnClient: AxiosInstance; - constructor(token: string, private httpClientOverwrite: AxiosInstance | null, private cdnHttpClientOverwrite: AxiosInstance | null) { - this.client = (httpClientOverwrite ?? getClient(false)).create({ + constructor(token: string) { + this.client = getClient(false).create({ headers: { "Authorization": token } }) - this.cdnClient = (cdnHttpClientOverwrite ?? getClient(true)).create({ + this.cdnClient = getClient(true).create({ headers: { "Authorization": token } diff --git a/src/services/networkService.test.ts b/src/services/networkService.test.ts index 61e7764..7c1363a 100644 --- a/src/services/networkService.test.ts +++ b/src/services/networkService.test.ts @@ -6,7 +6,7 @@ import {getClient} from "../core/http"; import {environment, SDKConfig} from "../core/environment"; describe("NetworkService", () => { - const service = new NetworkService("", "", "", new DatabaseMock(), getClient(false)) + const service = new NetworkService("", "", "", new DatabaseMock(), (action, data) => {}) it('should get invites', async () => { const invites = await service.getInvites(); diff --git a/src/services/networkService.ts b/src/services/networkService.ts index 4d72d3d..9d317fb 100644 --- a/src/services/networkService.ts +++ b/src/services/networkService.ts @@ -1,5 +1,4 @@ import {DatabaseAPI} from "../storage/database"; -import {AuthMethods} from "../domain/authService.schema"; import {getClient} from "../core/http"; import {AxiosInstance, isAxiosError} from "axios"; import {GenericErrorBody} from "../domain/http.schema"; @@ -18,7 +17,8 @@ import { UnbanMemberReq, UploadNewPictureReq } from "../domain/networkService.schema"; import {PublicUserData, RGB} from "../domain/common.schema"; -import {http} from "msw"; +import {WebSocketHandler} from "../core/webSocketHandler"; +import {MessageListener} from "../domain/websocket.schema"; export class NetworkService { userid: string; @@ -26,15 +26,26 @@ export class NetworkService { database: DatabaseAPI; client: AxiosInstance - constructor(userid: string, token: string, networkId: string, database: DatabaseAPI, httpClientOverwrite: AxiosInstance | null) { + constructor(userid: string, token: string, networkId: string, database: DatabaseAPI, wsMessageListener: MessageListener) { this.userid = userid; this.networkId = networkId; this.database = database; - this.client = (httpClientOverwrite ?? getClient(false)).create({ + this.client = getClient(false).create({ headers: { - "Authorization": token + "Authorization": token, + "X-WS-ID": WebSocketHandler.getInstance().connId } }) + WebSocketHandler.getInstance().registerService({ + identifier: networkId, + onNewConnId: this.onNewConnId, + onNewMessage: wsMessageListener, + }) + } + + private onNewConnId(newConnId: string) { + console.log("NetworkService: New connection id") + this.client.defaults.headers["X-WS-ID"] = newConnId; } /** @@ -101,7 +112,7 @@ export class NetworkService { */ async acceptInvite(inviteId: string): Promise { try { - const resp = await this.client.post("network/acceptInvite", { + await this.client.post("network/acceptInvite", { userid: this.userid, inviteId: inviteId, }); diff --git a/src/services/pictureService.test.ts b/src/services/pictureService.test.ts index 1d0b5c7..31ff204 100644 --- a/src/services/pictureService.test.ts +++ b/src/services/pictureService.test.ts @@ -4,7 +4,7 @@ import {DatabaseMock} from "../mocks/storage/database"; import {faker} from "@faker-js/faker/locale/en"; describe("PictureService", () => { - const service = new PictureService("", "", "", new DatabaseMock(), null, null) + const service = new PictureService("", "", "", new DatabaseMock()) it('should get pictures', async () => { const uploads = await service.get() diff --git a/src/services/pictureService.ts b/src/services/pictureService.ts index 50a8bb9..885a3f6 100644 --- a/src/services/pictureService.ts +++ b/src/services/pictureService.ts @@ -20,16 +20,16 @@ export class PictureService { client: AxiosInstance cdnClient: AxiosInstance - constructor(token: string, uploaderId: string, userid: string, database: DatabaseAPI, httpClientOverwrite: AxiosInstance | null, cdnClientOverwrite: AxiosInstance | null) { + constructor(token: string, uploaderId: string, userid: string, database: DatabaseAPI) { this.userid = userid; this.uploaderId = uploaderId; this.database = database; - this.client = (httpClientOverwrite ?? getClient(false)).create({ + this.client = getClient(false).create({ headers: { "Authorization": token } }) - this.cdnClient = (cdnClientOverwrite ?? getClient(true)).create({ + this.cdnClient = getClient(true).create({ headers: { "Authorization": token } diff --git a/tests/chatsService.test.ts b/tests/chatsService.test.ts new file mode 100644 index 0000000..fbb46d9 --- /dev/null +++ b/tests/chatsService.test.ts @@ -0,0 +1,14 @@ +import {describe, expect, it} from "vitest"; +import {DatabaseMock} from "../src/mocks/storage/database"; +import {ChatService} from "../src/services/chatService"; + +describe("ChatService Integration Testing", () => { + const CHAT_SERVICE_TESTING_USER_ID = "000000000000000000000000" + const CHAT_SERVICE_TESTING_TOKEN = "testingToken" + const service = new ChatService(CHAT_SERVICE_TESTING_USER_ID, CHAT_SERVICE_TESTING_TOKEN, new DatabaseMock(), () => {}) + + it('should get chats', async () => { + const chats = await service.get() + expect(chats[0].username).toBe("bob") + }) +}) \ No newline at end of file diff --git a/tests/fileUploadService.test.ts b/tests/fileUploadService.test.ts index e78aa88..f37d474 100644 --- a/tests/fileUploadService.test.ts +++ b/tests/fileUploadService.test.ts @@ -15,7 +15,7 @@ describe("FileUploadService Integration Testing", () => { responseType: 'blob' }); - const service = new FileUploadService(FILE_UPL_SERVICE_TESTING_TOKEN, getClient(false), getClient(true)); + const service = new FileUploadService(FILE_UPL_SERVICE_TESTING_TOKEN); await service.uploadFiles( FILE_UPL_SERVICE_TESTING_CHAT_ID, FILE_UPL_SERVICE_TESTING_USER_ID, diff --git a/tests/networkService.test.ts b/tests/networkService.test.ts index b167ef5..503311d 100644 --- a/tests/networkService.test.ts +++ b/tests/networkService.test.ts @@ -23,7 +23,7 @@ describe("NetworkService Integration Testing", () => { NETWORK_SERVICE_TESTING_TOKEN, NETWORK_SERVICE_TESTING_NETWORK_ID, new DatabaseMock(), - getClient(false), + (action, data) => {} ) it("should get invites", async () => { diff --git a/tests/pictureService.test.ts b/tests/pictureService.test.ts index 512ed27..0739712 100644 --- a/tests/pictureService.test.ts +++ b/tests/pictureService.test.ts @@ -15,9 +15,7 @@ describe("PictureService Integration Test", () => { PICTURE_SERVICE_TESTING_TOKEN, PICTURE_SERVICE_TESTING_USER_ID, PICTURE_SERVICE_TESTING_USER_ID, - new DatabaseMock(), - getClient(false), - getClient(true), + new DatabaseMock() ) it('should get uploads', async () => { diff --git a/tests/webSocketHandler.test.ts b/tests/webSocketHandler.test.ts new file mode 100644 index 0000000..2623182 --- /dev/null +++ b/tests/webSocketHandler.test.ts @@ -0,0 +1,14 @@ +import {describe, it} from "vitest"; +import {WebSocketHandler} from "../src/core/webSocketHandler"; + +const WEBSOCKET_HANDLER_TESTING_USER_ID = "000000000000000000000000" +const WEBSOCKET_HANDLER_TESTING_USER_TOKEN = "testingToken" + +describe("WebSocketHandler", () => { + it("should connect", async () => { + await WebSocketHandler.getInstance().connect( + WEBSOCKET_HANDLER_TESTING_USER_ID, + WEBSOCKET_HANDLER_TESTING_USER_TOKEN + ) + }) +}) \ No newline at end of file