From 7f868aabf746623f8c2da1e83e8abec770b0cdb2 Mon Sep 17 00:00:00 2001 From: "serhii.ku" Date: Tue, 30 Jun 2026 23:17:18 +0300 Subject: [PATCH 1/7] Added msrp functionality and demo --- msrp_demo/index.html | 12 + msrp_demo/package.json | 20 + msrp_demo/src/App.vue | 984 +++++++++++++++++++++ msrp_demo/src/composables/index.ts | 429 ++++++++++ msrp_demo/src/env.d.ts | 7 + msrp_demo/src/main.ts | 4 + msrp_demo/src/types/index.ts | 127 +++ msrp_demo/tsconfig.json | 29 + msrp_demo/tsconfig.node.json | 11 + msrp_demo/vite.config.ts | 40 + msrp_demo/yarn.lock | 363 ++++++++ package.json | 2 +- src/modules/msrp.ts | 1268 +++++++++++++++++++++++++--- src/types/listeners.d.ts | 50 ++ 14 files changed, 3213 insertions(+), 133 deletions(-) create mode 100644 msrp_demo/index.html create mode 100644 msrp_demo/package.json create mode 100644 msrp_demo/src/App.vue create mode 100644 msrp_demo/src/composables/index.ts create mode 100644 msrp_demo/src/env.d.ts create mode 100644 msrp_demo/src/main.ts create mode 100644 msrp_demo/src/types/index.ts create mode 100644 msrp_demo/tsconfig.json create mode 100644 msrp_demo/tsconfig.node.json create mode 100644 msrp_demo/vite.config.ts create mode 100644 msrp_demo/yarn.lock diff --git a/msrp_demo/index.html b/msrp_demo/index.html new file mode 100644 index 0000000..128a67b --- /dev/null +++ b/msrp_demo/index.html @@ -0,0 +1,12 @@ + + + + + + opensips-js MSRP Demo + + +
+ + + diff --git a/msrp_demo/package.json b/msrp_demo/package.json new file mode 100644 index 0000000..614dce9 --- /dev/null +++ b/msrp_demo/package.json @@ -0,0 +1,20 @@ +{ + "name": "opensips-msrp-demo", + "version": "0.0.1", + "private": true, + "description": "Standalone Vue demo for opensips-js MSRP module.", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "3.2.25" + }, + "devDependencies": { + "@types/node": "^18.14.2", + "@vitejs/plugin-vue": "^4.2.3", + "typescript": "^4.9.5", + "vite": "4.4.11" + } +} diff --git a/msrp_demo/src/App.vue b/msrp_demo/src/App.vue new file mode 100644 index 0000000..97f1095 --- /dev/null +++ b/msrp_demo/src/App.vue @@ -0,0 +1,984 @@ + + + + + diff --git a/msrp_demo/src/composables/index.ts b/msrp_demo/src/composables/index.ts new file mode 100644 index 0000000..01a535e --- /dev/null +++ b/msrp_demo/src/composables/index.ts @@ -0,0 +1,429 @@ +import { computed, ref } from 'vue' + +import OpenSIPSJS from '../../../src/index' +import type { + CustomLoggerType, + IOpenSIPSConfiguration, + IOpenSIPSJSOptions +} from '../../../src/types/rtc' +import type { IMessage } from '../../../src/types/msrp' +import type { + MSRPConversationState, + MSRPMemberRole, + MSRPUploadResult +} from '../../../src/modules/msrp' +import { MSRP_EVT } from '../../../src/modules/msrp' + +import type { + ConnectOptions, + MSRPTypingState, + PnExtraHeaders, + UnreadCounts, + VsipAPI +} from '../types' + +let openSIPSJS: OpenSIPSJS | undefined = undefined + +// ---------- Shared connection state ---------- +const isInitialized = ref(false) +const isOpenSIPSReady = ref(false) +const isOpenSIPSReconnecting = ref(false) + +// ---------- MSRP session state ---------- +const currentMsrpSession = ref(null) +const isMSRPInitializing = ref(false) + +// ---------- MSRP conversation state ---------- +// Conversation metadata and chat history are two separate stores. The +// metadata map is small and changes rarely (members, role, state +// events). The messages map is fat and churns on every new event, so +// keeping them apart prevents message activity from invalidating +// metadata-only views. +const conversations = ref<{ [key: string]: MSRPConversationState }>({}) +const messagesByConversation = ref<{ [conversationKey: string]: any[] }>({}) +const typingByConversation = ref<{ [conversationKey: string]: MSRPTypingState }>({}) + +// ---------- UI-only state ---------- +const currentConversationKey = ref(null) +const unreadByConversation = ref({}) + +const hasActiveMsrpSession = computed(() => currentMsrpSession.value !== null) + +const currentConversation = computed(() => { + const key = currentConversationKey.value + if (!key) return null + return conversations.value[key] ?? null +}) + +const currentMessages = computed(() => { + const key = currentConversationKey.value + if (!key) return [] + return messagesByConversation.value[key] ?? [] +}) + +const sortedConversations = computed(() => { + return Object.values(conversations.value).sort( + (a, b) => (b.updated_at || 0) - (a.updated_at || 0) + ) +}) + +// LOCAL HELPERS +function findMessage (messages: any[], eventId: string) { + return messages.find((msg: any) => msg.event_id === eventId) +} + +/** + * Apply a live `m.reaction` event to a target message's + * `reactions_summary` array, mirroring the exact shape the backend + * returns on sync: + * { emoji, count, user_ids, viewer_reacted } + * + * Invariant enforced: `count === user_ids.length`. A repeated "add" + * from the same sender is a no-op; a "remove" from a sender who isn't + * in `user_ids` is a no-op. `viewer_reacted` is recomputed from + * `user_ids` so we never depend on a server-supplied value that may be + * wrong (the backend has been observed to mis-attribute it). + */ +function applyReaction ( + target: any, + emoji: string, + action: 'add' | 'remove', + sender: string, + viewerUri: string +): void { + if (!target?.content) return + if (!Array.isArray(target.content.reactions_summary)) { + target.content.reactions_summary = [] + } + const summary = target.content.reactions_summary as any[] + // Lookup tolerates legacy entries that used `key` instead of `emoji`. + const existing = summary.find((r) => (r?.emoji || r?.key) === emoji) + + if (action === 'remove') { + if (!existing) return + const userIds: string[] = Array.isArray(existing.user_ids) ? existing.user_ids : [] + if (!userIds.includes(sender)) return + existing.user_ids = userIds.filter((u) => u !== sender) + existing.count = existing.user_ids.length + if (existing.count === 0) { + target.content.reactions_summary = summary.filter( + (r) => (r?.emoji || r?.key) !== emoji + ) + return + } + existing.viewer_reacted = existing.user_ids.includes(viewerUri) + return + } + + if (existing) { + const userIds: string[] = Array.isArray(existing.user_ids) ? existing.user_ids : [] + if (userIds.includes(sender)) return + existing.user_ids = [ ...userIds, sender ] + existing.count = existing.user_ids.length + if (!existing.emoji) existing.emoji = emoji + existing.viewer_reacted = existing.user_ids.includes(viewerUri) + return + } + + summary.push({ + emoji, + count: 1, + user_ids: [ sender ], + viewer_reacted: sender === viewerUri + }) +} + +function resolveLastEventId (conversationKey: string): string | null { + const messages = messagesByConversation.value[conversationKey] + if (!messages?.length) return null + const lastMsg = [ ...messages ] + .filter((m: any) => m.type === MSRP_EVT.MESSAGE && m.event_id) + .sort((a: any, b: any) => (a.origin_server_ts || 0) - (b.origin_server_ts || 0)) + .pop() + return lastMsg?.event_id ?? null +} + +export const vsipAPI: VsipAPI = { + state: { + isInitialized, + isOpenSIPSReady, + isOpenSIPSReconnecting, + isMSRPInitializing, + currentMsrpSession, + hasActiveMsrpSession, + conversations, + messagesByConversation, + currentConversationKey, + currentConversation, + currentMessages, + sortedConversations, + typingByConversation, + unreadByConversation + }, + actions: { + init ( + connectOptions: ConnectOptions, + pnExtraHeaders?: PnExtraHeaders, + opensipsConfiguration: Partial = {}, + logger?: CustomLoggerType + ) { + return new Promise((resolve, reject) => { + try { + const configuration: IOpenSIPSConfiguration = { + ...opensipsConfiguration, + session_timers: false, + uri: `sip:${connectOptions.username}@${connectOptions.domain}`, + password: connectOptions.password + } + + if (connectOptions.authorization_jwt) { + configuration.authorization_jwt = connectOptions.authorization_jwt + } + + const additionalOptions: Partial = {} + + if (connectOptions.msrpDomain) { + additionalOptions.msrpDomain = connectOptions.msrpDomain + } + + if (connectOptions.msrpWs) { + additionalOptions.msrpWs = connectOptions.msrpWs + } + + openSIPSJS = new OpenSIPSJS({ + configuration, + socketInterfaces: [ `wss://${connectOptions.domain}` ], + sipDomain: `${connectOptions.domain}`, + sipOptions: { + session_timers: false, + extraHeaders: [ 'X-Bar: bar' ], + pcConfig: {} + }, + modules: connectOptions.modules, + pnExtraHeaders, + ...additionalOptions + }, logger) + + openSIPSJS + .on('connection', (value: boolean) => { + isInitialized.value = true + isOpenSIPSReady.value = value + + resolve(openSIPSJS as OpenSIPSJS) + }) + .on('reconnecting', (value: boolean) => { + isOpenSIPSReconnecting.value = value + }) + .on('changeMsrpSession', (session: IMessage | null) => { + currentMsrpSession.value = session + }) + .on('isMSRPInitializingChanged', (value: boolean) => { + isMSRPInitializing.value = value + }) + .on('msrpSyncCompleted', (payload) => { + conversations.value = { ...payload.conversations } + messagesByConversation.value = { ...(payload.messagesByConversation ?? {}) } + + const nextUnread: UnreadCounts = {} + for (const k of Object.keys(unreadByConversation.value)) { + if (payload.conversations[k]) nextUnread[k] = unreadByConversation.value[k] + } + unreadByConversation.value = nextUnread + }) + .on('msrpConversationCreated', (payload) => { + conversations.value = { + ...conversations.value, + [payload.conversationKey]: payload.conversation + } + if (!messagesByConversation.value[payload.conversationKey]) { + messagesByConversation.value = { + ...messagesByConversation.value, + [payload.conversationKey]: [] + } + } + }) + .on('msrpConversationRemoved', (payload) => { + if (payload.conversationKey in conversations.value) { + const next = { ...conversations.value } + delete next[payload.conversationKey] + conversations.value = next + } + if (payload.conversationKey in messagesByConversation.value) { + const nextMsgs = { ...messagesByConversation.value } + delete nextMsgs[payload.conversationKey] + messagesByConversation.value = nextMsgs + } + if (unreadByConversation.value[payload.conversationKey]) { + const u = { ...unreadByConversation.value } + delete u[payload.conversationKey] + unreadByConversation.value = u + } + }) + .on('msrpConversationUpdated', (payload) => { + const c = conversations.value[payload.conversationKey] + if (!c) return + Object.assign(c, payload.patch) + }) + .on('msrpMessageAdded', (payload) => { + const c = conversations.value[payload.conversationKey] + if (!c) return + // Ensure the message bucket exists - it may not + // if the message arrives before a sync. + if (!messagesByConversation.value[payload.conversationKey]) { + messagesByConversation.value[payload.conversationKey] = [] + } + messagesByConversation.value[payload.conversationKey].push(payload.message) + c.updated_at = payload.message.origin_server_ts || Date.now() + + if (payload.conversationKey === currentConversationKey.value) return + const myUri = openSIPSJS?.configuration?.uri?.toString?.() ?? '' + if (payload.message?.sender && payload.message.sender === myUri) return + unreadByConversation.value = { + ...unreadByConversation.value, + [payload.conversationKey]: + (unreadByConversation.value[payload.conversationKey] || 0) + 1 + } + }) + .on('msrpReceiptChanged', (payload) => { + const messages = messagesByConversation.value[payload.conversationKey] + if (messages) { + const m = findMessage(messages, payload.eventId) + if (m?.content) m.content.status = payload.status + } + const c = conversations.value[payload.conversationKey] + if (c) c.updated_at = payload.updatedAt + }) + .on('msrpReactionChanged', (payload) => { + const messages = messagesByConversation.value[payload.conversationKey] + if (messages) { + const m = findMessage(messages, payload.eventId) + const viewerUri = openSIPSJS?.configuration?.uri?.toString?.() ?? '' + applyReaction(m, payload.emoji, payload.action, payload.sender, viewerUri) + if (m?.content) m.content.updated_at = payload.updatedAt + } + const c = conversations.value[payload.conversationKey] + if (c) c.updated_at = payload.updatedAt + }) + .on('msrpTyping', (payload: { conversationKey: string, sender: string, isTyping: boolean }) => { + if (payload.isTyping) { + typingByConversation.value = { + ...typingByConversation.value, + [payload.conversationKey]: { + sender: payload.sender, + isTyping: true, + updatedAt: Date.now() + } + } + } else { + const next = { ...typingByConversation.value } + delete next[payload.conversationKey] + typingByConversation.value = next + } + }) + .begin() + + resolve(openSIPSJS) + } catch (e) { + console.error(e) + + reject(e) + } + }) + }, + unregister () { + openSIPSJS?.unregister() + }, + register () { + openSIPSJS?.register() + }, + disconnect () { + openSIPSJS?.disconnect() + }, + initMSRP (options: object = {}) { + openSIPSJS?.msrp.initMSRP(options) + }, + initMSRPAndSendMessage (target: string, body: string, options: object = {}) { + openSIPSJS?.msrp.initMSRPAndSendMessage(target, body, options) + }, + msrpAnswer (callId: string) { + openSIPSJS?.msrp.msrpAnswer(callId) + }, + messageTerminate (callId: string) { + openSIPSJS?.msrp.messageTerminate(callId) + }, + sendMSRP (msrpSessionId: string, body: string) { + openSIPSJS?.msrp.sendMSRP(msrpSessionId, body) + }, + safeSendMSRP (body: string) { + return openSIPSJS?.msrp.safeSendMSRP(body) ?? false + }, + sendCreateConversationMessage (targetSip: string | string[]) { + return openSIPSJS?.msrp.sendCreateConversationMessage(targetSip) ?? false + }, + sendTextMessage (conversationKey: string, text: string) { + return openSIPSJS?.msrp.sendTextMessage(conversationKey, text) ?? false + }, + sendMediaMessage (conversationKey: string, uploadResult: MSRPUploadResult, caption = '') { + return openSIPSJS?.msrp.sendMediaMessage(conversationKey, uploadResult, caption) ?? false + }, + sendReaction (conversationKey: string, targetEventId: string, emoji: string) { + return openSIPSJS?.msrp.sendReaction(conversationKey, targetEventId, emoji) ?? false + }, + sendTypingIndicator (conversationKey: string, isTyping: boolean) { + return openSIPSJS?.msrp.sendTypingIndicator(conversationKey, isTyping) ?? false + }, + startTypingKeepAlive (conversationKey: string) { + openSIPSJS?.msrp.startTypingKeepAlive(conversationKey) + }, + stopTypingKeepAlive (sendStop = true) { + openSIPSJS?.msrp.stopTypingKeepAlive(sendStop) + }, + sendReadReceipt (conversationKey: string) { + const lastEventId = resolveLastEventId(conversationKey) + if (!lastEventId) return false + return openSIPSJS?.msrp.sendReadReceipt(conversationKey, lastEventId) ?? false + }, + closeConversation (conversationKey: string, reason?: string, cause?: string) { + return openSIPSJS?.msrp.closeConversation(conversationKey, reason, cause) ?? false + }, + changeMemberRole (conversationKey: string, targetUri: string, newRole: MSRPMemberRole) { + return openSIPSJS?.msrp.changeMemberRole(conversationKey, targetUri, newRole) ?? false + }, + acceptInvite (conversationKey: string) { + return openSIPSJS?.msrp.acceptInvite(conversationKey) ?? false + }, + rejectInvite (conversationKey: string) { + return openSIPSJS?.msrp.rejectInvite(conversationKey) ?? false + }, + leaveConversation (conversationKey: string) { + return openSIPSJS?.msrp.leaveConversation(conversationKey) ?? false + }, + setActiveConversation (conversationKey: string | null) { + if (currentConversationKey.value === conversationKey) return + currentConversationKey.value = conversationKey + if (conversationKey) { + const lastEventId = resolveLastEventId(conversationKey) + if (lastEventId) { + openSIPSJS?.msrp.sendReadReceipt(conversationKey, lastEventId) + } + if (unreadByConversation.value[conversationKey]) { + const next = { ...unreadByConversation.value } + delete next[conversationKey] + unreadByConversation.value = next + } + } + }, + requestUploadUrl (conversationKey: string, filename: string, mimeType: string, fileSize: number) { + if (!openSIPSJS) return Promise.reject(new Error('OpenSIPSJS not initialized')) + return openSIPSJS.msrp.requestUploadUrl(conversationKey, filename, mimeType, fileSize) + }, + requestFileAccess (conversationKey: string, eventId: string) { + if (!openSIPSJS) return Promise.reject(new Error('OpenSIPSJS not initialized')) + return openSIPSJS.msrp.requestFileAccess(conversationKey, eventId) + }, + uploadFile (conversationKey: string, file: File, caption = '') { + if (!openSIPSJS) return Promise.reject(new Error('OpenSIPSJS not initialized')) + return openSIPSJS.msrp.uploadFile(conversationKey, file, caption) + } + } +} diff --git a/msrp_demo/src/env.d.ts b/msrp_demo/src/env.d.ts new file mode 100644 index 0000000..bc40445 --- /dev/null +++ b/msrp_demo/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/msrp_demo/src/main.ts b/msrp_demo/src/main.ts new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/msrp_demo/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/msrp_demo/src/types/index.ts b/msrp_demo/src/types/index.ts new file mode 100644 index 0000000..db23493 --- /dev/null +++ b/msrp_demo/src/types/index.ts @@ -0,0 +1,127 @@ +// Local type contracts for the msrp_demo composable. +// +// In the main project these live at `@/types`; here we mirror the shape so +// the slice can later be lifted straight into that file. Keep field names +// identical to the main composable's VsipAPI - only the MSRP + shared +// connection surface is included (audio/video deliberately omitted). + +import type { ComputedRef, Ref } from 'vue' +import type { + CustomLoggerType, + IOpenSIPSConfiguration +} from '../../../src/types/rtc' +import type { IMessage } from '../../../src/types/msrp' +import type { + MSRPConversationState, + MSRPMemberRole, + MSRPUploadResult +} from '../../../src/modules/msrp' + +export type UnreadCounts = Record +import type { MODULES } from '../../../src/enum/modules' +import type OpenSIPSJS from '../../../src/index' + +export type ModuleName = typeof MODULES[keyof typeof MODULES] + +export interface ConnectOptions { + username: string + password: string + domain: string + authorization_jwt?: string + modules: Array + msrpDomain?: string + msrpWs?: boolean +} + +export type PnExtraHeaders = Record | undefined + +export interface MSRPTypingState { + sender: string + isTyping: boolean + updatedAt: number +} + +export interface VsipAPIState { + // ---------- Shared connection lifecycle ---------- + isInitialized: Ref + isOpenSIPSReady: Ref + isOpenSIPSReconnecting: Ref + + // ---------- MSRP session ---------- + isMSRPInitializing: Ref + currentMsrpSession: Ref + hasActiveMsrpSession: ComputedRef + + // ---------- MSRP conversations / messages ---------- + // Conversation metadata only - chat history lives in its own store + // (`messagesByConversation`) to keep slow-moving protocol data + // decoupled from fast-moving message updates. + conversations: Ref<{ [key: string]: MSRPConversationState }> + messagesByConversation: Ref<{ [conversationKey: string]: any[] }> + // The "currently focused" conversation is a UI concern (the protocol + // module exposes the full conversations map and lets the consumer + // decide which one is active). We track it here so the demo's UI can + // reactively highlight it. + currentConversationKey: Ref + currentConversation: ComputedRef + currentMessages: ComputedRef + sortedConversations: ComputedRef + typingByConversation: Ref<{ [conversationKey: string]: MSRPTypingState }> + // Same UI-concern story as currentConversationKey: unread tallying is + // not part of the MSRP protocol so it lives here. + unreadByConversation: Ref +} + +export interface VsipAPIActions { + // ---------- Shared connection lifecycle ---------- + init ( + connectOptions: ConnectOptions, + pnExtraHeaders?: PnExtraHeaders, + opensipsConfiguration?: Partial, + logger?: CustomLoggerType + ): Promise + register (): void + unregister (): void + disconnect (): void + + // ---------- MSRP session ---------- + initMSRP (options?: object): void + initMSRPAndSendMessage (target: string, body: string, options?: object): void + msrpAnswer (callId: string): void + messageTerminate (callId: string): void + sendMSRP (msrpSessionId: string, body: string): void + safeSendMSRP (body: string): boolean + + // ---------- MSRP - one method per UI action ---------- + sendCreateConversationMessage (targetSip: string | string[]): boolean + sendTextMessage (conversationKey: string, text: string): boolean + sendMediaMessage ( + conversationKey: string, + uploadResult: MSRPUploadResult, + caption?: string + ): boolean + sendReaction (conversationKey: string, targetEventId: string, emoji: string): boolean + sendTypingIndicator (conversationKey: string, isTyping: boolean): boolean + startTypingKeepAlive (conversationKey: string): void + stopTypingKeepAlive (sendStop?: boolean): void + sendReadReceipt (conversationKey: string): boolean + closeConversation (conversationKey: string, reason?: string, cause?: string): boolean + changeMemberRole (conversationKey: string, targetUri: string, newRole: MSRPMemberRole): boolean + acceptInvite (conversationKey: string): boolean + rejectInvite (conversationKey: string): boolean + leaveConversation (conversationKey: string): boolean + setActiveConversation (conversationKey: string | null): void + requestUploadUrl ( + conversationKey: string, + filename: string, + mimeType: string, + fileSize: number + ): Promise + requestFileAccess (conversationKey: string, eventId: string): Promise + uploadFile (conversationKey: string, file: File, caption?: string): Promise +} + +export interface VsipAPI { + state: VsipAPIState + actions: VsipAPIActions +} diff --git a/msrp_demo/tsconfig.json b/msrp_demo/tsconfig.json new file mode 100644 index 0000000..1f7618a --- /dev/null +++ b/msrp_demo/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "lib": [ "ES2020", "DOM", "DOM.Iterable" ], + "jsx": "preserve", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "allowSyntheticDefaultImports": true, + "types": [ "vite/client" ], + "baseUrl": ".", + "paths": { + "@/*": [ "../src/*" ] + }, + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue" + ], + "references": [ { "path": "./tsconfig.node.json" } ] +} diff --git a/msrp_demo/tsconfig.node.json b/msrp_demo/tsconfig.node.json new file mode 100644 index 0000000..e2d6db9 --- /dev/null +++ b/msrp_demo/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true + }, + "include": [ "vite.config.ts" ] +} diff --git a/msrp_demo/vite.config.ts b/msrp_demo/vite.config.ts new file mode 100644 index 0000000..229cacc --- /dev/null +++ b/msrp_demo/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +// Resolve once for clarity. +const rootDir = resolve(__dirname, '..') +const rootSrc = resolve(rootDir, 'src') + +// We intentionally do NOT depend on opensips-js as a package - the whole point +// of having this demo inside the same repo is to import the library directly +// from ../src (e.g. `import OpenSIPSJS from '../../src/index'`) so changes +// show up immediately via HMR. +export default defineConfig({ + plugins: [ vue() ], + resolve: { + alias: { + // Mirror the alias used by opensips-js library internals so files + // like `import x from '@/helpers/audio.helper'` keep resolving when + // the library source is loaded from ../src. + '@': rootSrc + }, + // Make sure there is only one copy of Vue at runtime (the one from + // msrp_demo/node_modules) even if anything upstream pulls Vue in. + dedupe: [ 'vue' ] + }, + server: { + port: 5174, + host: true, + fs: { + // The library source sits one level up; without this Vite would + // refuse to serve it during dev. + allow: [ rootDir ] + } + }, + optimizeDeps: { + // The library pulls these in via ../src. Pre-bundling them avoids + // dev-time CJS/ESM interop hiccups. + include: [ 'jssip', 'sdp-transform', 'loglevel', 'p-iteration', 'uuid', 'generate-unique-id' ] + } +}) diff --git a/msrp_demo/yarn.lock b/msrp_demo/yarn.lock new file mode 100644 index 0000000..e80adaa --- /dev/null +++ b/msrp_demo/yarn.lock @@ -0,0 +1,363 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/parser@^7.16.4": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + +"@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + +"@esbuild/android-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" + integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== + +"@esbuild/darwin-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" + integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== + +"@esbuild/darwin-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" + integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== + +"@esbuild/freebsd-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" + integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== + +"@esbuild/freebsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" + integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== + +"@esbuild/linux-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" + integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== + +"@esbuild/linux-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" + integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== + +"@esbuild/linux-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" + integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== + +"@esbuild/linux-loong64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" + integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== + +"@esbuild/linux-mips64el@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" + integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== + +"@esbuild/linux-ppc64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" + integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== + +"@esbuild/linux-riscv64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" + integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== + +"@esbuild/linux-s390x@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" + integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== + +"@esbuild/linux-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" + integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== + +"@esbuild/netbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" + integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== + +"@esbuild/openbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" + integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== + +"@esbuild/sunos-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" + integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== + +"@esbuild/win32-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" + integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== + +"@esbuild/win32-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" + integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== + +"@esbuild/win32-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" + integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== + +"@types/node@^18.14.2": + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + +"@vitejs/plugin-vue@^4.2.3": + version "4.6.2" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz#057d2ded94c4e71b94e9814f92dcd9306317aa46" + integrity sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw== + +"@vue/compiler-core@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.25.tgz#3fa88debc97a89a513e1d9b771e0802b9a006734" + integrity sha512-FlffKezIqztTCTyG0klkYRwhdyL6b1PTTCIerPb4p2R9qQaczccTX5g9ysi9w6tpLQ48a1WiXnFDJhWD7XoqwA== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/shared" "3.2.25" + estree-walker "^2.0.2" + source-map "^0.6.1" + +"@vue/compiler-dom@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.25.tgz#b1a3b1ef057aeacea5ee00f7b20dc3edb8bd1ed2" + integrity sha512-4JrburkRg4VWbc8AKpzKFWbNY4MDXshqjFl53+vINq7zaw3Z7aSqnLv0EkKh8B8ynf/MYsAdygGutyVbEWYxOw== + dependencies: + "@vue/compiler-core" "3.2.25" + "@vue/shared" "3.2.25" + +"@vue/compiler-sfc@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.25.tgz#6a5ae9e3c8db6a462d0bada53364108c9ba8f379" + integrity sha512-PminuOYIcFI7UZn+mdy2OPbogyAb0IHkVuqwmLDJiSRFhc/QAXQnO9KdS4nez3bQ9XlQmoAveQzcZuekHzdb5w== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.25" + "@vue/compiler-dom" "3.2.25" + "@vue/compiler-ssr" "3.2.25" + "@vue/reactivity-transform" "3.2.25" + "@vue/shared" "3.2.25" + estree-walker "^2.0.2" + magic-string "^0.25.7" + postcss "^8.1.10" + source-map "^0.6.1" + +"@vue/compiler-ssr@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.25.tgz#aa0ceb4dc46326ca839b90b572e0309fae6f367a" + integrity sha512-+BAl8U5D3JkGR6086PFx1BQQ5km3z9fT88hy/7lzf8i3vEDdPQodadnX2t6tndFjIux05MEKg43DeocOojT0mw== + dependencies: + "@vue/compiler-dom" "3.2.25" + "@vue/shared" "3.2.25" + +"@vue/reactivity-transform@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.25.tgz#d211fd14bb677345ac9921d9e65976d80d67a742" + integrity sha512-fOiW67PUalicMfMr4Sc9l8mUtkN7ZD+G1/zJV8blzQ8GEZSeRcJm11gqve6Ps623ju5YORu7V/Q1gZoOJ9WO4g== + dependencies: + "@babel/parser" "^7.16.4" + "@vue/compiler-core" "3.2.25" + "@vue/shared" "3.2.25" + estree-walker "^2.0.2" + magic-string "^0.25.7" + +"@vue/reactivity@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.25.tgz#71c5ea9f3c7b71ee40085328c9198a1dc26da71f" + integrity sha512-Dxc/u/dxoneIDqyfmuwPVBR0G3OQJqe3Dtz4z3NGt+CGj4UuOZQfN5raJPmp6xGYgrtC6PAWoCgHhyrgr1qCtg== + dependencies: + "@vue/shared" "3.2.25" + +"@vue/runtime-core@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.25.tgz#9a2087239d0b1d9dec6532cb801a2a95e1019b73" + integrity sha512-2+fo5+lofT4xr8W2rtjyz+AM+UB1U/UNLH6ISFdHWNWuveSWxF+vkCQaATmhp6O3XA7QJAbHoRqIZor20EWSfQ== + dependencies: + "@vue/reactivity" "3.2.25" + "@vue/shared" "3.2.25" + +"@vue/runtime-dom@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.25.tgz#3c0812faa1374ab6d03bd5006ef478f2c414bf74" + integrity sha512-3gGeyHnygn4yG6bssRKhQIxnE8vgB8FtYUUwoYoA/Pm0vZ+bGPoZax4TbtZD9eW9rvs8CY8boNp4t/sJaPJrRQ== + dependencies: + "@vue/runtime-core" "3.2.25" + "@vue/shared" "3.2.25" + csstype "^2.6.8" + +"@vue/server-renderer@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.25.tgz#c0853ade8fae115b5a44cab7d78d46bd5793befb" + integrity sha512-qFRmcyeyyhWbnTPn6cbCZ4bjeuPLSkUpFa98p4LEJtFBFbxjGnrHXHOjYxCY3Lznmxe0kMM3qG4t3GnjcXP12w== + dependencies: + "@vue/compiler-ssr" "3.2.25" + "@vue/shared" "3.2.25" + +"@vue/shared@3.2.25": + version "3.2.25" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.25.tgz#22818aa3638e15c0ff9fc8042d935e4b42ae3c0f" + integrity sha512-DkHJFV2gw9WBRmUCa21eyG0WvlF0l1QFOgTkWj29O4mt2Tv3BSE5PQOKhUruZIym4bBYCqx9ZGtoD1WohDprow== + +csstype@^2.6.8: + version "2.6.21" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e" + integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w== + +esbuild@^0.18.10: + version "0.18.20" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" + integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== + optionalDependencies: + "@esbuild/android-arm" "0.18.20" + "@esbuild/android-arm64" "0.18.20" + "@esbuild/android-x64" "0.18.20" + "@esbuild/darwin-arm64" "0.18.20" + "@esbuild/darwin-x64" "0.18.20" + "@esbuild/freebsd-arm64" "0.18.20" + "@esbuild/freebsd-x64" "0.18.20" + "@esbuild/linux-arm" "0.18.20" + "@esbuild/linux-arm64" "0.18.20" + "@esbuild/linux-ia32" "0.18.20" + "@esbuild/linux-loong64" "0.18.20" + "@esbuild/linux-mips64el" "0.18.20" + "@esbuild/linux-ppc64" "0.18.20" + "@esbuild/linux-riscv64" "0.18.20" + "@esbuild/linux-s390x" "0.18.20" + "@esbuild/linux-x64" "0.18.20" + "@esbuild/netbsd-x64" "0.18.20" + "@esbuild/openbsd-x64" "0.18.20" + "@esbuild/sunos-x64" "0.18.20" + "@esbuild/win32-arm64" "0.18.20" + "@esbuild/win32-ia32" "0.18.20" + "@esbuild/win32-x64" "0.18.20" + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +magic-string@^0.25.7: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + +nanoid@^3.3.12: + version "3.3.15" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316" + integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.1.10, postcss@^8.4.27: + version "8.5.15" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" + integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== + dependencies: + nanoid "^3.3.12" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +rollup@^3.27.1: + version "3.30.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.30.0.tgz#3fa506fee2c5ba9d540a38da87067376cd55966d" + integrity sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA== + optionalDependencies: + fsevents "~2.3.2" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +typescript@^4.9.5: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +vite@4.4.11: + version "4.4.11" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.4.11.tgz#babdb055b08c69cfc4c468072a2e6c9ca62102b0" + integrity sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A== + dependencies: + esbuild "^0.18.10" + postcss "^8.4.27" + rollup "^3.27.1" + optionalDependencies: + fsevents "~2.3.2" + +vue@3.2.25: + version "3.2.25" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.25.tgz#d271214000feeefb4a4ec1f3063f76fc1c701eb4" + integrity sha512-jU3t7fyQDHoCWCqhmRrnSmYZvHC35tOJTP704di7HGfq5EcFA1cU/1ZPjUV1eCxJev65Khjyfni+vk9oa+eTtw== + dependencies: + "@vue/compiler-dom" "3.2.25" + "@vue/compiler-sfc" "3.2.25" + "@vue/runtime-dom" "3.2.25" + "@vue/server-renderer" "3.2.25" + "@vue/shared" "3.2.25" diff --git a/package.json b/package.json index 93ce60e..a682a1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opensips-js", - "version": "0.1.46", + "version": "0.1.47", "description": "The JS package for opensips", "default": "src/index.ts", "jsdelivr": "dist/opensips-js.iife.js", diff --git a/src/modules/msrp.ts b/src/modules/msrp.ts index dc56cf3..efe9fdb 100644 --- a/src/modules/msrp.ts +++ b/src/modules/msrp.ts @@ -5,151 +5,233 @@ import { IMessage, MSRPSessionExtended, TriggerMSRPListenerOptions } from '@/typ import MSRPMessage from '@/lib/msrp/message' import { MSRPSessionEvent } from '@/helpers/UA' -export class MSRPModule { - private context: any +// ---------- CONVERSATION EVENT TYPES (backend v3 — June 2026) ---------- +export const MSRP_EVT = { + CREATE: 'm.conversation.create', + MESSAGE: 'm.conversation.message', + MEMBER: 'm.conversation.member', + CLOSED: 'm.conversation.closed', + SYNC: 'm.sync', + UPLOAD_REQUEST: 'm.upload.request', + UPLOAD_RESPONSE: 'm.upload.response', + FILE_ACCESS_REQUEST: 'm.file.access.request', + FILE_ACCESS_RESPONSE: 'm.file.access.response', + REACTION: 'm.reaction', + TYPING: 'm.typing', + SENT: 'm.sent', + DELIVERED: 'm.delivered', + READ: 'm.read', + FAILED: 'm.failed', + SENDING: 'm.sending' +} as const - private activeMessages: { [key: string]: IMessage } = {} - private extendedMessages: { [key: string]: IMessage } = {} - private msrpHistory: { [key: string]: Array } = {} +export const MSRP_STATE_MEMBER = 'm.conversation.member' +export const MSRP_STATE_CREATE = 'm.conversation.create' +export const MSRP_STATE_CLOSED = 'm.conversation.closed' - private isMSRPInitializingValue: boolean | undefined +export const MSRP_QUICK_REACTION_EMOJIS = [ '👍', '❤️', '😂', '😮', '😢', '🙏' ] as const - constructor (context) { - this.context = context +export type MSRPMemberRole = 'in_charge' | 'manager' | 'assigned' +export type MSRPMembership = 'join' | 'leave' | 'invite' | 'ban' +export type MSRPMessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed' - this.context.on( - this.context.newMSRPSessionEventName, - // this.newMSRPSessionCallback.bind(this.context) - this.newMSRPSessionCallback.bind(this) - ) +/** + * Protocol-level conversation state owned by MSRPModule. + * + * Intentionally does NOT carry a `messages` array - the module is a pure + * pipe for chat history. Live messages are emitted via `msrpMessageAdded` + * and historical messages from `m.sync` come through the + * `messagesByConversation` field of the `msrpSyncCompleted` payload. + * The consumer (e.g. a Vue composable) is responsible for collecting them + * into whatever data structure it needs and for downstream concerns like + * deduplication, reaction aggregation and receipt application. + */ +export interface MSRPConversationState { + conversationKey: string + creator: string | null + members: Set + memberRoles: Map + currentUserRole: MSRPMemberRole + currentUserStatus: MSRPMembership | null + state_events: { [key: string]: { [stateKey: string]: any } } + created_at: number + updated_at: number + status?: string +} + +export interface MSRPUploadResult { + upload_url: string + expires_in?: number + mime_type: string + request_id: string + filename?: string + preview_url?: string + icon_url?: string + transcription?: string + media_type?: string +} + +interface PendingPromise { + resolve: (value: T) => void + reject: (reason?: any) => void +} + +// Tiny RFC4122-ish UUID v4 fallback so the module works in any browser even +// when crypto.randomUUID() is unavailable. +function generateUuid (): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} - /*public begin () { - if (this.context.isConnected()) { - console.error('Connection is already established') - return - } +function generateRequestId (prefix = 'req'): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}` +} - this.context.on( - this.context.registeredEventName, - () => { - this.context.logger.log('Successfully registered to', this.context.options.socketInterfaces[0]) - this.context.setInitialized(true) - } - ) +function normalizeMessageContent (content: any) { + if (!content) return content + if (!Array.isArray(content.attachments)) { + content.attachments = [] + } + return content +} - this.context.on( - this.context.unregisteredEventName, - () => { - this.context.logger.log('Unregistered from', this.context.options.socketInterfaces[0]) - this.context.setInitialized(false) - } - ) +function normalizeIncomingEvent (event: any) { + if (!event) return event + if (event.content) normalizeMessageContent(event.content) + return event +} - this.context.on( - this.context.connectedEventName, - () => { - this.context.logger.log('Connected to', this.context.options.socketInterfaces[0]) - this.context.isReconnecting = false - } - ) +function normalizeStateEvents (stateEvents: any) { + return stateEvents ?? {} +} - this.context.on( - this.context.disconnectedEventName, - () => { - if (this.context.isReconnecting) { - return - } - this.context.logger.log('Disconnected from', this.context.options.socketInterfaces[0]) - this.context.logger.log('Reconnecting to', this.context.options.socketInterfaces[0]) - this.context.isReconnecting = true - this.context.stop() - this.context.setInitialized(false) - setTimeout(this.context.start.bind(this.context), 5000) - } - ) +function messageHasContent (content: any): boolean { + if (!content) return false + normalizeMessageContent(content) + return !!(content.content || content.attachments?.length) +} + +function getMessageTypeFromMime (mimeType?: string): string { + if (!mimeType) return 'file' + if (mimeType.startsWith('image/')) return 'image' + if (mimeType.startsWith('video/')) return 'video' + if (mimeType.startsWith('audio/')) return 'audio' + return 'file' +} + +export class MSRPModule { + private context: any + + private msrpSession: IMessage | null = null + private extendedSession: IMessage | null = null + + private isMSRPInitializingValue: boolean | undefined + + // ---------- CONVERSATION STATE ---------- + private conversationsMap: Map = new Map() + + // ---------- IN-FLIGHT REQUEST/RESPONSE TRACKING ---------- + private pendingUploads: Map> = new Map() + private pendingFileAccessRequests: Map> = new Map() + private uploadRequestTimeoutMs = 30000 + private fileAccessTimeoutMs = 30000 + + // ---------- TYPING KEEPALIVE ---------- + private typingKeepAliveInterval: ReturnType | null = null + private typingKeepAliveConversationKey: string | null = null + private typingKeepAliveIntervalMs = 2000 + + constructor (context: any) { + this.context = context this.context.on( this.context.newMSRPSessionEventName, - this.newMSRPSessionCallback.bind(this.context) + this.newMSRPSessionCallback.bind(this) ) + } - this.context.logger.log('Connecting to', this.context.options.socketInterfaces[0]) - this.context.start() - - return this.context - }*/ + // ===================================================================== + // PUBLIC GETTERS + // ===================================================================== public get isMSRPInitializing () { return this.isMSRPInitializingValue } - public get getActiveMessages () { - return this.activeMessages + public get getMsrpSession () { + return this.msrpSession + } + + public get hasActiveSession (): boolean { + return this.extendedSession !== null } - public msrpAnswer (callId: string) { - const call = this.extendedMessages[callId] + public get conversations (): { [key: string]: MSRPConversationState } { + const result: { [key: string]: MSRPConversationState } = {} + this.conversationsMap.forEach((value, key) => { + result[key] = value + }) + return result + } + + // ===================================================================== + // SESSION LIFECYCLE (existing) + // ===================================================================== + + public msrpAnswer (_callId: string) { + if (!this.extendedSession) { + return + } // TODO: uncomment - //call.answer(this.sipOptions) - this.updateMSRPSession(call) + //this.extendedSession.answer(this.sipOptions) + this.updateMSRPSession(this.extendedSession) } public updateMSRPSession (value: IMessage) { - this.activeMessages[value._id] = simplifyMessageObject(value) as IMessage - this.context.emit('changeActiveMessages', this.activeMessages) + this.msrpSession = simplifyMessageObject(value) as IMessage + this.context.emit('changeMsrpSession', this.msrpSession) } - private addMMSRPSession (value: IMessage) { - this.activeMessages = { - ...this.activeMessages, - [value._id]: simplifyMessageObject(value) as IMessage - } - - this.extendedMessages[value._id] = value - this.context.emit('changeActiveMessages', this.activeMessages) + private setMSRPSession (value: IMessage) { + this.msrpSession = simplifyMessageObject(value) as IMessage + this.extendedSession = value + this.context.emit('changeMsrpSession', this.msrpSession) } private addMSRPMessage (value: MSRPMessage, session: MSRPSessionExtended) { - const sessionMessages = this.msrpHistory[session.id] || [] - sessionMessages.push(value) - - this.msrpHistory = { - ...this.msrpHistory, - [session.id]: [ ...sessionMessages ] - } this.context.emit('newMSRPMessage', { message: value, session: session }) } - public messageTerminate (callId: string) { - const call = this.extendedMessages[callId] + public messageTerminate (_callId: string) { + if (!this.extendedSession) { + return + } - if (call._status !== 8) { - call.terminate() - //this.removeMMSRPSession(call._id) + if (this.extendedSession._status !== 8) { + this.extendedSession.terminate() } } private addMessageSession (session: MSRPSessionExtended) { - // For cases when session.direction === 'outgoing' and all the - // session properties are missing before answer if (!session._id) { return } - const sessionAlreadyInActiveMessages = this.getActiveMessages[session._id] - - if (sessionAlreadyInActiveMessages !== undefined) { + if (this.extendedSession?._id === session._id) { return } - const MSRPSession = session as IMessage - - this.addMMSRPSession(MSRPSession) + this.setMSRPSession(session as IMessage) } private triggerMSRPListener ({ listenerType, session, event }: TriggerMSRPListenerOptions) { @@ -159,50 +241,33 @@ export class MSRPModule { return } - listeners.forEach((listener) => { + listeners.forEach((listener: any) => { listener(session, event) }) } - private removeMMSRPSession (value: string) { - const stateActiveMessagesCopy = { ...this.activeMessages } - delete stateActiveMessagesCopy[value] + private clearMSRPSession () { + this.msrpSession = null + this.extendedSession = null - this.activeMessages = { - ...stateActiveMessagesCopy, - } - - const stateExtendedMessagesCopy = { ...this.extendedMessages } - delete stateExtendedMessagesCopy[value] - this.extendedMessages = { - ...stateExtendedMessagesCopy, - } - - this.context.emit('changeActiveMessages', this.activeMessages) - } - - private activeMessageListRemove (call: IMessage) { - this.removeMMSRPSession(call._id) + this.context.emit('changeMsrpSession', this.msrpSession) } private newMSRPSessionCallback (event: MSRPSessionEvent) { - if(!event.session._id) event.session._id = event.request.call_id + event.request.from._parameters.tag; + if (!event.session._id) { + event.session._id = event.request.call_id + event.request.from._parameters.tag + } const session = event.session as MSRPSessionExtended - /*if (this.isDND) { - session.terminate({ status_code: 486, reason_phrase: 'Do Not Disturb' }) - return - }*/ - - // stop timers on ended and failed session.on('ended', (event: EndEvent) => { this.triggerMSRPListener({ listenerType: CALL_EVENT_LISTENER_TYPE.CALL_ENDED, session, event }) - const s = this.getActiveMessages[session.id] - this.activeMessageListRemove(s) + if (this.extendedSession?._id === session._id) { + this.clearMSRPSession() + } }) session.on('failed', (event: EndEvent) => { @@ -212,8 +277,9 @@ export class MSRPModule { event }) - const s = this.getActiveMessages[session.id] - this.activeMessageListRemove(s) + if (this.extendedSession?._id === session._id) { + this.clearMSRPSession() + } }) session.on('confirmed', (event: IncomingAckEvent | OutgoingAckEvent) => { this.triggerMSRPListener({ @@ -226,6 +292,7 @@ export class MSRPModule { session.on('newMessage', (msg: unknown) => { this.addMSRPMessage(msg as MSRPMessage, session) + this.processIncomingMSRPMessage(msg) }) this.addMessageSession(session) @@ -236,9 +303,49 @@ export class MSRPModule { this.context.emit('isMSRPInitializingChanged', value) } - public initMSRP (target: string, body: string, options: any) { - //this.checkInitialized() + private getUserUri (): string { + if (!this.context.configuration?.uri) return '' + return typeof this.context.configuration.uri === 'string' + ? this.context.configuration.uri + : this.context.configuration.uri.toString() + } + + /** + * Open an MSRP session for the currently logged-in user. The SIP + * identity is resolved automatically from the OpenSIPSJS + * configuration, so the caller never has to pass it. Use this when + * the session is intended to act as a long-lived transport pipe and + * the first payload will be sent later through one of the higher-level + * actions (e.g. `sendCreateConversationMessage`). + */ + public initMSRP (options: any = {}) { + const identity = this.extractSipUser(this.getUserUri()) + if (!identity) { + return console.error('Cannot init MSRP: current user SIP identity not available') + } + + const session = this.context.startMSRP(identity, options) as MSRPSessionExtended + session.on('active', () => { + this.addMessageSession(session) + // Push an empty MSRP SEND the moment the session goes + // active. The conversation server relies on this initial + // frame to bind the freshly-opened pipe to our user in its + // routing table - without it the SIP/MSRP layer is alive + // but no invites or messages are ever delivered to us. + session.sendMSRP('') + this.setIsMSRPInitializing(false) + }) + + this.setIsMSRPInitializing(true) + } + /** + * Open an MSRP session and push `body` as the first MSRP frame the + * moment the session goes active. Use this for the legacy + * "compose-and-send" flow where the first message is known at the + * time the session is opened. + */ + public initMSRPAndSendMessage (target: string, body: string, options: any = {}) { if (target.length === 0) { return console.error('Target must be a valid string') } @@ -253,13 +360,910 @@ export class MSRPModule { this.setIsMSRPInitializing(true) } - public sendMSRP (msrpSessionId: string, body: string) { - const msrpSession = this.extendedMessages[msrpSessionId] - if (!msrpSession) { - throw new Error(`MSRP session with id ${msrpSessionId} doesn't exist!`) + /** + * Public raw send - kept for backward-compat with the old multi-session API. + * Ignores the passed callId/sessionId because we only ever hold one session. + */ + public sendMSRP (_msrpSessionId: string, body: string) { + if (!this.extendedSession) { + throw new Error('No active MSRP session') + } + + this.extendedSession.sendMSRP(body) + } + + /** + * Safe wrapper around session.sendMSRP. Returns true when the message was + * handed off to the session; false when there is no active session or the + * underlying send threw. On failure the stored session is cleared so the + * next send attempt does not silently drop traffic on a half-dead session. + */ + public safeSendMSRP (body: string): boolean { + if (!this.msrpSession || !this.extendedSession) { + console.warn('safeSendMSRP: no active MSRP session - message dropped', body) + return false + } + try { + this.extendedSession.sendMSRP(body) + return true + } catch (err) { + console.error('safeSendMSRP error:', err) + this.clearMSRPSession() + return false + } + } + + // EVENT BUILDERS + private buildCreateConversationEvent (inviteeSipUris: string[]) { + return { + type: MSRP_EVT.CREATE, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { + creator: this.getUserUri(), + invitee: inviteeSipUris + } + } + } + + private buildTextMessageEvent (conversationKey: string, text: string) { + return { + type: MSRP_EVT.MESSAGE, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { + message_type: 'text', + content: text, + txn_id: generateUuid() + } + } + } + + private buildMediaMessageEvent (conversationKey: string, uploadResult: MSRPUploadResult, caption = '') { + const messageType = uploadResult.media_type || getMessageTypeFromMime(uploadResult.mime_type) + const attachment: Record = { + kind: messageType, + mime_type: uploadResult.mime_type, + filename: uploadResult.filename + } + if (uploadResult.preview_url) attachment.preview_url = uploadResult.preview_url + if (uploadResult.icon_url) attachment.icon_url = uploadResult.icon_url + if (uploadResult.transcription) attachment.transcription = uploadResult.transcription + + return { + type: MSRP_EVT.MESSAGE, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { + message_type: messageType, + content: caption || uploadResult.filename || '', + attachments: [ attachment ], + txn_id: generateUuid() + } + } + } + + private buildMemberEvent ( + conversationKey: string, + stateKey: string, + membership: MSRPMembership, + role?: MSRPMemberRole + ) { + const evt: Record = { + type: MSRP_EVT.MEMBER, + conversationKey, + sender: this.getUserUri(), + state_key: stateKey, + origin_server_ts: Date.now(), + content: { membership } + } + if (role) evt.content.role = role + return evt + } + + private buildCloseConversationEvent ( + conversationKey: string, + reason = 'Conversation resolved', + cause = 'resolved' + ) { + const now = Date.now() + return { + type: MSRP_EVT.CLOSED, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: now, + content: { + reason, + cause, + closed_by: this.getUserUri(), + closed_at: now + } + } + } + + private buildReactionEvent (conversationKey: string, targetEventId: string, emoji: string) { + return { + type: MSRP_EVT.REACTION, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { + relates_to: { event_id: targetEventId, key: emoji } + } + } + } + + private buildTypingEvent (conversationKey: string, isTyping: boolean) { + return { + type: MSRP_EVT.TYPING, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { typing: isTyping } + } + } + + private buildReadReceiptEvent (conversationKey: string, lastEventId: string) { + return { + type: MSRP_EVT.READ, + event_id: lastEventId, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { ts: Date.now(), up_to: true } + } + } + + // PUBLIC ACTIONS — outgoing requests + /** + * Create a new conversation by inviting one or more SIP URIs. + * Accepts plain numbers/usernames and rewrites them into full SIP URIs + * against the domain OpenSIPSJS was initialized with. + */ + public sendCreateConversationMessage (targetSip: string | string[]): boolean { + if (!this.extendedSession) { + console.warn('No MSRP session available for creating conversation') + return false + } + + const targets = Array.isArray(targetSip) ? targetSip : [ targetSip ] + const sipUris = targets + .map((t) => (t ?? '').trim()) + .filter((t) => t.length > 0) + .map((t) => (t.startsWith('sip:') ? t : `sip:${t}@${this.context.sipDomain}`)) + + if (sipUris.length === 0) { + console.log('Room creation cancelled - invitee required') + return false + } + + return this.safeSendMSRP(JSON.stringify(this.buildCreateConversationEvent(sipUris))) + } + + public sendTextMessage (conversationKey: string, text: string): boolean { + const trimmed = (text ?? '').trim() + if (!conversationKey || !trimmed) return false + return this.safeSendMSRP(JSON.stringify(this.buildTextMessageEvent(conversationKey, trimmed))) + } + + public sendMediaMessage ( + conversationKey: string, + uploadResult: MSRPUploadResult, + caption = '' + ): boolean { + if (!conversationKey || !uploadResult) return false + return this.safeSendMSRP(JSON.stringify(this.buildMediaMessageEvent(conversationKey, uploadResult, caption))) + } + + public sendReaction (conversationKey: string, targetEventId: string, emoji: string): boolean { + if (!conversationKey || !targetEventId || !emoji) return false + return this.safeSendMSRP(JSON.stringify(this.buildReactionEvent(conversationKey, targetEventId, emoji))) + } + + public sendTypingIndicator (conversationKey: string, isTyping: boolean): boolean { + if (!conversationKey) return false + return this.safeSendMSRP(JSON.stringify(this.buildTypingEvent(conversationKey, isTyping))) + } + + /** + * Start an automatic typing keep-alive loop for `conversationKey`. While + * active, a typing=true event is re-sent every `typingKeepAliveIntervalMs` + * so the remote side does not time out the indicator. Calling this with a + * different conversationKey or calling `stopTypingKeepAlive()` cancels + * any in-flight loop. + */ + public startTypingKeepAlive (conversationKey: string): void { + if (!conversationKey) return + this.stopTypingKeepAlive(false) + this.typingKeepAliveConversationKey = conversationKey + this.sendTypingIndicator(conversationKey, true) + this.typingKeepAliveInterval = setInterval(() => { + if (this.typingKeepAliveConversationKey && this.hasActiveSession) { + this.sendTypingIndicator(this.typingKeepAliveConversationKey, true) + } else { + this.stopTypingKeepAlive(true) + } + }, this.typingKeepAliveIntervalMs) + } + + public stopTypingKeepAlive (sendStop = true): void { + if (this.typingKeepAliveInterval) { + clearInterval(this.typingKeepAliveInterval) + this.typingKeepAliveInterval = null + } + const stoppedKey = this.typingKeepAliveConversationKey + this.typingKeepAliveConversationKey = null + if (sendStop && stoppedKey) { + this.sendTypingIndicator(stoppedKey, false) + } + } + + /** + * Send a read receipt for `lastEventId` in `conversationKey`. The + * caller is responsible for resolving which event_id represents the + * "last seen" message because the module no longer stores chat history. + */ + public sendReadReceipt (conversationKey: string, lastEventId: string): boolean { + if (!conversationKey || !lastEventId || !this.hasActiveSession) return false + return this.safeSendMSRP(JSON.stringify(this.buildReadReceiptEvent(conversationKey, lastEventId))) + } + + /** + * Close a conversation. Only callers with role 'in_charge' or 'manager' + * are allowed by the backend. + */ + public closeConversation ( + conversationKey: string, + reason = 'Conversation resolved', + cause = 'resolved' + ): boolean { + const conversation = this.conversationsMap.get(conversationKey) + if (!conversation || this.isConversationClosed(conversation)) return false + + const myRole = conversation.currentUserRole || 'assigned' + if (myRole !== 'in_charge' && myRole !== 'manager') return false + + return this.safeSendMSRP(JSON.stringify(this.buildCloseConversationEvent(conversationKey, reason, cause))) + } + + /** + * Change another member's role inside a conversation. The current user + * must be 'in_charge' or 'manager'. + */ + public changeMemberRole ( + conversationKey: string, + targetUri: string, + newRole: MSRPMemberRole + ): boolean { + const conversation = this.conversationsMap.get(conversationKey) + const myRole = conversation?.currentUserRole || 'assigned' + if (myRole !== 'in_charge' && myRole !== 'manager') { + console.warn('Not authorized to change roles (role:', myRole, ')') + return false + } + return this.safeSendMSRP( + JSON.stringify(this.buildMemberEvent(conversationKey, targetUri, 'join', newRole)) + ) + } + + public acceptInvite (conversationKey: string): boolean { + return this.safeSendMSRP( + JSON.stringify(this.buildMemberEvent(conversationKey, this.getUserUri(), 'join')) + ) + } + + public rejectInvite (conversationKey: string): boolean { + const sent = this.safeSendMSRP( + JSON.stringify(this.buildMemberEvent(conversationKey, this.getUserUri(), 'leave')) + ) + if (sent && this.conversationsMap.delete(conversationKey)) { + this.context.emit('msrpConversationRemoved', { conversationKey }) + } + return sent + } + + public leaveConversation (conversationKey: string): boolean { + const sent = this.safeSendMSRP( + JSON.stringify(this.buildMemberEvent(conversationKey, this.getUserUri(), 'leave')) + ) + if (sent && this.conversationsMap.delete(conversationKey)) { + this.context.emit('msrpConversationRemoved', { conversationKey }) + } + return sent + } + + /** + * Ask the server for a presigned upload URL via MSRP. Resolves with the + * upload metadata once the matching `m.upload.response` arrives, or + * rejects after `uploadRequestTimeoutMs` if no response is received. + */ + public requestUploadUrl ( + conversationKey: string, + filename: string, + mimeType: string, + fileSize: number + ): Promise { + return new Promise((resolve, reject) => { + if (!this.hasActiveSession) { + reject(new Error('No MSRP session available')) + return + } + + const requestId = generateRequestId('upload') + const uploadRequest = { + type: MSRP_EVT.UPLOAD_REQUEST, + conversationKey, + sender: this.getUserUri(), + origin_server_ts: Date.now(), + content: { + request_id: requestId, + filename, + mime_type: mimeType, + file_size: fileSize + } + } + + this.pendingUploads.set(requestId, { resolve, reject }) + + setTimeout(() => { + if (this.pendingUploads.has(requestId)) { + this.pendingUploads.delete(requestId) + reject(new Error('Upload request timeout')) + } + }, this.uploadRequestTimeoutMs) + + if (!this.safeSendMSRP(JSON.stringify(uploadRequest))) { + this.pendingUploads.delete(requestId) + reject(new Error('Failed to send upload request')) + } + }) + } + + /** + * Ask the server for a one-shot download URL for a previously-uploaded + * file. Resolves with the download URL, rejects on timeout or explicit + * server error. + */ + public requestFileAccess (conversationKey: string, eventId: string): Promise { + return new Promise((resolve, reject) => { + if (!this.hasActiveSession) { + reject(new Error('No MSRP session available')) + return + } + + const requestId = generateRequestId('file-access-req') + const accessRequest = { + type: MSRP_EVT.FILE_ACCESS_REQUEST, + event_id: requestId, + conversationKey, + sender: this.getUserUri(), + content: { + request_id: requestId, + event_id: eventId + } + } + + this.pendingFileAccessRequests.set(requestId, { resolve, reject }) + + setTimeout(() => { + if (this.pendingFileAccessRequests.has(requestId)) { + this.pendingFileAccessRequests.delete(requestId) + reject(new Error('File access request timed out')) + } + }, this.fileAccessTimeoutMs) + + if (!this.safeSendMSRP(JSON.stringify(accessRequest))) { + this.pendingFileAccessRequests.delete(requestId) + reject(new Error('Failed to send file access request')) + } + }) + } + + /** + * High-level helper: request a presigned URL, POST the file to it, then + * send the resulting media message into the conversation in one go. + */ + public async uploadFile ( + conversationKey: string, + file: File, + caption = '' + ): Promise { + if (!conversationKey) throw new Error('conversationKey is required') + if (!file) throw new Error('file is required') + + const uploadMeta = await this.requestUploadUrl( + conversationKey, + file.name, + file.type || 'application/octet-stream', + file.size + ) + + const formData = new FormData() + formData.append('file', file) + + const response = await fetch(uploadMeta.upload_url, { method: 'POST', body: formData }) + if (!response.ok) { + const err: any = await response.json().catch(() => ({})) + throw new Error(err?.error || `Upload failed: HTTP ${response.status}`) + } + + const result = (await response.json()) as MSRPUploadResult + this.sendMediaMessage(conversationKey, result, caption) + return result + } + + // INCOMING EVENT PROCESSING + /** + * Parse a raw MSRP `newMessage` payload and dispatch it to the right + * conversation-state mutator. Outgoing messages and non-JSON payloads + * are ignored so the rest of the pipeline stays pure. + */ + private processIncomingMSRPMessage (msg: any) { + if (!msg || msg.direction === 'outgoing') return + + let event: any + try { + event = JSON.parse(msg.body) + } catch (e) { + console.warn('Received non-JSON MSRP message', msg.body) + return + } + + this.handleIncomingEvent(event) + } + + private handleIncomingEvent (rawEvent: any) { + const event = normalizeIncomingEvent(rawEvent) + if (!event || !event.type) return + + switch (event.type) { + case MSRP_EVT.SYNC: + this.handleIncomingSync(event) + break + case MSRP_EVT.CREATE: + this.handleIncomingConversationCreate(event) + break + case MSRP_EVT.MESSAGE: + this.handleIncomingConversationMessage(event) + break + case MSRP_EVT.MEMBER: + this.handleIncomingConversationMember(event) + break + case MSRP_EVT.CLOSED: + this.handleIncomingConversationClosed(event) + break + case MSRP_EVT.REACTION: + this.handleIncomingReaction(event) + break + case MSRP_EVT.TYPING: + this.handleIncomingTyping(event) + break + case MSRP_EVT.SENT: + case MSRP_EVT.SENDING: + case MSRP_EVT.DELIVERED: + case MSRP_EVT.READ: + case MSRP_EVT.FAILED: + this.handleIncomingReceipt(event) + break + case MSRP_EVT.UPLOAD_RESPONSE: + this.handleIncomingUploadResponse(event) + break + case MSRP_EVT.FILE_ACCESS_RESPONSE: + this.handleIncomingFileAccessResponse(event) + break + default: + console.warn('Unhandled MSRP event:', event.type, event) + } + } + + private handleIncomingSync (event: any) { + if (!event.content) return + const conversations = event.content.conversations ?? [] + const currentUserUri = this.getUserUri() + const currentUserName = this.extractSipUser(currentUserUri) + + const messagesByConversation: { [key: string]: any[] } = {} + + conversations.forEach((conv: any) => { + const key = this.conversationKeyOf(conv) + if (!key) return + const { creator, timeline, created_at, updated_at } = conv + const stateEvents = normalizeStateEvents(conv.state_events) + + const members = new Set() + const memberRoles = new Map() + let currentUserStatus: MSRPMembership | null = null + let currentUserRole: MSRPMemberRole = 'assigned' + + if (stateEvents[MSRP_STATE_MEMBER]) { + Object.entries(stateEvents[MSRP_STATE_MEMBER]).forEach(([ userId, memberEvent ]) => { + const membership = memberEvent.content?.membership as MSRPMembership | undefined + const role = (memberEvent.content?.role || 'assigned') as MSRPMemberRole + + const userName = this.extractSipUser(userId) + if (userName === currentUserName) { + currentUserStatus = membership ?? null + if (membership === 'join') { + currentUserRole = role + } + } + + if (membership === 'join') { + members.add(userId) + memberRoles.set(userId, role) + } + }) + } + + const historicalMessages = (timeline || []) + .filter((e: any) => e.type === MSRP_EVT.MESSAGE) + .map((e: any) => normalizeIncomingEvent({ ...e })) + if (historicalMessages.length > 0) { + messagesByConversation[key] = historicalMessages + } + + this.upsertConversationState(key, { + conversationKey: key, + creator: creator ?? null, + members, + memberRoles, + currentUserRole, + currentUserStatus, + state_events: stateEvents, + created_at: created_at || Date.now(), + updated_at: updated_at || Date.now(), + status: conv.status + }) + }) + + // m.sync is the one legitimate bulk-replace - everything else uses + // granular events. Hand consumers their own copy of every + // conversation so internal mutations stay isolated. Historical + // messages travel alongside in `messagesByConversation` because + // the module does not retain them. + this.context.emit('msrpSyncCompleted', { + conversations: this.snapshotConversationsMap(), + messagesByConversation + }) + } + + private handleIncomingConversationCreate (event: any) { + const conversationKey = this.conversationKeyOf(event) + if (!conversationKey) return + + // Duplicate create event for an already-known conversation - nothing + // changed, no event to emit. + if (this.conversationsMap.has(conversationKey)) return + + const userUri = this.getUserUri() + this.upsertConversationState(conversationKey, { + conversationKey, + creator: event.content?.creator || event.sender || null, + members: new Set([ userUri ]), + memberRoles: new Map([ [ userUri, 'in_charge' ] ]), + currentUserRole: 'in_charge', + state_events: { [MSRP_STATE_CREATE]: { '': event } }, + created_at: event.origin_server_ts || Date.now(), + updated_at: event.origin_server_ts || Date.now(), + currentUserStatus: 'join' + }) + this.context.emit('msrpConversationCreated', { + conversationKey, + conversation: this.snapshotConversation(this.conversationsMap.get(conversationKey)!) + }) + } + + private handleIncomingConversationMessage (event: any) { + const conversationKey = this.conversationKeyOf(event) + if (!conversationKey || !messageHasContent(event.content)) return + + const conversation = this.conversationsMap.get(conversationKey) + if (!conversation) return + + // Update protocol-level metadata (updated_at) but do NOT retain the + // message itself - chat history is owned by the consumer. + // Deduplication is also a consumer concern because only the consumer + // knows what it has already rendered. + conversation.updated_at = event.origin_server_ts || Date.now() + + this.context.emit('msrpMessageAdded', { conversationKey, message: event }) + } + + private handleIncomingConversationClosed (event: any) { + const conversationKey = this.conversationKeyOf(event) + if (!conversationKey) return + + const conversation = this.conversationsMap.get(conversationKey) + if (!conversation) return + + if (!conversation.state_events) conversation.state_events = {} + conversation.state_events[MSRP_STATE_CLOSED] = conversation.state_events[MSRP_STATE_CLOSED] || {} + conversation.state_events[MSRP_STATE_CLOSED][''] = event + conversation.status = 'closed' + conversation.updated_at = event.origin_server_ts || Date.now() + + this.context.emit('msrpConversationUpdated', { + conversationKey, + patch: { + status: conversation.status, + state_events: { ...conversation.state_events }, + updated_at: conversation.updated_at + } + }) + } + + private handleIncomingReceipt (event: any) { + const conversationKey = this.conversationKeyOf(event) + const targetEventId = event.event_id + if (!conversationKey || !targetEventId) return + + const statusMap: Record = { + [MSRP_EVT.SENDING]: 'pending', + [MSRP_EVT.SENT]: 'sent', + [MSRP_EVT.DELIVERED]: 'delivered', + [MSRP_EVT.READ]: 'read', + [MSRP_EVT.FAILED]: 'failed' + } + const status = statusMap[event.type] + if (!status) return + + const updatedAt = event.origin_server_ts || Date.now() + const conversation = this.conversationsMap.get(conversationKey) + if (conversation) conversation.updated_at = updatedAt + + // We do not look up the target message - the consumer holds the + // message history and is responsible for applying the new status. + this.context.emit('msrpReceiptChanged', { + conversationKey, + eventId: targetEventId, + status, + updatedAt + }) + } + + private handleIncomingTyping (event: any) { + const conversationKey = this.conversationKeyOf(event) + if (!conversationKey) return + if (event.sender === this.getUserUri()) return + + this.context.emit('msrpTyping', { + conversationKey, + sender: event.sender, + isTyping: !!event.content?.typing + }) + } + + private handleIncomingReaction (event: any) { + const conversationKey = this.conversationKeyOf(event) + const relatesTo = event.content?.relates_to + const emoji = relatesTo?.key || relatesTo?.emoji + if (!conversationKey || !relatesTo?.event_id || !emoji) return + + const action: 'add' | 'remove' = event.content?.action === 'remove' ? 'remove' : 'add' + const updatedAt = event.origin_server_ts || Date.now() + + const conversation = this.conversationsMap.get(conversationKey) + if (conversation) conversation.updated_at = updatedAt + + // Emit the raw reaction event. The consumer holds the message + // history and is the only place that can (and should) aggregate + // these into a reactions_summary array on the target message. + this.context.emit('msrpReactionChanged', { + conversationKey, + eventId: relatesTo.event_id, + emoji, + action, + sender: event.sender, + updatedAt + }) + } + + private handleIncomingConversationMember (event: any) { + const conversationKey = this.conversationKeyOf(event) + if (!conversationKey) return + const { state_key, content } = event + const membership = content?.membership as MSRPMembership | undefined + const role = (content?.role || 'assigned') as MSRPMemberRole + + const conversationExisted = this.conversationsMap.has(conversationKey) + let conversation = this.conversationsMap.get(conversationKey) + if (!conversation) { + conversation = { + conversationKey, + creator: null, + members: new Set(), + memberRoles: new Map(), + currentUserRole: 'assigned', + state_events: {}, + created_at: event.origin_server_ts || Date.now(), + updated_at: event.origin_server_ts || Date.now(), + currentUserStatus: null + } + this.conversationsMap.set(conversationKey, conversation) + } + + const userUri = this.getUserUri() + const stateKeyUsername = this.extractSipUser(state_key) + const currentUsername = this.extractSipUser(userUri) + const isCurrentUser = + state_key === userUri || + (!!stateKeyUsername && !!currentUsername && stateKeyUsername === currentUsername) + + let removedSelf = false + + if (membership === 'join') { + conversation.members.add(state_key) + conversation.memberRoles.set(state_key, role) + if (isCurrentUser) { + conversation.currentUserStatus = 'join' + conversation.currentUserRole = role + } + } else if (membership === 'leave' || membership === 'ban') { + conversation.members.delete(state_key) + conversation.memberRoles.delete(state_key) + if (isCurrentUser) { + conversation.currentUserStatus = 'leave' + this.conversationsMap.delete(conversationKey) + removedSelf = true + } + } else if (membership === 'invite') { + if (!isCurrentUser) return + conversation.currentUserStatus = 'invite' + conversation.memberRoles.set(state_key, role) + } + + conversation.updated_at = event.origin_server_ts || Date.now() + + if (removedSelf) { + this.context.emit('msrpConversationRemoved', { conversationKey }) + return + } + + // The conversation did not exist before this event - emit a single + // `created` so consumers can add it in one shot rather than + // `created` + `updated` back-to-back. + if (!conversationExisted) { + this.context.emit('msrpConversationCreated', { + conversationKey, + conversation: this.snapshotConversation(conversation) + }) + return + } + + // Clone members/memberRoles so the consumer owns its own copies - + // otherwise the next in-place mutation on conversation.members + // would bypass the consumer's reactivity layer. + this.context.emit('msrpConversationUpdated', { + conversationKey, + patch: { + members: new Set(conversation.members), + memberRoles: new Map(conversation.memberRoles), + currentUserRole: conversation.currentUserRole, + currentUserStatus: conversation.currentUserStatus, + updated_at: conversation.updated_at + } + }) + } + + private handleIncomingUploadResponse (event: any) { + const content = event.content || {} + const requestId = content.request_id + const pending = this.pendingUploads.get(requestId) + if (!pending) return + this.pendingUploads.delete(requestId) + + if (content.error) { + pending.reject(new Error(content.error)) + } else { + pending.resolve({ + upload_url: content.upload_url, + expires_in: content.expires_in, + mime_type: content.mime_type, + request_id: content.request_id, + filename: content.filename, + preview_url: content.preview_url, + icon_url: content.icon_url, + transcription: content.transcription, + media_type: content.media_type + }) } + } + + private handleIncomingFileAccessResponse (event: any) { + const content = event.content || {} + const requestId = content.request_id + const pending = this.pendingFileAccessRequests.get(requestId) + if (!pending) return + this.pendingFileAccessRequests.delete(requestId) - msrpSession.sendMSRP(body) + if (content.error) { + pending.reject(content.error) + } else if (content.download_url) { + pending.resolve(content.download_url) + } else { + pending.reject(new Error('No download URL in response')) + } } + // CONVERSATION STATE HELPERS + + private upsertConversationState (key: string, data: MSRPConversationState) { + this.conversationsMap.set(key, { ...data, conversationKey: key }) + } + + /** + * Make a shallow snapshot of a conversation that is safe to hand off + * to an external consumer (e.g. a Vue composable). + * + * The mutable top-level collections (members, memberRoles, + * state_events) are cloned so subsequent internal mutations by the + * module cannot leak into the consumer's state. There is no + * `messages` field - chat history is owned by the consumer. + */ + private snapshotConversation (c: MSRPConversationState): MSRPConversationState { + return { + ...c, + members: new Set(c.members), + memberRoles: new Map(c.memberRoles), + state_events: { ...c.state_events } + } + } + + /** + * Snapshot the whole conversations map - used only for the bulk + * `msrpSyncCompleted` emit. + */ + private snapshotConversationsMap (): { [key: string]: MSRPConversationState } { + const out: { [key: string]: MSRPConversationState } = {} + this.conversationsMap.forEach((conv, key) => { + out[key] = this.snapshotConversation(conv) + }) + return out + } + + public isConversationClosed (conversation: MSRPConversationState | undefined | null): boolean { + return !!conversation?.state_events?.[MSRP_STATE_CLOSED]?.[''] + } + + /** + * Extract the conversationKey from either a string or an event/conversation object. + */ + private conversationKeyOf (source: any): string | null { + if (!source) return null + if (typeof source === 'string') return source + return source.conversationKey ?? null + } + + /** + * Extract the user-part of a SIP URI, e.g. sip:103@example.com -> 103. + */ + public extractSipUser (sipUri: string | null | undefined): string | null { + if (!sipUri) return null + const uriString = typeof sipUri === 'string' ? sipUri : String(sipUri) + const match = uriString.match(/^sip:([^@]+)@/) + return match ? match[1] : null + } + + /** + * Best-effort human-readable name for any URI we may encounter + * (SIP / WhatsApp / SMS / GreenAPI). + */ + public extractDisplayName (uri: string | null | undefined): string { + if (!uri) return 'Unknown' + const uriString = typeof uri === 'string' ? uri : String(uri) + + if (uriString.startsWith('sip:')) { + const match = uriString.match(/^sip:([^@]+)@/) + return match ? match[1] : uriString + } + + if (uriString.startsWith('whatsapp:')) return uriString.slice('whatsapp:'.length) + if (uriString.startsWith('wa:')) return uriString.slice('wa:'.length) + if (uriString.startsWith('greenapi:')) return uriString.slice('greenapi:'.length) + if (uriString.startsWith('sms:')) return uriString.slice('sms:'.length) + + return uriString + } } diff --git a/src/types/listeners.d.ts b/src/types/listeners.d.ts index 3864cee..f65e005 100644 --- a/src/types/listeners.d.ts +++ b/src/types/listeners.d.ts @@ -5,6 +5,7 @@ import { ICall, RoomChangeEmitType, ICallStatus, RTCSessionExtended } from '@/ty import MSRPMessage from '@/lib/msrp/message' import { ITimeData } from '@/types/timer' import { IncomingMSRPSessionEvent, OutgoingMSRPSessionEvent } from '@/helpers/UA' +import { MSRPConversationState, MSRPMessageStatus } from '@/modules/msrp' export type MSRPMessageEventType = { message: MSRPMessage, @@ -65,6 +66,45 @@ export type memberHangupListener = (event: object) => void export type changeAudioStateListener = (state: boolean) => void export type changeVideoStateListener = (state: boolean) => void +// ---------- MSRP granular event listeners ---------- +export type changeMsrpSessionListener = (session: IMessage | null) => void +export type msrpSyncCompletedListener = (payload: { + conversations: { [key: string]: MSRPConversationState } + messagesByConversation: { [key: string]: any[] } +}) => void +export type msrpConversationCreatedListener = (payload: { + conversationKey: string + conversation: MSRPConversationState +}) => void +export type msrpConversationRemovedListener = (payload: { conversationKey: string }) => void +export type msrpConversationUpdatedListener = (payload: { + conversationKey: string + patch: Partial +}) => void +export type msrpMessageAddedListener = (payload: { + conversationKey: string + message: any +}) => void +export type msrpReceiptChangedListener = (payload: { + conversationKey: string + eventId: string + status: MSRPMessageStatus + updatedAt: number +}) => void +export type msrpReactionChangedListener = (payload: { + conversationKey: string + eventId: string + emoji: string + action: 'add' | 'remove' + sender: string + updatedAt: number +}) => void +export type msrpTypingListener = (payload: { + conversationKey: string + sender: string + isTyping: boolean +}) => void + export interface OpenSIPSEventMap extends UAEventMap { ready: readyListener connection: connectionListener @@ -97,6 +137,16 @@ export interface OpenSIPSEventMap extends UAEventMap { connectionStateChange: connectionStateChangeListener newMSRPMessage: MSRPMessageListener newMSRPSession: MSRPSessionListener + // MSRP - granular conversation events + changeMsrpSession: changeMsrpSessionListener + msrpSyncCompleted: msrpSyncCompletedListener + msrpConversationCreated: msrpConversationCreatedListener + msrpConversationRemoved: msrpConversationRemovedListener + msrpConversationUpdated: msrpConversationUpdatedListener + msrpMessageAdded: msrpMessageAddedListener + msrpReceiptChanged: msrpReceiptChangedListener + msrpReactionChanged: msrpReactionChangedListener + msrpTyping: msrpTypingListener // JANUS conferenceStart: conferenceStartListener conferenceEnd: conferenceEndListener From 57c078bedd243501ebb8577cd70212b4705c973e Mon Sep 17 00:00:00 2001 From: "serhii.ku" Date: Tue, 30 Jun 2026 23:34:52 +0300 Subject: [PATCH 2/7] Removed comments --- msrp_demo/src/composables/index.ts | 9 --------- msrp_demo/src/types/index.ts | 26 -------------------------- msrp_demo/vite.config.ts | 14 -------------- src/types/listeners.d.ts | 4 ++-- 4 files changed, 2 insertions(+), 51 deletions(-) diff --git a/msrp_demo/src/composables/index.ts b/msrp_demo/src/composables/index.ts index 01a535e..d6057ac 100644 --- a/msrp_demo/src/composables/index.ts +++ b/msrp_demo/src/composables/index.ts @@ -24,26 +24,17 @@ import type { let openSIPSJS: OpenSIPSJS | undefined = undefined -// ---------- Shared connection state ---------- const isInitialized = ref(false) const isOpenSIPSReady = ref(false) const isOpenSIPSReconnecting = ref(false) -// ---------- MSRP session state ---------- const currentMsrpSession = ref(null) const isMSRPInitializing = ref(false) -// ---------- MSRP conversation state ---------- -// Conversation metadata and chat history are two separate stores. The -// metadata map is small and changes rarely (members, role, state -// events). The messages map is fat and churns on every new event, so -// keeping them apart prevents message activity from invalidating -// metadata-only views. const conversations = ref<{ [key: string]: MSRPConversationState }>({}) const messagesByConversation = ref<{ [conversationKey: string]: any[] }>({}) const typingByConversation = ref<{ [conversationKey: string]: MSRPTypingState }>({}) -// ---------- UI-only state ---------- const currentConversationKey = ref(null) const unreadByConversation = ref({}) diff --git a/msrp_demo/src/types/index.ts b/msrp_demo/src/types/index.ts index db23493..790cb7f 100644 --- a/msrp_demo/src/types/index.ts +++ b/msrp_demo/src/types/index.ts @@ -1,10 +1,3 @@ -// Local type contracts for the msrp_demo composable. -// -// In the main project these live at `@/types`; here we mirror the shape so -// the slice can later be lifted straight into that file. Keep field names -// identical to the main composable's VsipAPI - only the MSRP + shared -// connection surface is included (audio/video deliberately omitted). - import type { ComputedRef, Ref } from 'vue' import type { CustomLoggerType, @@ -42,38 +35,23 @@ export interface MSRPTypingState { } export interface VsipAPIState { - // ---------- Shared connection lifecycle ---------- isInitialized: Ref isOpenSIPSReady: Ref isOpenSIPSReconnecting: Ref - - // ---------- MSRP session ---------- isMSRPInitializing: Ref currentMsrpSession: Ref hasActiveMsrpSession: ComputedRef - - // ---------- MSRP conversations / messages ---------- - // Conversation metadata only - chat history lives in its own store - // (`messagesByConversation`) to keep slow-moving protocol data - // decoupled from fast-moving message updates. conversations: Ref<{ [key: string]: MSRPConversationState }> messagesByConversation: Ref<{ [conversationKey: string]: any[] }> - // The "currently focused" conversation is a UI concern (the protocol - // module exposes the full conversations map and lets the consumer - // decide which one is active). We track it here so the demo's UI can - // reactively highlight it. currentConversationKey: Ref currentConversation: ComputedRef currentMessages: ComputedRef sortedConversations: ComputedRef typingByConversation: Ref<{ [conversationKey: string]: MSRPTypingState }> - // Same UI-concern story as currentConversationKey: unread tallying is - // not part of the MSRP protocol so it lives here. unreadByConversation: Ref } export interface VsipAPIActions { - // ---------- Shared connection lifecycle ---------- init ( connectOptions: ConnectOptions, pnExtraHeaders?: PnExtraHeaders, @@ -83,16 +61,12 @@ export interface VsipAPIActions { register (): void unregister (): void disconnect (): void - - // ---------- MSRP session ---------- initMSRP (options?: object): void initMSRPAndSendMessage (target: string, body: string, options?: object): void msrpAnswer (callId: string): void messageTerminate (callId: string): void sendMSRP (msrpSessionId: string, body: string): void safeSendMSRP (body: string): boolean - - // ---------- MSRP - one method per UI action ---------- sendCreateConversationMessage (targetSip: string | string[]): boolean sendTextMessage (conversationKey: string, text: string): boolean sendMediaMessage ( diff --git a/msrp_demo/vite.config.ts b/msrp_demo/vite.config.ts index 229cacc..f9ba070 100644 --- a/msrp_demo/vite.config.ts +++ b/msrp_demo/vite.config.ts @@ -2,39 +2,25 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' -// Resolve once for clarity. const rootDir = resolve(__dirname, '..') const rootSrc = resolve(rootDir, 'src') -// We intentionally do NOT depend on opensips-js as a package - the whole point -// of having this demo inside the same repo is to import the library directly -// from ../src (e.g. `import OpenSIPSJS from '../../src/index'`) so changes -// show up immediately via HMR. export default defineConfig({ plugins: [ vue() ], resolve: { alias: { - // Mirror the alias used by opensips-js library internals so files - // like `import x from '@/helpers/audio.helper'` keep resolving when - // the library source is loaded from ../src. '@': rootSrc }, - // Make sure there is only one copy of Vue at runtime (the one from - // msrp_demo/node_modules) even if anything upstream pulls Vue in. dedupe: [ 'vue' ] }, server: { port: 5174, host: true, fs: { - // The library source sits one level up; without this Vite would - // refuse to serve it during dev. allow: [ rootDir ] } }, optimizeDeps: { - // The library pulls these in via ../src. Pre-bundling them avoids - // dev-time CJS/ESM interop hiccups. include: [ 'jssip', 'sdp-transform', 'loglevel', 'p-iteration', 'uuid', 'generate-unique-id' ] } }) diff --git a/src/types/listeners.d.ts b/src/types/listeners.d.ts index f65e005..413986b 100644 --- a/src/types/listeners.d.ts +++ b/src/types/listeners.d.ts @@ -66,7 +66,7 @@ export type memberHangupListener = (event: object) => void export type changeAudioStateListener = (state: boolean) => void export type changeVideoStateListener = (state: boolean) => void -// ---------- MSRP granular event listeners ---------- +/* MSRP event listeners */ export type changeMsrpSessionListener = (session: IMessage | null) => void export type msrpSyncCompletedListener = (payload: { conversations: { [key: string]: MSRPConversationState } @@ -137,7 +137,7 @@ export interface OpenSIPSEventMap extends UAEventMap { connectionStateChange: connectionStateChangeListener newMSRPMessage: MSRPMessageListener newMSRPSession: MSRPSessionListener - // MSRP - granular conversation events + // MSRP events listeners changeMsrpSession: changeMsrpSessionListener msrpSyncCompleted: msrpSyncCompletedListener msrpConversationCreated: msrpConversationCreatedListener From ce11117321c314d083c52853cbb6a9333ba09f6d Mon Sep 17 00:00:00 2001 From: "serhii.ku" Date: Wed, 1 Jul 2026 11:53:30 +0300 Subject: [PATCH 3/7] Defined emojies list on demo --- msrp_demo/src/App.vue | 5 +++-- msrp_demo/src/composables/index.ts | 12 ------------ src/modules/msrp.ts | 2 -- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/msrp_demo/src/App.vue b/msrp_demo/src/App.vue index 97f1095..4a62c50 100644 --- a/msrp_demo/src/App.vue +++ b/msrp_demo/src/App.vue @@ -2,7 +2,8 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { vsipAPI } from './composables' import { MODULES } from '../../src/enum/modules' -import { MSRP_QUICK_REACTION_EMOJIS } from '../../src/modules/msrp' + +const QUICK_REACTION_EMOJIS = [ '👍', '❤️', '😂', '😮', '😢', '🙏' ] as const // ===================================================================== // LOCAL STORAGE KEYS - mirrors demo_example/main.js naming @@ -260,7 +261,7 @@ function statusIcon (status: string | undefined): string { } function quickReactions () { - return MSRP_QUICK_REACTION_EMOJIS + return QUICK_REACTION_EMOJIS } // ===================================================================== diff --git a/msrp_demo/src/composables/index.ts b/msrp_demo/src/composables/index.ts index d6057ac..a01945c 100644 --- a/msrp_demo/src/composables/index.ts +++ b/msrp_demo/src/composables/index.ts @@ -63,18 +63,6 @@ function findMessage (messages: any[], eventId: string) { return messages.find((msg: any) => msg.event_id === eventId) } -/** - * Apply a live `m.reaction` event to a target message's - * `reactions_summary` array, mirroring the exact shape the backend - * returns on sync: - * { emoji, count, user_ids, viewer_reacted } - * - * Invariant enforced: `count === user_ids.length`. A repeated "add" - * from the same sender is a no-op; a "remove" from a sender who isn't - * in `user_ids` is a no-op. `viewer_reacted` is recomputed from - * `user_ids` so we never depend on a server-supplied value that may be - * wrong (the backend has been observed to mis-attribute it). - */ function applyReaction ( target: any, emoji: string, diff --git a/src/modules/msrp.ts b/src/modules/msrp.ts index efe9fdb..90c7a78 100644 --- a/src/modules/msrp.ts +++ b/src/modules/msrp.ts @@ -29,8 +29,6 @@ export const MSRP_STATE_MEMBER = 'm.conversation.member' export const MSRP_STATE_CREATE = 'm.conversation.create' export const MSRP_STATE_CLOSED = 'm.conversation.closed' -export const MSRP_QUICK_REACTION_EMOJIS = [ '👍', '❤️', '😂', '😮', '😢', '🙏' ] as const - export type MSRPMemberRole = 'in_charge' | 'manager' | 'assigned' export type MSRPMembership = 'join' | 'leave' | 'invite' | 'ban' export type MSRPMessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed' From f0023b249430fdbb10a8c9a771ae5ae1bfe802fa Mon Sep 17 00:00:00 2001 From: "serhii.ku" Date: Wed, 1 Jul 2026 11:58:32 +0300 Subject: [PATCH 4/7] Removed large comments --- src/modules/msrp.ts | 82 +-------------------------------------------- 1 file changed, 1 insertion(+), 81 deletions(-) diff --git a/src/modules/msrp.ts b/src/modules/msrp.ts index 90c7a78..2b74f73 100644 --- a/src/modules/msrp.ts +++ b/src/modules/msrp.ts @@ -33,17 +33,6 @@ export type MSRPMemberRole = 'in_charge' | 'manager' | 'assigned' export type MSRPMembership = 'join' | 'leave' | 'invite' | 'ban' export type MSRPMessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed' -/** - * Protocol-level conversation state owned by MSRPModule. - * - * Intentionally does NOT carry a `messages` array - the module is a pure - * pipe for chat history. Live messages are emitted via `msrpMessageAdded` - * and historical messages from `m.sync` come through the - * `messagesByConversation` field of the `msrpSyncCompleted` payload. - * The consumer (e.g. a Vue composable) is responsible for collecting them - * into whatever data structure it needs and for downstream concerns like - * deduplication, reaction aggregation and receipt application. - */ export interface MSRPConversationState { conversationKey: string creator: string | null @@ -308,14 +297,6 @@ export class MSRPModule { : this.context.configuration.uri.toString() } - /** - * Open an MSRP session for the currently logged-in user. The SIP - * identity is resolved automatically from the OpenSIPSJS - * configuration, so the caller never has to pass it. Use this when - * the session is intended to act as a long-lived transport pipe and - * the first payload will be sent later through one of the higher-level - * actions (e.g. `sendCreateConversationMessage`). - */ public initMSRP (options: any = {}) { const identity = this.extractSipUser(this.getUserUri()) if (!identity) { @@ -325,11 +306,6 @@ export class MSRPModule { const session = this.context.startMSRP(identity, options) as MSRPSessionExtended session.on('active', () => { this.addMessageSession(session) - // Push an empty MSRP SEND the moment the session goes - // active. The conversation server relies on this initial - // frame to bind the freshly-opened pipe to our user in its - // routing table - without it the SIP/MSRP layer is alive - // but no invites or messages are ever delivered to us. session.sendMSRP('') this.setIsMSRPInitializing(false) }) @@ -337,12 +313,6 @@ export class MSRPModule { this.setIsMSRPInitializing(true) } - /** - * Open an MSRP session and push `body` as the first MSRP frame the - * moment the session goes active. Use this for the legacy - * "compose-and-send" flow where the first message is known at the - * time the session is opened. - */ public initMSRPAndSendMessage (target: string, body: string, options: any = {}) { if (target.length === 0) { return console.error('Target must be a valid string') @@ -358,10 +328,6 @@ export class MSRPModule { this.setIsMSRPInitializing(true) } - /** - * Public raw send - kept for backward-compat with the old multi-session API. - * Ignores the passed callId/sessionId because we only ever hold one session. - */ public sendMSRP (_msrpSessionId: string, body: string) { if (!this.extendedSession) { throw new Error('No active MSRP session') @@ -373,8 +339,7 @@ export class MSRPModule { /** * Safe wrapper around session.sendMSRP. Returns true when the message was * handed off to the session; false when there is no active session or the - * underlying send threw. On failure the stored session is cleared so the - * next send attempt does not silently drop traffic on a half-dead session. + * underlying send threw. */ public safeSendMSRP (body: string): boolean { if (!this.msrpSession || !this.extendedSession) { @@ -515,11 +480,6 @@ export class MSRPModule { } // PUBLIC ACTIONS — outgoing requests - /** - * Create a new conversation by inviting one or more SIP URIs. - * Accepts plain numbers/usernames and rewrites them into full SIP URIs - * against the domain OpenSIPSJS was initialized with. - */ public sendCreateConversationMessage (targetSip: string | string[]): boolean { if (!this.extendedSession) { console.warn('No MSRP session available for creating conversation') @@ -565,13 +525,6 @@ export class MSRPModule { return this.safeSendMSRP(JSON.stringify(this.buildTypingEvent(conversationKey, isTyping))) } - /** - * Start an automatic typing keep-alive loop for `conversationKey`. While - * active, a typing=true event is re-sent every `typingKeepAliveIntervalMs` - * so the remote side does not time out the indicator. Calling this with a - * different conversationKey or calling `stopTypingKeepAlive()` cancels - * any in-flight loop. - */ public startTypingKeepAlive (conversationKey: string): void { if (!conversationKey) return this.stopTypingKeepAlive(false) @@ -598,11 +551,6 @@ export class MSRPModule { } } - /** - * Send a read receipt for `lastEventId` in `conversationKey`. The - * caller is responsible for resolving which event_id represents the - * "last seen" message because the module no longer stores chat history. - */ public sendReadReceipt (conversationKey: string, lastEventId: string): boolean { if (!conversationKey || !lastEventId || !this.hasActiveSession) return false return this.safeSendMSRP(JSON.stringify(this.buildReadReceiptEvent(conversationKey, lastEventId))) @@ -793,11 +741,6 @@ export class MSRPModule { } // INCOMING EVENT PROCESSING - /** - * Parse a raw MSRP `newMessage` payload and dispatch it to the right - * conversation-state mutator. Outgoing messages and non-JSON payloads - * are ignored so the rest of the pipeline stays pure. - */ private processIncomingMSRPMessage (msg: any) { if (!msg || msg.direction === 'outgoing') return @@ -1191,15 +1134,6 @@ export class MSRPModule { this.conversationsMap.set(key, { ...data, conversationKey: key }) } - /** - * Make a shallow snapshot of a conversation that is safe to hand off - * to an external consumer (e.g. a Vue composable). - * - * The mutable top-level collections (members, memberRoles, - * state_events) are cloned so subsequent internal mutations by the - * module cannot leak into the consumer's state. There is no - * `messages` field - chat history is owned by the consumer. - */ private snapshotConversation (c: MSRPConversationState): MSRPConversationState { return { ...c, @@ -1209,10 +1143,6 @@ export class MSRPModule { } } - /** - * Snapshot the whole conversations map - used only for the bulk - * `msrpSyncCompleted` emit. - */ private snapshotConversationsMap (): { [key: string]: MSRPConversationState } { const out: { [key: string]: MSRPConversationState } = {} this.conversationsMap.forEach((conv, key) => { @@ -1225,18 +1155,12 @@ export class MSRPModule { return !!conversation?.state_events?.[MSRP_STATE_CLOSED]?.[''] } - /** - * Extract the conversationKey from either a string or an event/conversation object. - */ private conversationKeyOf (source: any): string | null { if (!source) return null if (typeof source === 'string') return source return source.conversationKey ?? null } - /** - * Extract the user-part of a SIP URI, e.g. sip:103@example.com -> 103. - */ public extractSipUser (sipUri: string | null | undefined): string | null { if (!sipUri) return null const uriString = typeof sipUri === 'string' ? sipUri : String(sipUri) @@ -1244,10 +1168,6 @@ export class MSRPModule { return match ? match[1] : null } - /** - * Best-effort human-readable name for any URI we may encounter - * (SIP / WhatsApp / SMS / GreenAPI). - */ public extractDisplayName (uri: string | null | undefined): string { if (!uri) return 'Unknown' const uriString = typeof uri === 'string' ? uri : String(uri) From 097d9f0c8a5a0c1e5d8fc256bf628eaac806d766 Mon Sep 17 00:00:00 2001 From: "serhii.ku" Date: Wed, 1 Jul 2026 15:47:11 +0300 Subject: [PATCH 5/7] Updated exports --- package.json | 2 +- src/index.ts | 59 ++++++++++++++++++++++++++++++++++++++++ src/modules/msrp.ts | 53 +++++++++++++++--------------------- src/types/listeners.d.ts | 8 ++++-- src/types/msrp.d.ts | 36 ++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index a682a1e..d626227 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opensips-js", - "version": "0.1.47", + "version": "0.1.48", "description": "The JS package for opensips", "default": "src/index.ts", "jsdelivr": "dist/opensips-js.iife.js", diff --git a/src/index.ts b/src/index.ts index d01552d..3d27a5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1559,3 +1559,62 @@ export { BaseProcessStreamPlugin, BaseNewStreamPlugin } + +/* ---------- Public API surface (types + runtime constants) ---------- + * Anything a consumer needs to reference from the outside should be + * re-exported here so it lands in `dist/index.d.ts` with an actual + * `export` keyword (not a module-internal `declare`). Consumers can + * then `import { ... } from 'opensips-js'` without touching internal + * subpaths. + */ + +// MSRP conversation types +export type { + IMessage, + MSRPSessionExtended, + TriggerMSRPListenerOptions, + ListenerEventType, + MSRPMemberRole, + MSRPMembership, + MSRPMessageStatus, + MSRPConversationState, + MSRPUploadResult +} from '@/types/msrp' + +// MSRP runtime constants +export { MSRP_EVT } from '@/modules/msrp' + +// Event map + the small set of listener helpers/payload shapes that a +// consumer might reference by name. The individual `*Listener` type +// aliases inside `@/types/listeners` are intentionally NOT re-exported: +// each is just `(arg) => void` and is reconstructible on demand as +// `OpenSIPSEventMap['']` (or `ListenerCallbackFnType<''>`). +// Keeping the surface small avoids locking us into ~40 alias names as +// public contracts. +export type { + OpenSIPSEventMap, + ListenerCallbackFnType, + MSRPMessageEventType, + ChangeVolumeEventType, + ConnectionStateChangeType +} from '@/types/listeners' + +// Call / RTC surface (shapes consumers pass in via options or receive +// back via events / accessors). +export type { + ICall, + IRoom, + ICallStatus, + IOpenSIPSConfiguration, + IOpenSIPSJSOptions, + NoiseReductionOptions, + NoiseReductionOptionsWithoutVadModule, + NoiseReductionMode, + CustomLoggerType +} from '@/types/rtc' + +// Call-timer payload +export type { ITimeData } from '@/types/timer' + +// WebRTC metrics config (accepted by OpenSIPSJS options) +export type { WebrtcMetricsConfigType } from '@/types/webrtcmetrics' diff --git a/src/modules/msrp.ts b/src/modules/msrp.ts index 2b74f73..f9f108f 100644 --- a/src/modules/msrp.ts +++ b/src/modules/msrp.ts @@ -1,11 +1,31 @@ import { simplifyMessageObject } from '@/helpers/audio.helper' import { CALL_EVENT_LISTENER_TYPE } from '@/enum/call.event.listener.type' import { EndEvent, IncomingAckEvent, OutgoingAckEvent } from 'jssip/lib/RTCSession' -import { IMessage, MSRPSessionExtended, TriggerMSRPListenerOptions } from '@/types/msrp' +import { + IMessage, + MSRPSessionExtended, + TriggerMSRPListenerOptions, + MSRPMemberRole, + MSRPMembership, + MSRPMessageStatus, + MSRPConversationState, + MSRPUploadResult +} from '@/types/msrp' import MSRPMessage from '@/lib/msrp/message' import { MSRPSessionEvent } from '@/helpers/UA' -// ---------- CONVERSATION EVENT TYPES (backend v3 — June 2026) ---------- +// Re-export so existing consumers that imported these from +// `@/modules/msrp` (or the module's compiled entry) keep working +// during the transition. New code should import from `@/types/msrp`. +export type { + MSRPMemberRole, + MSRPMembership, + MSRPMessageStatus, + MSRPConversationState, + MSRPUploadResult +} + +// ---------- CONVERSATION EVENT TYPES (backend v3 - June 2026) ---------- export const MSRP_EVT = { CREATE: 'm.conversation.create', MESSAGE: 'm.conversation.message', @@ -29,35 +49,6 @@ export const MSRP_STATE_MEMBER = 'm.conversation.member' export const MSRP_STATE_CREATE = 'm.conversation.create' export const MSRP_STATE_CLOSED = 'm.conversation.closed' -export type MSRPMemberRole = 'in_charge' | 'manager' | 'assigned' -export type MSRPMembership = 'join' | 'leave' | 'invite' | 'ban' -export type MSRPMessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed' - -export interface MSRPConversationState { - conversationKey: string - creator: string | null - members: Set - memberRoles: Map - currentUserRole: MSRPMemberRole - currentUserStatus: MSRPMembership | null - state_events: { [key: string]: { [stateKey: string]: any } } - created_at: number - updated_at: number - status?: string -} - -export interface MSRPUploadResult { - upload_url: string - expires_in?: number - mime_type: string - request_id: string - filename?: string - preview_url?: string - icon_url?: string - transcription?: string - media_type?: string -} - interface PendingPromise { resolve: (value: T) => void reject: (reason?: any) => void diff --git a/src/types/listeners.d.ts b/src/types/listeners.d.ts index 413986b..f0145ba 100644 --- a/src/types/listeners.d.ts +++ b/src/types/listeners.d.ts @@ -1,11 +1,15 @@ import { UAEventMap } from 'jssip/lib/UA' -import { IMessage, MSRPSessionExtended } from '@/types/msrp' +import { + IMessage, + MSRPSessionExtended, + MSRPConversationState, + MSRPMessageStatus +} from '@/types/msrp' import { ICall, RoomChangeEmitType, ICallStatus, RTCSessionExtended } from '@/types/rtc' import MSRPMessage from '@/lib/msrp/message' import { ITimeData } from '@/types/timer' import { IncomingMSRPSessionEvent, OutgoingMSRPSessionEvent } from '@/helpers/UA' -import { MSRPConversationState, MSRPMessageStatus } from '@/modules/msrp' export type MSRPMessageEventType = { message: MSRPMessage, diff --git a/src/types/msrp.d.ts b/src/types/msrp.d.ts index 1b1f34e..0a7fea1 100644 --- a/src/types/msrp.d.ts +++ b/src/types/msrp.d.ts @@ -53,3 +53,39 @@ export interface TriggerMSRPListenerOptions { session: MSRPSessionExtended event?: ListenerEventType } + +/* ---------- MSRP conversation shape types (backend v3 - June 2026) ---------- + * These are the public data structures produced by MSRPModule at runtime and + * consumed by external subscribers (Vue wrappers, tests, etc.). Kept in this + * shipped `src/types/` file so they resolve cleanly for consumers without + * needing internal module paths. + */ + +export type MSRPMemberRole = 'in_charge' | 'manager' | 'assigned' +export type MSRPMembership = 'join' | 'leave' | 'invite' | 'ban' +export type MSRPMessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed' + +export interface MSRPConversationState { + conversationKey: string + creator: string | null + members: Set + memberRoles: Map + currentUserRole: MSRPMemberRole + currentUserStatus: MSRPMembership | null + state_events: { [key: string]: { [stateKey: string]: any } } + created_at: number + updated_at: number + status?: string +} + +export interface MSRPUploadResult { + upload_url: string + expires_in?: number + mime_type: string + request_id: string + filename?: string + preview_url?: string + icon_url?: string + transcription?: string + media_type?: string +} From 11e4d4d8acfdee715e9c046c27298580fee43717 Mon Sep 17 00:00:00 2001 From: "serhii.ku" Date: Sun, 12 Jul 2026 14:37:52 +0300 Subject: [PATCH 6/7] Added endpoints for conversations/messaging and updated demo --- msrp_demo/package.json | 1 + msrp_demo/src/App.vue | 633 +++++++++++++++++++++++++---- msrp_demo/src/api/channels.ts | 103 +++++ msrp_demo/src/api/client.ts | 95 +++++ msrp_demo/src/api/conversations.ts | 107 +++++ msrp_demo/src/api/index.ts | 16 + msrp_demo/src/api/types.ts | 199 +++++++++ msrp_demo/src/composables/index.ts | 249 ++++++++---- msrp_demo/src/types/index.ts | 61 ++- msrp_demo/yarn.lock | 189 +++++++++ src/modules/msrp.ts | 534 ++++++++++++++++++------ src/types/listeners.d.ts | 40 +- src/types/msrp.d.ts | 7 +- 13 files changed, 1929 insertions(+), 305 deletions(-) create mode 100644 msrp_demo/src/api/channels.ts create mode 100644 msrp_demo/src/api/client.ts create mode 100644 msrp_demo/src/api/conversations.ts create mode 100644 msrp_demo/src/api/index.ts create mode 100644 msrp_demo/src/api/types.ts diff --git a/msrp_demo/package.json b/msrp_demo/package.json index 614dce9..10671c3 100644 --- a/msrp_demo/package.json +++ b/msrp_demo/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.18.1", "vue": "3.2.25" }, "devDependencies": { diff --git a/msrp_demo/src/App.vue b/msrp_demo/src/App.vue index 4a62c50..384a931 100644 --- a/msrp_demo/src/App.vue +++ b/msrp_demo/src/App.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { vsipAPI } from './composables' import { MODULES } from '../../src/enum/modules' +import { exportConversation, hasApiToken, setApiToken } from './api' const QUICK_REACTION_EMOJIS = [ '👍', '❤️', '😂', '😮', '😢', '🙏' ] as const @@ -12,6 +13,7 @@ const LS_DOMAIN = 'sipDomain' const LS_USERNAME = 'sipUsername' const LS_PASSWORD = 'sipPassword' const LS_MSRP_DOMAIN = 'msrpDomain' +const LS_API_TOKEN = 'voicenterApiToken' // ===================================================================== // LOGIN FORM (localStorage-backed, just like demo_example/main.js) @@ -20,6 +22,15 @@ const domain = ref(localStorage.getItem(LS_DOMAIN) ?? '') const username = ref(localStorage.getItem(LS_USERNAME) ?? '') const password = ref(localStorage.getItem(LS_PASSWORD) ?? '') const msrpDomain = ref(localStorage.getItem(LS_MSRP_DOMAIN) ?? '') +const apiToken = ref(localStorage.getItem(LS_API_TOKEN) ?? '') + +// Push any persisted token into the shared api client immediately, so +// REST calls from other parts of the demo (later: sidebar lazy-load, +// search, export, channel management) already work before the user +// touches the login form. +if (apiToken.value.trim()) { + setApiToken(apiToken.value.trim()) +} const connectError = ref('') const isConnecting = ref(false) @@ -43,6 +54,15 @@ async function handleConnect () { localStorage.setItem(LS_MSRP_DOMAIN, msrpDomain.value.trim()) } + const trimmedToken = apiToken.value.trim() + if (trimmedToken) { + localStorage.setItem(LS_API_TOKEN, trimmedToken) + setApiToken(trimmedToken) + } else { + localStorage.removeItem(LS_API_TOKEN) + setApiToken(null) + } + isConnecting.value = true try { await actions.init( @@ -76,10 +96,13 @@ function handleForget () { localStorage.removeItem(LS_USERNAME) localStorage.removeItem(LS_PASSWORD) localStorage.removeItem(LS_MSRP_DOMAIN) + localStorage.removeItem(LS_API_TOKEN) domain.value = '' username.value = '' password.value = '' msrpDomain.value = '' + apiToken.value = '' + setApiToken(null) } // ===================================================================== @@ -101,25 +124,60 @@ function handleCreateConversation () { if (ok) newConversationTarget.value = '' } -function handleSelectConversation (key: string) { - actions.setActiveConversation(key) +function handleSelectConversation (id: number | string) { + actions.setActiveConversation(String(id)) +} + +function handleLeaveConversation (id: number | string) { + actions.leaveConversation(id) } -function handleLeaveConversation (key: string) { - actions.leaveConversation(key) +function handleCloseConversation (id: number | string) { + actions.closeConversation(id) } -function handleCloseConversation (key: string) { - actions.closeConversation(key) +// ===================================================================== +// EXPORT CONVERSATION (PDF §7.3) +// Fetches the transcript from the REST API and triggers a browser +// download. Uses the JWT set on the shared api client (Token field on +// the login form). +// ===================================================================== +const isExporting = ref(false) +const exportError = ref('') + +async function handleExportConversation (id: number | string) { + if (!hasApiToken()) { + exportError.value = 'API token missing - fill the "Token" field on the login form and reconnect.' + return + } + + exportError.value = '' + isExporting.value = true + try { + const blob = await exportConversation(id, 'json') + const filename = `conversation-${id}-${new Date().toISOString().replace(/[:.]/g, '-')}.json` + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) + } catch (e) { + exportError.value = e instanceof Error ? e.message : String(e) + } finally { + isExporting.value = false + } } -function handleAcceptInvite (key: string) { - actions.acceptInvite(key) - actions.setActiveConversation(key) +function handleAcceptInvite (id: number | string) { + actions.acceptInvite(id) + actions.setActiveConversation(String(id)) } -function handleRejectInvite (key: string) { - actions.rejectInvite(key) +function handleRejectInvite (id: number | string) { + actions.rejectInvite(id) } const isCurrentConversationClosed = computed(() => { @@ -146,24 +204,137 @@ const canSend = computed(() => { }) // ===================================================================== -// COMPOSE BAR +// COMPOSE BAR + EDIT / REPLY / INTERNAL NOTE // ===================================================================== const draft = ref('') const chatMessagesEl = ref(null) +const draftEl = ref(null) + +// Draft textarea starts single-line and grows up to DRAFT_MAX_LINES, +// then scrolls internally. Measured via computed line-height so it +// respects font-size / zoom without hard-coded pixel constants. +const DRAFT_MAX_LINES = 3 + +function autoResizeDraft () { + const el = draftEl.value + if (!el) return + // Reset height so scrollHeight reflects actual content, not the + // previous (possibly larger) box. + el.style.height = 'auto' + const style = window.getComputedStyle(el) + const lineHeight = parseFloat(style.lineHeight) || 20 + const paddingY = (parseFloat(style.paddingTop) || 0) + + (parseFloat(style.paddingBottom) || 0) + const borderY = (parseFloat(style.borderTopWidth) || 0) + + (parseFloat(style.borderBottomWidth) || 0) + const maxHeight = lineHeight * DRAFT_MAX_LINES + paddingY + borderY + const nextHeight = Math.min(el.scrollHeight + borderY, maxHeight) + el.style.height = `${nextHeight}px` + el.style.overflowY = el.scrollHeight + borderY > maxHeight ? 'auto' : 'hidden' +} + +// Reply target — set via the per-message "Reply" action. The event_id +// is sent in `content.in_reply_to.event_id` by the module. +const replyingToMessage = ref(null) + +// Edit mode — set via the per-message "Edit" action on our own messages. +// Sending in edit mode routes to actions.editMessage() instead of +// sendTextMessage()/sendInternalNote(). +const editingMessageId = ref(null) + +// When true, the compose bar routes to sendInternalNote (operator-only +// message, not fanned out to external channels). +const sendAsInternalNote = ref(false) + +function isMyMessage (msg: any): boolean { + return !!msg?.sender && extractSipUser(msg.sender) === username.value +} + +function messageBodyText (msg: any): string { + return String(msg?.content?.content ?? '') +} + +function beginReply (msg: any) { + if (!msg?.event_id) return + // Cannot reply and edit at the same time - reply wins. + editingMessageId.value = null + replyingToMessage.value = msg +} + +function cancelReply () { + replyingToMessage.value = null +} + +function beginEdit (msg: any) { + if (!msg?.event_id || !isMyMessage(msg)) return + replyingToMessage.value = null + editingMessageId.value = msg.event_id + draft.value = messageBodyText(msg) + nextTick(() => { + autoResizeDraft() + draftEl.value?.focus() + }) +} + +function cancelEdit () { + editingMessageId.value = null + draft.value = '' + nextTick(() => autoResizeDraft()) +} + +function handleDelete (msg: any) { + if (!msg?.event_id || !isMyMessage(msg)) return + const key = state.currentConversationId.value + if (!key) return + // eslint-disable-next-line no-alert + if (!window.confirm('Delete this message for everyone?')) return + actions.deleteMessage(key, msg.event_id) +} + +// Surfaces the SDK boolean return value so a silent "false" doesn't +// leave the user wondering why their message didn't go anywhere. +const sendError = ref('') function handleSend () { - const key = state.currentConversationKey.value + sendError.value = '' + const key = state.currentConversationId.value const text = draft.value.trim() if (!key || !text) return - const ok = actions.sendTextMessage(key, text) + + let ok = false + let mode: 'edit' | 'note' | 'reply' | 'text' = 'text' + if (editingMessageId.value) { + mode = 'edit' + ok = actions.editMessage(key, editingMessageId.value, text) + if (ok) editingMessageId.value = null + } else if (sendAsInternalNote.value) { + mode = 'note' + ok = actions.sendInternalNote(key, text, { + replyToEventId: replyingToMessage.value?.event_id + }) + } else { + mode = replyingToMessage.value?.event_id ? 'reply' : 'text' + ok = actions.sendTextMessage(key, text, { + replyToEventId: replyingToMessage.value?.event_id + }) + } + + // eslint-disable-next-line no-console + console.debug('[compose] send', { mode, conversation_id: key, ok, text }) + if (ok) { draft.value = '' + replyingToMessage.value = null actions.stopTypingKeepAlive(true) + nextTick(() => autoResizeDraft()) + } else { + sendError.value = `Send failed (mode: ${mode}). SDK returned false — the MSRP session may be down, or the module rejected the payload. Check console.` } } function handleDraftInput () { - const key = state.currentConversationKey.value + autoResizeDraft() + const key = state.currentConversationId.value if (!key) return if (draft.value.trim()) { actions.startTypingKeepAlive(key) @@ -172,6 +343,18 @@ function handleDraftInput () { } } +/** + * Enter → send; Shift+Enter → newline (default textarea behavior). + * Ignored during IME composition so pressing Enter to confirm a Chinese/ + * Japanese/Korean composition doesn't accidentally submit the draft. + */ +function handleDraftKeydown (e: KeyboardEvent) { + if (e.key !== 'Enter') return + if (e.isComposing || e.shiftKey) return + e.preventDefault() + handleSend() +} + // ===================================================================== // FILE UPLOAD // ===================================================================== @@ -183,7 +366,7 @@ async function handleFileSelected (event: Event) { const target = event.target as HTMLInputElement const file = target.files?.[0] if (!file) return - const key = state.currentConversationKey.value + const key = state.currentConversationId.value if (!key) { uploadError.value = 'Select a conversation first.' return @@ -205,16 +388,29 @@ async function handleFileSelected (event: Event) { // REACTIONS // ===================================================================== function handleAddReaction (eventId: string, emoji: string) { - const key = state.currentConversationKey.value + const key = state.currentConversationId.value if (!key || !eventId) return actions.sendReaction(key, eventId, emoji) } +/** Toggle an existing reaction: click own reaction (viewer_reacted) to remove. */ +function handleToggleReaction (msg: any, emoji: string) { + const key = state.currentConversationId.value + if (!key || !msg?.event_id) return + const summary = (msg.content?.reactions_summary || []) as any[] + const existing = summary.find((r) => (r?.emoji || r?.key) === emoji) + if (existing?.viewer_reacted) { + actions.removeReaction(key, msg.event_id, emoji) + } else { + actions.sendReaction(key, msg.event_id, emoji, 'add') + } +} + // ===================================================================== // ROLE MANAGEMENT // ===================================================================== function handleChangeRole (targetUri: string, newRole: string) { - const key = state.currentConversationKey.value + const key = state.currentConversationId.value if (!key) return actions.changeMemberRole(key, targetUri, newRole as 'in_charge' | 'manager' | 'assigned') } @@ -264,6 +460,44 @@ function quickReactions () { return QUICK_REACTION_EMOJIS } +function isEdited (msg: any): boolean { + if (msg?.content?.edited_at) return true + return !!msg?.unsigned?.['m.relations']?.['m.replace'] +} + +function isDeleted (msg: any): boolean { + return !!msg?.content?.is_deleted +} + +function isInternalNote (msg: any): boolean { + return msg?.content?.message_type === 'internal_note' +} + +function replyPreviewFor (msg: any): { sender: string, text: string } | null { + const parentId = msg?.content?.in_reply_to?.event_id + if (!parentId) return null + const parent = state.currentMessages.value.find((m: any) => m.event_id === parentId) + if (!parent) { + return { sender: 'unknown', text: '(message not loaded)' } + } + return { + sender: extractSipUser(parent.sender), + text: isDeleted(parent) ? '(deleted)' : messageBodyText(parent).slice(0, 120) + } +} + +// Presence: the module emits a state string and last-seen ts. Map it to +// a small dot color for the members panel. +function presenceClass (uri: string): string { + const p = state.presenceBySender.value[uri]?.presence + switch (p) { + case 'online': return 'presence online' + case 'away': return 'presence away' + case 'offline': return 'presence offline' + default: return 'presence unknown' + } +} + // ===================================================================== // DEBUG PANEL - live JSON view of reactive state. // Map/Set values are not JSON-serialisable by default, so we coerce @@ -278,8 +512,8 @@ function debugReplacer (_key: string, value: unknown): unknown { const debugConversations = computed(() => JSON.stringify(state.conversations.value, debugReplacer, 2) ) -const debugCurrentConversationKey = computed(() => - JSON.stringify(state.currentConversationKey.value, debugReplacer, 2) +const debugCurrentConversationId = computed(() => + JSON.stringify(state.currentConversationId.value, debugReplacer, 2) ) const debugCurrentConversation = computed(() => JSON.stringify(state.currentConversation.value, debugReplacer, 2) @@ -291,10 +525,14 @@ const debugMessagesByConversation = computed(() => JSON.stringify(state.messagesByConversation.value, debugReplacer, 2) ) -// Reset draft + typing when active conversation changes -watch(() => state.currentConversationKey.value, (next, prev) => { +// Reset draft + typing + edit/reply/note toggle when active conversation changes +watch(() => state.currentConversationId.value, (next, prev) => { if (prev) actions.stopTypingKeepAlive(false) draft.value = '' + editingMessageId.value = null + replyingToMessage.value = null + sendAsInternalNote.value = false + nextTick(() => autoResizeDraft()) }) onBeforeUnmount(() => { @@ -345,6 +583,17 @@ onBeforeUnmount(() => { MSRP Domain (optional) +
-
@@ -411,14 +660,14 @@ onBeforeUnmount(() => { @@ -435,8 +684,8 @@ onBeforeUnmount(() => {
Debug · reactive state
-
currentConversationKey
-
{{ debugCurrentConversationKey }}
+
currentConversationId
+
{{ debugCurrentConversationId }}
currentConversation
@@ -466,7 +715,7 @@ onBeforeUnmount(() => {