From ff8aeb231f59af6c2f340ca136969c85d3f5141d Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Wed, 1 Mar 2023 17:18:02 +0100 Subject: [PATCH] Add an action to search for users in the user directory according to MSC3973 Signed-off-by: Dominik Henneke --- src/ClientWidgetApi.ts | 49 +++++ src/WidgetApi.ts | 30 +++ src/driver/WidgetDriver.ts | 22 ++ src/interfaces/ApiVersion.ts | 2 + src/interfaces/Capabilities.ts | 4 + src/interfaces/UserDirectorySearchAction.ts | 42 ++++ src/interfaces/WidgetApiAction.ts | 5 + test/ClientWidgetApi-test.ts | 232 ++++++++++++++++++++ test/WidgetApi-test.ts | 56 +++++ 9 files changed, 442 insertions(+) create mode 100644 src/interfaces/UserDirectorySearchAction.ts diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 79ca870..34226ef 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -77,6 +77,10 @@ import { IReadRelationsFromWidgetActionRequest, IReadRelationsFromWidgetResponseData, } from "./interfaces/ReadRelationsAction"; +import { + IUserDirectorySearchFromWidgetActionRequest, + IUserDirectorySearchFromWidgetResponseData, +} from "./interfaces/UserDirectorySearchAction"; /** * API handler for the client side of widgets. This raises events @@ -619,6 +623,49 @@ export class ClientWidgetApi extends EventEmitter { } } + private async handleUserDirectorySearch(request: IUserDirectorySearchFromWidgetActionRequest) { + if (!this.hasCapability(MatrixCapabilities.MSC3973UserDirectorySearch)) { + return this.transport.reply(request, { + error: { message: "Missing capability" }, + }); + } + + if (typeof request.data.search_term !== 'string') { + return this.transport.reply(request, { + error: { message: "Invalid request - missing search term" }, + }); + } + + if (request.data.limit !== undefined && request.data.limit < 0) { + return this.transport.reply(request, { + error: { message: "Invalid request - limit out of range" }, + }); + } + + try { + const result = await this.driver.searchUserDirectory( + request.data.search_term, request.data.limit, + ); + + return this.transport.reply( + request, + { + limited: result.limited, + results: result.results.map(r => ({ + user_id: r.userId, + display_name: r.displayName, + avatar_url: r.avatarUrl, + })), + }, + ); + } catch (e) { + console.error("error searching in the user directory", e); + await this.transport.reply(request, { + error: { message: "Unexpected error while searching in the user directory" }, + }); + } + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { @@ -650,6 +697,8 @@ export class ClientWidgetApi extends EventEmitter { return this.handleUnwatchTurnServers(ev.detail); case WidgetApiFromWidgetAction.MSC3869ReadRelations: return this.handleReadRelations(ev.detail); + case WidgetApiFromWidgetAction.MSC3973UserDirectorySearch: + return this.handleUserDirectorySearch(ev.detail) default: return this.transport.reply(ev.detail, { error: { diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 4229dff..b437ec4 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -68,6 +68,10 @@ import { IReadRelationsFromWidgetRequestData, IReadRelationsFromWidgetResponseData, } from "./interfaces/ReadRelationsAction"; +import { + IUserDirectorySearchFromWidgetRequestData, + IUserDirectorySearchFromWidgetResponseData, +} from "./interfaces/UserDirectorySearchAction"; /** * API handler for widgets. This raises events for each action @@ -592,6 +596,32 @@ export class WidgetApi extends EventEmitter { } } + /** + * Search for users in the user directory. + * @param searchTerm The term to search for. + * @param limit The maximum number of results to return. If not supplied, the + * @returns Resolves to the search results. + */ + public async searchUserDirectory( + searchTerm: string, + limit?: number, + ): Promise { + const versions = await this.getClientVersions(); + if (!versions.includes(UnstableApiVersion.MSC3973)) { + throw new Error("The user_directory_search action is not supported by the client.") + } + + const data: IUserDirectorySearchFromWidgetRequestData = { + search_term: searchTerm, + limit, + }; + + return this.transport.send< + IUserDirectorySearchFromWidgetRequestData, + IUserDirectorySearchFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, data); + } + /** * Starts the communication channel. This should be done early to ensure * that messages are not missed. Communication can only be stopped by the client. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 600f05b..1f18cc0 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -32,6 +32,15 @@ export interface IReadEventRelationsResult { prevBatch?: string; } +export interface ISearchUserDirectoryResult { + limited: boolean; + results: Array<{ + userId: string; + displayName?: string; + avatarUrl?: string; + }>; +} + /** * Represents the functions and behaviour the widget-api is unable to * do, such as prompting the user for information or interacting with @@ -222,4 +231,17 @@ export abstract class WidgetDriver { public getTurnServers(): AsyncGenerator { throw new Error("TURN server support is not implemented"); } + + /** + * Search for users in the user directory. + * @param searchTerm The term to search for. + * @param limit The maximum number of results to return. If not supplied, the + * @returns Resolves to the search results. + */ + public searchUserDirectory( + searchTerm: string, + limit?: number, + ): Promise { + return Promise.resolve({ limited: false, results: [] }); + } } diff --git a/src/interfaces/ApiVersion.ts b/src/interfaces/ApiVersion.ts index 99f60e3..6586c14 100644 --- a/src/interfaces/ApiVersion.ts +++ b/src/interfaces/ApiVersion.ts @@ -29,6 +29,7 @@ export enum UnstableApiVersion { MSC3819 = "org.matrix.msc3819", MSC3846 = "town.robin.msc3846", MSC3869 = "org.matrix.msc3869", + MSC3973 = "org.matrix.msc3973", } export type ApiVersion = MatrixApiVersion | UnstableApiVersion | string; @@ -45,4 +46,5 @@ export const CurrentApiVersions: ApiVersion[] = [ UnstableApiVersion.MSC3819, UnstableApiVersion.MSC3846, UnstableApiVersion.MSC3869, + UnstableApiVersion.MSC3973, ]; diff --git a/src/interfaces/Capabilities.ts b/src/interfaces/Capabilities.ts index 0d05443..1572105 100644 --- a/src/interfaces/Capabilities.ts +++ b/src/interfaces/Capabilities.ts @@ -30,6 +30,10 @@ export enum MatrixCapabilities { */ MSC2931Navigate = "org.matrix.msc2931.navigate", MSC3846TurnServers = "town.robin.msc3846.turn_servers", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC3973UserDirectorySearch = "org.matrix.msc3973.user_directory_search", } export type Capability = MatrixCapabilities | string; diff --git a/src/interfaces/UserDirectorySearchAction.ts b/src/interfaces/UserDirectorySearchAction.ts new file mode 100644 index 0000000..fb900cc --- /dev/null +++ b/src/interfaces/UserDirectorySearchAction.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; +import { IWidgetApiResponseData } from "./IWidgetApiResponse"; +import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; + +export interface IUserDirectorySearchFromWidgetRequestData extends IWidgetApiRequestData { + search_term: string; // eslint-disable-line camelcase + limit?: number; +} + +export interface IUserDirectorySearchFromWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch; + data: IUserDirectorySearchFromWidgetRequestData; +} + +export interface IUserDirectorySearchFromWidgetResponseData extends IWidgetApiResponseData { + limited: boolean; + results: Array<{ + user_id: string; // eslint-disable-line camelcase + display_name?: string; // eslint-disable-line camelcase + avatar_url?: string; // eslint-disable-line camelcase + }>; +} + +export interface IUserDirectorySearchFromWidgetActionResponse extends IUserDirectorySearchFromWidgetActionRequest { + response: IUserDirectorySearchFromWidgetResponseData; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index ac72f2a..34c8aa3 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -62,6 +62,11 @@ export enum WidgetApiFromWidgetAction { * @deprecated It is not recommended to rely on this existing - it can be removed without notice. */ MSC3869ReadRelations = "org.matrix.msc3869.read_relations", + + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC3973UserDirectorySearch = "org.matrix.msc3973.user_directory_search", } export type WidgetApiAction = WidgetApiToWidgetAction | WidgetApiFromWidgetAction | string; diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index c7db63a..c8501bd 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -23,6 +23,7 @@ import { IRoomEvent } from '../src/interfaces/IRoomEvent'; import { IWidgetApiRequest } from '../src/interfaces/IWidgetApiRequest'; import { IReadRelationsFromWidgetActionRequest } from '../src/interfaces/ReadRelationsAction'; import { ISupportedVersionsActionRequest } from '../src/interfaces/SupportedVersionsAction'; +import { IUserDirectorySearchFromWidgetActionRequest } from '../src/interfaces/UserDirectorySearchAction'; import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction'; import { WidgetApiDirection } from '../src/interfaces/WidgetApiDirection'; import { Widget } from '../src/models/Widget'; @@ -75,6 +76,7 @@ describe('ClientWidgetApi', () => { driver = { readEventRelations: jest.fn(), validateCapabilities: jest.fn(), + searchUserDirectory: jest.fn(), } as Partial as jest.Mocked; clientWidgetApi = new ClientWidgetApi( @@ -317,4 +319,234 @@ describe('ClientWidgetApi', () => { }); }); }); + + describe('org.matrix.msc3973.user_directory_search action', () => { + it('should present as supported api version', () => { + const event: ISupportedVersionsActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SupportedApiVersions, + data: {}, + }; + + emitEvent(new CustomEvent('', { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + supported_versions: expect.arrayContaining([ + UnstableApiVersion.MSC3973, + ]), + }); + }); + + it('should handle and process the request', async () => { + driver.searchUserDirectory.mockResolvedValue({ + limited: true, + results: [{ + userId: '@foo:bar.com', + }], + }); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo' }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + limited: true, + results: [{ + user_id: '@foo:bar.com', + display_name: undefined, + avatar_url: undefined, + }], + }); + }); + + expect(driver.searchUserDirectory).toBeCalledWith('foo', undefined); + }); + + it('should accept all options and pass it to the driver', async () => { + driver.searchUserDirectory.mockResolvedValue({ + limited: false, + results: [ + { + userId: '@foo:bar.com', + }, + { + userId: '@bar:foo.com', + displayName: 'Bar', + avatarUrl: 'mxc://...', + }, + ], + }); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { + search_term: 'foo', + limit: 5, + }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + limited: false, + results: [ + { + user_id: '@foo:bar.com', + display_name: undefined, + avatar_url: undefined, + }, + { + user_id: '@bar:foo.com', + display_name: 'Bar', + avatar_url: 'mxc://...', + }, + ], + }); + }); + + expect(driver.searchUserDirectory).toBeCalledWith('foo', 5); + }); + + it('should accept empty search_term', async () => { + driver.searchUserDirectory.mockResolvedValue({ + limited: false, + results: [], + }); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: '' }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + limited: false, + results: [], + }); + }); + + expect(driver.searchUserDirectory).toBeCalledWith('', undefined); + }); + + it('should reject requests when the capability was not requested', async () => { + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo' }, + }; + + emitEvent(new CustomEvent('', { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Missing capability' }, + }); + + expect(driver.searchUserDirectory).not.toBeCalled(); + }); + + it('should reject requests without search_term', async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: {}, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing search term' }, + }); + + expect(driver.searchUserDirectory).not.toBeCalled(); + }); + + it('should reject requests with a negative limit', async () => { + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { + search_term: 'foo', + limit: -1, + }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - limit out of range' }, + }); + + expect(driver.searchUserDirectory).not.toBeCalled(); + }); + + it('should reject requests when the driver throws an exception', async () => { + driver.searchUserDirectory.mockRejectedValue( + new Error("M_LIMIT_EXCEEDED: Too many requests"), + ); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo' }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Unexpected error while searching in the user directory' }, + }); + }); + }); + }); }); diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index bc0a56f..f8d42f5 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -17,6 +17,7 @@ import { UnstableApiVersion } from '../src/interfaces/ApiVersion'; import { IReadRelationsFromWidgetResponseData } from '../src/interfaces/ReadRelationsAction'; import { ISupportedVersionsActionResponseData } from '../src/interfaces/SupportedVersionsAction'; +import { IUserDirectorySearchFromWidgetResponseData } from '../src/interfaces/UserDirectorySearchAction'; import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction'; import { PostmessageTransport } from '../src/transport/PostmessageTransport'; import { WidgetApi } from '../src/WidgetApi'; @@ -122,4 +123,59 @@ describe('WidgetApi', () => { expect(PostmessageTransport.prototype.send).toBeCalledTimes(1); }) }); + + describe('searchUserDirectory', () => { + it('should forward the request to the ClientWidgetApi', async () => { + jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, + ); + jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + { + limited: false, + results: [], + } as IUserDirectorySearchFromWidgetResponseData, + ); + + await expect(widgetApi.searchUserDirectory( + 'foo', 10, + )).resolves.toEqual({ + limited: false, + results: [], + }); + + expect(PostmessageTransport.prototype.send).toBeCalledWith( + WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + { + search_term: 'foo', + limit: 10, + }, + ); + }); + + it('should reject the request if the api is not supported', async () => { + jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + { supported_versions: [] } as ISupportedVersionsActionResponseData, + ); + + await expect(widgetApi.searchUserDirectory( + 'foo', 10, + )).rejects.toThrow("The user_directory_search action is not supported by the client."); + + expect(PostmessageTransport.prototype.send) + .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, expect.anything()); + }); + + it('should handle an error', async () => { + jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, + ); + jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( + new Error('An error occurred'), + ); + + await expect(widgetApi.searchUserDirectory( + 'foo', 10, + )).rejects.toThrow('An error occurred'); + }); + }); });