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({ 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; 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[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(value: TValue): TValue { for (const propertyValue of Object.values(value)) { if (propertyValue && typeof propertyValue === 'object') { deepFreeze(propertyValue as Record); } } return Object.freeze(value); } async function playAudioClip(url: string, volume = 1): Promise { 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(); }