Files
Toju/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts
2026-04-29 01:14:14 +02:00

556 lines
19 KiB
TypeScript

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();
}