import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { Subscription } from 'rxjs'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { DatabaseService } from '../../../../infrastructure/persistence'; 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, PluginApiActionContext, PluginApiActionSource, PluginApiChannelRequest, PluginApiCustomStreamRequest, PluginApiMessageAsPluginUserRequest, PluginApiServerSettingsUpdate, PluginApiTypingEvent, TojuClientPluginApi, TojuPluginDisposable } from '../../domain/models/plugin-api.models'; import { PluginCapabilityService } from './plugin-capability.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'; @Injectable({ providedIn: 'root' }) export class PluginClientApiService { private readonly capabilities = inject(PluginCapabilityService); private readonly db = inject(DatabaseService); private readonly logger = inject(PluginLoggerService); private readonly messageBus = inject(PluginMessageBusService); 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) }, context: { getCurrent: () => this.createActionContext('manual') }, clientData: { read: async (key) => { requireCapability('storage.local'); return await this.storage.readClientData(pluginId, key); }, remove: async (key) => { requireCapability('storage.local'); await this.storage.removeClientData(pluginId, key); }, write: async (key, value) => { requireCapability('storage.local'); await this.storage.writeClientData(pluginId, key, value); } }, 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(pluginId, messageId); }, edit: (messageId, content) => { requireCapability('messages.editOwn'); this.editPluginMessage(pluginId, 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(pluginId, content, channelId); }, sendAsPluginUser: (request) => { requireCapability('messages.send'); this.receivePluginUserMessage(pluginId, request); }, setTyping: (isTyping, channelId) => { requireCapability('messages.send'); this.setTyping(pluginId, isTyping, channelId); }, subscribeTyping: (handler) => { requireCapability('messages.read'); return this.subscribeTyping(pluginId, handler); }, sync: (messages) => { requireCapability('messages.sync'); this.store.dispatch(MessagesActions.syncMessages({ messages })); } }, messageBus: { publish: (request) => { requireCapability('events.p2p.publish'); if (request.includeLatestMessages) { requireCapability('messages.read'); } return this.messageBus.publish(pluginId, request); }, sendLatestMessages: (request = {}) => { requireCapability('events.p2p.publish'); requireCapability('messages.read'); return this.messageBus.sendLatestMessages(pluginId, request); }, subscribe: (subscription) => { requireCapability('events.p2p.subscribe'); if (subscription.replayLatest) { requireCapability('messages.read'); } return this.messageBus.subscribe(pluginId, subscription); } }, 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 })); } } }); } createActionContext(source: PluginApiActionSource): PluginApiActionContext { const user = this.currentUser() ?? null; const server = this.currentRoom(); const channels = this.currentRoomChannels(); const activeChannelId = this.activeChannelId() ?? 'general'; const voiceChannelId = user?.voiceState?.roomId ?? null; return { server, source, textChannel: channels.find((channel) => channel.type === 'text' && channel.id === activeChannelId) ?? null, user, voiceChannel: voiceChannelId ? channels.find((channel) => channel.type === 'voice' && channel.id === voiceChannelId) ?? null : null }; } 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.persistPluginMessage(pluginId, message); this.store.dispatch(MessagesActions.receiveMessage({ message })); this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent); } private deletePluginMessage(pluginId: string, messageId: string): void { this.persistPluginMessageUpdate(pluginId, messageId, { content: '[Message deleted]', editedAt: Date.now(), isDeleted: true }); this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId })); this.voice.broadcastMessage({ deletedAt: Date.now(), messageId, type: 'message-deleted' } as unknown as ChatEvent); } private editPluginMessage(pluginId: string, messageId: string, content: string): void { const editedAt = Date.now(); this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt }); this.store.dispatch(MessagesActions.editMessageSuccess({ content, editedAt, messageId })); this.voice.broadcastMessage({ content, editedAt, messageId, type: 'message-edited' } as unknown as ChatEvent); } private sendPluginMessage(pluginId: string, 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.persistPluginMessage(pluginId, message); this.store.dispatch(MessagesActions.sendMessageSuccess({ message })); this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent); return message; } private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void { const roomId = this.requireRoomId(); try { this.realtime.sendRawMessage({ type: 'typing', serverId: roomId, channelId: channelId ?? this.activeChannelId() ?? 'general', isTyping }); } catch (error: unknown) { this.logger.warn(pluginId, 'Failed to publish typing state', error); } } private subscribeTyping(pluginId: string, handler: (event: PluginApiTypingEvent) => void): TojuPluginDisposable { const subscription = new Subscription(); subscription.add(this.realtime.onSignalingMessage.subscribe((message) => { const record = message as Record; if (record['type'] !== 'user_typing') { return; } const serverId = typeof record['serverId'] === 'string' ? record['serverId'] : ''; const currentServer = this.currentRoom(); if (!serverId || serverId !== currentServer?.id) { return; } const userId = typeof record['oderId'] === 'string' ? record['oderId'] : ''; const displayName = typeof record['displayName'] === 'string' ? record['displayName'] : userId; const channelId = typeof record['channelId'] === 'string' && record['channelId'].trim() ? record['channelId'].trim() : 'general'; const user = this.users().find((entry) => entry.oderId === userId || entry.id === userId) ?? null; const channels = this.currentRoomChannels(); handler({ channelId, displayName, isTyping: record['isTyping'] !== false, server: currentServer, serverId, textChannel: channels.find((channel) => channel.type === 'text' && channel.id === channelId) ?? null, user, userId, voiceChannel: user?.voiceState?.roomId ? channels.find((channel) => channel.type === 'voice' && channel.id === user.voiceState?.roomId) ?? null : null }); })); this.logger.info(pluginId, 'Subscribed to typing events'); return { dispose: () => { subscription.unsubscribe(); this.logger.info(pluginId, 'Unsubscribed from typing events'); } }; } private persistPluginMessage(pluginId: string, message: Message): void { void this.db.saveMessage(message).catch((error: unknown) => { this.logger.warn(pluginId, 'Failed to persist plugin message', error); }); } private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial): void { void this.db.updateMessage(messageId, updates).catch((error: unknown) => { this.logger.warn(pluginId, 'Failed to persist plugin message update', error); }); } 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(); }