diff --git a/docs-site/docs/developer/contributing.md b/docs-site/docs/developer/contributing.md new file mode 100644 index 0000000..418bca4 --- /dev/null +++ b/docs-site/docs/developer/contributing.md @@ -0,0 +1,87 @@ +--- +sidebar_position: 1 +--- + +# Contributing + +MetoYou is an npm-managed monorepo. + +## Packages + +| Path | Purpose | +| --- | --- | +| `toju-app/` | Angular renderer, chat client, voice UI, plugin runtime. | +| `electron/` | Electron main process, preload bridge, local database, local REST API, docs host. | +| `server/` | Node/TypeScript signaling server and server-directory HTTP API. | +| `website/` | Angular marketing site. | +| `docs-site/` | Docusaurus documentation site. | +| `e2e/` | Playwright browser and WebRTC tests. | + +## Setup + +Install root dependencies: + +```bash +npm install +``` + +Install server dependencies when working on the signaling server: + +```bash +cd server +npm install +``` + +## Development Commands + +From the repository root: + +```bash +npm run dev +``` + +Useful focused commands: + +```bash +npm run build +npm run build:electron +npm run build:docs +npm run server:build +npm run lint +npm run test +npm run test:e2e -- tests/chat-dm-flow.spec.ts +``` + +Run the Docusaurus dev server: + +```bash +cd docs-site +npm install +npm run start +``` + +Build static docs for Electron packaging: + +```bash +npm run build:docs +``` + +## Repository Rules + +- Keep changes inside the package that owns the behavior. +- Do not edit generated output in `dist/`, `dist-electron/`, `dist-server/`, `server/dist/`, `.angular/`, or `node_modules/`. +- Renderer-facing Electron capabilities must stay aligned across implementation, preload, and renderer bridge types. +- Signal-server plugin support stores metadata only. Plugin execution belongs to the client runtime. +- Update this documentation when user workflows, plugin APIs, REST routes, DOM structure, or development commands change. + +## Documentation Checklist + +When you change a related area, update these pages: + +| Change | Docs to check | +| --- | --- | +| Voice UI or settings | User Guide: Voice Channels and Calls, Developer Guide: App Pages and DOM Structure. | +| Text channels, messages, DMs | User Guide: Text and Direct Messages, plugin message API pages. | +| Plugin manifest/API/runtime | Plugin Development pages and LLM Plugin Builder Guide. | +| Local REST API routes or schemas | Developer Guide: Local REST API and `electron/api/openapi.ts`. | +| Docusaurus hosting | Developer Guide: Docusaurus Site and Desktop and Local API. | \ No newline at end of file diff --git a/docs-site/docs/developer/docusaurus-site.md b/docs-site/docs/developer/docusaurus-site.md new file mode 100644 index 0000000..8c00b17 --- /dev/null +++ b/docs-site/docs/developer/docusaurus-site.md @@ -0,0 +1,65 @@ +--- +sidebar_position: 2 +--- + +# Docusaurus Site + +The Docusaurus documentation lives in `docs-site/` and builds to static files in `docs-site/build/`. + +## Structure + +```text +docs-site/ + docusaurus.config.ts + sidebars.ts + docs/ + intro.md + user-guide/ + developer/ + plugin-development/ + src/css/custom.css +``` + +## Development + +Use the Docusaurus development server while writing docs: + +```bash +cd docs-site +npm run start +``` + +Build the static site: + +```bash +npm run build +``` + +From the repo root, use: + +```bash +npm run build:docs +``` + +## Electron Hosting + +Electron serves the built site through the local API server when Docusaurus docs are enabled. + +| Route | Purpose | +| --- | --- | +| `/docusaurus` | Docusaurus entrypoint. | +| `/docusaurus/*` | Static Docusaurus assets and generated pages. | + +The endpoint is off until the user opens documentation from the desktop app or enables it through local API settings. Electron serves static files only; it does not run `docusaurus start`. + +## Sidebar Rules + +Navigation is controlled by `docs-site/sidebars.ts`. Add every new page there unless it is intentionally hidden. Use categories for larger sections so non-technical users can find the user guide separately from developer material. + +## Content Rules + +- User docs should avoid implementation jargon. +- Developer docs should name exact files, commands, routes, capabilities, and data shapes. +- Plugin API examples should use literal sample input data. +- REST docs should stay aligned with `electron/api/openapi.ts` and `electron/api/router.ts`. +- DOM docs should stay aligned with Angular routes and component selectors. \ No newline at end of file diff --git a/docs-site/docs/developer/dom-structure.md b/docs-site/docs/developer/dom-structure.md new file mode 100644 index 0000000..ecfca9c --- /dev/null +++ b/docs-site/docs/developer/dom-structure.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 3 +--- + +# App Pages and DOM Structure + +This page maps the app routes and important DOM areas. It is useful for plugin authors, testers, and contributors who need stable mental models of where UI mounts. + +## Angular Routes + +| Route | Component | Purpose | +| --- | --- | --- | +| `/` | Redirect | Redirects to `/search`. | +| `/login` | `LoginComponent` | User login. | +| `/register` | `RegisterComponent` | User registration. | +| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. | +| `/search` | `ServerSearchComponent` | Search and join servers. | +| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. | +| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. | +| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. | +| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. | +| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. | +| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. | + +## Page Shell + +The renderer is an Angular app. The common shell contains router outlet content plus persistent app surfaces such as the server rail, title bar integrations, settings modals, and floating voice controls. + +High-level structure: + +```html + + + + +``` + +## Server Page DOM + +The server page is the most important page for plugins. + +```html + + + +
Text Channels
+
Voice Channels
+
+ +
+
Members
+
+
+ + + + + + + +
+
+``` + +## Text Channel Area + +Text channel UI is owned by the chat domain. + +```html + + + + + + + + +``` + +Plugin touchpoints: + +- `api.ui.registerComposerAction()` adds composer actions. +- `api.ui.registerEmbedRenderer()` renders declared custom embed payloads. +- `api.ui.mountElement()` can mount into a selector such as `app-chat-messages` when the plugin has `ui.dom`. + +## Voice Area + +Voice UI is split between channel membership, controls, and media workspace. + +```html + +
Voice Channels
+
+ + + + + +``` + +Plugin touchpoints: + +- `api.media.playAudioClip()` plays local audio. +- `api.media.addCustomAudioStream()` contributes audio to voice handling. +- `api.media.addCustomVideoStream()` contributes a video stream. +- `api.channels.addAudioChannel()` creates a voice channel entry when the plugin has channel management rights. + +## Plugin Store and Manager DOM + +```html + + + + + + + +``` + +Plugin pages registered through `api.ui.registerAppPage()` render at `/plugins/:pluginId/:pageId`: + +```html + + + +``` + +## Plugin Render Host + +`PluginRenderHostComponent` accepts plugin render functions that return either an `HTMLElement` or a string. Returning an `HTMLElement` is preferred for interactive UI. Returned strings are rendered as simple text content. + +## Stable Selectors for Tests and Plugins + +Prefer plugin APIs over DOM selectors. When direct DOM mounting is necessary, use stable app selectors and keep cleanup through the returned disposable. + +Common targets: + +| Selector | Area | +| --- | --- | +| `body` | Global overlays or modals. | +| `app-chat-messages` | Main text channel surface. | +| `app-rooms-side-panel` | Server side panel. | +| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar. | + +Avoid depending on Tailwind utility classes; they are layout details and may change. \ No newline at end of file diff --git a/docs-site/docs/developer/llm-plugin-builder-guide.md b/docs-site/docs/developer/llm-plugin-builder-guide.md new file mode 100644 index 0000000..c971b31 --- /dev/null +++ b/docs-site/docs/developer/llm-plugin-builder-guide.md @@ -0,0 +1,1432 @@ +--- +sidebar_position: 5 +--- + +# LLM Plugin Builder Guide + +Copy this page into an LLM prompt when you want it to build a MetoYou plugin. It is intentionally explicit about the app, communication model, visual structure, manifest format, runtime rules, API types, and examples so the model has fewer gaps to invent around. + +## Task For The LLM + +Build a MetoYou client plugin: a browser-safe JavaScript ES module with a `toju-plugin.json` manifest, loaded by the Angular renderer, running inside the user's local MetoYou app, using only browser APIs and the provided `TojuClientPluginApi`. + +Return a plugin folder like this: + +```text +my-plugin/ + toju-plugin.json + main.js + README.md + icon.svg +``` + +## Hard Rules + +- Do not modify MetoYou core unless the user explicitly asks for a core code change. +- Use plain browser ESM in `main.js`. Do not use Node APIs, `require`, `fs`, `path`, `child_process`, or build tooling unless explicitly requested. +- Use `toju-plugin.json` as the manifest name. +- Put every disposable returned by plugin APIs in `context.subscriptions`. +- Request only capabilities used by the code. +- Do not call moderation, delete, kick, ban, role, channel, server-setting, or other destructive APIs during `activate`. Put them behind visible user action and confirmation. +- Prefer `api.ui.*` extension points. Use `api.ui.mountElement` only when there is no suitable contribution API. +- Do not use `api.ui.mountElement` to add content to the server plugin sidebar. Use `api.ui.registerSidePanel` instead. +- Do not assume route-specific DOM such as `app-chat-messages`, `app-rooms-side-panel`, or `[data-testid="plugin-room-side-panel"]` exists during `activate`. Those elements exist only when the user is on the matching route and Angular has rendered that view. +- `serverData` is local per-user/per-server client data. It is not arbitrary remote server storage. +- Server-installed plugins are requirement metadata plus local client downloads. The signaling server never executes plugin entrypoints. +- Every event used with `api.events.*` must be declared in the manifest `events` array. + +## What MetoYou Is + +MetoYou is a Discord-like chat and voice app: + +- `toju-app/`: Angular renderer and plugin runtime. +- `electron/`: Electron desktop shell, preload bridge, local database, local REST API, local docs host. +- `server/`: Node/TypeScript signaling and directory server. +- `docs-site/`: Docusaurus documentation. + +Users join chat servers. A server has text channels, voice channels, members, roles, permissions, messages, attachments, voice state, screen share state, camera state, presence, and optional server plugin requirements. Users can also use direct messages, but the plugin API is primarily shaped around the current server workspace. + +## Communication Model + +There are three communication boundaries a plugin author must understand: + +```text +1. Signaling plane + Angular renderer <-> WebSocket signaling server + Used for identity, joining servers, presence, typing, plugin requirements, + server-relayed plugin events, WebRTC offers, answers, and ICE candidates. + +2. Peer plane + Angular renderer <-> WebRTC peer connections <-> other clients + Used for media and data-channel events: chat messages, message sync, + attachments, voice state, screen/camera state, and plugin message bus data. + +3. Desktop/local plane + Angular renderer <-> Electron preload bridge <-> Electron main process + Used for local SQLite, local files, cached plugin bundles, local REST API, + local Docusaurus docs hosting, and desktop-only features. +``` + +Plugins run only in the renderer. They do not run in Electron main and do not run on the signaling server. + +Choose communication APIs like this: + +| Need | Use | Notes | +| --- | --- | --- | +| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. | +| Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. | +| Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. | +| Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. | +| Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. | +| Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. | +| Local user preferences | `api.clientData` | User-scoped local storage/database. | +| Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. | +| App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. | +| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. | + +## How The App Looks + +The main app is a dense chat workspace. The most important plugin context is `/room/:roomId`. + +```html + + + + +``` + +Main server page shape: + +```html + + + +
Text Channels
+
Voice Channels
+
+ +
+
Members
+
+
+ + + + + + + +
+
+``` + +Important routes: + +| Route | Purpose | +| --- | --- | +| `/search` | Search and join servers. | +| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. | +| `/dm` and `/dm/:conversationId` | Direct-message workspace. | +| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. | +| `/plugin-store` | Browse and install plugins. | +| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. | + +Direct DOM mounting is a last resort. Route-specific targets may not exist when `activate` runs. If `api.ui.mountElement` cannot find the target, it throws `Plugin mount target not found: ` and plugin activation fails. + +Stable direct-mount targets when necessary: + +| Selector | Area | +| --- | --- | +| `body` | Safest global target for overlays, badges, and modals. It exists during activation. | +| `app-chat-messages` | Main text channel surface. Use only after checking the element exists. | +| `app-rooms-side-panel` | Server side panel. Use only after checking the element exists. Prefer `registerSidePanel` for plugin sidebar content. | + +Do not mount directly into `[data-testid="plugin-room-side-panel"]`. That area is owned by the plugin side-panel registry and is rendered only on the server page. For server sidebar UI, use: + +```js +context.subscriptions.push(api.ui.registerSidePanel('control-panel', { + label: 'Control Panel', + order: 20, + render: () => { + const root = document.createElement('section'); + const button = document.createElement('button'); + + button.type = 'button'; + button.textContent = 'Run Action'; + button.addEventListener('click', () => { + api.logger.info('Side-panel action clicked'); + }); + + root.append(button); + return root; + } +})); +``` + +Do not depend on Tailwind classes or internal styling classes. + +## Manifest + +Minimal manifest: + +```json +{ + "schemaVersion": 1, + "id": "example.my-plugin", + "title": "My Plugin", + "description": "Adds a focused MetoYou feature.", + "version": "1.0.0", + "kind": "client", + "scope": "client", + "apiVersion": "1.0.0", + "compatibility": { + "minimumTojuVersion": "1.0.0" + }, + "entrypoint": "./main.js", + "capabilities": ["ui.pages"] +} +``` + +Manifest type: + +```ts +type TojuPluginInstallScope = 'client' | 'server'; +type PluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint'; +type PluginEventScope = 'server' | 'channel' | 'user' | 'plugin'; + +type PluginCapabilityId = + | 'profile.read' + | 'profile.write' + | 'users.read' + | 'users.manage' + | 'roles.read' + | 'roles.manage' + | 'messages.read' + | 'messages.send' + | 'messages.editOwn' + | 'messages.deleteOwn' + | 'messages.moderate' + | 'messages.sync' + | 'channels.read' + | 'channels.manage' + | 'server.read' + | 'server.manage' + | 'p2p.data' + | 'p2p.media' + | 'media.playAudio' + | 'media.addAudioStream' + | 'media.addVideoStream' + | 'audio.volume' + | 'audio.effects' + | 'ui.settings' + | 'ui.pages' + | 'ui.sidePanel' + | 'ui.channelsSection' + | 'ui.embeds' + | 'ui.dom' + | 'storage.local' + | 'storage.serverData.read' + | 'storage.serverData.write' + | 'events.server.publish' + | 'events.server.subscribe' + | 'events.p2p.publish' + | 'events.p2p.subscribe'; + +interface TojuPluginManifest { + schemaVersion: 1; + id: string; + title: string; + description: string; + version: string; + kind: 'client' | 'library'; + scope?: TojuPluginInstallScope; + apiVersion: string; + compatibility: { + minimumTojuVersion: string; + maximumTojuVersion?: string; + verifiedTojuVersion?: string; + }; + entrypoint?: string; + capabilities?: PluginCapabilityId[]; + events?: { + eventName: string; + direction: PluginEventDirection; + scope: PluginEventScope; + maxPayloadBytes?: number; + schema?: string; + }[]; + data?: { + key: string; + scope: string; + storage: 'local' | 'serverData'; + schema?: string; + }[]; + pluginUser?: { + displayName: string; + label?: string; + avatar?: string; + }; + relationships?: { + after?: string[]; + before?: string[]; + conflicts?: string[]; + requires?: { id: string; versionRange?: string }[]; + optional?: { id: string; versionRange?: string }[]; + }; + authors?: { name: string; email?: string; url?: string }[]; + homepage?: string; + bugs?: string; + license?: string; + readme?: string; + changelog?: string; + settings?: Record; + ui?: Record; + bundle?: { url: string; entrypoint?: string }; + load?: { priority?: 'bootstrap' | 'high' | 'default' | 'low' }; +} +``` + +Validation rules: + +- `id` must be lowercase dotted/dashed id style: starts and ends with lowercase letter or number, and may contain lowercase letters, numbers, dots, and hyphens. +- `version` must look like semantic versioning: `1.0.0`, `1.2.3-beta.1`, or `1.2.3+build.4`. +- `schemaVersion` must be `1`. +- `kind` must be `client` or `library`. +- `scope` is optional, `client`, or `server`. +- `compatibility.minimumTojuVersion` is required. +- `kind: "client"` needs `entrypoint`. +- Every capability must be a known `PluginCapabilityId`. +- Every `api.events.*` event must be declared in `events`. + +Scope meanings: + +| Scope | Meaning | +| --- | --- | +| `client` or omitted | Installed globally for this local user/client. | +| `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. | + +Most generated plugins should use `kind: "client"`. Use `kind: "library"` only for dependency metadata with no executable entrypoint. + +## Runtime Lifecycle + +```ts +interface TojuPluginDisposable { + dispose: () => void; +} + +interface TojuPluginActivationContext { + api: TojuClientPluginApi; + manifest: TojuPluginManifest; + pluginId: string; + subscriptions: TojuPluginDisposable[]; +} + +interface TojuClientPluginModule { + activate?: (context: TojuPluginActivationContext) => Promise | void; + ready?: (context: TojuPluginActivationContext) => Promise | void; + deactivate?: (context: TojuPluginActivationContext) => Promise | void; + onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise | void; + onServerRequirementsChanged?: ( + context: TojuPluginActivationContext, + snapshot: PluginRequirementsSnapshot + ) => Promise | void; +} +``` + +Lifecycle behavior: + +- `activate(context)` runs after the plugin module is imported and capability grants are satisfied. +- The runtime passes a frozen `context.api`; never mutate it. +- `ready(context)` runs after the ready-plugin load-order pass. +- `deactivate(context)` runs during unload or reload. +- The host disposes `context.subscriptions` in reverse order after `deactivate`. +- UI contributions are removed by plugin id on unload. +- Activation state is remembered locally. + +Good boilerplate: + +```js +export function activate(context) { + const { api } = context; + + api.logger.info('Activating plugin', { pluginId: context.pluginId }); + + const page = api.ui.registerAppPage('home', { + label: 'My Plugin', + path: '/plugins/example.my-plugin/home', + render: () => { + const root = document.createElement('section'); + const title = document.createElement('h1'); + const button = document.createElement('button'); + const status = document.createElement('p'); + + title.textContent = 'My Plugin'; + button.type = 'button'; + button.textContent = 'Send hello'; + status.textContent = 'Ready.'; + + button.addEventListener('click', () => { + const channelId = api.context.getCurrent().textChannel?.id; + const message = api.messages.send('Hello from My Plugin', channelId); + status.textContent = `Sent message ${message.id}`; + }); + + root.append(title, button, status); + return root; + } + }); + + context.subscriptions.push(page); +} + +export function ready(context) { + context.api.logger.info('Plugin ready'); +} + +export function deactivate(context) { + context.api.logger.info('Plugin deactivating'); +} +``` + +Matching manifest capabilities for that boilerplate: + +```json +{ + "capabilities": ["ui.pages", "messages.send"] +} +``` + +## Shared App Types + +These are the main data shapes returned by plugin APIs. + +```ts +type ChannelType = 'text' | 'voice'; + +interface Channel { + id: string; + name: string; + type: ChannelType; + position: number; +} + +interface Room { + id: string; + name: string; + description?: string; + topic?: string; + hostId: string; + password?: string; + hasPassword?: boolean; + isPrivate: boolean; + createdAt: number; + userCount: number; + maxUsers?: number; + icon?: string; + iconUpdatedAt?: number; + slowModeInterval?: number; + permissions?: RoomPermissions; + channels?: Channel[]; + members?: RoomMember[]; + roles?: RoomRole[]; + roleAssignments?: RoomRoleAssignment[]; + channelPermissions?: ChannelPermissionOverride[]; + sourceId?: string; + sourceName?: string; + sourceUrl?: string; +} + +interface RoomPermissions { + adminsManageRooms?: boolean; + moderatorsManageRooms?: boolean; + adminsManageIcon?: boolean; + moderatorsManageIcon?: boolean; + allowVoice?: boolean; + allowScreenShare?: boolean; + allowFileUploads?: boolean; + slowModeInterval?: number; +} + +type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected'; +type UserRole = 'host' | 'admin' | 'moderator' | 'member'; + +interface User { + id: string; + oderId: string; + username: string; + displayName: string; + description?: string; + profileUpdatedAt?: number; + avatarUrl?: string; + avatarHash?: string; + avatarMime?: string; + avatarUpdatedAt?: number; + status: UserStatus; + role: UserRole; + joinedAt: number; + peerId?: string; + isOnline?: boolean; + isAdmin?: boolean; + isRoomOwner?: boolean; + presenceServerIds?: string[]; + voiceState?: VoiceState; + screenShareState?: ScreenShareState; + cameraState?: CameraState; + gameActivity?: unknown; +} + +interface RoomMember { + id: string; + oderId?: string; + username: string; + displayName: string; + description?: string; + profileUpdatedAt?: number; + avatarUrl?: string; + avatarHash?: string; + avatarMime?: string; + avatarUpdatedAt?: number; + role: UserRole; + roleIds?: string[]; + joinedAt: number; + lastSeenAt: number; +} + +interface VoiceState { + isConnected: boolean; + isMuted: boolean; + isDeafened: boolean; + isSpeaking: boolean; + isMutedByAdmin?: boolean; + volume?: number; + roomId?: string; + serverId?: string; +} + +interface Message { + id: string; + roomId: string; + channelId?: string; + senderId: string; + senderName: string; + content: string; + timestamp: number; + editedAt?: number; + reactions: Reaction[]; + isDeleted: boolean; + replyToId?: string; + linkMetadata?: LinkMetadata[]; +} + +interface Reaction { + id: string; + messageId: string; + oderId: string; + userId: string; + emoji: string; + timestamp: number; +} + +interface LinkMetadata { + url: string; + title?: string; + description?: string; + imageUrl?: string; + siteName?: string; + failed?: boolean; +} + +type RoomPermissionKey = + | 'manageServer' + | 'manageRoles' + | 'manageChannels' + | 'manageIcon' + | 'kickMembers' + | 'banMembers' + | 'manageBans' + | 'deleteMessages' + | 'joinVoice' + | 'shareScreen' + | 'uploadFiles'; + +type PermissionState = 'allow' | 'deny' | 'inherit'; +type RoomPermissionMatrix = Partial>; + +interface RoomRole { + id: string; + name: string; + color?: string; + position: number; + isSystem?: boolean; + permissions?: RoomPermissionMatrix; +} + +interface RoomRoleAssignment { + userId: string; + oderId?: string; + roleIds: string[]; +} + +interface ChannelPermissionOverride { + channelId: string; + targetType: 'role' | 'user'; + targetId: string; + permission: RoomPermissionKey; + value: PermissionState; +} +``` + +## Full Plugin API Types + +```ts +interface PluginApiProfileUpdate { displayName: string; description?: string } +interface PluginApiAvatarUpdate { avatarUrl: string; avatarMime: string; avatarHash: string } +interface PluginApiChannelRequest { name: string; id?: string; position?: number } +interface PluginApiServerSettingsUpdate { + name?: string; + description?: string; + topic?: string; + isPrivate?: boolean; + password?: string; + maxUsers?: number; +} +interface PluginApiPluginUserRequest { displayName: string; id?: string; avatarUrl?: string } +interface PluginApiMessageAsPluginUserRequest { pluginUserId: string; content: string; channelId?: string } +interface PluginApiAudioClipRequest { url: string; volume?: number } +interface PluginApiCustomStreamRequest { stream: MediaStream; label?: string } + +type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual'; +interface PluginApiActionContext { + source: PluginApiActionSource; + user: User | null; + server: Room | null; + textChannel: Channel | null; + voiceChannel: Channel | null; +} +interface PluginApiTypingEvent extends Omit { + channelId: string; + displayName: string; + isTyping: boolean; + serverId: string; + userId: string; +} +interface PluginEventEnvelope { + type: 'plugin_event'; + pluginId: string; + serverId: string; + eventName: string; + payload: TPayload; + eventId?: string; + emittedAt?: number; + sourceUserId?: string; + sourcePluginUserId?: string; +} +interface PluginApiEventSubscription { + eventName: string; + handler: (event: PluginEventEnvelope) => void; +} + +interface PluginApiMessageBusEnvelope { + eventId: string; + pluginId: string; + roomId: string; + sentAt: number; + topic: string; + channelId?: string; + payload?: unknown; + messages?: Message[]; + sourcePeerId?: string; + sourceUserId?: string; +} +interface PluginApiMessageBusLatestRequest { + targetPeerId?: string; + channelId?: string; + topic?: string; + limit?: number; + sinceTimestamp?: number; + includeDeleted?: boolean; +} +interface PluginApiMessageBusPublishRequest extends PluginApiMessageBusLatestRequest { + topic: string; + payload?: unknown; + includeLatestMessages?: boolean; + includeSelf?: boolean; +} +interface PluginApiMessageBusSubscription { + topic?: string; + channelId?: string; + replayLatest?: boolean; + latestMessageLimit?: number; + handler: (event: PluginApiMessageBusEnvelope) => void; +} + +interface PluginApiPageContribution { label: string; path: string; render: () => HTMLElement | string } +interface PluginApiSettingsPageContribution { label: string; settingsKey?: string; order?: number; render: () => HTMLElement | string } +interface PluginApiPanelContribution { label: string; order?: number; render: () => HTMLElement | string } +interface PluginApiChannelSectionContribution { label: string; type?: 'audio' | 'video' | 'custom'; order?: number } +interface PluginApiActionContribution { label: string; icon?: string; run: (context: PluginApiActionContext) => Promise | void } +interface PluginApiEmbedRendererContribution { embedType: string; render: (payload: unknown) => HTMLElement | string } +interface PluginApiDomMountRequest { target: Element | string; element: HTMLElement; position?: InsertPosition } + +interface TojuClientPluginApi { + readonly context: { getCurrent: () => PluginApiActionContext }; + readonly logger: { + debug: (message: string, data?: unknown) => void; + info: (message: string, data?: unknown) => void; + warn: (message: string, data?: unknown) => void; + error: (message: string, data?: unknown) => void; + }; + readonly profile: { + getCurrent: () => User | null; + update: (profile: PluginApiProfileUpdate) => void; + updateAvatar: (avatar: PluginApiAvatarUpdate) => void; + }; + readonly users: { + getCurrent: () => User | null; + list: () => User[]; + readMembers: () => RoomMember[]; + setRole: (userId: string, role: UserRole) => void; + kick: (userId: string) => void; + ban: (userId: string, reason?: string) => void; + }; + readonly roles: { + list: () => RoomRole[]; + setAssignments: (assignments: RoomRoleAssignment[]) => void; + }; + readonly server: { + getCurrent: () => Room | null; + registerPluginUser: (request: PluginApiPluginUserRequest) => string; + updatePermissions: (permissions: Partial) => void; + updateSettings: (settings: PluginApiServerSettingsUpdate) => void; + }; + readonly channels: { + list: () => Channel[]; + select: (channelId: string) => void; + addAudioChannel: (request: PluginApiChannelRequest) => void; + addVideoChannel: (request: PluginApiChannelRequest) => void; + rename: (channelId: string, name: string) => void; + remove: (channelId: string) => void; + }; + readonly messages: { + readCurrent: () => Message[]; + send: (content: string, channelId?: string) => Message; + sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void; + setTyping: (isTyping: boolean, channelId?: string) => void; + subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable; + edit: (messageId: string, content: string) => void; + delete: (messageId: string) => void; + moderateDelete: (messageId: string) => void; + sync: (messages: Message[]) => void; + }; + readonly events: { + publishServer: (eventName: string, payload: unknown) => void; + subscribeServer: (subscription: PluginApiEventSubscription) => TojuPluginDisposable; + publishP2p: (eventName: string, payload: unknown) => void; + subscribeP2p: (subscription: PluginApiEventSubscription) => TojuPluginDisposable; + }; + readonly messageBus: { + publish: (request: PluginApiMessageBusPublishRequest) => PluginApiMessageBusEnvelope; + sendLatestMessages: (request?: PluginApiMessageBusLatestRequest) => PluginApiMessageBusEnvelope; + subscribe: (subscription: PluginApiMessageBusSubscription) => TojuPluginDisposable; + }; + readonly p2p: { + connectedPeers: () => string[]; + broadcastData: (eventName: string, payload: unknown) => void; + sendData: (peerId: string, eventName: string, payload: unknown) => void; + }; + readonly media: { + playAudioClip: (request: PluginApiAudioClipRequest) => Promise; + addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise; + addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise; + setInputVolume: (volume: number) => void; + setOutputVolume: (volume: number) => void; + }; + readonly clientData: { + read: (key: string) => Promise; + write: (key: string, value: unknown) => Promise; + remove: (key: string) => Promise; + }; + readonly serverData: { + read: (key: string) => Promise; + write: (key: string, value: unknown) => Promise; + remove: (key: string) => Promise; + }; + readonly storage: { + get: (key: string) => unknown; + set: (key: string, value: unknown) => void; + remove: (key: string) => void; + }; + readonly ui: { + registerAppPage: (id: string, contribution: PluginApiPageContribution) => TojuPluginDisposable; + registerSettingsPage: (id: string, contribution: PluginApiSettingsPageContribution) => TojuPluginDisposable; + registerSidePanel: (id: string, contribution: PluginApiPanelContribution) => TojuPluginDisposable; + registerChannelSection: (id: string, contribution: PluginApiChannelSectionContribution) => TojuPluginDisposable; + registerComposerAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable; + registerProfileAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable; + registerToolbarAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable; + registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable; + mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable; + }; +} +``` + +## API Details And Examples + +### Context And Logger + +Capabilities: none. + +```js +const current = api.context.getCurrent(); +api.logger.info('Current context', { + userId: current.user?.id, + serverId: current.server?.id, + textChannelId: current.textChannel?.id, + voiceChannelId: current.voiceChannel?.id +}); +``` + +`context.getCurrent()` returns local snapshots for the current user, current server, active text channel, and the user's current voice channel. `logger.debug/info/warn/error` writes to plugin logs in plugin management UI. Do not log secrets. + +### Profile + +Capabilities: `profile.read`, `profile.write`. + +```js +const currentUser = api.profile.getCurrent(); + +api.profile.update({ + displayName: 'Ludde the Builder', + description: 'Building plugins for MetoYou.' +}); + +api.profile.updateAvatar({ + avatarUrl: 'https://example.com/avatar.png', + avatarMime: 'image/png', + avatarHash: 'sha256:0e5751c026e543b2e8ab2eb06099daa1' +}); +``` + +### Users And Roles + +Capabilities: `users.read`, `users.manage`, `roles.read`, `roles.manage`. + +```js +const knownUsers = api.users.list(); +const currentMembers = api.users.readMembers(); +const roles = api.roles.list(); + +api.users.setRole('5749584c-4ae6-44c1-b901-81ed4a80be63', 'moderator'); + +api.roles.setAssignments([ + { + userId: '5749584c-4ae6-44c1-b901-81ed4a80be63', + oderId: '5749584c-4ae6-44c1-b901-81ed4a80be63', + roleIds: ['moderator'] + } +]); +``` + +Moderation examples, only behind explicit user action: + +```js +api.users.kick('5749584c-4ae6-44c1-b901-81ed4a80be63'); +api.users.ban('5749584c-4ae6-44c1-b901-81ed4a80be63', 'Repeated spam after warning.'); +``` + +### Server + +Capabilities: `server.read`, `server.manage`, `users.manage` for plugin users. + +```js +const server = api.server.getCurrent(); + +api.server.updateSettings({ + name: 'Friday Build Room', + description: 'A server for focused testing sessions.', + topic: 'Plugin QA and voice checks', + isPrivate: false, + maxUsers: 24 +}); + +api.server.updatePermissions({ + allowVoice: true, + allowScreenShare: true, + allowFileUploads: true, + slowModeInterval: 0 +}); + +const botUserId = api.server.registerPluginUser({ + id: 'example.my-plugin:announcer', + displayName: 'Plugin Announcer', + avatarUrl: 'https://example.com/plugin-announcer.png' +}); +``` + +`registerPluginUser` creates a locally visible plugin-owned user record and returns its id. Reuse that id with `messages.sendAsPluginUser`. + +### Channels + +Capabilities: `channels.read`, `channels.manage`. + +```js +const channels = api.channels.list(); +const textChannels = channels.filter((channel) => channel.type === 'text'); +const voiceChannels = channels.filter((channel) => channel.type === 'voice'); + +api.channels.select('general'); +api.channels.addAudioChannel({ id: 'standup-voice', name: 'Standup Voice', position: 200 }); +api.channels.addVideoChannel({ id: 'demo-stage', name: 'Demo Stage', position: 300 }); +api.channels.rename('standup-voice', 'Daily Standup'); +api.channels.remove('demo-stage'); +``` + +`addAudioChannel` creates a voice channel in room state. `addVideoChannel` registers a plugin video channel section contribution, not a normal `Channel` model entry. + +### Messages And Typing + +Capabilities: `messages.read`, `messages.send`, `messages.editOwn`, `messages.deleteOwn`, `messages.moderate`, `messages.sync`. + +```js +const visibleMessages = api.messages.readCurrent(); + +const sent = api.messages.send( + 'Build completed successfully. Docs are ready for review.', + 'general' +); + +api.messages.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.'); +api.messages.delete(sent.id); +``` + +Plugin-user message: + +```js +const botUserId = api.server.registerPluginUser({ + id: 'example.my-plugin:status-bot', + displayName: 'Status Bot' +}); + +api.messages.sendAsPluginUser({ + pluginUserId: botUserId, + channelId: 'general', + content: 'Voice check starts in 5 minutes.' +}); +``` + +Typing state: + +```js +api.messages.setTyping(true, 'general'); + +setTimeout(() => { + api.messages.setTyping(false, 'general'); +}, 3000); + +const typingSubscription = api.messages.subscribeTyping((event) => { + api.logger.info('Typing event', { + channelId: event.channelId, + displayName: event.displayName, + isTyping: event.isTyping, + serverId: event.serverId, + userId: event.userId + }); +}); + +context.subscriptions.push(typingSubscription); +``` + +`messages.send` creates a message, persists it locally, dispatches it into the local message store, and broadcasts a `chat-message` peer event. `edit` and `delete` update local persistence and broadcast peer edit/delete events. `moderateDelete` should require explicit confirmation. `sync` injects an array of `Message` objects into state and should be used only by plugins that intentionally bridge or restore messages. + +### Events + +Capabilities: `events.server.publish`, `events.server.subscribe`, `events.p2p.publish`, `events.p2p.subscribe`. + +Manifest declaration is required: + +```json +{ + "events": [ + { + "eventName": "example.my-plugin.poll-vote", + "direction": "serverRelay", + "scope": "server", + "maxPayloadBytes": 4096, + "schema": "{\"type\":\"object\",\"required\":[\"pollId\",\"optionId\"]}" + } + ] +} +``` + +```js +api.events.publishServer('example.my-plugin.poll-vote', { + pollId: 'poll-2026-04-29-standup', + optionId: 'ship-it', + votedAt: Date.now() +}); + +const serverEventSubscription = api.events.subscribeServer({ + eventName: 'example.my-plugin.poll-vote', + handler: (event) => { + api.logger.info('Poll vote received', event.payload); + } +}); + +context.subscriptions.push(serverEventSubscription); +``` + +Important runtime detail: `subscribeServer` listens to signaling messages and calls the handler. `subscribeP2p` currently records/logs the subscription; for rich peer-to-peer plugin synchronization, prefer `api.messageBus`. + +### Message Bus + +Capabilities: `events.p2p.publish`, `events.p2p.subscribe`; also `messages.read` when including or replaying latest messages. + +The message bus sends `plugin-message-bus` data-channel events. It does not create normal chat messages. + +```js +const subscription = api.messageBus.subscribe({ + topic: 'example.my-plugin.checklist-state', + channelId: 'general', + replayLatest: true, + latestMessageLimit: 25, + handler: (event) => { + api.logger.info('Checklist bus event', { + topic: event.topic, + latestMessageCount: event.messages?.length ?? 0, + sourceUserId: event.sourceUserId + }); + } +}); + +context.subscriptions.push(subscription); + +const envelope = api.messageBus.publish({ + topic: 'example.my-plugin.checklist-state', + channelId: 'general', + includeSelf: true, + includeLatestMessages: true, + limit: 20, + payload: { + items: [ + { id: 'docs', label: 'Review docs', done: true }, + { id: 'voice', label: 'Test voice join', done: false } + ], + updatedAt: Date.now() + } +}); + +api.logger.debug('Published bus envelope', envelope); +``` + +Latest message snapshots default to `50` messages and are clamped to `1..250`. + +### P2P + +Capabilities: `p2p.data`. + +```js +const peerIds = api.p2p.connectedPeers(); + +api.p2p.broadcastData('example.my-plugin.presence-ping', { + status: 'reviewing-docs', + sentAt: Date.now() +}); + +if (peerIds.length > 0) { + api.p2p.sendData(peerIds[0], 'example.my-plugin.private-nudge', { + message: 'Can you check the voice channel?' + }); +} +``` + +`connectedPeers()` returns ids from the voice/WebRTC connection facade. + +### Media + +Capabilities: `media.playAudio`, `media.addAudioStream`, `media.addVideoStream`, `audio.volume`. + +```js +await api.media.playAudioClip({ + url: 'https://example.com/sounds/ding.mp3', + volume: 0.35 +}); + +api.media.setInputVolume(0.8); +api.media.setOutputVolume(0.6); +``` + +Create and contribute a browser `MediaStream`: + +```js +const audioContext = new AudioContext(); +const oscillator = audioContext.createOscillator(); +const destination = audioContext.createMediaStreamDestination(); + +oscillator.frequency.value = 440; +oscillator.connect(destination); +oscillator.start(); + +await api.media.addCustomAudioStream({ + label: 'Generated tone', + stream: destination.stream +}); +``` + +`addCustomAudioStream` currently sets the local voice stream through the voice facade. `addCustomVideoStream` registers/logs a video contribution; do not assume custom video rendering is complete unless the target app version confirms it. Audio clip volume is clamped to `0..1`. + +### Storage + +Capabilities: `storage.local`, `storage.serverData.read`, `storage.serverData.write`. + +Use async APIs for new plugins: + +```js +await api.clientData.write('preferences', { + compactMode: true, + favoriteChannelIds: ['general', 'standup-voice'], + updatedAt: Date.now() +}); + +const preferences = await api.clientData.read('preferences'); + +await api.serverData.write('server-checklist', { + items: [ + { id: 'setup', label: 'Create server channels', done: true }, + { id: 'invite', label: 'Invite test user', done: false } + ], + updatedAt: Date.now() +}); + +const checklist = await api.serverData.read('server-checklist'); +await api.serverData.remove('server-checklist'); +``` + +Legacy synchronous local storage: + +```js +api.storage.set('lastPanelTab', 'overview'); +const lastPanelTab = api.storage.get('lastPanelTab'); +api.storage.remove('lastPanelTab'); +``` + +Desktop uses Electron's local database when available, with renderer localStorage fallback. Browser-only clients use localStorage. `serverData` throws if no server is active. + +### UI + +Capabilities: + +| Method | Required capability | +| --- | --- | +| `registerAppPage` | `ui.pages` | +| `registerSettingsPage` | `ui.settings` | +| `registerSidePanel` | `ui.sidePanel` | +| `registerChannelSection` | `ui.channelsSection` | +| `registerComposerAction` | `ui.pages` | +| `registerProfileAction` | `ui.pages` | +| `registerToolbarAction` | `ui.pages` | +| `registerEmbedRenderer` | `ui.embeds` | +| `mountElement` | `ui.dom` | + +Register side panel: + +```js +context.subscriptions.push(api.ui.registerSidePanel('summary', { + label: 'Plugin Summary', + order: 10, + render: () => { + const root = document.createElement('aside'); + const heading = document.createElement('h2'); + const text = document.createElement('p'); + + heading.textContent = 'Plugin Summary'; + text.textContent = 'No active tasks.'; + root.append(heading, text); + return root; + } +})); +``` + +Use `registerSidePanel` for content that belongs in the server sidebar plugin area. Do not query `[data-testid="plugin-room-side-panel"]` and pass it to `mountElement`; that route-specific host may not exist while the plugin activates. + +Register app page: + +```js +context.subscriptions.push(api.ui.registerAppPage('dashboard', { + label: 'Build Dashboard', + path: '/plugins/example.build-dashboard/dashboard', + render: () => { + const root = document.createElement('section'); + const title = document.createElement('h1'); + const button = document.createElement('button'); + const output = document.createElement('p'); + + title.textContent = 'Build Dashboard'; + button.type = 'button'; + button.textContent = 'Send status'; + output.textContent = 'Idle.'; + + button.addEventListener('click', () => { + const message = api.messages.send('Build dashboard status: ready.'); + output.textContent = `Sent message ${message.id}`; + }); + + root.append(title, button, output); + return root; + } +})); +``` + +Register actions: + +```js +context.subscriptions.push(api.ui.registerComposerAction('insert-template', { + label: 'Insert Template', + icon: 'file-text', + run: (actionContext) => { + api.messages.send( + 'Template: Please review the latest build notes.', + actionContext.textChannel?.id + ); + } +})); + +context.subscriptions.push(api.ui.registerToolbarAction('post-standup', { + label: 'Post Standup', + icon: 'megaphone', + run: () => { + api.messages.send('Standup starts now. Join the voice channel when ready.'); + } +})); +``` + +Mount DOM directly: + +Use direct DOM mounting only for targets that exist now, or after your plugin has explicitly checked the target. `body` is safe during activation. Route-specific selectors are not safe during activation. + +```js +const banner = document.createElement('div'); +banner.textContent = 'Plugin banner mounted in chat messages.'; + +const target = document.querySelector('app-chat-messages'); + +if (target) { + context.subscriptions.push(api.ui.mountElement('chat-banner', { + target, + element: banner, + position: 'afterbegin' + })); +} +``` + +Global overlay example: + +```js +const badge = document.createElement('div'); +badge.textContent = 'Plugin active'; + +context.subscriptions.push(api.ui.mountElement('global-badge', { + target: 'body', + element: badge, + position: 'beforeend' +})); +``` + +`mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload. + +## Capability Cheat Sheet + +| API call group | Capabilities | +| --- | --- | +| `profile.getCurrent` | `profile.read` | +| `profile.update`, `profile.updateAvatar` | `profile.write` | +| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` | +| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` | +| `roles.list` | `roles.read` | +| `users.setRole`, `roles.setAssignments` | `roles.manage` | +| `server.getCurrent` | `server.read` | +| `server.updatePermissions`, `server.updateSettings` | `server.manage` | +| `channels.list`, `channels.select` | `channels.read` | +| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` | +| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` | +| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` | +| `messages.edit` | `messages.editOwn` | +| `messages.delete` | `messages.deleteOwn` | +| `messages.moderateDelete` | `messages.moderate` | +| `messages.sync` | `messages.sync` | +| `events.publishServer` | `events.server.publish` | +| `events.subscribeServer` | `events.server.subscribe` | +| `events.publishP2p` | `events.p2p.publish` | +| `events.subscribeP2p` | `events.p2p.subscribe` | +| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true | +| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` | +| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true | +| `p2p.*` | `p2p.data` | +| `media.playAudioClip` | `media.playAudio` | +| `media.addCustomAudioStream` | `media.addAudioStream` | +| `media.addCustomVideoStream` | `media.addVideoStream` | +| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` | +| `clientData.*`, `storage.*` | `storage.local` | +| `serverData.read` | `storage.serverData.read` | +| `serverData.write`, `serverData.remove` | `storage.serverData.write` | +| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` | +| `ui.registerSettingsPage` | `ui.settings` | +| `ui.registerSidePanel` | `ui.sidePanel` | +| `ui.registerChannelSection` | `ui.channelsSection` | +| `ui.registerEmbedRenderer` | `ui.embeds` | +| `ui.mountElement` | `ui.dom` | + +## Complete Example Plugin + +`toju-plugin.json`: + +```json +{ + "schemaVersion": 1, + "id": "example.voice-notes", + "title": "Voice Notes", + "description": "Adds a server panel for posting voice-session notes and syncing draft state with peers.", + "version": "1.0.0", + "kind": "client", + "scope": "server", + "apiVersion": "1.0.0", + "compatibility": { + "minimumTojuVersion": "1.0.0" + }, + "entrypoint": "./main.js", + "capabilities": [ + "server.read", + "channels.read", + "messages.read", + "messages.send", + "events.p2p.publish", + "events.p2p.subscribe", + "storage.serverData.read", + "storage.serverData.write", + "ui.sidePanel", + "ui.pages" + ] +} +``` + +`main.js`: + +```js +const DRAFT_KEY = 'voice-notes-draft'; +const BUS_TOPIC = 'example.voice-notes.draft'; + +export function activate(context) { + const { api } = context; + + api.logger.info('Voice Notes activated'); + + context.subscriptions.push(api.messageBus.subscribe({ + topic: BUS_TOPIC, + replayLatest: false, + handler: (event) => { + api.logger.debug('Received voice notes draft update', event.payload); + } + })); + + context.subscriptions.push(api.ui.registerSidePanel('voice-notes-panel', { + label: 'Voice Notes', + order: 20, + render: () => renderPanel(context) + })); + + context.subscriptions.push(api.ui.registerAppPage('voice-notes', { + label: 'Voice Notes', + path: '/plugins/example.voice-notes/voice-notes', + render: () => renderPanel(context) + })); +} + +function renderPanel(context) { + const { api } = context; + const root = document.createElement('section'); + const heading = document.createElement('h2'); + const meta = document.createElement('p'); + const textarea = document.createElement('textarea'); + const save = document.createElement('button'); + const post = document.createElement('button'); + const status = document.createElement('p'); + + const current = api.context.getCurrent(); + heading.textContent = 'Voice Notes'; + meta.textContent = current.voiceChannel + ? `Connected to ${current.voiceChannel.name}` + : 'Not connected to a voice channel.'; + textarea.rows = 6; + textarea.placeholder = 'Write notes from the current voice session.'; + save.type = 'button'; + save.textContent = 'Save Draft'; + post.type = 'button'; + post.textContent = 'Post Notes'; + status.textContent = 'Loading draft...'; + + void api.serverData.read(DRAFT_KEY).then((value) => { + if (value && typeof value === 'object' && typeof value.text === 'string') { + textarea.value = value.text; + } + + status.textContent = 'Draft loaded.'; + }).catch((error) => { + api.logger.warn('Could not load voice notes draft', error); + status.textContent = 'Could not load draft.'; + }); + + save.addEventListener('click', async () => { + const draft = { + text: textarea.value, + serverId: api.server.getCurrent()?.id ?? null, + channelId: api.context.getCurrent().textChannel?.id ?? null, + updatedAt: Date.now() + }; + + await api.serverData.write(DRAFT_KEY, draft); + api.messageBus.publish({ topic: BUS_TOPIC, includeSelf: false, payload: draft }); + status.textContent = 'Draft saved.'; + }); + + post.addEventListener('click', () => { + const text = textarea.value.trim(); + + if (!text) { + status.textContent = 'Write a note before posting.'; + return; + } + + api.messages.send(`Voice notes:\n\n${text}`, api.context.getCurrent().textChannel?.id); + status.textContent = 'Posted to the current text channel.'; + }); + + root.append(heading, meta, textarea, save, post, status); + return root; +} + +export function deactivate(context) { + context.api.logger.info('Voice Notes deactivated'); +} +``` + +## Final Checklist For Generated Plugins + +- Manifest is valid JSON with no comments. +- Manifest id is lowercase dotted or dashed. +- Manifest capabilities exactly match API calls used in `main.js`. +- Every `api.events.*` event name is declared in `events`. +- `main.js` exports `activate`; `ready` and `deactivate` are optional. +- Every subscription/disposable is pushed into `context.subscriptions`. +- Plugin uses browser DOM APIs and browser globals only. +- No destructive API runs automatically during activation. +- UI uses real `button`, `label`, `input`, `textarea`, headings, and status text. +- Async storage and media calls are awaited or handled with `.then/.catch`. +- README explains behavior, capabilities, and installation. + +## More Reference + +- User-facing behavior: User Guide pages. +- DOM structure: Developer Guide -> App Pages and DOM Structure. +- Local REST API: Developer Guide -> Local REST API. +- Plugin manifest: Plugin Development -> Manifest Model. +- Capabilities: Plugin Development -> Capabilities. +- Focused plugin API examples: Plugin Development -> API Reference and its API subpages. \ No newline at end of file diff --git a/docs-site/docs/developer/rest-api.md b/docs-site/docs/developer/rest-api.md new file mode 100644 index 0000000..a056ed0 --- /dev/null +++ b/docs-site/docs/developer/rest-api.md @@ -0,0 +1,300 @@ +--- +sidebar_position: 4 +--- + +# Local REST API + +The MetoYou desktop app exposes an optional local HTTP API for scripts and tools. It is implemented in Electron and reads local desktop data. + +## Enable the API + +1. Open Settings. +2. Open Local API settings. +3. Enable the local server. +4. Choose a port. The default is `17878`. +5. Add trusted signaling server URLs for authentication. +6. Enable Scalar docs if you want `/docs`. +7. Enable Docusaurus docs if you want `/docusaurus`. + +By default the server binds to `127.0.0.1`. Only enable LAN exposure when you understand the risk. + +## Authentication + +Protected routes require a bearer token. Get one by posting username, password, and an allowed signaling server URL. + +```bash +curl -s http://127.0.0.1:17878/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "alice", + "password": "correct horse battery staple", + "serverUrl": "https://tojusignal.example.com" + }' +``` + +Example response: + +```json +{ + "token": "local_4cddf95c5b8c4b6f9e0c", + "expiresAt": 1777477200000, + "user": { + "id": "user-alice-01", + "username": "alice", + "displayName": "Alice" + } +} +``` + +Use the token: + +```bash +curl -s http://127.0.0.1:17878/api/profile \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +Logout revokes the current token: + +```bash +curl -i -X POST http://127.0.0.1:17878/api/auth/logout \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +## OpenAPI and Scalar + +| Route | Auth | Purpose | +| --- | --- | --- | +| `GET /api/openapi.json` | No | OpenAPI 3.1 document. | +| `GET /docs` | No | Scalar API reference when enabled. | + +## Public Routes + +### GET /api/health + +Checks whether the local API server is running. + +```bash +curl -s http://127.0.0.1:17878/api/health +``` + +Example response: + +```json +{ + "status": "ok", + "version": "1.0.0", + "timestamp": 1777473600000, + "exposeOnLan": false +} +``` + +### GET /api/openapi.json + +Returns the machine-readable API document. + +```bash +curl -s http://127.0.0.1:17878/api/openapi.json +``` + +### POST /api/auth/login + +Issues a local bearer token after credentials are validated by an allowed signaling server. + +Request body: + +```json +{ + "username": "alice", + "password": "correct horse battery staple", + "serverUrl": "https://tojusignal.example.com" +} +``` + +Common errors: + +| Status | Error code | Meaning | +| --- | --- | --- | +| 400 | `INVALID_REQUEST` | Missing username, password, or server URL. | +| 403 | `NO_ALLOWED_SERVERS` | No allowed signaling servers are configured. | +| 403 | `SERVER_NOT_ALLOWED` | The server URL is not in the allowed list. | +| 401 | `INVALID_CREDENTIALS` | Signaling server rejected the login. | +| 502 | `UPSTREAM_UNREACHABLE` | The signaling server could not be reached. | + +## Protected Routes + +All routes below require: + +```http +Authorization: Bearer local_4cddf95c5b8c4b6f9e0c +``` + +### GET /api/profile + +Reads the current local user profile. + +```bash +curl -s http://127.0.0.1:17878/api/profile \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET /api/rooms + +Lists rooms known to this device. + +```bash +curl -s http://127.0.0.1:17878/api/rooms \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/rooms/{roomId}` + +Reads one room by id. + +```bash +curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75 \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/rooms/{roomId}/users` + +Lists users known for a room. + +```bash +curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75/users \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/rooms/{roomId}/messages` + +Lists local messages for a room. `limit` defaults to `100` and is clamped from `1` to `500`. `offset` defaults to `0`. + +```bash +curl -s 'http://127.0.0.1:17878/api/rooms/room-7ebdde75/messages?limit=50&offset=0' \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/rooms/{roomId}/messages/since` + +Lists local messages after a required timestamp. + +```bash +curl -s 'http://127.0.0.1:17878/api/rooms/room-7ebdde75/messages/since?sinceTimestamp=1777470000000' \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/rooms/{roomId}/bans` + +Lists active bans for a room. + +```bash +curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75/bans \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/rooms/{roomId}/bans/{userId}` + +Checks whether a user is banned in a room. + +```bash +curl -s http://127.0.0.1:17878/api/rooms/room-7ebdde75/bans/user-muse-01 \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +Example response: + +```json +{ "isBanned": false } +``` + +### GET `/api/messages/{messageId}` + +Reads one local message by id. + +```bash +curl -s http://127.0.0.1:17878/api/messages/msg-20260429-001 \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/messages/{messageId}/reactions` + +Lists reactions for a message. + +```bash +curl -s http://127.0.0.1:17878/api/messages/msg-20260429-001/reactions \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/messages/{messageId}/attachments` + +Lists attachments for a message. + +```bash +curl -s http://127.0.0.1:17878/api/messages/msg-20260429-001/attachments \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET `/api/users/{userId}` + +Reads one user by id. + +```bash +curl -s http://127.0.0.1:17878/api/users/user-muse-01 \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET /api/attachments + +Lists all attachments stored on this device. + +```bash +curl -s http://127.0.0.1:17878/api/attachments \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +### GET /api/plugin-data + +Reads a plugin data value from the local desktop database. `scope` must be `local` or `server`. Provide `serverId` when reading server-scoped data. + +```bash +curl -s 'http://127.0.0.1:17878/api/plugin-data?pluginId=example.soundboard&key=favorites&scope=server&serverId=room-7ebdde75' \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +Example response: + +```json +{ + "value": [ + { "label": "Chime", "url": "https://cdn.example.com/chime.wav" } + ] +} +``` + +### GET `/api/meta/{key}` + +Reads a desktop metadata value by key. + +```bash +curl -s http://127.0.0.1:17878/api/meta/metoyou_currentUserId \ + -H 'Authorization: Bearer local_4cddf95c5b8c4b6f9e0c' +``` + +Example response: + +```json +{ + "key": "metoyou_currentUserId", + "value": "user-alice-01" +} +``` + +## Data Model Notes + +Rooms, users, messages, reactions, attachments, and bans are returned from local desktop persistence. Many schemas allow additional properties because the local database can carry richer app state than the REST docs need to guarantee. + +## Security Notes + +- Keep the API bound to `127.0.0.1` unless LAN access is required. +- Only add signaling servers you trust to the allowed list. +- Bearer tokens are local to the running desktop app. +- Stop the local API server to clear issued tokens. \ No newline at end of file diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md index ddbd465..de58d85 100644 --- a/docs-site/docs/intro.md +++ b/docs-site/docs/intro.md @@ -5,18 +5,15 @@ sidebar_position: 1 # MetoYou Documentation -MetoYou is a desktop-first chat stack with a peer-to-peer product client, an Electron desktop shell, a Node signaling server, local persistence, realtime voice/video, a local automation API, and a client plugin runtime. +MetoYou is a desktop-first chat app with text channels, voice channels, direct messages, plugins, local desktop storage, a local REST API, and a Docusaurus documentation site bundled into the app. -The product experience is intentionally close to a modern team chat application: +This site is split into three paths: -- servers organize communities or workspaces; -- text channels keep room conversations scoped and searchable; -- direct messages stay separate from server context; -- voice, video, and screen sharing use WebRTC peer connections; -- invite links help members join the current server; -- local desktop settings control data, updates, plugins, and automation access. +- **User Guide** explains the app in non-technical terms: servers, text channels, voice channels, screen sharing, direct messages, plugins, and desktop settings. +- **Developer Guide** explains how to run the repo, how the app is structured, how Docusaurus is served, the app DOM/page structure, and the local REST API. +- **Plugin Development** explains how to build plugins, declare capabilities, distribute bundles, and call every exposed plugin API with concrete examples. -The Electron app also hosts this documentation. The docs endpoint is not a separate web server process: it is served from the same opt-in local HTTP server used for the Local API, and it only serves static files generated by Docusaurus. +The Electron app can host this documentation locally. The docs endpoint is not a separate web server process: it is served from the same opt-in local HTTP server used for the Local API, and it only serves static files generated by Docusaurus. ## What Is Included @@ -38,3 +35,14 @@ MetoYou keeps responsibilities split by package: - `docs-site/` is this Docusaurus site. The desktop documentation endpoint serves the static `docs-site/build` output. It does not run the Docusaurus development server inside Electron. + +## Fast Links + +- Start using the app: [First Steps](./user-guide/first-steps.md) +- Join voice: [Voice Channels and Calls](./user-guide/voice-channels.md) +- Install plugins: [Plugins for Users](./user-guide/plugins.md) +- Run the repo: [Contributing](./developer/contributing.md) +- Understand pages and DOM: [App Pages and DOM Structure](./developer/dom-structure.md) +- Use the REST API: [Local REST API](./developer/rest-api.md) +- Build a plugin: [Create a Plugin](./plugin-development/create-a-plugin.md) +- Give an LLM plugin context: [LLM Plugin Builder Guide](./developer/llm-plugin-builder-guide.md) diff --git a/docs-site/docs/plugin-development/api-reference.md b/docs-site/docs/plugin-development/api-reference.md index 725a49c..772ac48 100644 --- a/docs-site/docs/plugin-development/api-reference.md +++ b/docs-site/docs/plugin-development/api-reference.md @@ -6,6 +6,22 @@ sidebar_position: 4 `TojuClientPluginApi` is the object passed to a plugin activation context. The runtime freezes the API object before passing it to plugin code. +This page is the compact map. Use the focused API pages for concrete copy-paste examples with literal input data. + +## Focused API Pages + +- [Context and Logging](./api/context-and-logging.md) +- [Profile API](./api/profile.md) +- [Users and Roles API](./api/users-and-roles.md) +- [Server API](./api/server.md) +- [Channels API](./api/channels.md) +- [Messages and Typing API](./api/messages-and-typing.md) +- [Events API](./api/events.md) +- [Message Bus API](./api/message-bus.md) +- [P2P and Media API](./api/p2p-and-media.md) +- [Storage API](./api/storage.md) +- [UI API](./api/ui.md) + ## Activation Types ```ts @@ -123,6 +139,8 @@ interface PluginApiMessageAsPluginUserRequest { | `messages.readCurrent()` | `messages.read` | Returns current visible messages. | | `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. | | `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. | +| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. | +| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. | | `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. | | `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. | | `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. | @@ -299,3 +317,13 @@ interface PluginApiDomMountRequest { | `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds a toolbar action. | | `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. | | `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. | + +## Context and Logger + +| Method | Capability | Description | +| --- | --- | --- | +| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. | +| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. | +| `logger.info(message, data?)` | None | Writes an info plugin log entry. | +| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. | +| `logger.error(message, data?)` | None | Writes an error plugin log entry. | diff --git a/docs-site/docs/plugin-development/api/channels.md b/docs-site/docs/plugin-development/api/channels.md new file mode 100644 index 0000000..5e29a2d --- /dev/null +++ b/docs-site/docs/plugin-development/api/channels.md @@ -0,0 +1,85 @@ +--- +sidebar_position: 5 +--- + +# Channels API + +The channels API reads, selects, creates, renames, and removes server channels. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `channels.list()` | `channels.read` | +| `channels.select(channelId)` | `channels.read` | +| `channels.addAudioChannel(request)` | `channels.manage` | +| `channels.addVideoChannel(request)` | `channels.manage` | +| `channels.rename(channelId, name)` | `channels.manage` | +| `channels.remove(channelId)` | `channels.manage` | + +## List Channels + +```js +export function activate(context) { + const channels = context.api.channels.list(); + + context.api.logger.info('Channels', channels.map((channel) => ({ + id: channel.id, + name: channel.name, + type: channel.type + }))); +} +``` + +Example channel list: + +```json +[ + { "id": "general", "name": "general", "type": "text", "position": 0 }, + { "id": "support", "name": "support", "type": "text", "position": 1 }, + { "id": "lobby", "name": "Lobby", "type": "audio", "position": 10 } +] +``` + +## Select a Channel + +```js +export function activate(context) { + context.api.channels.select('support'); +} +``` + +## Add a Voice Channel + +```js +export function activate(context) { + context.api.channels.addAudioChannel({ + id: 'raid-voice', + name: 'Raid Voice', + position: 20 + }); +} +``` + +## Add a Video Channel Section + +```js +export function activate(context) { + context.api.channels.addVideoChannel({ + id: 'watch-party-video', + name: 'Watch Party', + position: 30 + }); +} +``` + +## Rename and Remove + +```js +export function activate(context) { + context.api.channels.rename('raid-voice', 'Raid Voice - Tonight'); + context.api.channels.remove('old-event-room'); +} +``` + +Channel creation, rename, and removal should be user-confirmed because they change the shared server structure. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/context-and-logging.md b/docs-site/docs/plugin-development/api/context-and-logging.md new file mode 100644 index 0000000..d8b8afd --- /dev/null +++ b/docs-site/docs/plugin-development/api/context-and-logging.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 1 +--- + +# Context and Logging + +Context and logging are available to every plugin. They do not require privileged capabilities. + +## context.getCurrent() + +Reads the current interaction context. + +```js +export function activate(context) { + const current = context.api.context.getCurrent(); + + context.api.logger.info('Current context', { + serverName: current.server?.name ?? 'No server open', + textChannel: current.textChannel?.name ?? 'No text channel selected', + voiceChannel: current.voiceChannel?.name ?? 'Not connected to voice', + user: current.user?.displayName ?? 'No user' + }); +} +``` + +Example context shape: + +```json +{ + "source": "manual", + "server": { "id": "room-7ebdde75", "name": "Friday Game Night" }, + "textChannel": { "id": "general", "name": "general", "type": "text" }, + "voiceChannel": { "id": "lobby", "name": "Lobby", "type": "audio" }, + "user": { "id": "user-alice-01", "displayName": "Alice" } +} +``` + +## Action Context + +Composer, toolbar, and profile actions receive context directly. + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerToolbarAction('where-am-i', { + label: 'Where am I?', + run: (actionContext) => { + context.api.logger.info('Toolbar action context', { + source: actionContext.source, + serverId: actionContext.server?.id, + textChannelId: actionContext.textChannel?.id, + voiceChannelId: actionContext.voiceChannel?.id + }); + } + })); +} +``` + +Capability required: `ui.pages` for the toolbar action. The context object itself needs no extra capability. + +## Logger Methods + +```js +export function activate(context) { + const { logger } = context.api; + + logger.debug('Preparing plugin', { pluginId: context.pluginId }); + logger.info('Plugin activated', { version: context.manifest.version }); + logger.warn('Optional service unavailable', { service: 'weather.example.com' }); + logger.error('Failed to parse saved preference', { key: 'soundboard:favorites' }); +} +``` + +Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/events.md b/docs-site/docs/plugin-development/api/events.md new file mode 100644 index 0000000..2eded9d --- /dev/null +++ b/docs-site/docs/plugin-development/api/events.md @@ -0,0 +1,100 @@ +--- +sidebar_position: 7 +--- + +# Events API + +Plugin events allow plugins to publish and subscribe to declared server or P2P events. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `events.publishServer(eventName, payload)` | `events.server.publish` | +| `events.subscribeServer(subscription)` | `events.server.subscribe` | +| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | +| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | + +## Declare Events in the Manifest + +```json +{ + "events": [ + { + "eventName": "poll:vote", + "direction": "p2pHint", + "scope": "channel", + "maxPayloadBytes": 2048 + }, + { + "eventName": "moderation:flag", + "direction": "serverRelay", + "scope": "server", + "maxPayloadBytes": 4096 + } + ] +} +``` + +## Publish and Subscribe to P2P Events + +```js +export function activate(context) { + context.subscriptions.push(context.api.events.subscribeP2p({ + eventName: 'poll:vote', + handler: (event) => { + context.api.logger.info('Vote received', { + optionId: event.payload?.optionId, + voterName: event.payload?.voterName, + eventId: event.eventId + }); + } + })); + + context.api.events.publishP2p('poll:vote', { + pollId: 'raid-night-2026-04-29', + optionId: 'dungeon', + voterName: 'Alice' + }); +} +``` + +## Publish and Subscribe to Server Events + +```js +export function activate(context) { + context.subscriptions.push(context.api.events.subscribeServer({ + eventName: 'moderation:flag', + handler: (event) => { + context.api.logger.warn('Moderation flag received', { + messageId: event.payload?.messageId, + reason: event.payload?.reason + }); + } + })); + + context.api.events.publishServer('moderation:flag', { + messageId: 'msg-20260429-flagged', + reason: 'Possible spam link', + reportedBy: 'user-alice-01' + }); +} +``` + +Example event envelope: + +```json +{ + "type": "plugin_event", + "eventName": "poll:vote", + "pluginId": "example.polls", + "serverId": "room-7ebdde75", + "eventId": "event-1777473600000-1", + "emittedAt": 1777473600000, + "payload": { + "pollId": "raid-night-2026-04-29", + "optionId": "dungeon", + "voterName": "Alice" + } +} +``` \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/message-bus.md b/docs-site/docs/plugin-development/api/message-bus.md new file mode 100644 index 0000000..da8138e --- /dev/null +++ b/docs-site/docs/plugin-development/api/message-bus.md @@ -0,0 +1,95 @@ +--- +sidebar_position: 8 +--- + +# Message Bus API + +The plugin message bus sends plugin-only P2P events. It can also include bounded latest-message snapshots for plugins that coordinate around recent chat state. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `messageBus.publish(request)` | `events.p2p.publish`, plus `messages.read` if `includeLatestMessages` is true | +| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | +| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, plus `messages.read` if replaying latest messages | + +## Subscribe + +```js +export function activate(context) { + context.subscriptions.push(context.api.messageBus.subscribe({ + topic: 'poll:votes', + channelId: 'general', + replayLatest: true, + latestMessageLimit: 10, + handler: (event) => { + context.api.logger.info('Poll bus event', { + topic: event.topic, + choice: event.payload?.choice, + messageCount: event.messages?.length ?? 0 + }); + } + })); +} +``` + +## Publish + +```js +export function activate(context) { + const envelope = context.api.messageBus.publish({ + topic: 'poll:votes', + channelId: 'general', + payload: { + pollId: 'raid-night-2026-04-29', + choice: 'healer', + voter: 'Alice' + }, + includeLatestMessages: true, + includeSelf: true, + latestMessageLimit: 10, + sinceTimestamp: 1777470000000 + }); + + context.api.logger.info('Published poll event', { eventId: envelope.eventId }); +} +``` + +Example envelope: + +```json +{ + "eventId": "plugin-bus-1777473600000-1", + "pluginId": "example.polls", + "roomId": "room-7ebdde75", + "channelId": "general", + "topic": "poll:votes", + "sentAt": 1777473600000, + "payload": { + "pollId": "raid-night-2026-04-29", + "choice": "healer", + "voter": "Alice" + }, + "messages": [ + { "id": "msg-1", "content": "Raid tonight?", "channelId": "general" } + ] +} +``` + +## Send Latest Messages + +```js +export function activate(context) { + context.api.messageBus.sendLatestMessages({ + topic: 'chat:snapshot', + channelId: 'support', + limit: 25, + includeDeleted: false, + sinceTimestamp: 1777460000000, + targetPeerId: 'peer-muse-laptop' + }); +} +``` + +Use the message bus for plugin coordination. Do not use it for normal user chat messages; use `messages.send()` for that. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/messages-and-typing.md b/docs-site/docs/plugin-development/api/messages-and-typing.md new file mode 100644 index 0000000..dcb93ad --- /dev/null +++ b/docs-site/docs/plugin-development/api/messages-and-typing.md @@ -0,0 +1,144 @@ +--- +sidebar_position: 6 +--- + +# Messages and Typing API + +The messages API reads current messages, sends messages, edits or deletes plugin-owned messages, moderates messages, syncs messages, and exposes typing state. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `messages.readCurrent()` | `messages.read` | +| `messages.send(content, channelId?)` | `messages.send` | +| `messages.sendAsPluginUser(request)` | `messages.send` | +| `messages.setTyping(isTyping, channelId?)` | `messages.send` | +| `messages.subscribeTyping(handler)` | `messages.read` | +| `messages.edit(messageId, content)` | `messages.editOwn` | +| `messages.delete(messageId)` | `messages.deleteOwn` | +| `messages.moderateDelete(messageId)` | `messages.moderate` | +| `messages.sync(messages)` | `messages.sync` | + +## Read Current Messages + +```js +export function activate(context) { + const messages = context.api.messages.readCurrent(); + + context.api.logger.info('Current messages', messages.slice(-3).map((message) => ({ + id: message.id, + channelId: message.channelId, + senderName: message.senderName, + content: message.content + }))); +} +``` + +## Send a Message + +```js +export function activate(context) { + const created = context.api.messages.send( + 'Reminder: raid starts at 20:00. Bring repairs and snacks.', + 'general' + ); + + context.api.logger.info('Sent reminder', { messageId: created.id }); +} +``` + +## Send as a Plugin User + +```js +export function activate(context) { + const botUserId = context.api.server.registerPluginUser({ + id: 'poll-bot', + displayName: 'Poll Bot' + }); + + context.api.messages.sendAsPluginUser({ + pluginUserId: botUserId, + channelId: 'general', + content: 'Poll is open: react with 1 for dungeon, 2 for arena, 3 for crafting.' + }); +} +``` + +Capabilities required: `users.manage` and `messages.send`. + +## Edit and Delete Plugin-Owned Messages + +```js +export function activate(context) { + const message = context.api.messages.send('Draft event reminder', 'announcements'); + + context.api.messages.edit(message.id, 'Event reminder: voice meetup starts in 15 minutes.'); + context.api.messages.delete(message.id); +} +``` + +## Moderation Delete + +```js +export function activate(context) { + context.api.messages.moderateDelete('msg-spam-20260429-001'); +} +``` + +Use moderation from explicit moderator actions, not automatic activation. + +## Typing State + +```js +export function activate(context) { + context.api.messages.setTyping(true, 'general'); + + setTimeout(() => { + context.api.messages.setTyping(false, 'general'); + }, 1500); + + context.subscriptions.push(context.api.messages.subscribeTyping((event) => { + context.api.logger.info('Typing event', { + displayName: event.displayName, + isTyping: event.isTyping, + channelId: event.channelId, + serverId: event.serverId, + voiceChannel: event.voiceChannel?.name ?? null + }); + })); +} +``` + +Example typing event: + +```json +{ + "serverId": "room-7ebdde75", + "channelId": "general", + "userId": "user-muse-01", + "displayName": "Muse", + "isTyping": true +} +``` + +## Sync Messages + +```js +export function activate(context) { + context.api.messages.sync([ + { + id: 'external-standup-001', + roomId: 'room-7ebdde75', + channelId: 'standup', + senderId: 'standup-importer', + senderName: 'Standup Importer', + content: 'Imported note: Alice is working on plugin docs.', + timestamp: 1777473600000, + isDeleted: false + } + ]); +} +``` + +Sync should preserve message ids and timestamps from the source system when possible. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/p2p-and-media.md b/docs-site/docs/plugin-development/api/p2p-and-media.md new file mode 100644 index 0000000..fcc3a3a --- /dev/null +++ b/docs-site/docs/plugin-development/api/p2p-and-media.md @@ -0,0 +1,128 @@ +--- +sidebar_position: 9 +--- + +# P2P and Media API + +P2P APIs send plugin data to connected peers. Media APIs play audio and contribute custom streams. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `p2p.connectedPeers()` | `p2p.data` | +| `p2p.broadcastData(eventName, payload)` | `p2p.data` | +| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | +| `media.playAudioClip(request)` | `media.playAudio` | +| `media.addCustomAudioStream(request)` | `media.addAudioStream` | +| `media.addCustomVideoStream(request)` | `media.addVideoStream` | +| `media.setInputVolume(volume)` | `audio.volume` | +| `media.setOutputVolume(volume)` | `audio.volume` | + +## Connected Peers + +```js +export function activate(context) { + const peerIds = context.api.p2p.connectedPeers(); + + context.api.logger.info('Connected peers', { peerIds }); +} +``` + +## Broadcast Data + +```js +export function activate(context) { + context.api.p2p.broadcastData('soundboard:played', { + soundId: 'airhorn-short', + label: 'Airhorn', + playedBy: 'Alice', + playedAt: 1777473600000 + }); +} +``` + +## Send Data to One Peer + +```js +export function activate(context) { + context.api.p2p.sendData('peer-muse-laptop', 'private-tool:ping', { + requestId: 'ping-20260429-001', + message: 'Are you receiving plugin data?' + }); +} +``` + +## Play an Audio Clip + +```js +export async function activate(context) { + await context.api.media.playAudioClip({ + url: 'https://cdn.example.com/metoyou/sounds/chime.wav', + volume: 0.65 + }); +} +``` + +## Add a Custom Audio Stream + +```js +export async function activate(context) { + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + const destination = audioContext.createMediaStreamDestination(); + + oscillator.type = 'sine'; + oscillator.frequency.value = 440; + gain.gain.value = 0.03; + oscillator.connect(gain); + gain.connect(destination); + oscillator.start(); + + await context.api.media.addCustomAudioStream({ + label: 'Tuning tone', + stream: destination.stream + }); + + setTimeout(async () => { + oscillator.stop(); + await audioContext.close(); + }, 1000); +} +``` + +## Add a Custom Video Stream + +```js +export async function activate(context) { + const canvas = document.createElement('canvas'); + canvas.width = 1280; + canvas.height = 720; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = '#111827'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#ffffff'; + ctx.font = '48px sans-serif'; + ctx.fillText('Plugin camera scene', 80, 120); + + const stream = canvas.captureStream(15); + + await context.api.media.addCustomVideoStream({ + label: 'Plugin camera scene', + stream + }); +} +``` + +## Set Volumes + +```js +export function activate(context) { + context.api.media.setInputVolume(0.85); + context.api.media.setOutputVolume(0.75); +} +``` + +Use media APIs with visible controls and clear user consent. Unexpected audio or video is a poor user experience. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/profile.md b/docs-site/docs/plugin-development/api/profile.md new file mode 100644 index 0000000..e3842aa --- /dev/null +++ b/docs-site/docs/plugin-development/api/profile.md @@ -0,0 +1,66 @@ +--- +sidebar_position: 2 +--- + +# Profile API + +The profile API reads and updates the current user's local profile details. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `profile.getCurrent()` | `profile.read` | +| `profile.update(profile)` | `profile.write` | +| `profile.updateAvatar(avatar)` | `profile.write` | + +## Read Current Profile + +```js +export function activate(context) { + const user = context.api.profile.getCurrent(); + + context.api.logger.info('Current profile', { + id: user?.id, + displayName: user?.displayName, + username: user?.username + }); +} +``` + +Example result: + +```json +{ + "id": "user-alice-01", + "username": "alice", + "displayName": "Alice", + "description": "Raids on Fridays", + "avatarUrl": "/avatars/alice.webp" +} +``` + +## Update Display Profile + +```js +export function activate(context) { + context.api.profile.update({ + displayName: 'Alice - Support Lead', + description: 'Available for onboarding and support questions.' + }); +} +``` + +## Update Avatar + +```js +export function activate(context) { + context.api.profile.updateAvatar({ + avatarUrl: 'https://cdn.example.com/metoyou/avatars/alice-support.png', + avatarMime: 'image/png', + avatarHash: 'sha256:9df5d5e4b0d8f41f3a3cf5d1f5a2c1f4' + }); +} +``` + +Use `profile.write` carefully. A plugin that changes a user's identity should explain why in its readme and UI. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/server.md b/docs-site/docs/plugin-development/api/server.md new file mode 100644 index 0000000..bf29429 --- /dev/null +++ b/docs-site/docs/plugin-development/api/server.md @@ -0,0 +1,81 @@ +--- +sidebar_position: 4 +--- + +# Server API + +The server API reads the active server, registers plugin-owned users, and updates server settings or permissions. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `server.getCurrent()` | `server.read` | +| `server.registerPluginUser(request)` | `users.manage` | +| `server.updatePermissions(permissions)` | `server.manage` | +| `server.updateSettings(settings)` | `server.manage` | + +## Read Current Server + +```js +export function activate(context) { + const server = context.api.server.getCurrent(); + + context.api.logger.info('Current server', { + id: server?.id, + name: server?.name, + topic: server?.topic, + isPrivate: server?.isPrivate + }); +} +``` + +## Register a Plugin User + +Plugin users are useful for bot-style messages. + +```js +export function activate(context) { + const botUserId = context.api.server.registerPluginUser({ + id: 'standup-helper-bot', + displayName: 'Standup Helper', + avatarUrl: 'https://cdn.example.com/metoyou/plugins/standup-helper.png' + }); + + context.api.messages.sendAsPluginUser({ + pluginUserId: botUserId, + channelId: 'general', + content: 'Standup reminder: share yesterday, today, and blockers.' + }); +} +``` + +Capabilities required: `users.manage` and `messages.send`. + +## Update Server Settings + +```js +export function activate(context) { + context.api.server.updateSettings({ + name: 'Friday Game Night', + topic: 'Co-op games, voice chat, and clips', + description: 'A friendly server for Friday sessions.', + maxUsers: 64, + isPrivate: false + }); +} +``` + +## Update Permissions + +```js +export function activate(context) { + context.api.server.updatePermissions({ + allowVoice: true, + allowVideo: true, + allowScreenShare: true + }); +} +``` + +Only update settings or permissions as part of an explicit admin flow. Plugins should not silently rename servers or change access rules. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/storage.md b/docs-site/docs/plugin-development/api/storage.md new file mode 100644 index 0000000..384ced9 --- /dev/null +++ b/docs-site/docs/plugin-development/api/storage.md @@ -0,0 +1,101 @@ +--- +sidebar_position: 10 +--- + +# Storage API + +Plugins can store local client data and per-server data. Desktop builds use Electron persistence when available; browser fallback uses renderer storage. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `clientData.read(key)` | `storage.local` | +| `clientData.write(key, value)` | `storage.local` | +| `clientData.remove(key)` | `storage.local` | +| `serverData.read(key)` | `storage.serverData.read` | +| `serverData.write(key, value)` | `storage.serverData.write` | +| `serverData.remove(key)` | `storage.serverData.write` | +| `storage.get(key)` | `storage.local` | +| `storage.set(key, value)` | `storage.local` | +| `storage.remove(key)` | `storage.local` | + +## Client Data + +Client data belongs to this local user and client. + +```js +export async function activate(context) { + await context.api.clientData.write('soundboard:volume', { + masterVolume: 0.7, + updatedAt: 1777473600000 + }); + + const value = await context.api.clientData.read('soundboard:volume'); + + context.api.logger.info('Loaded client data', value); +} +``` + +## Server Data + +Server data is local per-user/per-server state. It is not arbitrary signal-server persistence. + +```js +export async function activate(context) { + await context.api.serverData.write('soundboard:favorites', [ + { id: 'chime', label: 'Chime', url: 'https://cdn.example.com/chime.wav' }, + { id: 'ready', label: 'Ready Check', url: 'https://cdn.example.com/ready.wav' } + ]); + + const favorites = await context.api.serverData.read('soundboard:favorites'); + + context.api.logger.info('Loaded server favorites', favorites); +} +``` + +## Remove Data + +```js +export async function activate(context) { + await context.api.clientData.remove('soundboard:volume'); + await context.api.serverData.remove('soundboard:favorites'); +} +``` + +## Legacy Synchronous Storage + +The `storage.*` methods are legacy local storage helpers. Prefer `clientData.*` for new plugins when async reads are acceptable. + +```js +export function activate(context) { + context.api.storage.set('quick-toggle', { enabled: true }); + + const saved = context.api.storage.get('quick-toggle'); + + context.api.logger.info('Legacy storage value', saved); + + context.api.storage.remove('quick-toggle'); +} +``` + +## Manifest Data Declarations + +Declare important data keys in the manifest. + +```json +{ + "data": [ + { + "key": "soundboard:volume", + "scope": "client", + "storage": "local" + }, + { + "key": "soundboard:favorites", + "scope": "server", + "storage": "serverData" + } + ] +} +``` \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/ui.md b/docs-site/docs/plugin-development/api/ui.md new file mode 100644 index 0000000..1265868 --- /dev/null +++ b/docs-site/docs/plugin-development/api/ui.md @@ -0,0 +1,235 @@ +--- +sidebar_position: 11 +--- + +# UI API + +The UI API lets plugins add pages, settings pages, side panels, channel sections, actions, embed renderers, and controlled DOM mounts. + +Prefer registered UI contributions over direct DOM mounting. Contribution APIs let Angular render the plugin UI when the matching app surface exists. Direct DOM mounting runs immediately and throws if the target selector is not present. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `ui.registerAppPage(id, contribution)` | `ui.pages` | +| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | +| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | +| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | +| `ui.registerComposerAction(id, contribution)` | `ui.pages` | +| `ui.registerProfileAction(id, contribution)` | `ui.pages` | +| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | +| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | +| `ui.mountElement(id, request)` | `ui.dom` | + +Every registration returns a disposable. Push it into `context.subscriptions`. + +## App Page + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerAppPage('dashboard', { + label: 'Raid Dashboard', + path: '/plugins/example.raid-helper/dashboard', + render: () => { + const root = document.createElement('section'); + root.innerHTML = '

Raid Dashboard

Tonight: dungeon practice.

'; + return root; + } + })); +} +``` + +The page is hosted by `/plugins/:pluginId/:pageId`. + +## Settings Page + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerSettingsPage('preferences', { + label: 'Raid Helper', + settingsKey: 'raid-helper', + order: 20, + render: () => { + const wrapper = document.createElement('section'); + const label = document.createElement('label'); + const checkbox = document.createElement('input'); + + checkbox.type = 'checkbox'; + checkbox.checked = true; + label.append(checkbox, ' Enable ready-check reminders'); + wrapper.append(label); + return wrapper; + } + })); +} +``` + +## Side Panel + +Use `ui.registerSidePanel` for content that belongs in the server sidebar plugin area. Do not mount directly into `[data-testid="plugin-room-side-panel"]`; that host is route-specific and may not exist during plugin activation. + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerSidePanel('soundboard', { + label: 'Soundboard', + order: 10, + render: () => { + const panel = document.createElement('div'); + const button = document.createElement('button'); + + button.type = 'button'; + button.textContent = 'Play chime'; + button.onclick = () => context.api.media.playAudioClip({ + url: 'https://cdn.example.com/chime.wav', + volume: 0.6 + }); + panel.append(button); + return panel; + } + })); +} +``` + +Capabilities required: `ui.sidePanel` and `media.playAudio`. + +## Channel Section + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerChannelSection('events', { + label: 'Event Rooms', + type: 'custom', + order: 50 + })); +} +``` + +## Composer Action + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerComposerAction('insert-standup', { + icon: 'ST', + label: 'Insert standup prompt', + run: (actionContext) => { + context.api.messages.send( + 'Standup: yesterday I..., today I..., blocked by...', + actionContext.textChannel?.id + ); + } + })); +} +``` + +Capabilities required: `ui.pages` and `messages.send`. + +## Profile Action + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerProfileAction('wave', { + label: 'Wave', + run: (actionContext) => { + context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`); + } + })); +} +``` + +## Toolbar Action + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerToolbarAction('open-dashboard', { + label: 'Raid Helper', + run: () => { + context.api.logger.info('Open the Raid Helper plugin page from /plugins/example.raid-helper/dashboard'); + } + })); +} +``` + +## Embed Renderer + +```js +export function activate(context) { + context.subscriptions.push(context.api.ui.registerEmbedRenderer('raid-card', { + embedType: 'raid.card', + render: (payload) => { + const card = document.createElement('article'); + const title = document.createElement('h3'); + const body = document.createElement('p'); + + title.textContent = payload?.title ?? 'Raid'; + body.textContent = payload?.description ?? 'No description provided.'; + card.append(title, body); + return card; + } + })); +} +``` + +Example message content for this embed: + +```text +toju:embed:raid.card:{"title":"Friday Raid","description":"Meet in Lobby at 20:00."} +``` + +## DOM Mount + +Use DOM mounting only when normal UI contribution points are not enough. `ui.mountElement` resolves its target immediately. If the target does not exist, plugin activation fails with `Plugin mount target not found: `. + +Safe uses: + +- Mounting a global overlay, badge, or modal into `body` during activation. +- Mounting into a route-specific element only after checking that element exists. + +Avoid: + +- Mounting sidebar content into `[data-testid="plugin-room-side-panel"]`. Use `ui.registerSidePanel`. +- Mounting chat content into `app-chat-messages` during activation without checking for the element. + +```js +export function activate(context) { + const badge = document.createElement('div'); + badge.textContent = 'Raid helper active'; + badge.style.position = 'fixed'; + badge.style.right = '16px'; + badge.style.bottom = '16px'; + badge.style.padding = '8px 10px'; + badge.style.background = '#111827'; + badge.style.color = 'white'; + badge.style.borderRadius = '6px'; + + context.subscriptions.push(context.api.ui.mountElement('active-badge', { + target: 'body', + position: 'beforeend', + element: badge + })); +} +``` + +Route-specific mount example with a guard: + +```js +export function activate(context) { + const target = document.querySelector('app-chat-messages'); + + if (!target) { + context.api.logger.warn('Chat messages host is not rendered yet; skipping chat mount'); + return; + } + + const banner = document.createElement('div'); + banner.textContent = 'Raid helper active in this chat.'; + + context.subscriptions.push(context.api.ui.mountElement('chat-banner', { + target, + position: 'afterbegin', + element: banner + })); +} +``` + +The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/api/users-and-roles.md b/docs-site/docs/plugin-development/api/users-and-roles.md new file mode 100644 index 0000000..e2bdeb5 --- /dev/null +++ b/docs-site/docs/plugin-development/api/users-and-roles.md @@ -0,0 +1,89 @@ +--- +sidebar_position: 3 +--- + +# Users and Roles API + +The users and roles APIs read known users, read room members, and perform moderation or role changes when granted. + +## Required Capabilities + +| Method | Capability | +| --- | --- | +| `users.getCurrent()` | `users.read` | +| `users.list()` | `users.read` | +| `users.readMembers()` | `users.read` | +| `users.setRole(userId, role)` | `roles.manage` | +| `users.kick(userId)` | `users.manage` | +| `users.ban(userId, reason?)` | `users.manage` | +| `roles.list()` | `roles.read` | +| `roles.setAssignments(assignments)` | `roles.manage` | + +## Read Users + +```js +export function activate(context) { + const currentUser = context.api.users.getCurrent(); + const knownUsers = context.api.users.list(); + const roomMembers = context.api.users.readMembers(); + + context.api.logger.info('Room user summary', { + currentUser: currentUser?.displayName, + knownUserCount: knownUsers.length, + memberCount: roomMembers.length + }); +} +``` + +Example member data: + +```json +[ + { "id": "member-1", "userId": "user-alice-01", "displayName": "Alice", "role": "admin" }, + { "id": "member-2", "userId": "user-muse-01", "displayName": "Muse", "role": "member" } +] +``` + +## Read Roles + +```js +export function activate(context) { + const roles = context.api.roles.list(); + + context.api.logger.info('Available roles', roles.map((role) => ({ + id: role.id, + name: role.name, + permissions: role.permissions + }))); +} +``` + +## Set a User Role + +```js +export function activate(context) { + context.api.users.setRole('user-muse-01', 'moderator'); +} +``` + +## Replace Role Assignments + +```js +export function activate(context) { + context.api.roles.setAssignments([ + { userId: 'user-alice-01', roleId: 'admin' }, + { userId: 'user-muse-01', roleId: 'moderator' } + ]); +} +``` + +## Kick or Ban a User + +```js +export function activate(context) { + context.api.users.kick('user-spam-01'); + context.api.users.ban('user-spam-02', 'Repeated spam in support channels'); +} +``` + +Moderation calls should normally be behind an explicit user action in plugin UI. Do not run destructive moderation automatically on activation. \ No newline at end of file diff --git a/docs-site/docs/plugin-development/manifest.md b/docs-site/docs/plugin-development/manifest.md index 94defbe..8686c62 100644 --- a/docs-site/docs/plugin-development/manifest.md +++ b/docs-site/docs/plugin-development/manifest.md @@ -131,6 +131,8 @@ interface TojuPluginManifest { `scope: "server"` marks a plugin as server-scoped. Server-scoped store entries can be installed to a chat server as requirements. Required server plugins are auto-installed for members when that server opens; optional requirements stay listed but do not auto-install. +When a user installs a server-scoped plugin into the server they are currently viewing, MetoYou enables that plugin id locally and activates the plugin immediately after the local manifest is registered. Installing a server-scoped plugin for another server records the activation preference so it activates when that server is opened. + ## Entrypoint and Bundle Use `entrypoint` for a browser-resolvable module relative to the manifest. Use `bundle.url` when publishing a cached browser bundle through a plugin source manifest. Desktop installs cache bundle files into app data and load the cached manifest afterward. diff --git a/docs-site/docs/user-guide/first-steps.md b/docs-site/docs/user-guide/first-steps.md new file mode 100644 index 0000000..519e861 --- /dev/null +++ b/docs-site/docs/user-guide/first-steps.md @@ -0,0 +1,66 @@ +--- +sidebar_position: 1 +--- + +# First Steps + +MetoYou is a chat app for servers, text conversations, direct messages, and live voice. You do not need to understand the technical parts to use it. + +## Main Words + +| Word | Meaning | +| --- | --- | +| Server | A shared space for a community, team, or group. | +| Text channel | A named chat room inside a server. Messages stay in that channel. | +| Voice channel | A named live room inside a server. Join it when you want to talk, share camera, or share screen. | +| Direct message | A private conversation outside a server channel. | +| Plugin | An add-on that can add buttons, panels, tools, integrations, or server-specific features. | + +## Sign In + +1. Open MetoYou. +2. Sign in with your username and password. +3. If you use more than one signaling server, choose the server endpoint that owns your account. + +A signaling server handles accounts, server discovery, membership, and connection setup. In normal use you can think of it as the place MetoYou checks when you log in and join servers. + +## Find a Server + +1. Open the server search page. +2. Search by server name or browse the available list. +3. Select a server. +4. Join directly if it is public, enter the password if it is protected, or use an invite link if someone sent you one. + +After joining, the server appears in the vertical server rail on the left. Click a server icon there to switch servers. + +## Read and Send Messages + +1. Click a server in the left rail. +2. Pick a text channel under **Text Channels**. +3. Type in the composer at the bottom of the chat. +4. Press Enter or use the send button. + +Text channels keep different topics separate. For example, a server might have `general`, `announcements`, and `support` as separate text channels. + +## Start Talking + +1. Open a server. +2. Pick a voice channel under **Voice Channels**. +3. Click the voice channel to join. +4. Use the voice controls to mute, deafen, start camera, share screen, or leave. + +Voice is live. Text messages are written chat. They can happen at the same time, but they are different channel types. + +## Use Direct Messages + +Direct messages are one-to-one conversations. They are separate from server text channels, so they do not depend on which server you are viewing. + +## Open Settings + +Settings contain account, voice, plugin, server, desktop, update, local API, theme, and data controls. Desktop users can also manage local data import/export and local documentation/API hosting. + +## Install Plugins + +Plugins are installed from the Plugin Store or Plugin Manager. Some plugins are global client plugins. Other plugins are server-scoped and only apply to a specific server. + +See [Plugins for Users](./plugins.md) for the full non-technical plugin guide. \ No newline at end of file diff --git a/docs-site/docs/user-guide/plugins.md b/docs-site/docs/user-guide/plugins.md new file mode 100644 index 0000000..21b2f2a --- /dev/null +++ b/docs-site/docs/user-guide/plugins.md @@ -0,0 +1,82 @@ +--- +sidebar_position: 5 +--- + +# Plugins for Users + +Plugins add features to MetoYou. They can add pages, buttons, panels, settings, sounds, message tools, custom embeds, or server-specific behavior. + +## Types of Plugins + +| Type | What it means | +| --- | --- | +| Client plugin | Installed for your app. It follows you across servers when active. | +| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. | +| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. | + +## Install from the Plugin Store + +1. Open the Plugin Store from the title bar or Settings. +2. Browse or search available plugins. +3. Open the plugin details. +4. Read the description, version, source, and capability list. +5. Choose install. +6. Review and grant only the capabilities you trust. +7. Activate the plugin. + +Server-scoped plugins installed to the server you are currently viewing are enabled and activated automatically after install, so their panels, actions, or embeds can appear immediately. + +## Install a Local Plugin + +Desktop builds can discover local plugin folders from the app data plugins directory. + +1. Put the plugin folder in the desktop plugins directory. +2. Open Settings. +3. Open the Plugin Manager. +4. Refresh or register local plugins. +5. Grant capabilities and activate the plugin. + +## Server Plugin Prompts + +When a server uses plugins, MetoYou may show a prompt. + +| Status | Meaning | +| --- | --- | +| Required | You must install the plugin to join or continue using that server. | +| Recommended | The server suggests the plugin, but you can choose. | +| Optional | The plugin is available for the server, but not required. | +| Blocked | The server marks the plugin as not allowed. | +| Incompatible | The plugin version does not work with your app version or the server requirement. | + +Required plugins are still installed locally on your device. The signaling server stores requirement metadata only; it does not run plugin code. + +## Capability Grants + +Plugins must ask for capabilities before using sensitive features. + +Examples: + +| Capability area | Why a plugin might ask | +| --- | --- | +| Messages | Send messages, read current messages, moderate messages, or render embeds. | +| Users and roles | Read member lists, create plugin users, or manage users. | +| Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. | +| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. | +| Storage | Save plugin preferences locally or per server. | + +Only grant capabilities to plugins you trust. + +## Manage Plugins + +The Plugin Manager lets you: + +- activate, deactivate, reload, or unload plugins; +- grant or revoke capabilities; +- inspect plugin logs; +- see plugin UI contribution counts; +- review server plugin requirements; +- uninstall plugins. + +## Plugin Safety Notes + +Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it. \ No newline at end of file diff --git a/docs-site/docs/user-guide/servers-and-channels.md b/docs-site/docs/user-guide/servers-and-channels.md new file mode 100644 index 0000000..0ba24b3 --- /dev/null +++ b/docs-site/docs/user-guide/servers-and-channels.md @@ -0,0 +1,65 @@ +--- +sidebar_position: 2 +--- + +# Servers and Channels + +A server is the main shared space in MetoYou. Servers contain members, channels, permissions, optional plugins, and server settings. + +## Server Rail + +The server rail is the vertical list of servers on the left side of the app. + +- Click a server icon to open it. +- Use the add/search control to find or join more servers. +- A badge can show unread activity. +- Server context actions can include invite, leave, or server settings depending on your permissions. + +## Text Channels + +Text channels are written conversations. Each text channel has its own message list. + +Common examples: + +| Channel | Use | +| --- | --- | +| `general` | Everyday chat. | +| `announcements` | Updates from owners or admins. | +| `support` | Help requests. | +| `clips` | Shared media or links. | + +Messages, replies, reactions, attachments, GIFs, typing indicators, and plugin-created messages are scoped to the active text channel. + +## Voice Channels + +Voice channels are live spaces. Joining a voice channel connects your microphone and lets you use camera or screen sharing when enabled. + +Voice channel examples: + +| Channel | Use | +| --- | --- | +| `Lobby` | Casual drop-in voice. | +| `Gaming` | In-game voice. | +| `Meeting` | Focused calls. | +| `Support Room` | Live help. | + +## Text Channels vs Voice Channels + +| Text channel | Voice channel | +| --- | --- | +| Written messages. | Live audio and media. | +| You can read later. | You join and leave in real time. | +| Uses the message composer. | Uses voice controls. | +| Good for searchable discussions. | Good for conversations, calls, screen shares, and quick coordination. | + +## Server Members + +The member list shows people known to the server. Online members appear separately from offline members. Depending on permissions, owners, admins, or moderators can move users between voice channels, kick users, ban users, or change roles. + +## Invites + +Invite links help other users join a server. If a server is private or password-protected, the invite or password controls who can enter. + +## Server Plugins + +A server can recommend or require plugins. Required server plugins may block joining until you choose whether to install them. Optional and recommended plugins can be skipped. \ No newline at end of file diff --git a/docs-site/docs/user-guide/settings.md b/docs-site/docs/user-guide/settings.md new file mode 100644 index 0000000..70e21a9 --- /dev/null +++ b/docs-site/docs/user-guide/settings.md @@ -0,0 +1,33 @@ +--- +sidebar_position: 6 +--- + +# Settings and Data + +Settings control the app, voice, plugins, servers, themes, updates, local APIs, and desktop behavior. + +## Common Settings + +| Area | What you can manage | +| --- | --- | +| Account | Current profile, display details, and avatar metadata. | +| Voice | Devices, volumes, bitrate, latency, noise reduction, screen share preferences. | +| Plugins | Installed plugins, capability grants, plugin logs, and plugin store sources. | +| Server | Server details, channels, roles, moderation, plugin requirements, and member controls. | +| Theme | App colors and visual preferences. | +| Desktop | Tray behavior, auto-start, hardware acceleration, updates, and local data tools. | +| Local API | Local HTTP server, API docs, Docusaurus docs, and allowed signaling servers. | + +## Local Data + +Desktop MetoYou stores local app data on your device. That can include rooms, messages, users, plugin data, settings, and metadata. The desktop settings include data import/export tools. + +## Local API and Documentation Hosting + +The desktop app can start a local HTTP server. It is off by default. When enabled, it can serve: + +- Local REST API endpoints under `/api/...`; +- Scalar REST API docs at `/docs`; +- this Docusaurus site at `/docusaurus`. + +Authentication for protected local API routes uses a local bearer token. Login is checked against an allowed signaling server that you configure in settings. \ No newline at end of file diff --git a/docs-site/docs/user-guide/text-and-direct-messages.md b/docs-site/docs/user-guide/text-and-direct-messages.md new file mode 100644 index 0000000..06c2054 --- /dev/null +++ b/docs-site/docs/user-guide/text-and-direct-messages.md @@ -0,0 +1,37 @@ +--- +sidebar_position: 3 +--- + +# Text and Direct Messages + +Text channels and direct messages both use written chat, but they are meant for different situations. + +## Text Channels + +Text channels belong to a server. Everyone with access to that server and channel can participate. + +You can use text channels to: + +- send normal messages; +- edit or delete your own messages when allowed; +- react to messages; +- send attachments; +- browse and send GIFs when available; +- see typing indicators; +- read synced message history stored on your device. + +## Direct Messages + +Direct messages are private conversations outside a server channel. Use them when a message is meant for one person instead of the server. + +## Attachments and Media + +Attachments can appear as files, images, audio, or video depending on the file type and what the app can preview. If an image or link cannot load directly, the app can use fallback paths where available. + +## Message Sync + +MetoYou stores messages locally and syncs recent messages with peers when connections are available. If you were offline, messages may appear after peers reconnect and exchange their recent message lists. + +## Plugin Messages + +Some plugins can send messages, create bot-style plugin users, render custom embeds, or add composer buttons. MetoYou asks for plugin capability grants before plugins can use privileged message features. \ No newline at end of file diff --git a/docs-site/docs/user-guide/voice-channels.md b/docs-site/docs/user-guide/voice-channels.md new file mode 100644 index 0000000..845b012 --- /dev/null +++ b/docs-site/docs/user-guide/voice-channels.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 4 +--- + +# Voice Channels and Calls + +Voice channels are live rooms inside a server. Join one when you want to talk, share camera, or share your screen. + +## Join a Voice Channel + +1. Open the server from the left server rail. +2. Find **Voice Channels** in the server side panel. +3. Click the voice channel you want to join. +4. Allow microphone access if your system asks. +5. Use the voice controls to manage your call. + +When you join, other users in the same voice channel can hear you unless you are muted. Users in other voice channels are not part of your live voice room. + +## Voice Controls + +The voice controls can include: + +| Control | What it does | +| --- | --- | +| Mute microphone | Stops sending your microphone audio. | +| Deafen | Stops playback and usually mutes your microphone too. | +| Camera | Starts or stops webcam video. | +| Screen share | Shares a screen or window. | +| Settings | Opens voice device and quality settings. | +| Leave | Disconnects from the voice channel. | + +## Screen Sharing + +1. Join a voice channel. +2. Click screen share. +3. Choose a screen or window. +4. Choose whether to include system audio when available. +5. Stop sharing from the voice controls when done. + +The screen share picker can show screens and windows. Desktop audio support depends on operating system support and the selected source. + +## Voice Workspace + +When someone shares camera or screen, the voice workspace can expand into a larger media area. It can show focused streams, a grid of streams, or a minimized mini-window. + +## Floating Voice Controls + +If you navigate away from the server while still connected to voice, MetoYou can show floating voice controls. Use them to return to the voice server or leave the call. + +## Voice Settings + +Voice settings can include: + +- input device; +- output device; +- input volume; +- output volume; +- audio bitrate; +- latency profile; +- noise reduction; +- screen share quality; +- system audio preference. + +## Troubleshooting + +| Problem | Try this | +| --- | --- | +| Nobody hears you | Check mute, input device, system microphone permission, and input volume. | +| You hear nobody | Check deafen, output device, output volume, and whether others are in the same voice channel. | +| Screen share is missing | Check desktop permissions and try a different screen or window. | +| Voice drops after switching servers | Return to the server with the active voice session or leave and rejoin the voice channel. | + +Voice and screen sharing use peer-to-peer WebRTC media. The signaling server helps users connect, but the media itself travels through peer connections. \ No newline at end of file diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index 4915e5b..386629d 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -34,8 +34,10 @@ const config: Config = { title: 'MetoYou Docs', items: [ { type: 'docSidebar', sidebarId: 'mainSidebar', position: 'left', label: 'Guides' }, + { to: '/user-guide/first-steps', label: 'User Guide', position: 'left' }, + { to: '/developer/contributing', label: 'Developer Guide', position: 'left' }, { to: '/plugin-development/create-a-plugin', label: 'Plugin Guide', position: 'left' }, - { to: '/plugin-development/api-reference', label: 'Plugin API', position: 'left' } + { to: '/developer/rest-api', label: 'REST API', position: 'left' } ] }, footer: { @@ -44,9 +46,13 @@ const config: Config = { { title: 'Docs', items: [ - { label: 'Using MetoYou', to: '/using-metoyou' }, + { label: 'First Steps', to: '/user-guide/first-steps' }, + { label: 'Voice Channels', to: '/user-guide/voice-channels' }, + { label: 'Plugins for Users', to: '/user-guide/plugins' }, + { label: 'Contributing', to: '/developer/contributing' }, { label: 'Create a Plugin', to: '/plugin-development/create-a-plugin' }, - { label: 'Plugin API Reference', to: '/plugin-development/api-reference' } + { label: 'Plugin API Reference', to: '/plugin-development/api-reference' }, + { label: 'Local REST API', to: '/developer/rest-api' } ] } ], diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index de18106..14ed58d 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -3,8 +3,31 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; const sidebars: SidebarsConfig = { mainSidebar: [ 'intro', - 'using-metoyou', - 'desktop-and-local-api', + { + type: 'category', + label: 'User Guide', + items: [ + 'user-guide/first-steps', + 'user-guide/servers-and-channels', + 'user-guide/text-and-direct-messages', + 'user-guide/voice-channels', + 'user-guide/plugins', + 'user-guide/settings', + 'using-metoyou' + ] + }, + { + type: 'category', + label: 'Developer Guide', + items: [ + 'developer/contributing', + 'developer/docusaurus-site', + 'developer/dom-structure', + 'developer/rest-api', + 'developer/llm-plugin-builder-guide', + 'desktop-and-local-api' + ] + }, { type: 'category', label: 'Plugin Development', @@ -13,6 +36,23 @@ const sidebars: SidebarsConfig = { 'plugin-development/manifest', 'plugin-development/capabilities', 'plugin-development/api-reference', + { + type: 'category', + label: 'Plugin API Examples', + items: [ + 'plugin-development/api/context-and-logging', + 'plugin-development/api/profile', + 'plugin-development/api/users-and-roles', + 'plugin-development/api/server', + 'plugin-development/api/channels', + 'plugin-development/api/messages-and-typing', + 'plugin-development/api/events', + 'plugin-development/api/message-bus', + 'plugin-development/api/p2p-and-media', + 'plugin-development/api/storage', + 'plugin-development/api/ui' + ] + }, 'plugin-development/examples' ] } diff --git a/electron/api/openapi.ts b/electron/api/openapi.ts index 68a7eff..fd45e3f 100644 --- a/electron/api/openapi.ts +++ b/electron/api/openapi.ts @@ -5,6 +5,15 @@ export interface OpenApiBuildOptions { export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown { const { baseUrl, appVersion } = options; + const roomIdPathParameter = { name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }; + const userIdPathParameter = { name: 'userId', in: 'path', required: true, schema: { type: 'string' } }; + const messageIdPathParameter = { name: 'messageId', in: 'path', required: true, schema: { type: 'string' } }; + const sinceTimestampQueryParameter = { + name: 'sinceTimestamp', + in: 'query', + required: true, + schema: { type: 'integer', minimum: 0, format: 'int64' } + }; return { openapi: '3.1.0', @@ -96,6 +105,19 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown { }, additionalProperties: true }, + User: { + type: 'object', + properties: { + id: { type: 'string' }, + oderId: { type: 'string' }, + username: { type: 'string' }, + displayName: { type: 'string' }, + status: { type: 'string' }, + role: { type: 'string' }, + isOnline: { type: 'boolean' } + }, + additionalProperties: true + }, Message: { type: 'object', properties: { @@ -110,6 +132,59 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown { isDeleted: { type: 'boolean' } }, additionalProperties: true + }, + Reaction: { + type: 'object', + properties: { + id: { type: 'string' }, + messageId: { type: 'string' }, + userId: { type: 'string' }, + oderId: { type: 'string' }, + emoji: { type: 'string' }, + timestamp: { type: 'integer', format: 'int64' } + }, + additionalProperties: true + }, + Attachment: { + type: 'object', + properties: { + id: { type: 'string' }, + messageId: { type: 'string' }, + filename: { type: 'string' }, + size: { type: 'integer' }, + mime: { type: 'string' }, + isImage: { type: 'boolean' }, + filePath: { type: 'string' }, + savedPath: { type: 'string' } + }, + additionalProperties: true + }, + Ban: { + type: 'object', + properties: { + oderId: { type: 'string' }, + roomId: { type: 'string' }, + userId: { type: 'string' }, + bannedBy: { type: 'string' }, + displayName: { type: 'string' }, + reason: { type: 'string' }, + expiresAt: { type: 'integer', format: 'int64' }, + timestamp: { type: 'integer', format: 'int64' } + }, + additionalProperties: true + }, + PluginDataValue: { + type: 'object', + properties: { + value: {} + } + }, + MetaValue: { + type: 'object', + properties: { + key: { type: 'string' }, + value: { type: ['string', 'null'] } + } } } }, @@ -225,11 +300,47 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown { } } }, + '/api/rooms/{roomId}': { + get: { + summary: 'Get a room by id', + parameters: [roomIdPathParameter], + responses: { + '200': { + description: 'Room details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Room' } + } + } + }, + '404': { + description: 'Room not found', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } + } + } + } + }, + '/api/rooms/{roomId}/users': { + get: { + summary: 'List users known for a room', + parameters: [roomIdPathParameter], + responses: { + '200': { + description: 'Users array', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/User' } } + } + } + } + } + } + }, '/api/rooms/{roomId}/messages': { get: { summary: 'List messages for a room', parameters: [ - { name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }, + roomIdPathParameter, { name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } }, { name: 'offset', in: 'query', required: false, schema: { type: 'integer', minimum: 0 } } ], @@ -247,6 +358,182 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown { } } } + }, + '/api/rooms/{roomId}/messages/since': { + get: { + summary: 'List room messages after a timestamp', + parameters: [roomIdPathParameter, sinceTimestampQueryParameter], + responses: { + '200': { + description: 'Messages array', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Message' } } + } + } + } + } + } + }, + '/api/rooms/{roomId}/bans': { + get: { + summary: 'List active bans for a room', + parameters: [roomIdPathParameter], + responses: { + '200': { + description: 'Bans array', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Ban' } } + } + } + } + } + } + }, + '/api/rooms/{roomId}/bans/{userId}': { + get: { + summary: 'Check whether a user is banned in a room', + parameters: [roomIdPathParameter, userIdPathParameter], + responses: { + '200': { + description: 'Ban status', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['isBanned'], + properties: { isBanned: { type: 'boolean' } } + } + } + } + } + } + } + }, + '/api/messages/{messageId}': { + get: { + summary: 'Get a message by id', + parameters: [messageIdPathParameter], + responses: { + '200': { + description: 'Message details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Message' } + } + } + }, + '404': { + description: 'Message not found', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } + } + } + } + }, + '/api/messages/{messageId}/reactions': { + get: { + summary: 'List reactions for a message', + parameters: [messageIdPathParameter], + responses: { + '200': { + description: 'Reactions array', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Reaction' } } + } + } + } + } + } + }, + '/api/messages/{messageId}/attachments': { + get: { + summary: 'List attachments for a message', + parameters: [messageIdPathParameter], + responses: { + '200': { + description: 'Attachments array', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } } + } + } + } + } + } + }, + '/api/users/{userId}': { + get: { + summary: 'Get a user by id', + parameters: [userIdPathParameter], + responses: { + '200': { + description: 'User details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' } + } + } + }, + '404': { + description: 'User not found', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } + } + } + } + }, + '/api/attachments': { + get: { + summary: 'List all attachments stored on this device', + responses: { + '200': { + description: 'Attachments array', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Attachment' } } + } + } + } + } + } + }, + '/api/plugin-data': { + get: { + summary: 'Read a plugin data value', + parameters: [ + { name: 'pluginId', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'key', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'scope', in: 'query', required: true, schema: { type: 'string', enum: ['local', 'server'] } }, + { name: 'serverId', in: 'query', required: false, schema: { type: 'string' } } + ], + responses: { + '200': { + description: 'Plugin data value', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PluginDataValue' } + } + } + } + } + } + }, + '/api/meta/{key}': { + get: { + summary: 'Read a desktop metadata value', + parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Metadata value', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MetaValue' } + } + } + } + } + } } } }; diff --git a/electron/api/router.ts b/electron/api/router.ts index 0f63f07..8440094 100644 --- a/electron/api/router.ts +++ b/electron/api/router.ts @@ -104,6 +104,37 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n return Math.max(min, Math.min(max, Math.floor(parsed))); } +function getTrailingPathParam(pathname: string, pattern: RegExp, name: string): string { + const value = pattern.exec(pathname)?.[1]; + + if (!value) { + throw new HttpError(400, `${name} is required`, 'INVALID_REQUEST'); + } + + return decodeURIComponent(value); +} + +function getRequiredQueryParam(ctx: RouteContext, name: string): string { + const value = ctx.request.url.searchParams.get(name)?.trim() ?? ''; + + if (!value) { + throw new HttpError(400, `${name} is required`, 'INVALID_REQUEST'); + } + + return value; +} + +function getRequiredTimestamp(ctx: RouteContext, name: string): number { + const raw = getRequiredQueryParam(ctx, name); + const value = Number(raw); + + if (!Number.isFinite(value) || value < 0) { + throw new HttpError(400, `${name} must be a non-negative timestamp`, 'INVALID_REQUEST'); + } + + return Math.floor(value); +} + const ROUTES: RouteDefinition[] = [ defineRoute('GET', '/api/health', async (ctx): Promise => ({ status: 200, @@ -276,20 +307,165 @@ const ROUTES: RouteDefinition[] = [ return { status: 200, body: rooms ?? [] }; }, true), + defineRoute('GET', '/api/rooms/{roomId}', async (ctx): Promise => { + const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)$/u, 'roomId'); + const room = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetRoom, + payload: { roomId } + }); + + if (!room) { + throw new HttpError(404, 'Room not found on this device', 'ROOM_NOT_FOUND'); + } + + return { status: 200, body: room }; + }, true), + + defineRoute('GET', '/api/rooms/{roomId}/users', async (ctx): Promise => { + const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/users$/u, 'roomId'); + const users = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetUsersByRoom, + payload: { roomId } + }); + + return { status: 200, body: users ?? [] }; + }, true), + defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise => { - const roomId = ctx.request.url.pathname.match(/\/api\/rooms\/([^/]+)\/messages$/u)?.[1]; - - if (!roomId) - throw new HttpError(400, 'roomId is required', 'INVALID_REQUEST'); - + const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/messages$/u, 'roomId'); const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100); const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0); const messages = await runQuery(requireDataSource(ctx.dataSource), { type: QueryType.GetMessages, - payload: { roomId: decodeURIComponent(roomId), limit, offset } + payload: { roomId, limit, offset } }); return { status: 200, body: messages ?? [] }; + }, true), + + defineRoute('GET', '/api/rooms/{roomId}/messages/since', async (ctx): Promise => { + const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/messages\/since$/u, 'roomId'); + const sinceTimestamp = getRequiredTimestamp(ctx, 'sinceTimestamp'); + const messages = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetMessagesSince, + payload: { roomId, sinceTimestamp } + }); + + return { status: 200, body: messages ?? [] }; + }, true), + + defineRoute('GET', '/api/rooms/{roomId}/bans', async (ctx): Promise => { + const roomId = getTrailingPathParam(ctx.request.pathname, /\/api\/rooms\/([^/]+)\/bans$/u, 'roomId'); + const bans = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetBansForRoom, + payload: { roomId } + }); + + return { status: 200, body: bans ?? [] }; + }, true), + + defineRoute('GET', '/api/rooms/{roomId}/bans/{userId}', async (ctx): Promise => { + const match = /\/api\/rooms\/([^/]+)\/bans\/([^/]+)$/u.exec(ctx.request.pathname); + + if (!match) { + throw new HttpError(400, 'roomId and userId are required', 'INVALID_REQUEST'); + } + + const isBanned = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.IsUserBanned, + payload: { roomId: decodeURIComponent(match[1]), userId: decodeURIComponent(match[2]) } + }); + + return { status: 200, body: { isBanned } }; + }, true), + + defineRoute('GET', '/api/messages/{messageId}', async (ctx): Promise => { + const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)$/u, 'messageId'); + const message = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetMessageById, + payload: { messageId } + }); + + if (!message) { + throw new HttpError(404, 'Message not found on this device', 'MESSAGE_NOT_FOUND'); + } + + return { status: 200, body: message }; + }, true), + + defineRoute('GET', '/api/messages/{messageId}/reactions', async (ctx): Promise => { + const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)\/reactions$/u, 'messageId'); + const reactions = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetReactionsForMessage, + payload: { messageId } + }); + + return { status: 200, body: reactions ?? [] }; + }, true), + + defineRoute('GET', '/api/messages/{messageId}/attachments', async (ctx): Promise => { + const messageId = getTrailingPathParam(ctx.request.pathname, /\/api\/messages\/([^/]+)\/attachments$/u, 'messageId'); + const attachments = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetAttachmentsForMessage, + payload: { messageId } + }); + + return { status: 200, body: attachments ?? [] }; + }, true), + + defineRoute('GET', '/api/users/{userId}', async (ctx): Promise => { + const userId = getTrailingPathParam(ctx.request.pathname, /\/api\/users\/([^/]+)$/u, 'userId'); + const user = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetUser, + payload: { userId } + }); + + if (!user) { + throw new HttpError(404, 'User not found on this device', 'USER_NOT_FOUND'); + } + + return { status: 200, body: user }; + }, true), + + defineRoute('GET', '/api/attachments', async (ctx): Promise => { + const attachments = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetAllAttachments, + payload: {} + }); + + return { status: 200, body: attachments ?? [] }; + }, true), + + defineRoute('GET', '/api/plugin-data', async (ctx): Promise => { + const pluginId = getRequiredQueryParam(ctx, 'pluginId'); + const key = getRequiredQueryParam(ctx, 'key'); + const scope = getRequiredQueryParam(ctx, 'scope'); + + if (scope !== 'local' && scope !== 'server') { + throw new HttpError(400, 'scope must be local or server', 'INVALID_REQUEST'); + } + + const value = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetPluginData, + payload: { + key, + pluginId, + scope, + serverId: ctx.request.url.searchParams.get('serverId') ?? undefined + } + }); + + return { status: 200, body: { value } }; + }, true), + + defineRoute('GET', '/api/meta/{key}', async (ctx): Promise => { + const key = getTrailingPathParam(ctx.request.pathname, /\/api\/meta\/([^/]+)$/u, 'key'); + const value = await runQuery(requireDataSource(ctx.dataSource), { + type: QueryType.GetMeta, + payload: { key } + }); + + return { status: 200, body: { key, value } }; }, true) ]; diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 99bb883..cf301ec 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index 840fe6d..7413812 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -213,6 +213,10 @@ export class PluginStoreService { ? await this.installedPluginsForServer(targetServerId) : this.installedPluginsForScope(installScope); const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id); + const installOptions = { + ...options, + activate: options.activate === true || (installScope === 'server' && !existing) + }; const installedPlugin = await this.cacheInstalledPlugin({ bundleUrl: manifest.bundle?.url ?? plugin.bundleUrl, installedAt: existing?.installedAt ?? now, @@ -226,8 +230,8 @@ export class PluginStoreService { .concat(installedPlugin) .sort(sortInstalledPlugins); - await this.persistInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options); - await this.registerInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options); + await this.persistInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, installOptions); + await this.registerInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, installOptions); return installedPlugin; } @@ -272,6 +276,10 @@ export class PluginStoreService { await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins); } + if (installScope === 'server' && options.activate) { + this.registry.setEnabled(installedPlugin.manifest.id, true); + } + if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) { if (options.activate) { await this.host.rememberActivation(installedPlugin.manifest.id); @@ -367,6 +375,7 @@ export class PluginStoreService { if (options.activate) { for (const installedPlugin of installedPlugins) { + this.registry.setEnabled(installedPlugin.manifest.id, true); await this.host.rememberActivation(installedPlugin.manifest.id); } }