feat: Add slashcommand api

This commit is contained in:
2026-06-05 17:12:26 +02:00
parent 4070ef6caf
commit 8ecfc9a1fe
101 changed files with 3526 additions and 147 deletions

View File

@@ -24,6 +24,8 @@ Plugins can inspect the current interaction context through `api.context.getCurr
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.

View File

@@ -0,0 +1,653 @@
import { Injector, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../../server-directory';
import { AttachmentFacade } from '../../../attachment';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type {
Channel,
Message,
Room,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import {
selectActiveChannelId,
selectCurrentRoom,
selectCurrentRoomChannels,
selectCurrentRoomId
} from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import {
PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
assertPluginApiSurfaceImplemented,
collectPluginApiMethodPaths,
type PluginClientApiMethodPath
} from '../../domain/logic/plugin-client-api-surface.rules';
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginMessageBusService } from './plugin-message-bus.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
const TEST_MANIFEST = createTestManifest();
describe('PluginClientApiService', () => {
let context: ServiceTestContext;
beforeEach(() => {
context = createServiceTestContext();
context.capabilities.grantAll(TEST_MANIFEST);
});
it('implements the full public plugin API surface', () => {
const api = context.service.createApi(TEST_MANIFEST);
expect(() => assertPluginApiSurfaceImplemented(api as Record<string, Record<string, unknown>>)).not.toThrow();
});
it('freezes the API object returned to plugins', () => {
const api = context.service.createApi(TEST_MANIFEST);
expect(Object.isFrozen(api)).toBe(true);
expect(Object.isFrozen(api.commands)).toBe(true);
expect(Object.isFrozen(api.messages)).toBe(true);
});
it('exposes current interaction context without capability checks', () => {
const api = context.service.createApi(TEST_MANIFEST);
expect(api.context.getCurrent()).toEqual({
server: context.room(),
source: 'manual',
textChannel: context.channels().find((channel) => channel.id === 'general') ?? null,
user: context.currentUser(),
voiceChannel: null
});
});
it('routes logger calls to the plugin logger service', () => {
const api = context.service.createApi(TEST_MANIFEST);
api.logger.debug('debug');
api.logger.error('error');
api.logger.info('info');
api.logger.warn('warn');
expect(context.logger.debug).toHaveBeenCalledWith(TEST_MANIFEST.id, 'debug', undefined);
expect(context.logger.error).toHaveBeenCalledWith(TEST_MANIFEST.id, 'error', undefined);
expect(context.logger.info).toHaveBeenCalledWith(TEST_MANIFEST.id, 'info', undefined);
expect(context.logger.warn).toHaveBeenCalledWith(TEST_MANIFEST.id, 'warn', undefined);
});
it('dispatches profile updates through the users store', () => {
const api = context.service.createApi(TEST_MANIFEST);
api.profile.update({ displayName: 'Plugin User' });
api.profile.updateAvatar({
avatarHash: 'hash',
avatarMime: 'image/png',
avatarUrl: '/avatar.png'
});
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserProfile({
profile: expect.objectContaining({ displayName: 'Plugin User' })
}));
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserAvatar({
avatar: expect.objectContaining({ avatarUrl: '/avatar.png' })
}));
});
it('sends plugin messages and broadcasts them to peers', () => {
const api = context.service.createApi(TEST_MANIFEST);
const message = api.messages.send('hello plugin');
expect(message.content).toBe('hello plugin');
expect(message.roomId).toBe('room-1');
expect(context.store.dispatch).toHaveBeenCalledWith(MessagesActions.sendMessageSuccess({ message }));
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'chat-message',
message
}));
});
it('publishes typing state through the realtime facade', () => {
const api = context.service.createApi(TEST_MANIFEST);
api.messages.setTyping(true, 'general');
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({
type: 'typing',
serverId: 'room-1',
channelId: 'general',
isTyping: true
});
});
it('forwards slash command registration to the UI registry', () => {
const api = context.service.createApi(TEST_MANIFEST);
const contribution = {
name: 'echo',
run: () => {}
};
api.commands.register('echo', contribution);
expect(context.uiRegistry.registerSlashCommand).toHaveBeenCalledWith(
TEST_MANIFEST.id,
'echo',
contribution
);
});
it('lists slash commands from the UI registry', () => {
const commands = [{ name: 'echo', run: () => {} }];
context.uiRegistry.slashCommands.mockReturnValue(commands);
const api = context.service.createApi(TEST_MANIFEST);
expect(api.commands.list()).toBe(commands);
});
it('routes storage APIs through the plugin storage service', async () => {
const api = context.service.createApi(TEST_MANIFEST);
api.storage.set('key', { ok: true });
api.storage.get('key');
api.storage.remove('key');
await api.clientData.write('client-key', { ok: true });
await api.clientData.read('client-key');
await api.clientData.remove('client-key');
await api.serverData.write('server-key', { ok: true });
await api.serverData.read('server-key');
await api.serverData.remove('server-key');
expect(context.storage.setLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key', { ok: true });
expect(context.storage.getLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
expect(context.storage.removeLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
expect(context.storage.writeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key', { ok: true });
expect(context.storage.readClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
expect(context.storage.removeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
expect(context.storage.writeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key', { ok: true });
expect(context.storage.readServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
expect(context.storage.removeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
});
it('publishes declared server events through the realtime facade', () => {
const api = context.service.createApi(TEST_MANIFEST);
api.events.publishServer('e2e:server', { ok: true });
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'plugin_event',
eventName: 'e2e:server',
payload: { ok: true },
pluginId: TEST_MANIFEST.id,
serverId: 'room-1'
}));
});
it('rejects undeclared plugin events', () => {
const api = context.service.createApi(TEST_MANIFEST);
expect(() => api.events.publishServer('missing:event', {})).toThrow(/did not declare event/);
});
it('enforces capability grants for privileged API methods', async () => {
const api = context.service.createApi(TEST_MANIFEST);
context.capabilities.revokeAll(TEST_MANIFEST.id);
for (const path of collectPluginApiMethodPaths()) {
const capability = PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS[path as PluginClientApiMethodPath];
if (!capability) {
continue;
}
const [namespace, method] = path.split('.') as [keyof typeof api, string];
const target = api[namespace] as Record<string, (...args: unknown[]) => unknown>;
await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError);
}
});
});
interface ServiceTestContext {
capabilities: PluginCapabilityService;
channels: ReturnType<typeof signal<Channel[]>>;
currentUser: ReturnType<typeof signal<User | null>>;
logger: {
debug: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
};
messages: ReturnType<typeof signal<Message[]>>;
realtime: {
onSignalingMessage: Subject<unknown>;
sendRawMessage: ReturnType<typeof vi.fn>;
};
room: ReturnType<typeof signal<Room | null>>;
service: PluginClientApiService;
storage: {
getLocal: ReturnType<typeof vi.fn>;
readClientData: ReturnType<typeof vi.fn>;
readServerData: ReturnType<typeof vi.fn>;
removeClientData: ReturnType<typeof vi.fn>;
removeLocal: ReturnType<typeof vi.fn>;
removeServerData: ReturnType<typeof vi.fn>;
setLocal: ReturnType<typeof vi.fn>;
writeClientData: ReturnType<typeof vi.fn>;
writeServerData: ReturnType<typeof vi.fn>;
};
store: {
dispatch: ReturnType<typeof vi.fn>;
};
uiRegistry: {
registerSlashCommand: ReturnType<typeof vi.fn>;
slashCommands: ReturnType<typeof vi.fn>;
};
voice: {
broadcastMessage: ReturnType<typeof vi.fn>;
getConnectedPeers: ReturnType<typeof vi.fn>;
setInputVolume: ReturnType<typeof vi.fn>;
setLocalStream: ReturnType<typeof vi.fn>;
setOutputVolume: ReturnType<typeof vi.fn>;
};
}
function createServiceTestContext(): ServiceTestContext {
installLocalStorageMock();
installBrowserMediaMocks();
const currentUser = signal<User | null>(createUser());
const users = signal<User[]>(currentUser() ? [currentUser() as User] : []);
const room = signal<Room | null>(createRoom());
const channels = signal<Channel[]>(room()?.channels ?? []);
const messages = signal<Message[]>([]);
const activeChannelId = signal('general');
const roomId = signal('room-1');
const logger = {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
};
const storage = {
getLocal: vi.fn(),
setLocal: vi.fn(),
removeLocal: vi.fn(),
readClientData: vi.fn(async () => null),
writeClientData: vi.fn(async () => undefined),
removeClientData: vi.fn(async () => undefined),
readServerData: vi.fn(async () => null),
writeServerData: vi.fn(async () => undefined),
removeServerData: vi.fn(async () => undefined)
};
const uiRegistry = {
registerSlashCommand: vi.fn(() => ({ dispose: vi.fn() })),
slashCommands: vi.fn(() => [])
};
const voice = {
broadcastMessage: vi.fn(),
getConnectedPeers: vi.fn(() => []),
setInputVolume: vi.fn(),
setLocalStream: vi.fn(async () => undefined),
setOutputVolume: vi.fn()
};
const realtime = {
onSignalingMessage: new Subject<unknown>(),
sendRawMessage: vi.fn()
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return users;
}
if (selector === selectCurrentRoom) {
return room;
}
if (selector === selectCurrentRoomChannels) {
return channels;
}
if (selector === selectCurrentRoomMessages) {
return messages;
}
if (selector === selectActiveChannelId) {
return activeChannelId;
}
if (selector === selectCurrentRoomId) {
return roomId;
}
throw new Error(`Unexpected selector in PluginClientApiService test: ${String(selector)}`);
})
};
const injector = Injector.create({
providers: [
PluginClientApiService,
PluginCapabilityService,
{
provide: AttachmentFacade,
useValue: {
publishAttachments: vi.fn(async () => undefined),
rememberMessageRoom: vi.fn()
}
},
{
provide: DatabaseService,
useValue: {
saveMessage: vi.fn(async () => undefined),
updateMessage: vi.fn(async () => undefined),
updateRoom: vi.fn(async () => undefined)
}
},
{
provide: PluginDesktopStateService,
useValue: {
readJson: vi.fn(async () => null),
writeJson: vi.fn(async () => undefined)
}
},
{
provide: PluginLoggerService,
useValue: logger
},
{
provide: PluginMessageBusService,
useValue: {
publish: vi.fn(() => ({ topic: 'test' })),
sendLatestMessages: vi.fn(() => ({ topic: 'test' })),
subscribe: vi.fn(() => ({ dispose: vi.fn() }))
}
},
{
provide: PluginStorageService,
useValue: storage
},
{
provide: PluginUiRegistryService,
useValue: uiRegistry
},
{
provide: RealtimeSessionFacade,
useValue: {
broadcastMessage: vi.fn(),
onSignalingMessage: realtime.onSignalingMessage.asObservable(),
sendRawMessage: realtime.sendRawMessage
}
},
{
provide: ServerDirectoryFacade,
useValue: {
updateServer: vi.fn(() => ({ subscribe: vi.fn() }))
}
},
{
provide: Store,
useValue: store
},
{
provide: VoiceConnectionFacade,
useValue: voice
}
]
});
return {
capabilities: injector.get(PluginCapabilityService),
channels,
currentUser,
logger,
messages,
realtime,
room,
service: injector.get(PluginClientApiService),
storage,
store,
uiRegistry,
voice
};
}
function createTestManifest(): TojuPluginManifest {
return {
apiVersion: '1.0.0',
capabilities: [
'profile.read',
'profile.write',
'users.read',
'users.manage',
'roles.read',
'roles.manage',
'messages.read',
'messages.send',
'messages.editOwn',
'messages.deleteOwn',
'messages.moderate',
'messages.sync',
'channels.read',
'channels.manage',
'server.read',
'server.manage',
'p2p.data',
'media.playAudio',
'media.addAudioStream',
'media.addVideoStream',
'audio.volume',
'ui.settings',
'ui.pages',
'ui.sidePanel',
'ui.channelsSection',
'ui.embeds',
'ui.dom',
'ui.commands',
'storage.local',
'storage.serverData.read',
'storage.serverData.write',
'events.server.publish',
'events.server.subscribe',
'events.p2p.publish',
'events.p2p.subscribe'
],
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Plugin API service test fixture',
events: [
{
direction: 'serverRelay',
eventName: 'e2e:server',
scope: 'server'
}
],
id: 'test.plugin-api',
kind: 'client',
schemaVersion: 1,
title: 'Plugin API Service Fixture',
version: '1.0.0'
};
}
function createUser(): User {
return {
displayName: 'Alice',
id: 'user-1',
isOnline: true,
joinedAt: Date.now(),
oderId: 'user-1',
role: 'host',
status: 'online',
username: 'alice'
};
}
function createRoom(): Room {
return {
channels: [{ id: 'general', name: 'general', position: 0, type: 'text' }, { id: 'voice', name: 'voice', position: 1, type: 'voice' }],
description: 'Plugin API room',
hostId: 'user-1',
id: 'room-1',
isPrivate: false,
members: [],
name: 'Plugin API Room',
roles: []
};
}
async function invokeApiMethod(method: (...args: unknown[]) => unknown, path: string): Promise<unknown> {
switch (path) {
case 'attachments.import':
return method({ files: [], messageId: 'message-1' });
case 'channels.addAudioChannel':
return method({ name: 'Audio' });
case 'channels.addTextChannel':
return method({ name: 'Text' });
case 'channels.addVideoChannel':
return method({ name: 'Video' });
case 'channels.remove':
return method('general');
case 'channels.rename':
return method('general', 'renamed');
case 'channels.select':
return method('general');
case 'clientData.read':
case 'serverData.read':
return method('key');
case 'clientData.remove':
case 'serverData.remove':
return method('key');
case 'clientData.write':
case 'serverData.write':
return method('key', { ok: true });
case 'commands.register':
return method('echo', { name: 'echo', run: () => {} });
case 'events.publishP2p':
case 'events.publishServer':
return method('e2e:server', {});
case 'events.subscribeP2p':
case 'events.subscribeServer':
return method({ eventName: 'e2e:server', handler: () => {} });
case 'media.addCustomAudioStream':
case 'media.addCustomVideoStream':
return method({ stream: new MediaStream() });
case 'media.playAudioClip':
return method({ url: 'data:audio/wav;base64,' });
case 'media.setInputVolume':
case 'media.setOutputVolume':
return method(0.5);
case 'messageBus.publish':
return method({ topic: 'test' });
case 'messageBus.sendLatestMessages':
return method({});
case 'messageBus.subscribe':
return method({ handler: () => {} });
case 'messages.delete':
return method('message-1');
case 'messages.edit':
return method('message-1', 'updated');
case 'messages.moderateDelete':
return method('message-1');
case 'messages.import':
return method([]);
case 'messages.send':
return method('hello');
case 'messages.sendAsPluginUser':
return method({ content: 'hello', pluginUserId: 'bot' });
case 'messages.setTyping':
return method(true);
case 'messages.subscribeTyping':
return method(() => {});
case 'messages.sync':
return method([]);
case 'p2p.broadcastData':
return method('e2e:server', {});
case 'p2p.sendData':
return method('peer-1', 'e2e:server', {});
case 'profile.update':
return method({ displayName: 'Updated' });
case 'profile.updateAvatar':
return method({ avatarHash: 'hash', avatarMime: 'image/png', avatarUrl: '/avatar.png' });
case 'roles.setAssignments':
return method([]);
case 'server.registerPluginUser':
return method({ displayName: 'Bot' });
case 'server.updateIcon':
return method('icon-hash');
case 'server.updatePermissions':
return method({ allowVoice: true });
case 'server.updateSettings':
return method({ name: 'Room' });
case 'storage.get':
return method('key');
case 'storage.remove':
return method('key');
case 'storage.set':
return method('key', { ok: true });
case 'ui.mountElement':
return method('mount', { element: { tagName: 'DIV' }, target: 'body' });
case 'ui.registerAppPage':
case 'ui.registerChannelSection':
case 'ui.registerComposerAction':
case 'ui.registerEmbedRenderer':
case 'ui.registerProfileAction':
case 'ui.registerSettingsPage':
case 'ui.registerSidePanel':
case 'ui.registerToolbarAction':
return method('id', { label: 'Test', render: () => 'ok', run: () => {} });
case 'users.ban':
return method('user-2', 'reason');
case 'users.kick':
return method('user-2');
case 'users.setRole':
return method('user-2', 'member');
default:
return Promise.resolve(method());
}
}
function installBrowserMediaMocks(): void {
vi.stubGlobal('MediaStream', class MediaStream {});
vi.stubGlobal('Audio', class Audio {
volume = 1;
async play(): Promise<void> {}
});
}
function installLocalStorageMock(): void {
const storage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
}
});
}

View File

@@ -39,6 +39,7 @@ import type {
PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest,
PluginApiServerSettingsUpdate,
PluginApiSlashCommandContext,
PluginApiTypingEvent,
TojuClientPluginApi,
TojuPluginDisposable
@@ -77,6 +78,16 @@ export class PluginClientApiService {
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
return deepFreeze<TojuClientPluginApi>({
commands: {
list: () => {
requireCapability('ui.commands');
return this.uiRegistry.slashCommands();
},
register: (id, contribution) => {
requireCapability('ui.commands');
return this.uiRegistry.registerSlashCommand(pluginId, id, contribution);
}
},
channels: {
addAudioChannel: (request) => {
requireCapability('channels.manage');
@@ -513,6 +524,15 @@ export class PluginClientApiService {
};
}
createSlashCommandContext(request: { args: Record<string, string>; command: string; rawArgs: string }): PluginApiSlashCommandContext {
return {
...this.createActionContext('slashCommand'),
args: request.args,
command: request.command,
rawArgs: request.rawArgs
};
}
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;

View File

@@ -12,6 +12,7 @@ import type {
PluginApiPageContribution,
PluginApiPanelContribution,
PluginApiSettingsPageContribution,
PluginApiSlashCommandContribution,
PluginApiUiContributionMap,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
@@ -53,6 +54,8 @@ export class PluginUiRegistryService {
readonly settingsPageRecords = this.createContributionRecordSignal('settingsPages');
readonly sidePanels = this.createContributionSignal('sidePanels');
readonly sidePanelRecords = this.createContributionRecordSignal('sidePanels');
readonly slashCommands = this.createContributionSignal('slashCommands');
readonly slashCommandRecords = this.createContributionRecordSignal('slashCommands');
readonly toolbarActions = this.createContributionSignal('toolbarActions');
readonly toolbarActionRecords = this.createContributionRecordSignal('toolbarActions');
readonly conflicts = computed(() => this.collectConflicts());
@@ -66,6 +69,7 @@ export class PluginUiRegistryService {
profileActions: PluginUiContributionRecord<PluginApiActionContribution>[];
settingsPages: PluginUiContributionRecord<PluginApiSettingsPageContribution>[];
sidePanels: PluginUiContributionRecord<PluginApiPanelContribution>[];
slashCommands: PluginUiContributionRecord<PluginApiSlashCommandContribution>[];
toolbarActions: PluginUiContributionRecord<PluginApiActionContribution>[];
}>({
appPages: [],
@@ -75,6 +79,7 @@ export class PluginUiRegistryService {
profileActions: [],
settingsPages: [],
sidePanels: [],
slashCommands: [],
toolbarActions: []
});
@@ -125,6 +130,10 @@ export class PluginUiRegistryService {
return this.register('sidePanels', pluginId, id, contribution);
}
registerSlashCommand(pluginId: string, id: string, contribution: PluginApiSlashCommandContribution): TojuPluginDisposable {
return this.register('slashCommands', pluginId, id, contribution);
}
registerToolbarAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('toolbarActions', pluginId, id, contribution);
}
@@ -144,6 +153,7 @@ export class PluginUiRegistryService {
profileActions: current.profileActions.filter((entry) => entry.pluginId !== pluginId),
settingsPages: current.settingsPages.filter((entry) => entry.pluginId !== pluginId),
sidePanels: current.sidePanels.filter((entry) => entry.pluginId !== pluginId),
slashCommands: current.slashCommands.filter((entry) => entry.pluginId !== pluginId),
toolbarActions: current.toolbarActions.filter((entry) => entry.pluginId !== pluginId)
}));
}

View File

@@ -0,0 +1,180 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { PLUGIN_CAPABILITIES } from '../../../../shared-kernel';
import {
PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
PLUGIN_CLIENT_API_SURFACE,
assertPluginApiSurfaceImplemented,
collectPluginApiMethodPaths,
collectRequiredPluginApiCapabilities
} from './plugin-client-api-surface.rules';
const E2E_ALL_API_MANIFEST_PATH = resolve(
process.cwd(),
'public/plugins/e2e-all-api/toju.plugin.json'
);
describe('plugin client API surface rules', () => {
it('lists every documented namespace and method', () => {
expect(collectPluginApiMethodPaths()).toEqual([
'attachments.import',
'channels.addAudioChannel',
'channels.addTextChannel',
'channels.addVideoChannel',
'channels.list',
'channels.remove',
'channels.rename',
'channels.select',
'clientData.read',
'clientData.remove',
'clientData.write',
'commands.list',
'commands.register',
'context.getCurrent',
'events.publishP2p',
'events.publishServer',
'events.subscribeP2p',
'events.subscribeServer',
'logger.debug',
'logger.error',
'logger.info',
'logger.warn',
'media.addCustomAudioStream',
'media.addCustomVideoStream',
'media.playAudioClip',
'media.setInputVolume',
'media.setOutputVolume',
'messageBus.publish',
'messageBus.sendLatestMessages',
'messageBus.subscribe',
'messages.delete',
'messages.edit',
'messages.import',
'messages.moderateDelete',
'messages.readCurrent',
'messages.send',
'messages.sendAsPluginUser',
'messages.setTyping',
'messages.subscribeTyping',
'messages.sync',
'p2p.broadcastData',
'p2p.connectedPeers',
'p2p.sendData',
'profile.getCurrent',
'profile.update',
'profile.updateAvatar',
'roles.list',
'roles.setAssignments',
'server.getCurrent',
'server.registerPluginUser',
'server.updateIcon',
'server.updatePermissions',
'server.updateSettings',
'serverData.read',
'serverData.remove',
'serverData.write',
'storage.get',
'storage.remove',
'storage.set',
'ui.mountElement',
'ui.registerAppPage',
'ui.registerChannelSection',
'ui.registerComposerAction',
'ui.registerEmbedRenderer',
'ui.registerProfileAction',
'ui.registerSettingsPage',
'ui.registerSidePanel',
'ui.registerToolbarAction',
'users.ban',
'users.getCurrent',
'users.kick',
'users.list',
'users.readMembers',
'users.setRole'
]);
});
it('maps privileged methods to known plugin capabilities', () => {
for (const capability of Object.values(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS)) {
expect(PLUGIN_CAPABILITIES).toContain(capability);
}
});
it('requires a capability for every privileged namespace method', () => {
const privilegedNamespaces = Object.entries(PLUGIN_CLIENT_API_SURFACE)
.filter(([namespace]) => !['context', 'logger'].includes(namespace));
for (const [namespace, methods] of privilegedNamespaces) {
for (const method of methods) {
const path = `${namespace}.${method}`;
expect(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS).toHaveProperty(path);
}
}
});
it('detects missing API methods', () => {
expect(() => assertPluginApiSurfaceImplemented({
commands: { list: () => [] }
})).toThrow(/Plugin API surface is incomplete/);
});
it('accepts a fully implemented API object', () => {
const api = Object.fromEntries(
Object.entries(PLUGIN_CLIENT_API_SURFACE).map(([namespace, methods]) => {
const namespaceApi = Object.fromEntries(methods.map((method) => [method, () => undefined]));
return [namespace, namespaceApi];
})
);
expect(() => assertPluginApiSurfaceImplemented(api)).not.toThrow();
});
it('keeps the E2E all-api fixture manifest granted for full API coverage', () => {
const manifest = JSON.parse(readFileSync(E2E_ALL_API_MANIFEST_PATH, 'utf8')) as TojuPluginManifest;
expect(manifest.capabilities ?? []).toEqual(expect.arrayContaining(collectRequiredPluginApiCapabilities()));
});
it('collects the full capability set needed for API coverage', () => {
expect(collectRequiredPluginApiCapabilities()).toEqual([
'audio.volume',
'channels.manage',
'channels.read',
'events.p2p.publish',
'events.p2p.subscribe',
'events.server.publish',
'events.server.subscribe',
'media.addAudioStream',
'media.addVideoStream',
'media.playAudio',
'messages.deleteOwn',
'messages.editOwn',
'messages.moderate',
'messages.read',
'messages.send',
'messages.sync',
'p2p.data',
'profile.read',
'profile.write',
'roles.manage',
'roles.read',
'server.manage',
'server.read',
'storage.local',
'storage.serverData.read',
'storage.serverData.write',
'ui.channelsSection',
'ui.commands',
'ui.dom',
'ui.embeds',
'ui.pages',
'ui.settings',
'ui.sidePanel',
'users.manage',
'users.read'
]);
});
});

View File

@@ -0,0 +1,228 @@
import type { PluginCapabilityId } from '../../../../shared-kernel';
/**
* Canonical registry of every public `TojuClientPluginApi` namespace and method.
* Keep this list aligned with `plugin-api.models.ts` when the contract changes.
*/
export const PLUGIN_CLIENT_API_SURFACE = {
attachments: ['import'],
channels: [
'addAudioChannel',
'addTextChannel',
'addVideoChannel',
'list',
'remove',
'rename',
'select'
],
clientData: [
'read',
'remove',
'write'
],
commands: ['list', 'register'],
context: ['getCurrent'],
events: [
'publishP2p',
'publishServer',
'subscribeP2p',
'subscribeServer'
],
logger: [
'debug',
'error',
'info',
'warn'
],
media: [
'addCustomAudioStream',
'addCustomVideoStream',
'playAudioClip',
'setInputVolume',
'setOutputVolume'
],
messageBus: [
'publish',
'sendLatestMessages',
'subscribe'
],
messages: [
'delete',
'edit',
'import',
'moderateDelete',
'readCurrent',
'send',
'sendAsPluginUser',
'setTyping',
'subscribeTyping',
'sync'
],
p2p: [
'broadcastData',
'connectedPeers',
'sendData'
],
profile: [
'getCurrent',
'update',
'updateAvatar'
],
roles: ['list', 'setAssignments'],
server: [
'getCurrent',
'registerPluginUser',
'updateIcon',
'updatePermissions',
'updateSettings'
],
serverData: [
'read',
'remove',
'write'
],
storage: [
'get',
'remove',
'set'
],
ui: [
'mountElement',
'registerAppPage',
'registerChannelSection',
'registerComposerAction',
'registerEmbedRenderer',
'registerProfileAction',
'registerSettingsPage',
'registerSidePanel',
'registerToolbarAction'
],
users: [
'ban',
'getCurrent',
'kick',
'list',
'readMembers',
'setRole'
]
} as const;
export type PluginClientApiNamespace = keyof typeof PLUGIN_CLIENT_API_SURFACE;
export type PluginClientApiMethodPath = {
[Namespace in PluginClientApiNamespace]: `${Namespace & string}.${typeof PLUGIN_CLIENT_API_SURFACE[Namespace][number]}`;
}[PluginClientApiNamespace];
/**
* Capability required before a method may run. Methods omitted here are always available.
*/
export const PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS: Partial<Record<PluginClientApiMethodPath, PluginCapabilityId>> = {
'attachments.import': 'messages.sync',
'channels.addAudioChannel': 'channels.manage',
'channels.addTextChannel': 'channels.manage',
'channels.addVideoChannel': 'channels.manage',
'channels.list': 'channels.read',
'channels.remove': 'channels.manage',
'channels.rename': 'channels.manage',
'channels.select': 'channels.read',
'clientData.read': 'storage.local',
'clientData.remove': 'storage.local',
'clientData.write': 'storage.local',
'commands.list': 'ui.commands',
'commands.register': 'ui.commands',
'events.publishP2p': 'events.p2p.publish',
'events.publishServer': 'events.server.publish',
'events.subscribeP2p': 'events.p2p.subscribe',
'events.subscribeServer': 'events.server.subscribe',
'media.addCustomAudioStream': 'media.addAudioStream',
'media.addCustomVideoStream': 'media.addVideoStream',
'media.playAudioClip': 'media.playAudio',
'media.setInputVolume': 'audio.volume',
'media.setOutputVolume': 'audio.volume',
'messageBus.publish': 'events.p2p.publish',
'messageBus.sendLatestMessages': 'events.p2p.publish',
'messageBus.subscribe': 'events.p2p.subscribe',
'messages.delete': 'messages.deleteOwn',
'messages.edit': 'messages.editOwn',
'messages.import': 'messages.sync',
'messages.moderateDelete': 'messages.moderate',
'messages.readCurrent': 'messages.read',
'messages.send': 'messages.send',
'messages.sendAsPluginUser': 'messages.send',
'messages.setTyping': 'messages.send',
'messages.subscribeTyping': 'messages.read',
'messages.sync': 'messages.sync',
'p2p.broadcastData': 'p2p.data',
'p2p.connectedPeers': 'p2p.data',
'p2p.sendData': 'p2p.data',
'profile.getCurrent': 'profile.read',
'profile.update': 'profile.write',
'profile.updateAvatar': 'profile.write',
'roles.list': 'roles.read',
'roles.setAssignments': 'roles.manage',
'server.getCurrent': 'server.read',
'server.registerPluginUser': 'users.manage',
'server.updateIcon': 'server.manage',
'server.updatePermissions': 'server.manage',
'server.updateSettings': 'server.manage',
'serverData.read': 'storage.serverData.read',
'serverData.remove': 'storage.serverData.write',
'serverData.write': 'storage.serverData.write',
'storage.get': 'storage.local',
'storage.remove': 'storage.local',
'storage.set': 'storage.local',
'ui.mountElement': 'ui.dom',
'ui.registerAppPage': 'ui.pages',
'ui.registerChannelSection': 'ui.channelsSection',
'ui.registerComposerAction': 'ui.pages',
'ui.registerEmbedRenderer': 'ui.embeds',
'ui.registerProfileAction': 'ui.pages',
'ui.registerSettingsPage': 'ui.settings',
'ui.registerSidePanel': 'ui.sidePanel',
'ui.registerToolbarAction': 'ui.pages',
'users.ban': 'users.manage',
'users.getCurrent': 'users.read',
'users.kick': 'users.manage',
'users.list': 'users.read',
'users.readMembers': 'users.read',
'users.setRole': 'roles.manage'
};
export function collectPluginApiMethodPaths(
surface: typeof PLUGIN_CLIENT_API_SURFACE = PLUGIN_CLIENT_API_SURFACE
): PluginClientApiMethodPath[] {
return Object.entries(surface).flatMap(([namespace, methods]) =>
methods.map((method) => `${namespace}.${method}` as PluginClientApiMethodPath)
);
}
export function getPluginApiMethod(
api: Record<string, Record<string, unknown>>,
path: PluginClientApiMethodPath
): unknown {
const [namespace, method] = path.split('.') as [PluginClientApiNamespace, string];
return api[namespace]?.[method];
}
export function assertPluginApiSurfaceImplemented(
api: Record<string, Record<string, unknown>>,
surface: typeof PLUGIN_CLIENT_API_SURFACE = PLUGIN_CLIENT_API_SURFACE
): void {
const missing: string[] = [];
for (const path of collectPluginApiMethodPaths(surface)) {
const method = getPluginApiMethod(api, path);
if (typeof method !== 'function') {
missing.push(path);
}
}
if (missing.length > 0) {
throw new Error(`Plugin API surface is incomplete: ${missing.join(', ')}`);
}
}
export function collectRequiredPluginApiCapabilities(): PluginCapabilityId[] {
return Array.from(new Set(Object.values(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS))).sort();
}

View File

@@ -0,0 +1,143 @@
import {
describe,
expect,
it
} from 'vitest';
import type { PluginApiSlashCommandContribution } from '../models/plugin-api.models';
import {
filterSlashCommands,
findSlashCommand,
parseSlashCommandArguments,
parseSlashCommandInput,
parseSlashCommandQuery,
selectAvailableSlashCommands,
type SlashCommandEntry
} from './slash-command.rules';
function entry(name: string, overrides: Partial<PluginApiSlashCommandContribution> = {}, pluginId = 'plugin.test'): SlashCommandEntry {
return {
contribution: {
name,
run: () => {},
...overrides
},
id: `${pluginId}:${name}`,
pluginId
};
}
describe('parseSlashCommandQuery', () => {
it('returns the empty query for a lone slash', () => {
expect(parseSlashCommandQuery('/')).toBe('');
});
it('returns the partial name while typing', () => {
expect(parseSlashCommandQuery('/gi')).toBe('gi');
});
it('returns null once whitespace (arguments) are typed', () => {
expect(parseSlashCommandQuery('/giphy cat')).toBeNull();
});
it('returns null for non-command text', () => {
expect(parseSlashCommandQuery('hello')).toBeNull();
});
});
describe('parseSlashCommandInput', () => {
it('parses a command without arguments', () => {
expect(parseSlashCommandInput('/ping')).toEqual({ name: 'ping', rawArgs: '' });
});
it('parses a command with arguments', () => {
expect(parseSlashCommandInput('/giphy funny cat ')).toEqual({ name: 'giphy', rawArgs: 'funny cat' });
});
it('returns null for a lone slash', () => {
expect(parseSlashCommandInput('/')).toBeNull();
});
it('returns null for non-command text', () => {
expect(parseSlashCommandInput('not a command')).toBeNull();
});
});
describe('selectAvailableSlashCommands', () => {
const entries = [
entry('alpha', { scope: 'global' }),
entry('zeta', { scope: 'server' }),
entry('beta', { scope: 'global' })
];
it('includes global and server commands on a server surface, sorted by name', () => {
expect(selectAvailableSlashCommands(entries, 'server').map((item) => item.contribution.name)).toEqual([
'alpha',
'beta',
'zeta'
]);
});
it('excludes server-scoped commands on a direct surface', () => {
expect(selectAvailableSlashCommands(entries, 'direct').map((item) => item.contribution.name)).toEqual(['alpha', 'beta']);
});
it('treats a missing scope as global', () => {
expect(selectAvailableSlashCommands([entry('plain')], 'direct')).toHaveLength(1);
});
});
describe('filterSlashCommands', () => {
const entries = [
entry('giphy'),
entry('gif-search'),
entry('roll')
];
it('returns all entries for an empty query', () => {
expect(filterSlashCommands(entries, '')).toHaveLength(3);
});
it('ranks prefix matches above contains matches', () => {
const names = filterSlashCommands([entry('a-gif'), entry('gif')], 'gif').map((item) => item.contribution.name);
expect(names).toEqual(['gif', 'a-gif']);
});
it('matches case-insensitively', () => {
expect(filterSlashCommands(entries, 'GIF').map((item) => item.contribution.name)).toContain('gif-search');
});
});
describe('findSlashCommand', () => {
const entries = [entry('Ping'), entry('roll')];
it('matches the command name case-insensitively', () => {
expect(findSlashCommand(entries, 'ping')?.contribution.name).toBe('Ping');
});
it('returns null when no command matches', () => {
expect(findSlashCommand(entries, 'unknown')).toBeNull();
});
});
describe('parseSlashCommandArguments', () => {
it('returns an empty record when the command has no options', () => {
expect(parseSlashCommandArguments('anything here', [])).toEqual({});
});
it('maps positional tokens to option names', () => {
const args = parseSlashCommandArguments('6 advantage', [{ name: 'sides' }, { name: 'mode' }]);
expect(args).toEqual({ sides: '6', mode: 'advantage' });
});
it('captures the remaining text for a rest option', () => {
const args = parseSlashCommandArguments('happy birthday to you', [{ name: 'tone' }, { name: 'message', type: 'rest' }]);
expect(args).toEqual({ tone: 'happy', message: 'birthday to you' });
});
it('fills missing positional options with empty strings', () => {
expect(parseSlashCommandArguments('', [{ name: 'first' }])).toEqual({ first: '' });
});
});

View File

@@ -0,0 +1,141 @@
import type {
PluginApiSlashCommandContribution,
PluginApiSlashCommandOption,
PluginApiSlashCommandScope
} from '../models/plugin-api.models';
/**
* The chat surface a composer is rendered on. `server` surfaces expose both
* global and server-scoped commands; `direct` surfaces (DMs/group chats) only
* expose global commands.
*/
export type SlashCommandSurface = 'server' | 'direct';
/** A registered slash command together with the owning plugin. */
export interface SlashCommandEntry {
contribution: PluginApiSlashCommandContribution;
id: string;
pluginId: string;
}
export interface ParsedSlashCommandInput {
name: string;
rawArgs: string;
}
function resolveScope(contribution: PluginApiSlashCommandContribution): PluginApiSlashCommandScope {
return contribution.scope === 'server' ? 'server' : 'global';
}
function normalizeName(value: string): string {
return value.trim().toLowerCase();
}
/**
* Returns the live query while the user is still typing a command name, e.g.
* `/gi` -> `gi` and `/` -> ``. Returns `null` once the input no longer looks
* like an in-progress command (contains whitespace or does not start with `/`).
*/
export function parseSlashCommandQuery(text: string): string | null {
const match = /^\/(\S*)$/.exec(text);
return match ? match[1] : null;
}
/**
* Parses a fully typed command for execution. Returns the command name and the
* raw remaining argument string, or `null` when the text is not a command.
*/
export function parseSlashCommandInput(text: string): ParsedSlashCommandInput | null {
if (!text.startsWith('/')) {
return null;
}
const body = text.slice(1);
const whitespaceIndex = body.search(/\s/);
if (whitespaceIndex === -1) {
return body.length > 0 ? { name: body, rawArgs: '' } : null;
}
const name = body.slice(0, whitespaceIndex);
if (!name) {
return null;
}
return { name, rawArgs: body.slice(whitespaceIndex + 1).trim() };
}
/**
* Filters the registered commands down to the ones available on the given chat
* surface, sorted alphabetically by name.
*/
export function selectAvailableSlashCommands(entries: readonly SlashCommandEntry[], surface: SlashCommandSurface): SlashCommandEntry[] {
return entries
.filter((entry) => resolveScope(entry.contribution) === 'global' || surface === 'server')
.slice()
.sort((left, right) => left.contribution.name.localeCompare(right.contribution.name));
}
/**
* Narrows available commands by the typed query. Commands whose name starts
* with the query rank above commands that merely contain it.
*/
export function filterSlashCommands(entries: readonly SlashCommandEntry[], query: string): SlashCommandEntry[] {
const normalizedQuery = normalizeName(query);
if (!normalizedQuery) {
return entries.slice();
}
const matches = entries.filter((entry) => normalizeName(entry.contribution.name).includes(normalizedQuery));
return matches.sort((left, right) => {
const leftStarts = normalizeName(left.contribution.name).startsWith(normalizedQuery);
const rightStarts = normalizeName(right.contribution.name).startsWith(normalizedQuery);
if (leftStarts !== rightStarts) {
return leftStarts ? -1 : 1;
}
return left.contribution.name.localeCompare(right.contribution.name);
});
}
/** Finds the command to execute for an exact (case-insensitive) name match. */
export function findSlashCommand(entries: readonly SlashCommandEntry[], name: string): SlashCommandEntry | null {
const normalizedName = normalizeName(name);
return entries.find((entry) => normalizeName(entry.contribution.name) === normalizedName) ?? null;
}
/**
* Splits a raw argument string into named values according to the command's
* declared options. A `rest` option captures the remaining text verbatim;
* commands without options receive an empty record.
*/
export function parseSlashCommandArguments(rawArgs: string, options: readonly PluginApiSlashCommandOption[] = []): Record<string, string> {
const args: Record<string, string> = {};
if (options.length === 0) {
return args;
}
let remaining = rawArgs.trim();
for (const option of options) {
if (option.type === 'rest') {
args[option.name] = remaining;
remaining = '';
continue;
}
const match = /^(\S+)\s*/.exec(remaining);
args[option.name] = match ? match[1] : '';
remaining = match ? remaining.slice(match[0].length) : '';
}
return args;
}

View File

@@ -89,7 +89,7 @@ export interface PluginApiEventSubscription {
handler: (event: PluginEventEnvelope) => void;
}
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'slashCommand' | 'manual';
export interface PluginApiActionContext {
server: Room | null;
@@ -99,6 +99,40 @@ export interface PluginApiActionContext {
voiceChannel: Channel | null;
}
/**
* Where a slash command is allowed to appear. `global` commands are available
* everywhere (chat servers and direct messages); `server` commands only appear
* while a chat server is the active surface.
*/
export type PluginApiSlashCommandScope = 'global' | 'server';
export type PluginApiSlashCommandOptionType = 'string' | 'number' | 'boolean' | 'rest';
export interface PluginApiSlashCommandOption {
description?: string;
name: string;
required?: boolean;
type?: PluginApiSlashCommandOptionType;
}
export interface PluginApiSlashCommandContext extends PluginApiActionContext {
/** Parsed positional/named argument values keyed by option name. */
args: Record<string, string>;
/** The invoked command name without the leading slash. */
command: string;
/** The raw, unparsed argument string typed after the command name. */
rawArgs: string;
}
export interface PluginApiSlashCommandContribution {
description?: string;
icon?: string;
name: string;
options?: PluginApiSlashCommandOption[];
run: (context: PluginApiSlashCommandContext) => Promise<void> | void;
scope?: PluginApiSlashCommandScope;
}
export interface PluginApiTypingEvent extends Omit<PluginApiActionContext, 'source'> {
channelId: string;
displayName: string;
@@ -194,10 +228,15 @@ export interface PluginApiUiContributionMap {
profileActions: PluginApiActionContribution[];
settingsPages: PluginApiSettingsPageContribution[];
sidePanels: PluginApiPanelContribution[];
slashCommands: PluginApiSlashCommandContribution[];
toolbarActions: PluginApiActionContribution[];
}
export interface TojuClientPluginApi {
readonly commands: {
list: () => PluginApiSlashCommandContribution[];
register: (id: string, contribution: PluginApiSlashCommandContribution) => TojuPluginDisposable;
};
readonly channels: {
addAudioChannel: (request: PluginApiChannelRequest) => void;
addTextChannel: (request: PluginApiChannelRequest) => void;

View File

@@ -143,6 +143,7 @@
{ label: 'Composer actions', value: extensionCounts().composerActions },
{ label: 'Profile actions', value: extensionCounts().profileActions },
{ label: 'Toolbar actions', value: extensionCounts().toolbarActions },
{ label: 'Slash commands', value: extensionCounts().slashCommands },
{ label: 'Embed renderers', value: extensionCounts().embeds }
];
track item.label

View File

@@ -106,6 +106,7 @@ export class PluginManagerComponent {
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
}));
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);

View File

@@ -563,10 +563,6 @@ export class PluginStoreComponent implements OnInit {
return this.brokenImageKeys().has(this.imageKey(plugin));
}
private imageKey(plugin: PluginStoreEntry): string {
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
}
trackServer(index: number, server: Room): string {
return server.id;
}
@@ -585,6 +581,10 @@ export class PluginStoreComponent implements OnInit {
: this.getPrimaryActionLabel(plugin);
}
private imageKey(plugin: PluginStoreEntry): string {
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
}
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
return [
plugin.author,

View File

@@ -13,6 +13,7 @@ export * from './application/services/plugin-store.service';
export * from './application/services/plugin-ui-registry.service';
export * from './domain/logic/plugin-dependency-resolver.logic';
export * from './domain/logic/plugin-manifest-validation.logic';
export * from './domain/logic/slash-command.rules';
export * from './domain/models/plugin-api.models';
export * from './domain/models/plugin-runtime.models';
export * from './domain/models/plugin-store.models';