feat: plugins v1
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
||||
import type {
|
||||
Channel,
|
||||
ChatEvent,
|
||||
Message,
|
||||
PluginCapabilityId,
|
||||
PluginEventEnvelope,
|
||||
TojuPluginManifest,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
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 type {
|
||||
PluginApiAvatarUpdate,
|
||||
PluginApiChannelRequest,
|
||||
PluginApiCustomStreamRequest,
|
||||
PluginApiMessageAsPluginUserRequest,
|
||||
PluginApiServerSettingsUpdate,
|
||||
TojuClientPluginApi
|
||||
} from '../../domain/models/plugin-api.models';
|
||||
import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginLoggerService } from './plugin-logger.service';
|
||||
import { PluginStorageService } from './plugin-storage.service';
|
||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginClientApiService {
|
||||
private readonly capabilities = inject(PluginCapabilityService);
|
||||
private readonly logger = inject(PluginLoggerService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly store = inject(Store);
|
||||
private readonly storage = inject(PluginStorageService);
|
||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
|
||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
|
||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
|
||||
createApi(manifest: TojuPluginManifest): TojuClientPluginApi {
|
||||
const pluginId = manifest.id;
|
||||
const requireCapability = (capability: PluginCapabilityId): void => this.capabilities.assert(pluginId, capability);
|
||||
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
|
||||
|
||||
return deepFreeze<TojuClientPluginApi>({
|
||||
channels: {
|
||||
addAudioChannel: (request) => {
|
||||
requireCapability('channels.manage');
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
|
||||
},
|
||||
addVideoChannel: (request) => {
|
||||
requireCapability('channels.manage');
|
||||
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
|
||||
label: request.name,
|
||||
order: request.position,
|
||||
type: 'video'
|
||||
});
|
||||
},
|
||||
list: () => {
|
||||
requireCapability('channels.read');
|
||||
return this.currentRoomChannels();
|
||||
},
|
||||
remove: (channelId) => {
|
||||
requireCapability('channels.manage');
|
||||
this.store.dispatch(RoomsActions.removeChannel({ channelId }));
|
||||
},
|
||||
rename: (channelId, name) => {
|
||||
requireCapability('channels.manage');
|
||||
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
|
||||
},
|
||||
select: (channelId) => {
|
||||
requireCapability('channels.read');
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
}
|
||||
},
|
||||
events: {
|
||||
publishP2p: (eventName, payload) => {
|
||||
requireCapability('events.p2p.publish');
|
||||
assertEvent(eventName);
|
||||
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
|
||||
},
|
||||
publishServer: (eventName, payload) => {
|
||||
requireCapability('events.server.publish');
|
||||
assertEvent(eventName);
|
||||
this.publishServerPluginEvent(pluginId, eventName, payload);
|
||||
},
|
||||
subscribeP2p: (subscription) => {
|
||||
requireCapability('events.p2p.subscribe');
|
||||
assertEvent(subscription.eventName);
|
||||
return this.rememberSubscription(pluginId, subscription.eventName);
|
||||
},
|
||||
subscribeServer: (subscription) => {
|
||||
requireCapability('events.server.subscribe');
|
||||
assertEvent(subscription.eventName);
|
||||
return this.subscribeServerPluginEvent(pluginId, subscription.eventName, subscription.handler);
|
||||
}
|
||||
},
|
||||
logger: {
|
||||
debug: (message, data) => this.logger.debug(pluginId, message, data),
|
||||
error: (message, data) => this.logger.error(pluginId, message, data),
|
||||
info: (message, data) => this.logger.info(pluginId, message, data),
|
||||
warn: (message, data) => this.logger.warn(pluginId, message, data)
|
||||
},
|
||||
media: {
|
||||
addCustomAudioStream: async (request) => {
|
||||
requireCapability('media.addAudioStream');
|
||||
await this.voice.setLocalStream(request.stream);
|
||||
},
|
||||
addCustomVideoStream: async (_request: PluginApiCustomStreamRequest) => {
|
||||
requireCapability('media.addVideoStream');
|
||||
this.logger.info(pluginId, 'Video stream contribution registered');
|
||||
},
|
||||
playAudioClip: async (request) => {
|
||||
requireCapability('media.playAudio');
|
||||
await playAudioClip(request.url, request.volume);
|
||||
},
|
||||
setInputVolume: (volume) => {
|
||||
requireCapability('audio.volume');
|
||||
this.voice.setInputVolume(volume);
|
||||
},
|
||||
setOutputVolume: (volume) => {
|
||||
requireCapability('audio.volume');
|
||||
this.voice.setOutputVolume(volume);
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
delete: (messageId) => {
|
||||
requireCapability('messages.deleteOwn');
|
||||
this.deletePluginMessage(messageId);
|
||||
},
|
||||
edit: (messageId, content) => {
|
||||
requireCapability('messages.editOwn');
|
||||
this.editPluginMessage(messageId, content);
|
||||
},
|
||||
moderateDelete: (messageId) => {
|
||||
requireCapability('messages.moderate');
|
||||
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId }));
|
||||
},
|
||||
readCurrent: () => {
|
||||
requireCapability('messages.read');
|
||||
return this.currentMessages();
|
||||
},
|
||||
send: (content, channelId) => {
|
||||
requireCapability('messages.send');
|
||||
return this.sendPluginMessage(content, channelId);
|
||||
},
|
||||
sendAsPluginUser: (request) => {
|
||||
requireCapability('messages.send');
|
||||
this.receivePluginUserMessage(pluginId, request);
|
||||
},
|
||||
sync: (messages) => {
|
||||
requireCapability('messages.sync');
|
||||
this.store.dispatch(MessagesActions.syncMessages({ messages }));
|
||||
}
|
||||
},
|
||||
p2p: {
|
||||
broadcastData: (eventName, payload) => {
|
||||
requireCapability('p2p.data');
|
||||
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
|
||||
},
|
||||
connectedPeers: () => {
|
||||
requireCapability('p2p.data');
|
||||
return this.voice.getConnectedPeers();
|
||||
},
|
||||
sendData: (peerId, eventName, payload) => {
|
||||
requireCapability('p2p.data');
|
||||
this.broadcastPluginEvent(pluginId, eventName, { payload, peerId }, 'p2p');
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
getCurrent: () => {
|
||||
requireCapability('profile.read');
|
||||
return this.currentUser() ?? null;
|
||||
},
|
||||
update: (profile) => {
|
||||
requireCapability('profile.write');
|
||||
this.store.dispatch(UsersActions.updateCurrentUserProfile({
|
||||
profile: {
|
||||
...profile,
|
||||
profileUpdatedAt: Date.now()
|
||||
}
|
||||
}));
|
||||
},
|
||||
updateAvatar: (avatar: PluginApiAvatarUpdate) => {
|
||||
requireCapability('profile.write');
|
||||
this.store.dispatch(UsersActions.updateCurrentUserAvatar({
|
||||
avatar: {
|
||||
...avatar,
|
||||
avatarUpdatedAt: Date.now()
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
roles: {
|
||||
list: () => {
|
||||
requireCapability('roles.read');
|
||||
return this.currentRoom()?.roles ?? [];
|
||||
},
|
||||
setAssignments: (assignments) => {
|
||||
requireCapability('roles.manage');
|
||||
this.updateRoomAccessControl({ roleAssignments: assignments });
|
||||
}
|
||||
},
|
||||
server: {
|
||||
getCurrent: () => {
|
||||
requireCapability('server.read');
|
||||
return this.currentRoom();
|
||||
},
|
||||
registerPluginUser: (request) => {
|
||||
requireCapability('users.manage');
|
||||
const userId = request.id ?? `${pluginId}:${slug(request.displayName)}`;
|
||||
|
||||
this.store.dispatch(UsersActions.userJoined({
|
||||
user: {
|
||||
avatarUrl: request.avatarUrl,
|
||||
displayName: request.displayName,
|
||||
id: userId,
|
||||
isOnline: true,
|
||||
joinedAt: Date.now(),
|
||||
oderId: userId,
|
||||
role: 'member',
|
||||
status: 'online',
|
||||
username: userId
|
||||
}
|
||||
}));
|
||||
|
||||
return userId;
|
||||
},
|
||||
updatePermissions: (permissions) => {
|
||||
requireCapability('server.manage');
|
||||
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
|
||||
},
|
||||
updateSettings: (settings: PluginApiServerSettingsUpdate) => {
|
||||
requireCapability('server.manage');
|
||||
this.store.dispatch(RoomsActions.updateRoomSettings({
|
||||
roomId: this.requireRoomId(),
|
||||
settings: {
|
||||
description: settings.description,
|
||||
hasPassword: !!settings.password,
|
||||
isPrivate: settings.isPrivate ?? this.currentRoom()?.isPrivate ?? false,
|
||||
maxUsers: settings.maxUsers,
|
||||
name: settings.name ?? this.currentRoom()?.name ?? 'Server',
|
||||
password: settings.password,
|
||||
rules: [],
|
||||
topic: settings.topic
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
serverData: {
|
||||
read: async (key) => {
|
||||
requireCapability('storage.serverData.read');
|
||||
return await this.storage.readServerData(pluginId, key);
|
||||
},
|
||||
remove: async (key) => {
|
||||
requireCapability('storage.serverData.write');
|
||||
await this.storage.removeServerData(pluginId, key);
|
||||
},
|
||||
write: async (key, value) => {
|
||||
requireCapability('storage.serverData.write');
|
||||
await this.storage.writeServerData(pluginId, key, value);
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
get: (key) => {
|
||||
requireCapability('storage.local');
|
||||
return this.storage.getLocal(pluginId, key);
|
||||
},
|
||||
remove: (key) => {
|
||||
requireCapability('storage.local');
|
||||
this.storage.removeLocal(pluginId, key);
|
||||
},
|
||||
set: (key, value) => {
|
||||
requireCapability('storage.local');
|
||||
this.storage.setLocal(pluginId, key, value);
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
registerAppPage: (id, contribution) => {
|
||||
requireCapability('ui.pages');
|
||||
return this.uiRegistry.registerAppPage(pluginId, id, contribution);
|
||||
},
|
||||
registerChannelSection: (id, contribution) => {
|
||||
requireCapability('ui.channelsSection');
|
||||
return this.uiRegistry.registerChannelSection(pluginId, id, contribution);
|
||||
},
|
||||
registerComposerAction: (id, contribution) => {
|
||||
requireCapability('ui.pages');
|
||||
return this.uiRegistry.registerComposerAction(pluginId, id, contribution);
|
||||
},
|
||||
registerEmbedRenderer: (id, contribution) => {
|
||||
requireCapability('ui.embeds');
|
||||
return this.uiRegistry.registerEmbedRenderer(pluginId, id, contribution);
|
||||
},
|
||||
mountElement: (id, request) => {
|
||||
requireCapability('ui.dom');
|
||||
return this.uiRegistry.mountElement(pluginId, id, request);
|
||||
},
|
||||
registerProfileAction: (id, contribution) => {
|
||||
requireCapability('ui.pages');
|
||||
return this.uiRegistry.registerProfileAction(pluginId, id, contribution);
|
||||
},
|
||||
registerSettingsPage: (id, contribution) => {
|
||||
requireCapability('ui.settings');
|
||||
return this.uiRegistry.registerSettingsPage(pluginId, id, contribution);
|
||||
},
|
||||
registerSidePanel: (id, contribution) => {
|
||||
requireCapability('ui.sidePanel');
|
||||
return this.uiRegistry.registerSidePanel(pluginId, id, contribution);
|
||||
},
|
||||
registerToolbarAction: (id, contribution) => {
|
||||
requireCapability('ui.pages');
|
||||
return this.uiRegistry.registerToolbarAction(pluginId, id, contribution);
|
||||
}
|
||||
},
|
||||
users: {
|
||||
ban: (userId, reason) => {
|
||||
requireCapability('users.manage');
|
||||
this.store.dispatch(UsersActions.banUser({ reason, userId }));
|
||||
},
|
||||
getCurrent: () => {
|
||||
requireCapability('users.read');
|
||||
return this.currentUser() ?? null;
|
||||
},
|
||||
kick: (userId) => {
|
||||
requireCapability('users.manage');
|
||||
this.store.dispatch(UsersActions.kickUser({ userId }));
|
||||
},
|
||||
list: () => {
|
||||
requireCapability('users.read');
|
||||
return this.users();
|
||||
},
|
||||
readMembers: () => {
|
||||
requireCapability('users.read');
|
||||
return this.currentRoom()?.members ?? [];
|
||||
},
|
||||
setRole: (userId, role: User['role']) => {
|
||||
requireCapability('roles.manage');
|
||||
this.store.dispatch(UsersActions.updateUserRole({ role, userId }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
|
||||
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
|
||||
|
||||
if (!declared) {
|
||||
throw new Error(`Plugin ${manifest.id} did not declare event ${eventName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastPluginEvent(pluginId: string, eventName: string, payload: unknown, target: 'p2p' | 'server'): void {
|
||||
const roomId = this.currentRoomId() ?? 'local';
|
||||
const event: PluginEventEnvelope = {
|
||||
emittedAt: Date.now(),
|
||||
eventId: createId(),
|
||||
eventName,
|
||||
payload,
|
||||
pluginId,
|
||||
serverId: roomId,
|
||||
type: 'plugin_event'
|
||||
};
|
||||
|
||||
this.voice.broadcastMessage({
|
||||
data: JSON.stringify({ event, target }),
|
||||
roomId,
|
||||
timestamp: Date.now(),
|
||||
type: 'plugin-event'
|
||||
} as unknown as ChatEvent);
|
||||
}
|
||||
|
||||
private publishServerPluginEvent(pluginId: string, eventName: string, payload: unknown): void {
|
||||
this.realtime.sendRawMessage({
|
||||
type: 'plugin_event',
|
||||
eventId: createId(),
|
||||
eventName,
|
||||
payload,
|
||||
pluginId,
|
||||
serverId: this.requireRoomId()
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeServerPluginEvent(
|
||||
pluginId: string,
|
||||
eventName: string,
|
||||
handler: (event: PluginEventEnvelope) => void
|
||||
) {
|
||||
const subscription = new Subscription();
|
||||
|
||||
subscription.add(this.realtime.onSignalingMessage.subscribe((message) => {
|
||||
const record = message as Record<string, unknown>;
|
||||
|
||||
if (record['type'] !== 'plugin_event' || record['pluginId'] !== pluginId || record['eventName'] !== eventName) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler(message as PluginEventEnvelope);
|
||||
}));
|
||||
|
||||
this.logger.info(pluginId, `Subscribed to server event ${eventName}`);
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
subscription.unsubscribe();
|
||||
this.logger.info(pluginId, `Unsubscribed from server event ${eventName}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
|
||||
const roomId = this.requireRoomId();
|
||||
const message: Message = {
|
||||
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
|
||||
content: request.content,
|
||||
id: createId(),
|
||||
isDeleted: false,
|
||||
reactions: [],
|
||||
roomId,
|
||||
senderId: request.pluginUserId,
|
||||
senderName: request.pluginUserId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
|
||||
this.store.dispatch(MessagesActions.receiveMessage({ message }));
|
||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||
}
|
||||
|
||||
private deletePluginMessage(messageId: string): void {
|
||||
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
this.voice.broadcastMessage({
|
||||
deletedAt: Date.now(),
|
||||
messageId,
|
||||
type: 'message-deleted'
|
||||
} as unknown as ChatEvent);
|
||||
}
|
||||
|
||||
private editPluginMessage(messageId: string, content: string): void {
|
||||
const editedAt = Date.now();
|
||||
|
||||
this.store.dispatch(MessagesActions.editMessageSuccess({
|
||||
content,
|
||||
editedAt,
|
||||
messageId
|
||||
}));
|
||||
|
||||
this.voice.broadcastMessage({
|
||||
content,
|
||||
editedAt,
|
||||
messageId,
|
||||
type: 'message-edited'
|
||||
} as unknown as ChatEvent);
|
||||
}
|
||||
|
||||
private sendPluginMessage(content: string, channelId?: string): Message {
|
||||
const currentUser = this.currentUser();
|
||||
const roomId = this.requireRoomId();
|
||||
const message: Message = {
|
||||
channelId: channelId ?? this.activeChannelId() ?? 'general',
|
||||
content,
|
||||
id: createId(),
|
||||
isDeleted: false,
|
||||
reactions: [],
|
||||
roomId,
|
||||
senderId: currentUser?.id ?? 'plugin',
|
||||
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
|
||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private rememberSubscription(pluginId: string, eventName: string) {
|
||||
this.logger.info(pluginId, `Subscribed to ${eventName}`);
|
||||
|
||||
return {
|
||||
dispose: () => this.logger.info(pluginId, `Unsubscribed from ${eventName}`)
|
||||
};
|
||||
}
|
||||
|
||||
private requireRoomId(): string {
|
||||
const roomId = this.currentRoomId();
|
||||
|
||||
if (!roomId) {
|
||||
throw new Error('No active server');
|
||||
}
|
||||
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private updateRoomAccessControl(changes: Parameters<typeof RoomsActions.updateRoomAccessControl>[0]['changes']): void {
|
||||
this.store.dispatch(RoomsActions.updateRoomAccessControl({
|
||||
changes,
|
||||
roomId: this.requireRoomId()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function createChannel(request: PluginApiChannelRequest, type: Channel['type']): Channel {
|
||||
return {
|
||||
id: request.id ?? slug(request.name),
|
||||
name: request.name,
|
||||
position: request.position ?? Date.now(),
|
||||
type
|
||||
};
|
||||
}
|
||||
|
||||
function createId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `plugin-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2)}`;
|
||||
}
|
||||
|
||||
function deepFreeze<TValue extends object>(value: TValue): TValue {
|
||||
for (const propertyValue of Object.values(value)) {
|
||||
if (propertyValue && typeof propertyValue === 'object') {
|
||||
deepFreeze(propertyValue as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
async function playAudioClip(url: string, volume = 1): Promise<void> {
|
||||
const audio = new Audio(url);
|
||||
|
||||
audio.volume = Math.max(0, Math.min(1, volume));
|
||||
await audio.play();
|
||||
}
|
||||
|
||||
function slug(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
.replace(/[^a-z0-9.-]+/g, '-')
|
||||
.replace(/(^-+|-+$)/g, '') || createId();
|
||||
}
|
||||
Reference in New Issue
Block a user