RocketChat/Rocket.Chat

View on GitHub
packages/ddp-client/src/livechat/LivechatClientImpl.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { RestClient } from '@rocket.chat/api-client';
import type { IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';

import { ClientStreamImpl } from '../ClientStream';
import { ConnectionImpl } from '../Connection';
import { DDPDispatcher } from '../DDPDispatcher';
import { DDPSDK } from '../DDPSDK';
import { TimeoutControl } from '../TimeoutControl';
import { AccountImpl } from '../types/Account';
import type { ClientStream } from '../types/ClientStream';
import type { DDPDispatchOptions } from '../types/DDPClient';
import type { ServerMethodReturn, ServerMethods } from '../types/methods';
import type { StreamKeys, StreamNames, StreamerCallbackArgs } from '../types/streams';
import type { LivechatEndpoints, LivechatRoomEvents, LivechatStream } from './types/LivechatSDK';

declare module '../ClientStream' {
    interface ClientStream {
        callAsync<MethodName extends keyof ServerMethods>(
            methodName: MethodName,
            ...params: Parameters<ServerMethods[MethodName]>
        ): Promise<ServerMethodReturn<MethodName>>;
        callAsyncWithOptions<MethodName extends keyof ServerMethods>(
            methodName: MethodName,
            options: DDPDispatchOptions,
            ...params: Parameters<ServerMethods[MethodName]>
        ): Promise<ServerMethodReturn<MethodName>>;
    }
}

declare module '../DDPSDK' {
    interface DDPSDK {
        stream<N extends StreamNames, K extends StreamKeys<N>>(
            streamName: N,
            data: K | [K, unknown],
            callback: (...args: StreamerCallbackArgs<N, K>) => void,
        ): ReturnType<ClientStream['subscribe']>;
    }
}

export class LivechatClientImpl extends DDPSDK implements LivechatStream, LivechatEndpoints {
    private token?: string;

    public readonly credentials: { token?: string } = { token: this.token };

    private ev = new Emitter<{
        userActivity: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>;
        message: StreamerCallbackArgs<'room-messages', string>;
        delete: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>;
    }>();

    subscribeRoom(rid: string) {
        return Promise.all([
            this.onRoomMessage(rid, (...args) => this.ev.emit('message', args)),
            this.onRoomUserActivity(rid, (...args) => this.ev.emit('userActivity', args)),
            this.onRoomDeleteMessage(rid, (...args) => this.ev.emit('delete', args)),
        ]);
    }

    onMessage(cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void {
        return this.ev.on('message', (args) => cb(...args));
    }

    onUserActivity(cb: (username: string, events: string[]) => void): () => void {
        return this.ev.on('userActivity', (args) => args[1] && cb(args[0], args[1]));
    }

    onRoomMessage(rid: string, cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void) {
        return this.stream('room-messages', [rid, { token: this.token, visitorToken: this.token }], cb).stop;
    }

    onRoomUserActivity(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void) {
        return this.stream('notify-room', [`${rid}/user-activity`, { token: this.token, visitorToken: this.token }], cb).stop;
    }

    onRoomDeleteMessage(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>) => void) {
        return this.stream('notify-room', [`${rid}/deleteMessage`, { token: this.token, visitorToken: this.token }], cb).stop;
    }

    onAgentChange(rid: string, cb: (data: LivechatRoomEvents<'agentData'>) => void): () => void {
        return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
            if (data.type === 'agentData') {
                cb(data.data);
            }
        }).stop;
    }

    onAgentStatusChange(rid: string, cb: (data: LivechatRoomEvents<'agentStatus'>) => void): () => void {
        return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
            if (data.type === 'agentStatus') {
                cb(data.status);
            }
        }).stop;
    }

    onQueuePositionChange(rid: string, cb: (data: LivechatRoomEvents<'queueData' | 'agentData'>) => void): () => void {
        return this.stream('livechat-room', rid, (data) => {
            if (data.type === 'queueData') {
                cb(data.data);
            }
        }).stop;
    }

    onVisitorChange(rid: string, cb: (data: LivechatRoomEvents<'visitorData'>) => void): () => void {
        return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
            if (data.type === 'visitorData') {
                cb(data.visitor);
            }
        }).stop;
    }

    notifyVisitorActivity(rid: string, username: string, activity: string[]) {
        return this.client.callAsync('stream-notify-room', `${rid}/user-activity`, username, activity, { token: this.token });
    }

    notifyCallDeclined(rid: string) {
        return this.client.callAsync('stream-notify-room', `${rid}/call`, 'decline');
    }

    // API GETTERS

    async config(
        params: OperationParams<'GET', '/v1/livechat/config'>,
    ): Promise<Serialized<OperationResult<'GET', '/v1/livechat/config'>['config']>> {
        const { config } = await this.rest.get('/v1/livechat/config', params);
        return config;
    }

    async room(params: OperationParams<'GET', '/v1/livechat/room'>): Promise<Serialized<IOmnichannelRoom>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }

        const result = await this.rest.get('/v1/livechat/room', { ...params, token: this.token });

        // TODO: On major version bump, normalize the return of /v1/livechat/room
        function isRoomObject(
            room: Serialized<IOmnichannelRoom> | { room: Serialized<IOmnichannelRoom> },
        ): room is { room: Serialized<IOmnichannelRoom> } {
            return (room as { room: Serialized<IOmnichannelRoom> }).room !== undefined;
        }

        if (isRoomObject(result)) {
            return result.room;
        }

        return result;
    }

    visitor(
        params: OperationParams<'GET', '/v1/livechat/visitor'>,
    ): Promise<Serialized<OperationResult<'GET', '/v1/livechat/visitor'>['visitor']>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        const endpoint = `/v1/livechat/visitor/${this.token}`;
        return this.rest.get(endpoint as '/v1/livechat/visitor/:token', params);
    }

    nextAgent(
        params: OperationParams<'GET', '/v1/livechat/agent.next/:token'>,
    ): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.next/:token'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.get(`/v1/livechat/agent.next/${this.token}`, params);
    }

    async agent(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.info/:rid/:token'>['agent']>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        const { agent } = await this.rest.get(`/v1/livechat/agent.info/${rid}/${this.token}`);
        return agent;
    }

    message(
        id: string,
        params: OperationParams<'GET', '/v1/livechat/message/:_id'>,
    ): Promise<Serialized<OperationResult<'GET', '/v1/livechat/message/:_id'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.get(`/v1/livechat/message/${id}`, { ...params, token: this.token });
    }

    async loadMessages(
        rid: string,
        params: OperationParams<'GET', '/v1/livechat/messages.history/:rid'>,
    ): Promise<Serialized<OperationResult<'GET', '/v1/livechat/messages.history/:rid'>['messages']>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        const { messages } = await this.rest.get(`/v1/livechat/messages.history/${rid}`, { ...params, token: this.token });
        return messages;
    }

    // API POST

    transferChat({
        rid,
        department,
    }: {
        rid: string;
        department: string;
    }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.transfer'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post('/v1/livechat/room.transfer', { rid, token: this.token, department });
    }

    async grantVisitor(
        guest: OperationParams<'POST', '/v1/livechat/visitor'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>> {
        const result = await this.rest.post('/v1/livechat/visitor', guest);
        this.token = result?.visitor.token;
        return result;
    }

    login(guest: OperationParams<'POST', '/v1/livechat/visitor'>) {
        return this.grantVisitor(guest);
    }

    closeChat({ rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.close'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post('/v1/livechat/room.close', { rid, token: this.token });
    }

    chatSurvey(
        params: OperationParams<'POST', '/v1/livechat/room.survey'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.survey'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post('/v1/livechat/room.survey', { rid: params.rid, token: this.token, data: params.data });
    }

    updateCallStatus(
        callStatus: string,
        rid: string,
        callId: string,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor.callStatus'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post('/v1/livechat/visitor.callStatus', { token: this.token, callStatus, rid, callId });
    }

    sendMessage(
        params: OperationParams<'POST', '/v1/livechat/message'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/message'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post('/v1/livechat/message', { ...params, token: this.token });
    }

    sendOfflineMessage(
        params: OperationParams<'POST', '/v1/livechat/offline.message'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/offline.message'>>> {
        return this.rest.post('/v1/livechat/offline.message', params);
    }

    sendVisitorNavigation(
        params: OperationParams<'POST', '/v1/livechat/page.visited'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/page.visited'>>> {
        return this.rest.post('/v1/livechat/page.visited', params);
    }

    requestTranscript(email: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/transcript'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post('/v1/livechat/transcript', { token: this.token, rid, email });
    }

    sendCustomField(
        params: OperationParams<'POST', '/v1/livechat/custom.field'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.field'>>> {
        return this.rest.post('/v1/livechat/custom.field', params);
    }

    sendCustomFields(
        params: OperationParams<'POST', '/v1/livechat/custom.fields'>,
    ): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.fields'>>> {
        return this.rest.post('/v1/livechat/custom.fields', params);
    }

    async updateVisitorStatus(newStatus: string): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor.status'>['status']>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        const { status } = await this.rest.post('/v1/livechat/visitor.status', { token: this.token, status: newStatus });
        return status;
    }

    uploadFile(rid: string, file: File): Promise<ProgressEvent<EventTarget>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }

        if (!file) {
            throw new Error('Invalid file');
        }

        return new Promise((resolve, reject) => {
            if (!this.token) {
                return reject(new Error('Invalid token'));
            }

            return this.rest.upload(
                `/v1/livechat/upload/${rid}`,
                { file },
                {
                    load: resolve,
                    error: reject,
                },
                { headers: { 'x-visitor-token': this.token } },
            );
        });
    }

    async sendUiInteraction(
        payload: OperationParams<'POST', '/apps/ui.interaction/:id'>,
        appId: string,
    ): Promise<Serialized<OperationResult<'POST', '/apps/ui.interaction/:id'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.post(`/apps/ui.interaction/${appId}`, payload, { headers: { 'x-visitor-token': this.token } });
    }

    // API DELETE

    deleteMessage(id: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/message/:_id'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.delete(`/v1/livechat/message/${id}`, { rid, token: this.token });
    }

    deleteVisitor(): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/visitor'>>> {
        if (!this.token) {
            throw new Error('Invalid token');
        }
        return this.rest.delete(`/v1/livechat/visitor/${this.token}` as any);
    }

    // API PUT

    editMessage(
        id: string,
        params: OperationParams<'PUT', '/v1/livechat/message/:_id'>,
    ): Promise<Serialized<OperationResult<'PUT', '/v1/livechat/message/:_id'>>> {
        return this.rest.put(`/v1/livechat/message/${id}`, params);
    }

    unsubscribeAll(): Promise<unknown> {
        const subscriptions = Array.from(this.client.subscriptions.keys());
        return Promise.all(subscriptions.map((subscription) => this.client.unsubscribe(subscription)));
    }

    static create(url: string, retryOptions = { retryCount: 3, retryTime: 10000 }): LivechatClientImpl {
        // TODO: Decide what to do with the EJSON objects
        const ddp = new DDPDispatcher();

        const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);

        const stream = new ClientStreamImpl(ddp, ddp);

        const account = new AccountImpl(stream);

        const timeoutControl = TimeoutControl.create(ddp, connection);

        const rest = new RestClient({ baseUrl: url.replace(/^ws/, 'http') });

        const sdk = new LivechatClientImpl(connection, stream, account, timeoutControl, rest);

        connection.on('connected', () => {
            for (const [, sub] of stream.subscriptions.entries()) {
                ddp.subscribeWithId(sub.id, sub.name, sub.params);
            }
        });

        return sdk;
    }
}