import { Injectable, inject } from '@angular/core'; import { Action, Store } from '@ngrx/store'; import { Subscription } from 'rxjs'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { ServerDirectoryFacade } from '../../../server-directory'; import { resolveRoomPermission } from '../../../access-control'; import { AttachmentFacade } from '../../../attachment'; import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade'; import type { Channel, ChatEvent, Message, MessageRevision, PluginCapabilityId, PluginEventEnvelope, TojuPluginManifest, User } from '../../../../shared-kernel'; import { MessagesActions } from '../../../../store/messages/messages.actions'; import { CHUNK_SIZE, chunkArray } from '../../../../store/messages/messages.helpers'; 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 { defaultChannels } from '../../../../store/rooms/room-channels.defaults'; import { isChannelNameTaken, normalizeChannelName } from '../../../../store/rooms/room-channels.rules'; import type { PluginApiAvatarUpdate, PluginApiActionContext, PluginApiActionSource, PluginApiAttachmentImportRequest, PluginApiChannelRequest, PluginApiCustomStreamRequest, PluginApiMessageAsPluginUserRequest, PluginApiServerSettingsUpdate, PluginApiSlashCommandContext, 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'; import { MessageRevisionService } from '../../../chat/application/services/message-revision.service'; import { materializeMessageFromRevision } from '../../../chat/domain/rules/message-revision.builder.rules'; @Injectable({ providedIn: 'root' }) export class PluginClientApiService { private readonly attachments = inject(AttachmentFacade); 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 serverDirectory = inject(ServerDirectoryFacade); private readonly store = inject(Store); private readonly storage = inject(PluginStorageService); private readonly uiRegistry = inject(PluginUiRegistryService); private readonly voice = inject(VoiceConnectionFacade); private readonly messageRevisions = inject(MessageRevisionService); 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({ 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'); this.addPluginManagedChannel(pluginId, createChannel(request, 'voice')); }, addTextChannel: (request) => { requireCapability('channels.manage'); this.addPluginManagedChannel(pluginId, createChannel(request, 'text')); }, 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); } }, attachments: { import: async (request: PluginApiAttachmentImportRequest) => { requireCapability('messages.sync'); const roomId = this.requireRoomId(); this.attachments.rememberMessageRoom(request.messageId, roomId); await this.attachments.publishAttachments(request.messageId, request.files, this.currentUser()?.id); } }, 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); }, import: async (messages) => { requireCapability('messages.sync'); await this.importPluginMessages(pluginId, messages); }, 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; }, updateIcon: async (icon) => { requireCapability('server.manage'); const room = this.currentRoom(); const currentUser = this.currentUser(); if (!room) { throw new Error('Room not found'); } if (!currentUser) { throw new Error('Not logged in'); } const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host'; const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon'); if (!isOwner && !isServerAdmin && !canByRole) { throw new Error('Permission denied'); } const iconUpdatedAt = Date.now(); await this.db.updateRoom(room.id, { icon, iconUpdatedAt }); this.store.dispatch(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt })); this.realtime.broadcastMessage({ type: 'server-icon-update', roomId: room.id, icon, iconUpdatedAt }); this.realtime.sendRawMessage({ type: 'server_icon_available', serverId: room.id, iconUpdatedAt }); this.serverDirectory.updateServer(room.id, { actingRole: isOwner ? 'host' : undefined, icon, iconUpdatedAt }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); }, 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 }; } createSlashCommandContext(request: { args: Record; 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; 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 timestamp = Date.now(); const draftMessage: Message = { channelId: request.channelId ?? this.activeChannelId() ?? undefined, content: request.content, id: createId(), isDeleted: false, reactions: [], roomId, senderId: request.pluginUserId, senderName: request.pluginUserId, timestamp, revision: 0 }; void this.emitPluginMessageRevision(pluginId, { draftMessage, type: 'create', actorId: request.pluginUserId, editedAt: timestamp, pluginId, sign: false, dispatch: (message) => MessagesActions.receiveMessage({ message }) }); } private deletePluginMessage(pluginId: string, messageId: string): void { void this.emitPluginMessageMutation(pluginId, messageId, { type: 'plugin-delete', apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({ message: existing, type: 'plugin-delete', actorId: this.currentUser()?.id ?? pluginId, editedAt, isDeleted: true, pluginId, sign: false }), legacyBroadcast: (editedAt) => ({ deletedAt: editedAt, messageId, type: 'message-deleted' }), dispatch: () => MessagesActions.deleteMessageSuccess({ messageId }) }); } private editPluginMessage(pluginId: string, messageId: string, content: string): void { void this.emitPluginMessageMutation(pluginId, messageId, { type: 'plugin-edit', content, apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({ message: existing, type: 'plugin-edit', actorId: this.currentUser()?.id ?? pluginId, content, editedAt, pluginId, sign: false }), legacyBroadcast: (editedAt) => ({ content, editedAt, messageId, type: 'message-edited' }), dispatch: (message) => MessagesActions.editMessageSuccess({ content: message.content, editedAt: message.editedAt ?? Date.now(), messageId }) }); } private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message { const currentUser = this.currentUser(); const roomId = this.requireRoomId(); const timestamp = Date.now(); const draftMessage: Message = { channelId: channelId ?? this.activeChannelId() ?? 'general', content, id: createId(), isDeleted: false, reactions: [], roomId, senderId: currentUser?.id ?? 'plugin', senderName: currentUser?.displayName || currentUser?.username || 'Plugin', timestamp, revision: 0 }; void this.emitPluginMessageRevision(pluginId, { draftMessage, type: 'create', actorId: currentUser?.id ?? pluginId, editedAt: timestamp, pluginId, sign: !!currentUser, dispatch: (message) => MessagesActions.sendMessageSuccess({ message }) }); return draftMessage; } private async emitPluginMessageRevision( pluginId: string, input: { draftMessage: Message; type: 'create'; actorId: string; editedAt: number; pluginId: string; sign: boolean; dispatch: (message: Message) => Action; } ): Promise { try { const revision = await this.messageRevisions.createSignedRevision({ message: input.draftMessage, type: input.type, actorId: input.actorId, editedAt: input.editedAt, pluginId: input.pluginId, sign: input.sign }); const message = materializeMessageFromRevision(null, revision); this.logger.info(pluginId, 'Plugin message emitted', { messageId: message.id }); await this.db.saveMessage(message); await this.messageRevisions.persistRevision(revision); this.store.dispatch(input.dispatch(message)); this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent); this.messageRevisions.broadcastRevision(revision); } catch (error: unknown) { this.logger.warn(pluginId, 'Failed to emit plugin message revision', error); } } private async emitPluginMessageMutation( pluginId: string, messageId: string, input: { type: 'plugin-edit' | 'plugin-delete'; content?: string; apply: (existing: Message, editedAt: number) => Promise; legacyBroadcast: (editedAt: number) => ChatEvent; dispatch: (message: Message) => Action; } ): Promise { try { const existing = await this.db.getMessageById(messageId); if (!existing) { this.logger.warn(pluginId, 'Plugin message mutation target not found', { messageId }); return; } const editedAt = Date.now(); const revision = await input.apply(existing, editedAt); const message = materializeMessageFromRevision(existing, revision); await this.db.saveMessage(message); await this.messageRevisions.persistRevision(revision); this.store.dispatch(input.dispatch(message)); this.voice.broadcastMessage(input.legacyBroadcast(editedAt) as unknown as ChatEvent); this.messageRevisions.broadcastRevision(revision); } catch (error: unknown) { this.logger.warn(pluginId, 'Failed to emit plugin message mutation revision', error); } } 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 async importPluginMessages(pluginId: string, messages: Message[]): Promise { const roomId = this.requireRoomId(); const normalizedMessages = messages .filter((message) => message.roomId === roomId) .map((message) => ({ ...message, channelId: message.channelId ?? this.activeChannelId() ?? 'general', isDeleted: message.isDeleted === true, reactions: message.reactions ?? [] })); if (normalizedMessages.length === 0) { return; } for (const message of normalizedMessages) { await this.db.saveMessage(message); } this.store.dispatch(MessagesActions.syncMessages({ messages: normalizedMessages })); // Broadcast imported history to peers in CHUNK_SIZE batches so they don't // depend on the inventory-limited background sync to discover bulk imports. for (const chunk of chunkArray(normalizedMessages, CHUNK_SIZE)) { this.voice.broadcastMessage({ type: 'chat-sync-batch', roomId, messages: chunk } as unknown as ChatEvent); } this.logger.info(pluginId, 'Historical messages imported', { count: normalizedMessages.length }); } private addPluginManagedChannel(pluginId: string, channel: Channel): void { const room = this.currentRoom(); const currentUser = this.currentUser(); if (!room || !currentUser) { return; } const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host'; const canManageChannels = resolveRoomPermission(room, currentUser, 'manageChannels'); if (!isOwner && !isServerAdmin && !canManageChannels) { this.logger.warn(pluginId, 'Plugin channel creation denied by room permissions', { channelId: channel.id, roomId: room.id }); return; } const existingChannels = room.channels ?? defaultChannels(); const normalizedName = normalizeChannelName(channel.name); const channelExists = existingChannels.some((entry) => entry.id === channel.id) || isChannelNameTaken(existingChannels, normalizedName, channel.type); if (!normalizedName || channelExists) { return; } const channels = [ ...existingChannels, { ...channel, name: normalizedName } ]; this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id, changes: { channels } })); void this.db.updateRoom(room.id, { channels }).catch((error: unknown) => { this.logger.warn(pluginId, 'Failed to persist plugin-created channel', error); }); this.realtime.broadcastMessage({ type: 'channels-update', roomId: room.id, channels }); this.serverDirectory.updateServer(room.id, { actingRole: isOwner ? 'host' : undefined, channels }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); this.logger.info(pluginId, 'Plugin channel created', { channelId: channel.id, roomId: room.id }); } 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(); }