556 lines
19 KiB
TypeScript
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();
|
|
}
|