diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..13a89ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# vcon-js + +A JavaScript/TypeScript library for creating and managing vCons (Virtual Conversations), compliant with IETF draft-ietf-vcon-vcon-core-01. + +## Project Overview + +This library provides a complete implementation of the vCon specification for JavaScript/TypeScript environments. vCon is a standardized JSON format for representing conversational data, encompassing metadata, conversation media, related documents, and analysis. + +## Architecture + +``` +src/ +├── types.ts # TypeScript type definitions for vcon-core-01 +├── vcon.ts # Main Vcon class for managing conversation containers +├── party.ts # Party and PartyHistory classes +├── dialog.ts # Dialog class for conversation segments +├── attachment.ts # Attachment class for related files +├── index.ts # Public API exports +└── __tests__/ # Jest test files + ├── vcon.test.ts + ├── party.test.ts + ├── dialog.test.ts + ├── attachment.test.ts + └── utils.ts # Test utilities for loading synthetic vCons + +test-vcons/ # Synthetic test data (261 vCon files across 19 categories) +examples/ # Usage examples +``` + +## Key Classes + +### Vcon +The main container class. Use `Vcon.buildNew()` to create a new vCon or `Vcon.buildFromJson(json)` to parse existing JSON. + +### Party +Represents conversation participants with identifiers (tel, sip, mailto, stir, did, uuid) and metadata. + +### Dialog +Represents conversation segments. Four types per vcon-core-01: `recording`, `text`, `transfer`, `incomplete`. + +### Attachment +Represents attached files. Can be inline (body/encoding) or external (url/content_hash). + +## vcon-core-01 Compliance + +The library implements draft-ietf-vcon-vcon-core-01: + +- **Dialog types**: recording, text, transfer, incomplete +- **Dispositions** (for incomplete): no-answer, congestion, failed, busy, hung-up, voicemail-no-message +- **Encodings**: base64url, json, none (note: base64 is NOT valid per spec) +- **Extensions**: support for `extensions` and `critical` arrays +- **Content hash**: SHA-512 format for external references +- **Timestamps**: RFC3339 format + +## Development Commands + +```bash +npm install # Install dependencies +npm run build # Compile TypeScript to dist/ +npm test # Run Jest tests (61 tests) +npm run lint # Run ESLint +npm run format # Format with Prettier + +# Tutorial examples +npm run example:chat # Text chat conversation +npm run example:call # Phone call with analysis +npm run example:conference # Video conference with attachments +npm run examples # Run all examples +``` + +## Tutorial Examples + +Three comprehensive examples in `examples/` directory: + +1. **01-text-chat.ts** - Customer support chat demonstrating text dialogs, parties, tags, serialization +2. **02-call-recording.ts** - Phone call with transcription/sentiment analysis, party history, contact center extension +3. **03-video-conference.ts** - Multi-party meeting with attachments, groups, incomplete dialogs, validation + +## Testing + +Tests use Jest with ts-jest preset. Synthetic test vCons in `test-vcons/` directory provide real-world conversation scenarios for validation. Test utilities in `src/__tests__/utils.ts` help load these fixtures. + +## Key Implementation Notes + +1. **Date handling**: Dates are stored as ISO strings (RFC3339) in serialized output, but can be passed as Date objects or strings to constructors. + +2. **Encoding validation**: Only `base64url`, `json`, and `none` are valid encodings per vcon-core-01. The older `base64` is not supported. + +3. **parties field**: Can be a single integer or array of integers per vcon-core-01. + +4. **created_at is immutable**: Once set, should not be changed. Use `updated_at` to track modifications. + +5. **Extension framework**: Use `addExtension()` for optional extensions, `addCriticalExtension()` for required ones. + +## Common Tasks + +### Creating a vCon with conversation +```typescript +const vcon = Vcon.buildNew(); +vcon.addParty(new Party({ tel: '+1234567890', name: 'Agent', role: 'agent' })); +vcon.addParty(new Party({ tel: '+0987654321', name: 'Customer', role: 'customer' })); +vcon.addDialog(new Dialog({ + type: 'text', + start: new Date(), + parties: [0, 1], + body: 'Hello!', + mediatype: 'text/plain' +})); +``` + +### Adding external media reference +```typescript +const dialog = new Dialog({ type: 'recording', start: new Date(), parties: [0, 1] }); +dialog.addExternalData('https://example.com/audio.wav', 'audio/wav', { + filename: 'call.wav', + content_hash: 'sha512-abc123...' +}); +``` + +### Adding analysis +```typescript +vcon.addAnalysis({ + type: 'sentiment', + dialog: 0, + vendor: 'analyzer-service', + product: 'v2', + body: { score: 0.8, label: 'positive' }, + encoding: 'json' +}); +``` diff --git a/README.md b/README.md index d845346..afe03c3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # vcon-js -A JavaScript library for creating and managing vCons (Virtual Conversations). +A JavaScript/TypeScript library for creating and managing vCons (Virtual Conversations), compliant with [IETF draft-ietf-vcon-vcon-core-01](https://datatracker.ietf.org/doc/html/draft-ietf-vcon-vcon-core-01). ## Installation @@ -21,23 +21,25 @@ const vcon = Vcon.buildNew(); // Add parties const party1 = new Party({ tel: '+1234567890', - name: 'John Doe' + name: 'John Doe', + role: 'agent' }); const party2 = new Party({ tel: '+0987654321', - name: 'Jane Smith' + name: 'Jane Smith', + role: 'customer' }); vcon.addParty(party1); vcon.addParty(party2); -// Add a dialog +// Add a text dialog const dialog = new Dialog({ - type: 'text/plain', + type: 'text', start: new Date(), - parties: [0, 1], // References to party indices + parties: [0, 1], body: 'Hello, this is a conversation!', - mimetype: 'text/plain' + mediatype: 'text/plain' }); vcon.addDialog(dialog); @@ -62,15 +64,27 @@ import { Vcon, Attachment } from 'vcon-js'; const vcon = Vcon.buildNew(); -// Add an attachment -const attachment = vcon.addAttachment( - 'application/pdf', - 'base64EncodedContent', - 'base64' -); +// Add an inline attachment +const attachment = vcon.addAttachment({ + type: 'application/pdf', + body: 'base64EncodedContent', + encoding: 'base64url', + filename: 'document.pdf' +}); + +// Add an external attachment +vcon.addAttachment({ + purpose: 'transcript', + url: 'https://example.com/transcript.txt', + content_hash: 'sha512-abc123...', + mediatype: 'text/plain' +}); // Find an attachment by type const pdfAttachment = vcon.findAttachmentByType('application/pdf'); + +// Find an attachment by purpose +const transcript = vcon.findAttachmentByPurpose('transcript'); ``` ### Working with Analysis @@ -80,21 +94,105 @@ import { Vcon } from 'vcon-js'; const vcon = Vcon.buildNew(); -// Add analysis +// Add inline analysis vcon.addAnalysis({ type: 'sentiment', - dialog: 0, // Reference to dialog index + dialog: 0, vendor: 'sentiment-analyzer', + product: 'analyzer-v2', body: { score: 0.8, label: 'positive' - } + }, + encoding: 'json' +}); + +// Add analysis with external reference +vcon.addAnalysis({ + type: 'transcription', + dialog: [0, 1], + vendor: 'whisper', + url: 'https://example.com/transcription.json', + content_hash: 'sha512-xyz789...', + mediatype: 'application/json' }); // Find analysis by type const sentimentAnalysis = vcon.findAnalysisByType('sentiment'); ``` +### Working with Dialog Types + +vcon-core-01 defines four dialog types: `recording`, `text`, `transfer`, and `incomplete`. + +```typescript +import { Dialog } from 'vcon-js'; + +// Text dialog +const textDialog = new Dialog({ + type: 'text', + start: new Date(), + parties: [0, 1], + body: 'Hello!', + mediatype: 'text/plain' +}); + +// Recording dialog with external audio +const recordingDialog = new Dialog({ + type: 'recording', + start: new Date(), + parties: [0, 1], + duration: 300 +}); +recordingDialog.addExternalData( + 'https://example.com/audio.wav', + 'audio/wav', + { filename: 'call.wav', content_hash: 'sha512-abc...' } +); + +// Incomplete dialog (e.g., no answer) +const incompleteDialog = new Dialog({ + type: 'incomplete', + start: new Date(), + parties: [0], + disposition: 'no-answer' +}); + +// Check dialog types +console.log(textDialog.isText()); // true +console.log(recordingDialog.isRecording()); // true +console.log(incompleteDialog.isIncomplete()); // true +``` + +### Working with Extensions (vcon-core-01) + +```typescript +import { Vcon } from 'vcon-js'; + +const vcon = Vcon.buildNew(); + +// Add a non-critical extension +vcon.addExtension('contact_center'); + +// Add a critical extension (must be understood by processors) +vcon.addCriticalExtension('encrypted'); + +// Check extensions +console.log(vcon.hasExtension('contact_center')); // true +console.log(vcon.isCriticalExtension('encrypted')); // true +``` + +### Working with Groups + +```typescript +import { Vcon } from 'vcon-js'; + +const vcon = Vcon.buildNew(); + +// Add a group reference (for linking related vCons) +vcon.addGroup({ uuid: 'conversation-thread-uuid', type: 'thread' }); +``` + ### Working with Tags ```typescript @@ -109,28 +207,122 @@ vcon.addTag('category', 'support'); const category = vcon.getTag('category'); ``` +### Working with Party History + +Track party events within a dialog (join, leave, hold, etc.): + +```typescript +import { Dialog, PartyHistory } from 'vcon-js'; + +const dialog = new Dialog({ + type: 'recording', + start: new Date(), + parties: [0, 1] +}); + +// Add party history events +dialog.party_history = [ + new PartyHistory(0, 'joined', new Date()).toDict(), + new PartyHistory(1, 'joined', new Date(Date.now() + 5000)).toDict(), + new PartyHistory(1, 'hold', new Date(Date.now() + 60000)).toDict(), + new PartyHistory(1, 'resume', new Date(Date.now() + 120000)).toDict(), + new PartyHistory(0, 'left', new Date(Date.now() + 300000)).toDict(), + new PartyHistory(1, 'left', new Date(Date.now() + 300000)).toDict() +]; +``` + +### Transfer Dialogs + +Represent call transfers between parties: + +```typescript +import { Dialog } from 'vcon-js'; + +const transferDialog = new Dialog({ + type: 'transfer', + start: new Date(), + parties: [0, 1, 2], // Original caller, original agent, new agent + transferor: 1, // Agent initiating transfer + transferee: 0, // Caller being transferred + transfer_target: 2 // New agent receiving transfer +}); +``` + +### Party Identifiers + +vcon-core-01 supports multiple party identifier types: + +```typescript +import { Party } from 'vcon-js'; + +const party = new Party({ + tel: '+1234567890', // Telephone URL + sip: 'sip:user@example.com', // SIP address + mailto: 'user@example.com', // Email address + stir: 'eyJhbGci...', // STIR PASSporT + did: 'did:example:123', // Decentralized Identifier + name: 'John Doe', + timezone: 'America/New_York', + role: 'agent' +}); + +// Check if party has an identifier +console.log(party.hasIdentifier()); // true + +// Get primary identifier +console.log(party.getPrimaryIdentifier()); // '+1234567890' +``` + ## API Reference ### Vcon The main class for working with vCons. -#### Methods +#### Static Methods + +- `buildNew()`: Creates a new vCon with UUID and timestamp +- `buildFromJson(jsonString: string)`: Creates a vCon from JSON string + +#### Instance Methods -- `static buildNew()`: Creates a new vCon -- `static buildFromJson(jsonString: string)`: Creates a vCon from JSON - `addParty(party: Party)`: Adds a party to the vCon - `addDialog(dialog: Dialog)`: Adds a dialog to the vCon -- `addAttachment(type: string, body: any, encoding?: Encoding)`: Adds an attachment -- `addAnalysis(params: AnalysisParams)`: Adds analysis data -- `addTag(tagName: string, tagValue: string)`: Adds a tag -- `findPartyIndex(by: string, val: string)`: Finds a party index -- `findDialog(by: string, val: any)`: Finds a dialog -- `findAttachmentByType(type: string)`: Finds an attachment by type -- `findAnalysisByType(type: string)`: Finds analysis by type -- `getTag(tagName: string)`: Gets a tag value -- `toJson()`: Converts the vCon to JSON -- `toDict()`: Converts the vCon to a dictionary +- `addAttachment(params)`: Adds an attachment (inline or external) +- `addAnalysis(params)`: Adds analysis data +- `addTag(tagName, tagValue)`: Adds a tag +- `addExtension(name)`: Adds a non-critical extension +- `addCriticalExtension(name)`: Adds a critical extension +- `addGroup(group)`: Adds a group reference +- `findPartyIndex(by, val)`: Finds a party index by property +- `findDialog(by, val)`: Finds a dialog by property +- `findAttachmentByType(type)`: Finds an attachment by type +- `findAttachmentByPurpose(purpose)`: Finds an attachment by purpose +- `findAnalysisByType(type)`: Finds analysis by type +- `hasExtension(name)`: Checks if extension is used +- `isCriticalExtension(name)`: Checks if extension is critical +- `getTag(tagName)`: Gets a tag value +- `toJson()`: Converts the vCon to JSON string +- `toDict()`: Converts the vCon to a plain object + +#### Properties + +- `uuid`: Unique identifier +- `vcon`: Version string +- `created_at`: Creation timestamp (RFC3339) +- `updated_at`: Last modification timestamp +- `subject`: Conversation subject +- `parties`: Array of parties +- `dialog`: Array of dialogs +- `attachments`: Array of attachments +- `analysis`: Array of analysis results +- `tags`: Tag dictionary +- `extensions`: Non-critical extensions array +- `critical`: Critical extensions array +- `group`: Group references +- `redacted`: Redaction reference +- `amended`: Amendment reference +- `meta`: Additional metadata ### Party @@ -138,58 +330,288 @@ Class for representing parties in a vCon. #### Properties -- `tel?: string`: Telephone number -- `stir?: string`: STIR identifier +- `tel?: string`: Telephone URL (TEL format) +- `sip?: string`: SIP address - `mailto?: string`: Email address +- `stir?: string`: STIR PASSporT +- `did?: string`: Decentralized Identifier - `name?: string`: Display name -- `validation?: string`: Validation information +- `uuid?: string`: Participant identifier +- `validation?: string`: Identity validation method - `gmlpos?: string`: GML position - `civicaddress?: CivicAddress`: Civic address -- `uuid?: string`: UUID -- `role?: string`: Role -- `contact_list?: string`: Contact list +- `timezone?: string`: Location timezone +- `role?: string`: Role in conversation - `meta?: Record`: Additional metadata +#### Methods + +- `toDict()`: Converts to plain object +- `hasIdentifier()`: Checks if party has any identifier +- `getPrimaryIdentifier()`: Gets the primary identifier +- `validate()`: Validates against vcon-core-01 recommendations + ### Dialog Class for representing dialogs in a vCon. +#### Static Constants + +```typescript +Dialog.DIALOG_TYPES // ['recording', 'text', 'transfer', 'incomplete'] +Dialog.DISPOSITIONS // ['no-answer', 'congestion', 'failed', 'busy', 'hung-up', 'voicemail-no-message'] +Dialog.VALID_ENCODINGS // ['base64url', 'json', 'none'] +``` + #### Properties -- `type: string`: Dialog type -- `start: Date`: Start time -- `parties: number[]`: Party indices +- `type: string`: Dialog type (`recording`, `text`, `transfer`, `incomplete`) +- `start: Date | string`: Start time (RFC3339) +- `parties?: number | number[]`: Party indices - `originator?: number`: Originator party index -- `mimetype?: string`: MIME type -- `filename?: string`: Filename -- `body?: string`: Dialog content -- `encoding?: string`: Content encoding -- `url?: string`: External URL -- `signature?: string`: Digital signature +- `mediatype?: string`: MIME type +- `filename?: string`: Original filename +- `body?: string`: Inline content +- `encoding?: string`: Content encoding (`base64url`, `json`, `none`) +- `url?: string`: External URL reference +- `content_hash?: string`: Content hash for external files - `duration?: number`: Duration in seconds -- `meta?: Record`: Additional metadata +- `disposition?: string`: Disposition for incomplete dialogs +- `session_id?: SessionId`: Session identifier +- `party_history?: PartyHistory[]`: Party event history +- `application?: string`: Application that created the dialog (e.g., 'zoom', 'teams') #### Methods -- `addExternalData(url: string, filename: string, mimetype: string)`: Adds external data -- `addInlineData(body: string, filename: string, mimetype: string)`: Adds inline data -- `isExternalData()`: Checks if dialog has external data -- `isInlineData()`: Checks if dialog has inline data -- `isText()`: Checks if dialog is text -- `isAudio()`: Checks if dialog is audio -- `isVideo()`: Checks if dialog is video -- `isEmail()`: Checks if dialog is email +- `toDict()`: Converts to plain object +- `addExternalData(url, mediatype, options?)`: Adds external data reference +- `addInlineData(body, mediatype, options?)`: Adds inline data +- `isExternalData()`: Checks if has external data +- `isInlineData()`: Checks if has inline data +- `isText()`: Checks if text type +- `isRecording()`: Checks if recording type +- `isTransfer()`: Checks if transfer type +- `isIncomplete()`: Checks if incomplete type +- `isAudio()`: Checks if audio content +- `isVideo()`: Checks if video content +- `isEmail()`: Checks if email content +- `validate()`: Validates against vcon-core-01 ### Attachment Class for representing attachments in a vCon. +#### Static Constants + +```typescript +Attachment.VALID_ENCODINGS // ['base64url', 'json', 'none'] +``` + +#### Properties + +- `type?: string`: Attachment type (MIME type) +- `purpose?: string`: Purpose/category +- `start?: Date | string`: Reference time +- `party?: number`: Related party index +- `dialog?: number | number[]`: Related dialog indices +- `mediatype?: string`: Media type +- `filename?: string`: Original filename +- `body?: any`: Inline content +- `encoding?: string`: Content encoding +- `url?: string`: External URL +- `content_hash?: string`: Content hash + +#### Methods + +- `toDict()`: Converts to plain object +- `addExternalData(url, mediatype, options?)`: Adds external reference +- `addInlineData(body, mediatype, options?)`: Adds inline content +- `isExternalData()`: Checks if has external data +- `isInlineData()`: Checks if has inline data +- `validate()`: Validates against vcon-core-01 + +### PartyHistory + +Class for tracking party events within a dialog. + +#### Constructor + +```typescript +new PartyHistory(party: number, event: string, time: Date | string) +``` + #### Properties -- `type: string`: Attachment type -- `body: any`: Attachment content -- `encoding: Encoding`: Content encoding +- `party: number`: Party index +- `event: string`: Event type (e.g., 'joined', 'left', 'hold', 'resume', 'mute', 'unmute') +- `time: Date | string`: Event timestamp + +#### Methods + +- `toDict()`: Converts to plain object with ISO timestamp +- `static fromDict(data)`: Creates PartyHistory from plain object + +### Constants + +```typescript +import { VCON_VERSION } from 'vcon-js'; + +console.log(VCON_VERSION); // '0.0.1' +``` + +## Tutorial Examples + +The `examples/` directory contains three comprehensive tutorials demonstrating real-world usage: + +### Example 1: Text Chat Conversation + +**File:** `examples/01-text-chat.ts` +**Run:** `npm run example:chat` + +A customer support chat conversation demonstrating: +- Creating parties with different identifiers (tel, mailto) +- Building a multi-turn text conversation +- Setting conversation subject and tags +- Serializing and deserializing vCons + +```typescript +// Quick start - text chat +const vcon = Vcon.buildNew(); +vcon.addParty(new Party({ tel: '+1-555-123-4567', name: 'Customer', role: 'customer' })); +vcon.addParty(new Party({ mailto: 'agent@company.com', name: 'Agent', role: 'agent' })); + +vcon.addDialog(new Dialog({ + type: 'text', + start: new Date().toISOString(), + parties: [0, 1], + originator: 0, + body: 'Hi, I need help with my account.', + mediatype: 'text/plain' +})); +``` + +### Example 2: Phone Call Recording with Analysis + +**File:** `examples/02-call-recording.ts` +**Run:** `npm run example:call` + +An insurance claim phone call demonstrating: +- Recording type dialogs with duration +- External media references with content_hash +- Party validation (STIR/SHAKEN) +- Multiple analysis types (transcription, sentiment, topic classification) +- Contact center extensions +- Party history tracking (join, hold, resume, leave events) + +```typescript +// Quick start - call recording +const vcon = Vcon.buildNew(); +vcon.addExtension('contact_center'); + +const recordingDialog = new Dialog({ + type: 'recording', + start: new Date().toISOString(), + parties: [0, 1], + duration: 847, + campaign: 'claims-inbound' +}); + +recordingDialog.addExternalData( + 'https://storage.example.com/call.wav', + 'audio/wav', + { content_hash: 'sha512-abc123...' } +); + +vcon.addAnalysis({ + type: 'transcription', + dialog: 0, + vendor: 'whisper', + product: 'large-v3', + body: { segments: [...] } +}); +``` + +### Example 3: Video Conference with Attachments + +**File:** `examples/03-video-conference.ts` +**Run:** `npm run example:conference` + +A multi-party product roadmap meeting demonstrating: +- 5+ party conferences +- Video recording dialogs +- Incomplete dialogs (failed join attempts) +- Multiple attachments (presentations, notes, chat transcripts) +- Inline and external content storage +- Group references for meeting series +- Meeting-specific analysis (action items, summaries) +- Validation of dialog objects + +```typescript +// Quick start - video conference +const vcon = Vcon.buildNew(); +vcon.subject = 'Q1 Product Roadmap Review'; +vcon.addExtension('meeting'); + +// Add multiple participants +['Host', 'Engineer', 'Designer', 'QA', 'Marketing'].forEach((role, i) => { + vcon.addParty(new Party({ mailto: `${role.toLowerCase()}@company.com`, role: i === 0 ? 'host' : 'participant' })); +}); + +// Add recording with party history +const videoDialog = new Dialog({ + type: 'recording', + start: new Date().toISOString(), + parties: [0, 1, 2, 3, 4], + duration: 3720, + application: 'zoom' +}); + +// Add attachments +vcon.addAttachment({ + purpose: 'presentation', + filename: 'roadmap.pptx', + body: '...', + encoding: 'base64url' +}); + +// Link to meeting series +vcon.addGroup({ uuid: 'roadmap-series-2025', type: 'meeting-series' }); +``` + +### Running All Examples + +```bash +# Run individual examples +npm run example:chat # Text chat conversation +npm run example:call # Phone call with analysis +npm run example:conference # Video conference + +# Run all examples +npm run examples +``` + +## vcon-core-01 Compliance + +This library implements the [IETF draft-ietf-vcon-vcon-core-01](https://datatracker.ietf.org/doc/html/draft-ietf-vcon-vcon-core-01) specification, including: + +- **Dialog Types**: `recording`, `text`, `transfer`, `incomplete` +- **Dispositions**: `no-answer`, `congestion`, `failed`, `busy`, `hung-up`, `voicemail-no-message` +- **Encodings**: `base64url`, `json`, `none` +- **Content Hash**: SHA-512 hash format for external references +- **Extensions**: Support for `extensions` and `critical` arrays +- **Party Identifiers**: tel, sip, mailto, stir, did, uuid +- **Date Format**: RFC3339 timestamps + +## Development + +```bash +npm install # Install dependencies +npm run build # Compile TypeScript +npm test # Run tests (61 tests) +npm run lint # Run ESLint +npm run format # Format with Prettier +``` ## License -MIT \ No newline at end of file +MIT diff --git a/examples/01-text-chat.ts b/examples/01-text-chat.ts new file mode 100644 index 0000000..650d94c --- /dev/null +++ b/examples/01-text-chat.ts @@ -0,0 +1,108 @@ +/** + * Example 1: Text Chat Conversation + * + * This example demonstrates creating a vCon for a simple customer support + * text chat conversation between an agent and a customer. + * + * Key concepts covered: + * - Creating a new vCon + * - Adding parties with different identifiers + * - Creating text dialogs + * - Adding tags for classification + * - Serializing to JSON + */ + +import { Vcon, Party, Dialog } from '../src'; + +console.log('=== Example 1: Text Chat Conversation ===\n'); + +// Step 1: Create a new vCon +const vcon = Vcon.buildNew(); +console.log(`Created vCon with UUID: ${vcon.uuid}`); +console.log(`vCon version: ${vcon.vcon}`); +console.log(`Created at: ${vcon.created_at}\n`); + +// Step 2: Add the customer party +const customer = new Party({ + tel: '+1-555-123-4567', + name: 'Alice Johnson', + role: 'customer', + timezone: 'America/New_York' +}); +vcon.addParty(customer); +console.log('Added customer:', customer.name); + +// Step 3: Add the agent party +const agent = new Party({ + mailto: 'support@company.com', + name: 'Bob Smith', + role: 'agent', + timezone: 'America/Los_Angeles' +}); +vcon.addParty(agent); +console.log('Added agent:', agent.name); + +// Step 4: Create the conversation (simulating a chat) +const conversationMessages = [ + { from: 0, text: "Hi, I'm having trouble logging into my account." }, + { from: 1, text: "Hello Alice! I'd be happy to help you with that. Can you tell me what error message you're seeing?" }, + { from: 0, text: "It says 'Invalid credentials' but I'm sure my password is correct." }, + { from: 1, text: "I understand how frustrating that can be. Let me check your account status." }, + { from: 1, text: "I can see there were multiple failed login attempts. For security, the account was temporarily locked." }, + { from: 0, text: "Oh, that makes sense. How can I unlock it?" }, + { from: 1, text: "I've unlocked it for you now. Please try logging in again, and if it doesn't work, use the 'Forgot Password' link." }, + { from: 0, text: "It worked! Thank you so much for your help!" }, + { from: 1, text: "You're welcome, Alice! Is there anything else I can help you with today?" }, + { from: 0, text: "No, that's all. Thanks again!" } +]; + +// Add each message as a dialog +let messageTime = new Date(); +conversationMessages.forEach((msg, index) => { + const dialog = new Dialog({ + type: 'text', + start: messageTime.toISOString(), + parties: [0, 1], // Both parties are part of the conversation + originator: msg.from, // Who sent this message + body: msg.text, + mediatype: 'text/plain', + encoding: 'none' + }); + vcon.addDialog(dialog); + + // Simulate time passing between messages (10-30 seconds) + messageTime = new Date(messageTime.getTime() + (10 + Math.random() * 20) * 1000); +}); + +console.log(`\nAdded ${conversationMessages.length} dialog messages`); + +// Step 5: Set conversation subject and add tags +vcon.subject = 'Account Login Issue - Locked Account'; +vcon.addTag('category', 'account-support'); +vcon.addTag('resolution', 'resolved'); +vcon.addTag('satisfaction', 'positive'); + +console.log('\nConversation metadata:'); +console.log(` Subject: ${vcon.subject}`); +console.log(` Tags: ${JSON.stringify(vcon.tags)}`); + +// Step 6: Serialize to JSON +const jsonOutput = vcon.toJson(); +console.log('\n--- Serialized vCon JSON ---'); +console.log(JSON.stringify(JSON.parse(jsonOutput), null, 2)); + +// Step 7: Demonstrate loading from JSON +console.log('\n--- Loading vCon from JSON ---'); +const loadedVcon = Vcon.buildFromJson(jsonOutput); +console.log(`Loaded vCon UUID: ${loadedVcon.uuid}`); +console.log(`Number of parties: ${loadedVcon.parties.length}`); +console.log(`Number of dialogs: ${loadedVcon.dialog.length}`); + +// Print conversation summary +console.log('\n--- Conversation Summary ---'); +loadedVcon.dialog.forEach((d, i) => { + const sender = loadedVcon.parties[d.originator as number]; + console.log(`[${i + 1}] ${sender.name}: ${d.body}`); +}); + +console.log('\n=== Example 1 Complete ==='); diff --git a/examples/02-call-recording.ts b/examples/02-call-recording.ts new file mode 100644 index 0000000..d30f6bb --- /dev/null +++ b/examples/02-call-recording.ts @@ -0,0 +1,223 @@ +/** + * Example 2: Phone Call Recording with Analysis + * + * This example demonstrates creating a vCon for a recorded phone call + * with transcription and sentiment analysis. + * + * Key concepts covered: + * - Recording type dialogs + * - External media references with content_hash + * - Adding transcription analysis + * - Adding sentiment analysis + * - Using extensions for specialized features + * - Party validation and identifiers + */ + +import { Vcon, Party, Dialog, PartyHistory } from '../src'; + +console.log('=== Example 2: Phone Call Recording with Analysis ===\n'); + +// Step 1: Create a new vCon for a phone call +const vcon = Vcon.buildNew(); +vcon.subject = 'Insurance Claim Discussion'; + +// Add contact center extension to indicate this uses CC-specific features +vcon.addExtension('contact_center'); + +console.log(`Created vCon: ${vcon.uuid}`); +console.log(`Extensions: ${vcon.extensions}\n`); + +// Step 2: Add parties with full identity information +const caller = new Party({ + tel: '+1-555-867-5309', + name: 'Maria Garcia', + role: 'customer', + timezone: 'America/Chicago', + validation: 'verified', // STIR/SHAKEN verified caller + civicaddress: { + country: 'US', + a1: 'TX', // State + a3: 'Austin', // City + pc: '78701' // Postal code + } +}); + +const agentParty = new Party({ + tel: '+1-800-555-1234', + sip: 'sip:agent42@insurance.example.com', + mailto: 'maria.agent@insurance.example.com', + name: 'James Wilson', + role: 'agent', + timezone: 'America/New_York', + // Extension fields for contact center + id: 'agent-42', + meta: { + department: 'Claims', + skill_level: 'senior' + } +}); + +vcon.addParty(caller); +vcon.addParty(agentParty); + +// Demonstrate party validation +console.log('Party validation:'); +console.log(` Caller has identifier: ${caller.hasIdentifier()}`); +console.log(` Caller primary ID: ${caller.getPrimaryIdentifier()}`); +console.log(` Agent has identifier: ${agentParty.hasIdentifier()}`); +console.log(` Agent primary ID: ${agentParty.getPrimaryIdentifier()}\n`); + +// Step 3: Create the call recording dialog +const callStart = new Date('2025-01-15T14:30:00-06:00'); +const callDuration = 847; // 14 minutes 7 seconds + +const recordingDialog = new Dialog({ + type: 'recording', + start: callStart.toISOString(), + parties: [0, 1], + originator: 0, // Customer initiated the call + duration: callDuration, + // Contact center specific fields + campaign: 'claims-inbound', + skill: 'auto-claims', + interaction: 'voice' +}); + +// Add external reference to the audio file +recordingDialog.addExternalData( + 'https://storage.insurance.example.com/recordings/2025/01/15/call-abc123.wav', + 'audio/wav', + { + filename: 'call-abc123.wav', + content_hash: 'sha512-4dff4ea340f0a823f15d3f4f01ab62eae0e5da579ccb851f8db9dfe84c58b2b37b89903a740e1ee172da793a6e79d560e5f7f9bd058a12a280433ed6fa46510a' + } +); + +// Add party history to track call events +recordingDialog.party_history = [ + new PartyHistory(0, 'joined', callStart).toDict(), + new PartyHistory(1, 'joined', new Date(callStart.getTime() + 15000)).toDict(), // Agent joined after 15s + new PartyHistory(1, 'hold', new Date(callStart.getTime() + 300000)).toDict(), // Put on hold at 5 min + new PartyHistory(1, 'resume', new Date(callStart.getTime() + 420000)).toDict(), // Resumed at 7 min + new PartyHistory(0, 'left', new Date(callStart.getTime() + callDuration * 1000)).toDict(), + new PartyHistory(1, 'left', new Date(callStart.getTime() + callDuration * 1000 + 2000)).toDict() +]; + +vcon.addDialog(recordingDialog); + +console.log('Call recording details:'); +console.log(` Type: ${recordingDialog.type}`); +console.log(` Duration: ${Math.floor(callDuration / 60)}m ${callDuration % 60}s`); +console.log(` External URL: ${recordingDialog.url}`); +console.log(` Content hash: ${recordingDialog.content_hash?.substring(0, 30)}...`); +console.log(` Party events: ${recordingDialog.party_history?.length}\n`); + +// Step 4: Add transcription analysis +vcon.addAnalysis({ + type: 'transcription', + dialog: 0, + vendor: 'openai', + product: 'whisper-large-v3', + schema: 'urn:ietf:params:vcon:analysis:transcription', + mediatype: 'application/json', + encoding: 'json', + body: { + language: 'en', + confidence: 0.94, + segments: [ + { start: 0, end: 5.2, speaker: 0, text: "Hi, I'm calling about my auto insurance claim." }, + { start: 5.5, end: 12.1, speaker: 1, text: "Hello, thank you for calling. I'd be happy to help you with your claim. May I have your policy number?" }, + { start: 12.5, end: 18.3, speaker: 0, text: "Yes, it's A-I-C seven four two nine eight one." }, + { start: 18.8, end: 25.0, speaker: 1, text: "Thank you, Ms. Garcia. I can see your claim for the fender bender on January 10th. How can I assist you today?" }, + { start: 25.5, end: 35.2, speaker: 0, text: "I wanted to check on the status. The repair shop said they submitted the estimate last week." }, + // ... more segments would follow + { start: 830, end: 847, speaker: 1, text: "You're all set. The payment will be processed within 3 to 5 business days. Is there anything else I can help you with?" } + ] + } +}); + +console.log('Added transcription analysis'); + +// Step 5: Add sentiment analysis +vcon.addAnalysis({ + type: 'sentiment', + dialog: 0, + vendor: 'internal', + product: 'sentiment-analyzer-v2', + schema: 'urn:ietf:params:vcon:analysis:sentiment', + encoding: 'json', + body: { + overall_sentiment: 'positive', + overall_score: 0.72, + customer_sentiment: { + start: 'neutral', + middle: 'slightly_negative', // During hold + end: 'positive' + }, + agent_sentiment: 'professional', + key_moments: [ + { time: 300, event: 'frustration_detected', severity: 'low' }, + { time: 420, event: 'resolution_started', severity: 'positive' }, + { time: 800, event: 'satisfaction_expressed', severity: 'positive' } + ] + } +}); + +console.log('Added sentiment analysis'); + +// Step 6: Add topic classification +vcon.addAnalysis({ + type: 'topic-classification', + dialog: 0, + vendor: 'internal', + product: 'topic-classifier-v1', + encoding: 'json', + body: { + primary_topic: 'claim-status-inquiry', + secondary_topics: ['payment-processing', 'repair-estimate'], + confidence: 0.89, + keywords: ['claim', 'estimate', 'payment', 'repair shop', 'fender bender'] + } +}); + +console.log('Added topic classification\n'); + +// Step 7: Add tags +vcon.addTag('call_type', 'inbound'); +vcon.addTag('department', 'claims'); +vcon.addTag('outcome', 'resolved'); +vcon.addTag('follow_up_required', 'false'); + +// Step 8: Display summary +console.log('--- vCon Summary ---'); +console.log(`UUID: ${vcon.uuid}`); +console.log(`Subject: ${vcon.subject}`); +console.log(`Parties: ${vcon.parties.length}`); +console.log(`Dialogs: ${vcon.dialog.length}`); +console.log(`Analysis items: ${vcon.analysis.length}`); +console.log(`Extensions: ${vcon.extensions?.join(', ')}`); +console.log(`Tags: ${JSON.stringify(vcon.tags)}`); + +// Step 9: Validate the dialog +const validation = recordingDialog.validate(); +console.log(`\nDialog validation: ${validation.valid ? 'PASSED' : 'FAILED'}`); +if (validation.errors.length > 0) { + console.log('Errors:', validation.errors); +} + +// Step 10: Output JSON (truncated for readability) +const jsonOutput = JSON.parse(vcon.toJson()); +console.log('\n--- vCon JSON (structure) ---'); +console.log(JSON.stringify({ + uuid: jsonOutput.uuid, + vcon: jsonOutput.vcon, + subject: jsonOutput.subject, + created_at: jsonOutput.created_at, + parties: `[${jsonOutput.parties.length} parties]`, + dialog: `[${jsonOutput.dialog.length} dialog - recording type]`, + analysis: `[${jsonOutput.analysis.length} analysis items]`, + extensions: jsonOutput.extensions, + tags: jsonOutput.tags +}, null, 2)); + +console.log('\n=== Example 2 Complete ==='); diff --git a/examples/03-video-conference.ts b/examples/03-video-conference.ts new file mode 100644 index 0000000..2fbd246 --- /dev/null +++ b/examples/03-video-conference.ts @@ -0,0 +1,331 @@ +/** + * Example 3: Video Conference with Multiple Parties and Attachments + * + * This example demonstrates creating a vCon for a video conference + * with multiple participants, screen sharing, and file attachments. + * + * Key concepts covered: + * - Multi-party conferences (more than 2 parties) + * - Video recording dialogs + * - Multiple attachments with different purposes + * - Group references for related vCons + * - Incomplete dialogs (failed join attempts) + * - External and inline content + * - Amendment and redaction references + */ + +import { Vcon, Party, Dialog, Attachment } from '../src'; + +console.log('=== Example 3: Video Conference with Attachments ===\n'); + +// Step 1: Create the main vCon +const vcon = Vcon.buildNew(); +vcon.subject = 'Q1 2025 Product Roadmap Review'; + +// Mark that this vCon uses the meeting extension +vcon.addExtension('meeting'); + +console.log(`Created vCon: ${vcon.uuid}`); + +// Step 2: Add multiple parties +const parties = [ + new Party({ + mailto: 'sarah.chen@company.com', + name: 'Sarah Chen', + role: 'host', + timezone: 'America/Los_Angeles', + meta: { title: 'VP of Product' } + }), + new Party({ + mailto: 'mike.johnson@company.com', + name: 'Mike Johnson', + role: 'participant', + timezone: 'America/New_York', + meta: { title: 'Engineering Lead' } + }), + new Party({ + mailto: 'emma.wilson@company.com', + name: 'Emma Wilson', + role: 'participant', + timezone: 'Europe/London', + meta: { title: 'Design Director' } + }), + new Party({ + mailto: 'raj.patel@company.com', + name: 'Raj Patel', + role: 'participant', + timezone: 'Asia/Kolkata', + meta: { title: 'QA Manager' } + }), + new Party({ + mailto: 'lisa.kim@company.com', + name: 'Lisa Kim', + role: 'participant', + timezone: 'Asia/Seoul', + meta: { title: 'Marketing Lead' } + }) +]; + +parties.forEach(p => vcon.addParty(p)); +console.log(`Added ${parties.length} conference participants:`); +parties.forEach((p, i) => console.log(` [${i}] ${p.name} (${p.role}) - ${p.meta?.title}`)); + +// Step 3: Add an incomplete dialog for a failed join attempt +const failedJoinTime = new Date('2025-01-20T09:58:00-08:00'); +const failedJoinDialog = new Dialog({ + type: 'incomplete', + start: failedJoinTime.toISOString(), + parties: 3, // Raj tried to join (single party as integer) + disposition: 'failed', + meta: { + error: 'network_timeout', + retry_count: 2 + } +}); +vcon.addDialog(failedJoinDialog); +console.log('\nAdded incomplete dialog (failed join attempt)'); + +// Step 4: Create the main video conference recording +const conferenceStart = new Date('2025-01-20T10:00:00-08:00'); +const conferenceDuration = 3720; // 62 minutes + +const videoDialog = new Dialog({ + type: 'recording', + start: conferenceStart.toISOString(), + parties: [0, 1, 2, 3, 4], // All participants + originator: 0, // Sarah (host) started the meeting + duration: conferenceDuration, + application: 'zoom', + session_id: { id: 'mtg-98765432', type: 'zoom-meeting-id' } +}); + +// Add external video reference +videoDialog.addExternalData( + 'https://recordings.company.com/meetings/2025/01/20/roadmap-review.mp4', + 'video/mp4', + { + filename: 'q1-roadmap-review-2025-01-20.mp4', + content_hash: 'sha512-7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730' + } +); + +// Track when participants joined/left +videoDialog.party_history = [ + { party: 0, event: 'joined', time: conferenceStart.toISOString() }, + { party: 1, event: 'joined', time: new Date(conferenceStart.getTime() + 30000).toISOString() }, + { party: 2, event: 'joined', time: new Date(conferenceStart.getTime() + 45000).toISOString() }, + { party: 3, event: 'joined', time: new Date(conferenceStart.getTime() + 180000).toISOString() }, // Raj joined late after retry + { party: 4, event: 'joined', time: new Date(conferenceStart.getTime() + 60000).toISOString() }, + { party: 2, event: 'left', time: new Date(conferenceStart.getTime() + 3000000).toISOString() }, // Emma left at 50min + { party: 0, event: 'left', time: new Date(conferenceStart.getTime() + conferenceDuration * 1000).toISOString() }, + { party: 1, event: 'left', time: new Date(conferenceStart.getTime() + conferenceDuration * 1000).toISOString() }, + { party: 3, event: 'left', time: new Date(conferenceStart.getTime() + conferenceDuration * 1000).toISOString() }, + { party: 4, event: 'left', time: new Date(conferenceStart.getTime() + conferenceDuration * 1000).toISOString() } +]; + +vcon.addDialog(videoDialog); +console.log(`Added video recording dialog (${Math.floor(conferenceDuration / 60)} minutes)`); + +// Step 5: Add a screen sharing segment as a separate dialog +const screenShareDialog = new Dialog({ + type: 'recording', + start: new Date(conferenceStart.getTime() + 600000).toISOString(), // 10 min into meeting + parties: [0, 1, 2, 3, 4], + originator: 0, // Sarah shared her screen + duration: 1200, // 20 minutes of screen share + mediatype: 'video/webm', + meta: { content_type: 'screen_share' } +}); + +screenShareDialog.addExternalData( + 'https://recordings.company.com/meetings/2025/01/20/roadmap-screenshare.webm', + 'video/webm', + { filename: 'roadmap-screenshare.webm' } +); + +vcon.addDialog(screenShareDialog); +console.log('Added screen share recording dialog'); + +// Step 6: Add attachments + +// Presentation slides (inline, base64url encoded) +const presentationAttachment = vcon.addAttachment({ + type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + purpose: 'presentation', + mediatype: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + filename: 'Q1-2025-Product-Roadmap.pptx', + body: 'UEsDBBQAAAAIAGiC...', // Truncated base64url content + encoding: 'base64url', + party: 0, // Shared by Sarah + start: new Date(conferenceStart.getTime() + 300000).toISOString() +}); +console.log('\nAdded presentation attachment'); + +// Meeting notes (external reference) +vcon.addAttachment({ + purpose: 'meeting-notes', + url: 'https://docs.company.com/meetings/2025-01-20-roadmap-notes.md', + content_hash: 'sha512-abc123def456...', + mediatype: 'text/markdown', + filename: 'meeting-notes.md', + party: 1 // Mike took notes +}); +console.log('Added meeting notes attachment (external)'); + +// Action items spreadsheet +vcon.addAttachment({ + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + purpose: 'action-items', + url: 'https://docs.company.com/meetings/2025-01-20-action-items.xlsx', + mediatype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: 'action-items.xlsx', + dialog: [1, 2] // Related to main recording and screen share +}); +console.log('Added action items spreadsheet'); + +// Chat transcript +vcon.addAttachment({ + purpose: 'chat-transcript', + mediatype: 'text/plain', + filename: 'meeting-chat.txt', + body: `[10:02] Mike Johnson: Good morning everyone! +[10:02] Emma Wilson: Morning from London! +[10:03] Lisa Kim: Hello from Seoul - it's late here but excited to discuss the roadmap +[10:05] Raj Patel: Sorry I'm late, had connection issues +[10:15] Mike Johnson: Sarah, can you share the timeline slide again? +[10:32] Emma Wilson: The new design system looks great +[10:45] Raj Patel: QA will need at least 2 weeks for the beta testing +[10:50] Emma Wilson: I need to drop off - another meeting. Great discussion! +[11:00] Lisa Kim: Can someone share the recording link after? +[11:01] Sarah Chen: Yes, I'll send it to everyone`, + encoding: 'none' +}); +console.log('Added chat transcript'); + +// Step 7: Add analysis +vcon.addAnalysis({ + type: 'transcription', + dialog: 1, // Main video recording + vendor: 'assembly-ai', + product: 'universal-2', + url: 'https://storage.company.com/transcripts/2025-01-20-roadmap.json', + content_hash: 'sha512-transcript123...', + mediatype: 'application/json' +}); + +vcon.addAnalysis({ + type: 'action-items', + dialog: 1, + vendor: 'internal', + product: 'meeting-ai-v1', + encoding: 'json', + body: { + items: [ + { assignee: 'Mike Johnson', task: 'Finalize API specifications', due: '2025-01-27' }, + { assignee: 'Emma Wilson', task: 'Complete design mockups for mobile', due: '2025-01-31' }, + { assignee: 'Raj Patel', task: 'Set up beta testing environment', due: '2025-02-03' }, + { assignee: 'Lisa Kim', task: 'Prepare launch marketing materials', due: '2025-02-10' }, + { assignee: 'Sarah Chen', task: 'Schedule follow-up meeting', due: '2025-01-22' } + ], + total_count: 5 + } +}); + +vcon.addAnalysis({ + type: 'summary', + dialog: 1, + vendor: 'internal', + product: 'meeting-summarizer-v2', + encoding: 'json', + body: { + executive_summary: 'The team reviewed the Q1 2025 product roadmap, focusing on the new mobile app features and API redesign. Key decisions were made regarding the beta testing timeline and marketing launch strategy.', + key_decisions: [ + 'Beta launch scheduled for February 15, 2025', + 'Design system v2.0 approved for implementation', + 'API v3 will maintain backward compatibility' + ], + next_meeting: '2025-01-27T10:00:00-08:00' + } +}); + +console.log('Added 3 analysis items (transcription, action-items, summary)'); + +// Step 8: Add group reference (this meeting is part of a series) +vcon.addGroup({ + uuid: 'meeting-series-roadmap-2025', + type: 'meeting-series', + meta: { series_name: 'Q1 2025 Roadmap Reviews', occurrence: 3 } +}); +console.log('\nAdded group reference (meeting series)'); + +// Step 9: Add tags +vcon.addTag('meeting_type', 'roadmap-review'); +vcon.addTag('department', 'product'); +vcon.addTag('quarter', 'Q1-2025'); +vcon.addTag('recording_available', 'true'); +vcon.addTag('confidentiality', 'internal'); + +// Step 10: Display comprehensive summary +console.log('\n--- vCon Summary ---'); +console.log(`UUID: ${vcon.uuid}`); +console.log(`Version: ${vcon.vcon}`); +console.log(`Subject: ${vcon.subject}`); +console.log(`Created: ${vcon.created_at}`); +console.log(`Updated: ${vcon.updated_at}`); + +console.log('\nParties:'); +vcon.parties.forEach((p, i) => { + console.log(` [${i}] ${p.name} <${p.mailto}> - ${p.role}`); +}); + +console.log('\nDialogs:'); +vcon.dialog.forEach((d, i) => { + const partyInfo = Array.isArray(d.parties) ? `parties ${d.parties.join(',')}` : `party ${d.parties}`; + console.log(` [${i}] ${d.type} - ${partyInfo}${d.duration ? ` (${d.duration}s)` : ''}`); +}); + +console.log('\nAttachments:'); +vcon.attachments.forEach((a, i) => { + const location = a.url ? 'external' : 'inline'; + console.log(` [${i}] ${a.purpose || a.type} - ${a.filename} (${location})`); +}); + +console.log('\nAnalysis:'); +vcon.analysis.forEach((a, i) => { + const storage = a.url ? 'external' : 'inline'; + console.log(` [${i}] ${a.type} by ${a.vendor}/${a.product} (${storage})`); +}); + +console.log('\nExtensions:', vcon.extensions); +console.log('Groups:', vcon.group); +console.log('Tags:', vcon.tags); + +// Step 11: Validate dialogs +console.log('\n--- Validation ---'); +vcon.dialog.forEach((d, i) => { + const dialogObj = new Dialog(d as any); + const result = dialogObj.validate(); + console.log(`Dialog ${i}: ${result.valid ? 'VALID' : 'INVALID'}`); + if (result.errors.length > 0) { + result.errors.forEach(e => console.log(` - ${e}`)); + } +}); + +// Step 12: Show JSON structure +console.log('\n--- vCon JSON Structure ---'); +const json = JSON.parse(vcon.toJson()); +console.log(`{ + uuid: "${json.uuid}", + vcon: "${json.vcon}", + subject: "${json.subject}", + created_at: "${json.created_at}", + parties: [${json.parties.length} parties], + dialog: [${json.dialog.length} dialogs], + attachments: [${json.attachments.length} attachments], + analysis: [${json.analysis.length} analysis items], + group: [${json.group?.length || 0} groups], + extensions: ${JSON.stringify(json.extensions)}, + tags: ${JSON.stringify(json.tags)} +}`); + +console.log('\n=== Example 3 Complete ==='); diff --git a/package-lock.json b/package-lock.json index 673ba34..0c41279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vcon-js", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vcon-js", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "jose": "^6.0.10", diff --git a/package.json b/package.json index b2f50c0..c8b37ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vcon-js", - "version": "0.1.0", + "version": "0.2.0", "description": "JavaScript library for creating and managing vCons (Virtual Conversations)", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -9,8 +9,10 @@ "test": "jest", "lint": "eslint . --ext .ts", "format": "prettier --write \"src/**/*.ts\"", - "example": "ts-node examples/simple.ts", - "example:av": "ts-node examples/audio-video.ts" + "example:chat": "ts-node examples/01-text-chat.ts", + "example:call": "ts-node examples/02-call-recording.ts", + "example:conference": "ts-node examples/03-video-conference.ts", + "examples": "ts-node examples/01-text-chat.ts && ts-node examples/02-call-recording.ts && ts-node examples/03-video-conference.ts" }, "keywords": [ "vcon", diff --git a/src/__tests__/attachment.test.ts b/src/__tests__/attachment.test.ts index c26c1da..488c574 100644 --- a/src/__tests__/attachment.test.ts +++ b/src/__tests__/attachment.test.ts @@ -2,52 +2,170 @@ import { Attachment } from '../attachment'; import { Encoding } from '../types'; describe('Attachment', () => { - it('should create an attachment with default encoding', () => { - const attachment = new Attachment('application/pdf', 'content'); - + it('should create an attachment with type and body', () => { + const attachment = new Attachment({ + type: 'application/pdf', + body: 'content' + }); + expect(attachment.type).toBe('application/pdf'); expect(attachment.body).toBe('content'); expect(attachment.encoding).toBe('none'); - expect(attachment.toDict()).toEqual({ - type: 'application/pdf', + }); + + it('should create an attachment with purpose', () => { + const attachment = new Attachment({ + purpose: 'transcript', body: 'content', - encoding: 'none' + mediatype: 'text/plain' }); + + expect(attachment.purpose).toBe('transcript'); + expect(attachment.mediatype).toBe('text/plain'); + expect(attachment.encoding).toBe('none'); }); - it('should create an attachment with base64 encoding', () => { - const attachment = new Attachment('application/pdf', 'base64content', 'base64'); - - expect(attachment.type).toBe('application/pdf'); - expect(attachment.body).toBe('base64content'); - expect(attachment.encoding).toBe('base64'); - expect(attachment.toDict()).toEqual({ + it('should create an attachment with base64url encoding', () => { + const attachment = new Attachment({ type: 'application/pdf', - body: 'base64content', - encoding: 'base64' + body: 'base64urlcontent', + encoding: 'base64url' }); - }); - it('should create an attachment with base64url encoding', () => { - const attachment = new Attachment('application/pdf', 'base64urlcontent', 'base64url'); - expect(attachment.type).toBe('application/pdf'); expect(attachment.body).toBe('base64urlcontent'); expect(attachment.encoding).toBe('base64url'); - expect(attachment.toDict()).toEqual({ - type: 'application/pdf', - body: 'base64urlcontent', - encoding: 'base64url' + }); + + it('should create an attachment with json encoding', () => { + const attachment = new Attachment({ + type: 'application/json', + body: '{"key": "value"}', + encoding: 'json' }); + + expect(attachment.type).toBe('application/json'); + expect(attachment.encoding).toBe('json'); }); it('should throw error for invalid encoding', () => { expect(() => { - new Attachment('application/pdf', 'content', 'invalid' as Encoding); - }).toThrow('Invalid encoding: invalid. Must be one of base64, base64url, none'); + new Attachment({ + type: 'application/pdf', + body: 'content', + encoding: 'base64' as Encoding // base64 is not valid, only base64url + }); + }).toThrow('Invalid encoding: base64. Must be one of base64url, json, none'); }); it('should have valid encodings constant', () => { - expect(Attachment.VALID_ENCODINGS).toEqual(['base64', 'base64url', 'none']); + expect(Attachment.VALID_ENCODINGS).toEqual(['base64url', 'json', 'none']); + }); + + it('should handle external data', () => { + const attachment = new Attachment({ type: 'image/png' }); + + attachment.addExternalData( + 'https://example.com/image.png', + 'image/png', + { filename: 'image.png', content_hash: 'sha512-xyz789' } + ); + + expect(attachment.isExternalData()).toBe(true); + expect(attachment.isInlineData()).toBe(false); + expect(attachment.url).toBe('https://example.com/image.png'); + expect(attachment.mediatype).toBe('image/png'); + expect(attachment.filename).toBe('image.png'); + expect(attachment.content_hash).toBe('sha512-xyz789'); + }); + + it('should handle inline data', () => { + const attachment = new Attachment({ type: 'text/plain' }); + + attachment.addInlineData( + 'Hello World', + 'text/plain', + { encoding: 'none', filename: 'greeting.txt' } + ); + + expect(attachment.isExternalData()).toBe(false); + expect(attachment.isInlineData()).toBe(true); + expect(attachment.body).toBe('Hello World'); + expect(attachment.mediatype).toBe('text/plain'); + expect(attachment.filename).toBe('greeting.txt'); + expect(attachment.encoding).toBe('none'); + }); + + it('should support all vcon-core-01 fields', () => { + const start = new Date('2025-01-15T10:00:00Z'); + const attachment = new Attachment({ + type: 'document', + purpose: 'contract', + start, + party: 0, + dialog: [0, 1], + mediatype: 'application/pdf', + filename: 'contract.pdf', + body: 'base64content', + encoding: 'base64url' + }); + + expect(attachment.type).toBe('document'); + expect(attachment.purpose).toBe('contract'); + expect(attachment.start).toBe(start); + expect(attachment.party).toBe(0); + expect(attachment.dialog).toEqual([0, 1]); + expect(attachment.mediatype).toBe('application/pdf'); + expect(attachment.filename).toBe('contract.pdf'); + }); + + it('should convert to dict with ISO date strings', () => { + const start = new Date('2025-01-15T10:00:00Z'); + const attachment = new Attachment({ + type: 'document', + start, + body: 'content' + }); + + const dict = attachment.toDict(); + expect(dict.start).toBe('2025-01-15T10:00:00.000Z'); + }); + + it('should validate attachment correctly', () => { + // Valid attachment with type + const validWithType = new Attachment({ type: 'document', body: 'content' }); + expect(validWithType.validate().valid).toBe(true); + + // Valid attachment with purpose + const validWithPurpose = new Attachment({ purpose: 'transcript', body: 'content' }); + expect(validWithPurpose.validate().valid).toBe(true); + + // Invalid - no type or purpose + const noTypeOrPurpose = new Attachment({ body: 'content' }); + const result1 = noTypeOrPurpose.validate(); + expect(result1.valid).toBe(false); + expect(result1.errors).toContain('Attachment must have either type or purpose'); + + // Invalid - both inline and external data + const bothData = new Attachment({ + type: 'document', + body: 'content', + url: 'https://example.com' + }); + const result2 = bothData.validate(); + expect(result2.valid).toBe(false); + expect(result2.errors).toContain('Attachment cannot have both inline (body) and external (url) data'); + }); + + it('should support single dialog integer', () => { + const attachment = new Attachment({ + type: 'document', + dialog: 0, + body: 'content' + }); + + expect(attachment.dialog).toBe(0); + const dict = attachment.toDict(); + expect(dict.dialog).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/dialog.test.ts b/src/__tests__/dialog.test.ts index a5561f9..59c0b67 100644 --- a/src/__tests__/dialog.test.ts +++ b/src/__tests__/dialog.test.ts @@ -5,39 +5,56 @@ describe('Dialog', () => { it('should create a dialog with minimal properties', () => { const start = new Date(); const dialog = new Dialog({ - type: 'text/plain', + type: 'text', start, parties: [0, 1] }); - - expect(dialog.type).toBe('text/plain'); + + expect(dialog.type).toBe('text'); expect(dialog.start).toBe(start); expect(dialog.parties).toEqual([0, 1]); - expect(dialog.toDict()).toEqual({ - type: 'text/plain', + }); + + it('should serialize start date to ISO string in toDict', () => { + const start = new Date('2025-01-15T10:00:00Z'); + const dialog = new Dialog({ + type: 'text', start, - parties: [0, 1] + parties: [0] }); + + const dict = dialog.toDict(); + expect(dict.start).toBe('2025-01-15T10:00:00.000Z'); + }); + + it('should accept string start dates', () => { + const startStr = '2025-01-15T10:00:00Z'; + const dialog = new Dialog({ + type: 'text', + start: startStr, + parties: [0] + }); + + expect(dialog.start).toBe(startStr); }); it('should create a dialog with all properties', () => { const start = new Date(); const partyHistory = new PartyHistory(0, 'joined', start); - + const dialog = new Dialog({ - type: 'text/plain', + type: 'text', start, parties: [0, 1], originator: 0, - mimetype: 'text/plain', + mediatype: 'text/plain', filename: 'conversation.txt', body: 'Hello!', - encoding: 'utf-8', - url: 'https://example.com/audio.wav', + encoding: 'none', alg: 'sha256', signature: 'signature', - disposition: 'inline', - party_history: [partyHistory], + disposition: 'no-answer', + party_history: [partyHistory.toDict()], transferee: 1, transferor: 0, transfer_target: 2, @@ -50,20 +67,19 @@ describe('Dialog', () => { duration: 300, meta: { key: 'value' } }); - - expect(dialog.type).toBe('text/plain'); + + expect(dialog.type).toBe('text'); expect(dialog.start).toBe(start); expect(dialog.parties).toEqual([0, 1]); expect(dialog.originator).toBe(0); - expect(dialog.mimetype).toBe('text/plain'); + expect(dialog.mediatype).toBe('text/plain'); expect(dialog.filename).toBe('conversation.txt'); expect(dialog.body).toBe('Hello!'); - expect(dialog.encoding).toBe('utf-8'); - expect(dialog.url).toBe('https://example.com/audio.wav'); + expect(dialog.encoding).toBe('none'); expect(dialog.alg).toBe('sha256'); expect(dialog.signature).toBe('signature'); - expect(dialog.disposition).toBe('inline'); - expect(dialog.party_history).toEqual([partyHistory]); + expect(dialog.disposition).toBe('no-answer'); + expect(dialog.party_history).toBeDefined(); expect(dialog.transferee).toBe(1); expect(dialog.transferor).toBe(0); expect(dialog.transfer_target).toBe(2); @@ -77,94 +93,178 @@ describe('Dialog', () => { expect(dialog.meta).toEqual({ key: 'value' }); }); - it('should handle external data', () => { + it('should handle external data with new API', () => { const dialog = new Dialog({ - type: 'text/plain', + type: 'recording', start: new Date(), parties: [0] }); - + dialog.addExternalData( 'https://example.com/audio.wav', - 'audio.wav', - 'audio/wav' + 'audio/wav', + { filename: 'audio.wav', content_hash: 'sha512-abc123' } ); - + expect(dialog.isExternalData()).toBe(true); expect(dialog.isInlineData()).toBe(false); expect(dialog.url).toBe('https://example.com/audio.wav'); expect(dialog.filename).toBe('audio.wav'); - expect(dialog.mimetype).toBe('audio/wav'); + expect(dialog.mediatype).toBe('audio/wav'); + expect(dialog.content_hash).toBe('sha512-abc123'); expect(dialog.body).toBeUndefined(); expect(dialog.encoding).toBeUndefined(); }); - it('should handle inline data', () => { + it('should handle inline data with new API', () => { const dialog = new Dialog({ - type: 'text/plain', + type: 'text', start: new Date(), parties: [0] }); - + dialog.addInlineData( 'Hello!', - 'message.txt', - 'text/plain' + 'text/plain', + { encoding: 'none', filename: 'message.txt' } ); - + expect(dialog.isExternalData()).toBe(false); expect(dialog.isInlineData()).toBe(true); expect(dialog.body).toBe('Hello!'); expect(dialog.filename).toBe('message.txt'); - expect(dialog.mimetype).toBe('text/plain'); + expect(dialog.mediatype).toBe('text/plain'); + expect(dialog.encoding).toBe('none'); expect(dialog.url).toBeUndefined(); }); - it('should validate MIME types', () => { - const dialog = new Dialog({ - type: 'text/plain', + it('should check dialog types per vcon-core-01', () => { + const textDialog = new Dialog({ + type: 'text', + start: new Date(), + parties: [0] + }); + expect(textDialog.isText()).toBe(true); + expect(textDialog.isRecording()).toBe(false); + expect(textDialog.isTransfer()).toBe(false); + expect(textDialog.isIncomplete()).toBe(false); + + const recordingDialog = new Dialog({ + type: 'recording', + start: new Date(), + parties: [0] + }); + expect(recordingDialog.isRecording()).toBe(true); + expect(recordingDialog.isText()).toBe(false); + + const transferDialog = new Dialog({ + type: 'transfer', start: new Date(), parties: [0] }); - - expect(() => { - dialog.addExternalData( - 'https://example.com/file', - 'file', - 'invalid/mime' - ); - }).toThrow('Invalid MIME type'); - - expect(() => { - dialog.addInlineData( - 'content', - 'file', - 'invalid/mime' - ); - }).toThrow('Invalid MIME type'); + expect(transferDialog.isTransfer()).toBe(true); + + const incompleteDialog = new Dialog({ + type: 'incomplete', + start: new Date(), + parties: [0], + disposition: 'no-answer' + }); + expect(incompleteDialog.isIncomplete()).toBe(true); }); - it('should check content types', () => { + it('should check content types based on mediatype', () => { const dialog = new Dialog({ - type: 'text/plain', + type: 'recording', start: new Date(), parties: [0], - mimetype: 'text/plain' + mediatype: 'audio/wav' }); - - expect(dialog.isText()).toBe(true); - expect(dialog.isAudio()).toBe(false); + + expect(dialog.isAudio()).toBe(true); expect(dialog.isVideo()).toBe(false); expect(dialog.isEmail()).toBe(false); - - dialog.mimetype = 'audio/wav'; - expect(dialog.isText()).toBe(false); - expect(dialog.isAudio()).toBe(true); - - dialog.mimetype = 'video/mp4'; + + dialog.mediatype = 'video/mp4'; expect(dialog.isVideo()).toBe(true); - - dialog.mimetype = 'message/rfc822'; + expect(dialog.isAudio()).toBe(false); + + dialog.mediatype = 'message/rfc822'; expect(dialog.isEmail()).toBe(true); }); -}); \ No newline at end of file + + it('should validate dialog correctly', () => { + // Valid text dialog + const validDialog = new Dialog({ + type: 'text', + start: new Date(), + parties: [0], + body: 'Hello' + }); + expect(validDialog.validate().valid).toBe(true); + + // Incomplete dialog without disposition + const incompleteNoDisposition = new Dialog({ + type: 'incomplete', + start: new Date(), + parties: [0] + }); + const result1 = incompleteNoDisposition.validate(); + expect(result1.valid).toBe(false); + expect(result1.errors).toContain('Disposition is required for incomplete dialogs'); + + // Dialog with both inline and external data + const bothData = new Dialog({ + type: 'text', + start: new Date(), + parties: [0], + body: 'test', + url: 'https://example.com' + }); + const result2 = bothData.validate(); + expect(result2.valid).toBe(false); + expect(result2.errors).toContain('Dialog cannot have both inline (body) and external (url) data'); + }); + + it('should have valid dialog types constant', () => { + expect(Dialog.DIALOG_TYPES).toEqual(['recording', 'text', 'transfer', 'incomplete']); + }); + + it('should have valid dispositions constant', () => { + expect(Dialog.DISPOSITIONS).toEqual([ + 'no-answer', + 'congestion', + 'failed', + 'busy', + 'hung-up', + 'voicemail-no-message' + ]); + }); + + it('should have valid encodings constant', () => { + expect(Dialog.VALID_ENCODINGS).toEqual(['base64url', 'json', 'none']); + }); + + it('should handle mimetype to mediatype compatibility', () => { + const dialog = new Dialog({ + type: 'text', + start: new Date(), + parties: [0], + mimetype: 'text/plain' + }); + + expect(dialog.mediatype).toBe('text/plain'); + }); + + it('should support single party integer per vcon-core-01', () => { + const dialog = new Dialog({ + type: 'text', + start: new Date(), + parties: 0 + }); + + expect(dialog.parties).toBe(0); + const dict = dialog.toDict(); + expect(dict.parties).toBe(0); + }); +}); diff --git a/src/__tests__/party.test.ts b/src/__tests__/party.test.ts index 27580df..519a844 100644 --- a/src/__tests__/party.test.ts +++ b/src/__tests__/party.test.ts @@ -1,6 +1,5 @@ -import { Party } from '../party'; +import { Party, PartyHistory } from '../party'; import { CivicAddress } from '../types'; -import { PartyHistory } from '../party'; describe('Party', () => { it('should create a party with minimal properties', () => { @@ -8,7 +7,7 @@ describe('Party', () => { tel: '+1234567890', name: 'John Doe' }); - + expect(party.tel).toBe('+1234567890'); expect(party.name).toBe('John Doe'); expect(party.toDict()).toEqual({ @@ -17,69 +16,179 @@ describe('Party', () => { }); }); - it('should create a party with all properties', () => { + it('should create a party with all vcon-core-01 properties', () => { const civicAddress: CivicAddress = { country: 'US', + a1: 'NY', + a3: 'New York', + pc: '10001', + a6: '123 Main St', + // Legacy fields locality: 'New York', region: 'NY', postcode: '10001', street: '123 Main St' }; - + const party = new Party({ tel: '+1234567890', - stir: 'stir-id', + sip: 'sip:john@example.com', mailto: 'john@example.com', + stir: 'stir-passport-token', + did: 'did:example:123456', name: 'John Doe', - validation: 'validated', + uuid: 'test-uuid', + validation: 'verified', gmlpos: '40.7128,-74.0060', civicaddress: civicAddress, - uuid: 'test-uuid', + timezone: 'America/New_York', role: 'customer', contact_list: 'contacts', - meta: { key: 'value' } + meta: { key: 'value' }, + id: 'john.doe' }); - + expect(party.tel).toBe('+1234567890'); - expect(party.stir).toBe('stir-id'); + expect(party.sip).toBe('sip:john@example.com'); expect(party.mailto).toBe('john@example.com'); + expect(party.stir).toBe('stir-passport-token'); + expect(party.did).toBe('did:example:123456'); expect(party.name).toBe('John Doe'); - expect(party.validation).toBe('validated'); + expect(party.uuid).toBe('test-uuid'); + expect(party.validation).toBe('verified'); expect(party.gmlpos).toBe('40.7128,-74.0060'); expect(party.civicaddress).toEqual(civicAddress); - expect(party.uuid).toBe('test-uuid'); + expect(party.timezone).toBe('America/New_York'); expect(party.role).toBe('customer'); expect(party.contact_list).toBe('contacts'); expect(party.meta).toEqual({ key: 'value' }); + expect(party.id).toBe('john.doe'); }); it('should handle undefined properties in toDict', () => { const party = new Party({ tel: '+1234567890' }); - + const dict = party.toDict(); expect(dict).toEqual({ tel: '+1234567890' }); expect(dict.name).toBeUndefined(); + expect(dict.sip).toBeUndefined(); + expect(dict.did).toBeUndefined(); + }); + + it('should check for identifier', () => { + const partyWithTel = new Party({ tel: '+1234567890' }); + expect(partyWithTel.hasIdentifier()).toBe(true); + + const partyWithSip = new Party({ sip: 'sip:user@example.com' }); + expect(partyWithSip.hasIdentifier()).toBe(true); + + const partyWithMailto = new Party({ mailto: 'user@example.com' }); + expect(partyWithMailto.hasIdentifier()).toBe(true); + + const partyWithDid = new Party({ did: 'did:example:123' }); + expect(partyWithDid.hasIdentifier()).toBe(true); + + const partyWithUuid = new Party({ uuid: 'test-uuid' }); + expect(partyWithUuid.hasIdentifier()).toBe(true); + + const partyWithStir = new Party({ stir: 'stir-token' }); + expect(partyWithStir.hasIdentifier()).toBe(true); + + const partyNoIdentifier = new Party({ name: 'John' }); + expect(partyNoIdentifier.hasIdentifier()).toBe(false); + }); + + it('should get primary identifier', () => { + const partyWithTel = new Party({ tel: '+1234567890', mailto: 'user@example.com' }); + expect(partyWithTel.getPrimaryIdentifier()).toBe('+1234567890'); + + const partyWithSip = new Party({ sip: 'sip:user@example.com' }); + expect(partyWithSip.getPrimaryIdentifier()).toBe('sip:user@example.com'); + + const partyNoIdentifier = new Party({ name: 'John' }); + expect(partyNoIdentifier.getPrimaryIdentifier()).toBeUndefined(); + }); + + it('should validate party', () => { + const partyWithIdentifier = new Party({ tel: '+1234567890' }); + const result1 = partyWithIdentifier.validate(); + expect(result1.valid).toBe(true); + expect(result1.warnings.length).toBe(0); + + const partyNoIdentifier = new Party({ name: 'John' }); + const result2 = partyNoIdentifier.validate(); + expect(result2.valid).toBe(true); // Parties are always valid + expect(result2.warnings.length).toBe(1); + expect(result2.warnings[0]).toContain('no identifier'); + }); + + it('should support arbitrary extension properties', () => { + const party = new Party({ + tel: '+1234567890', + customField: 'custom value', + anotherField: 123 + }); + + expect(party.customField).toBe('custom value'); + expect(party.anotherField).toBe(123); + + const dict = party.toDict(); + expect(dict.customField).toBe('custom value'); + expect(dict.anotherField).toBe(123); }); }); describe('PartyHistory', () => { - it('should create party history with all properties', () => { - const time = new Date(); + it('should create party history with Date object', () => { + const time = new Date('2025-01-15T10:00:00Z'); const history = new PartyHistory(0, 'joined', time); - + expect(history.party).toBe(0); expect(history.event).toBe('joined'); expect(history.time).toBe(time); - + const dict = history.toDict(); expect(dict).toEqual({ party: 0, event: 'joined', - time: time.toISOString() + time: '2025-01-15T10:00:00.000Z' + }); + }); + + it('should create party history with string time', () => { + const timeStr = '2025-01-15T10:00:00Z'; + const history = new PartyHistory(0, 'joined', timeStr); + + expect(history.time).toBe(timeStr); + + const dict = history.toDict(); + expect(dict.time).toBe(timeStr); + }); + + it('should create party history from dict', () => { + const data = { + party: 1, + event: 'left', + time: '2025-01-15T11:00:00Z' + }; + + const history = PartyHistory.fromDict(data); + + expect(history.party).toBe(1); + expect(history.event).toBe('left'); + expect(history.time).toBe('2025-01-15T11:00:00Z'); + }); + + it('should support various event types', () => { + const events = ['joined', 'left', 'hold', 'resume', 'mute', 'unmute']; + + events.forEach(event => { + const history = new PartyHistory(0, event, new Date()); + expect(history.event).toBe(event); }); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/vcon.test.ts b/src/__tests__/vcon.test.ts index 20d2beb..f9f88fb 100644 --- a/src/__tests__/vcon.test.ts +++ b/src/__tests__/vcon.test.ts @@ -2,15 +2,17 @@ import { Vcon } from '../vcon'; import { Party } from '../party'; import { Dialog } from '../dialog'; import { Attachment } from '../attachment'; +import { VCON_VERSION } from '../types'; import { loadTestVcon, getAllTestVcons, getTestVconsByDirectory } from './utils'; describe('Vcon', () => { it('should create a new vCon with default values', () => { const vcon = Vcon.buildNew(); - + expect(vcon.uuid).toBeDefined(); - expect(vcon.created_at).toBeInstanceOf(Date); - expect(vcon.updated_at).toBeInstanceOf(Date); + expect(vcon.vcon).toBe(VCON_VERSION); + expect(typeof vcon.created_at).toBe('string'); + expect(vcon.updated_at).toBeUndefined(); expect(vcon.parties).toEqual([]); expect(vcon.dialog).toEqual([]); expect(vcon.attachments).toEqual([]); @@ -21,18 +23,19 @@ describe('Vcon', () => { it('should create a vCon from JSON', () => { const json = JSON.stringify({ uuid: 'test-uuid', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + vcon: '0.0.1', + created_at: '2025-01-15T10:00:00Z', parties: [], dialog: [], attachments: [], analysis: [], tags: {} }); - + const vcon = Vcon.buildFromJson(json); - + expect(vcon.uuid).toBe('test-uuid'); + expect(vcon.vcon).toBe('0.0.1'); expect(vcon.parties).toEqual([]); expect(vcon.dialog).toEqual([]); }); @@ -43,56 +46,75 @@ describe('Vcon', () => { tel: '+1234567890', name: 'John Doe' }); - + vcon.addParty(party); - + expect(vcon.parties.length).toBe(1); expect(vcon.parties[0].tel).toBe('+1234567890'); expect(vcon.parties[0].name).toBe('John Doe'); - + const partyIndex = vcon.findPartyIndex('tel', '+1234567890'); expect(partyIndex).toBe(0); }); + it('should return undefined for non-existent party', () => { + const vcon = Vcon.buildNew(); + const partyIndex = vcon.findPartyIndex('tel', '+1234567890'); + expect(partyIndex).toBeUndefined(); + }); + it('should add and find dialogs', () => { const vcon = Vcon.buildNew(); const dialog = new Dialog({ - type: 'text/plain', + type: 'text', start: new Date(), parties: [0], body: 'Hello!', - mimetype: 'text/plain' + mediatype: 'text/plain' }); - + vcon.addDialog(dialog); - + expect(vcon.dialog.length).toBe(1); expect(vcon.dialog[0].body).toBe('Hello!'); - + const foundDialog = vcon.findDialog('body', 'Hello!'); expect(foundDialog).toBeDefined(); expect(foundDialog?.body).toBe('Hello!'); }); - it('should add and find attachments', () => { + it('should add attachments with new API', () => { const vcon = Vcon.buildNew(); - const attachment = vcon.addAttachment( - 'application/pdf', - 'base64EncodedContent', - 'base64' - ); - + const attachment = vcon.addAttachment({ + type: 'application/pdf', + body: 'base64EncodedContent', + encoding: 'base64url' + }); + expect(vcon.attachments.length).toBe(1); expect(vcon.attachments[0].type).toBe('application/pdf'); - + const foundAttachment = vcon.findAttachmentByType('application/pdf'); expect(foundAttachment).toBeDefined(); expect(foundAttachment?.type).toBe('application/pdf'); }); + it('should find attachments by purpose', () => { + const vcon = Vcon.buildNew(); + vcon.addAttachment({ + purpose: 'transcript', + body: 'Transcript content', + mediatype: 'text/plain' + }); + + const foundAttachment = vcon.findAttachmentByPurpose('transcript'); + expect(foundAttachment).toBeDefined(); + expect(foundAttachment?.purpose).toBe('transcript'); + }); + it('should add and find analysis', () => { const vcon = Vcon.buildNew(); - + vcon.addAnalysis({ type: 'sentiment', dialog: 0, @@ -102,10 +124,10 @@ describe('Vcon', () => { label: 'positive' } }); - + expect(vcon.analysis.length).toBe(1); expect(vcon.analysis[0].type).toBe('sentiment'); - + const foundAnalysis = vcon.findAnalysisByType('sentiment'); expect(foundAnalysis).toBeDefined(); expect(foundAnalysis?.type).toBe('sentiment'); @@ -113,11 +135,12 @@ describe('Vcon', () => { it('should add and get tags', () => { const vcon = Vcon.buildNew(); - + vcon.addTag('category', 'support'); - + expect(vcon.tags).toEqual({ category: 'support' }); expect(vcon.getTag('category')).toBe('support'); + expect(vcon.updated_at).toBeDefined(); }); it('should convert to JSON and back', () => { @@ -127,26 +150,102 @@ describe('Vcon', () => { name: 'John Doe' }); vcon.addParty(party); - + const json = vcon.toJson(); const newVcon = Vcon.buildFromJson(json); - + expect(newVcon.parties.length).toBe(1); expect(newVcon.parties[0].tel).toBe('+1234567890'); expect(newVcon.parties[0].name).toBe('John Doe'); }); + + it('should handle subject property', () => { + const vcon = Vcon.buildNew(); + expect(vcon.subject).toBeUndefined(); + + vcon.subject = 'Test conversation'; + expect(vcon.subject).toBe('Test conversation'); + expect(vcon.updated_at).toBeDefined(); + }); + + it('should handle meta property', () => { + const vcon = Vcon.buildNew(); + expect(vcon.meta).toBeUndefined(); + + vcon.meta = { custom: 'data' }; + expect(vcon.meta).toEqual({ custom: 'data' }); + expect(vcon.updated_at).toBeDefined(); + }); +}); + +describe('Vcon extensions (vcon-core-01)', () => { + it('should add and check extensions', () => { + const vcon = Vcon.buildNew(); + + vcon.addExtension('contact_center'); + expect(vcon.hasExtension('contact_center')).toBe(true); + expect(vcon.hasExtension('other')).toBe(false); + expect(vcon.extensions).toContain('contact_center'); + }); + + it('should not duplicate extensions', () => { + const vcon = Vcon.buildNew(); + + vcon.addExtension('contact_center'); + vcon.addExtension('contact_center'); + + expect(vcon.extensions?.length).toBe(1); + }); + + it('should add critical extensions', () => { + const vcon = Vcon.buildNew(); + + vcon.addCriticalExtension('encrypted'); + expect(vcon.hasExtension('encrypted')).toBe(true); + expect(vcon.isCriticalExtension('encrypted')).toBe(true); + expect(vcon.critical).toContain('encrypted'); + }); + + it('should handle redacted property', () => { + const vcon = Vcon.buildNew(); + expect(vcon.redacted).toBeUndefined(); + + vcon.redacted = { uuid: 'original-uuid' }; + expect(vcon.redacted).toEqual({ uuid: 'original-uuid' }); + + vcon.redacted = true; + expect(vcon.redacted).toBe(true); + }); + + it('should handle amended property', () => { + const vcon = Vcon.buildNew(); + expect(vcon.amended).toBeUndefined(); + + vcon.amended = { uuid: 'amended-uuid' }; + expect(vcon.amended).toEqual({ uuid: 'amended-uuid' }); + }); + + it('should add groups', () => { + const vcon = Vcon.buildNew(); + + vcon.addGroup({ uuid: 'group-uuid', type: 'thread' }); + expect(vcon.group?.length).toBe(1); + + vcon.addGroup('another-group-uuid'); + expect(vcon.group?.length).toBe(2); + }); }); describe('Vcon with synthetic data', () => { it('should load and parse synthetic vcons correctly', () => { const vcons = getAllTestVcons(); expect(vcons.length).toBeGreaterThan(0); - + // Test the first vcon const vcon = vcons[0]; expect(vcon.uuid).toBeDefined(); expect(typeof vcon.created_at).toBe('string'); - expect(new Date(vcon.created_at)).toBeInstanceOf(Date); // Verify it's a valid date string + expect(new Date(vcon.created_at as string)).toBeInstanceOf(Date); expect(vcon.parties).toBeDefined(); expect(vcon.dialog).toBeDefined(); }); @@ -154,19 +253,19 @@ describe('Vcon with synthetic data', () => { it('should handle real-world conversation scenarios', () => { const vcons = getTestVconsByDirectory('01'); expect(vcons.length).toBeGreaterThan(0); - + const vcon = vcons[0]; - + // Test party information expect(vcon.parties.length).toBeGreaterThan(0); const agent = vcon.parties.find(p => p.role === 'agent'); const contact = vcon.parties.find(p => p.role === 'contact'); - + expect(agent).toBeDefined(); expect(contact).toBeDefined(); expect(agent?.mailto).toBeDefined(); expect(contact?.tel).toBeDefined(); - + // Test dialog content expect(vcon.dialog.length).toBeGreaterThan(0); const firstMessage = vcon.dialog[0]; @@ -179,27 +278,66 @@ describe('Vcon with synthetic data', () => { it('should maintain conversation flow and timing', () => { const vcons = getTestVconsByDirectory('01'); const vcon = vcons[0]; - + // Test chronological order for (let i = 1; i < vcon.dialog.length; i++) { - const current = new Date(vcon.dialog[i].start); - const previous = new Date(vcon.dialog[i-1].start); + const current = new Date(vcon.dialog[i].start as string); + const previous = new Date(vcon.dialog[i - 1].start as string); expect(current.getTime()).toBeGreaterThanOrEqual(previous.getTime()); } - + // Test conversation flow const messages = vcon.dialog.map(d => d.body).filter((m): m is string => m !== undefined); expect(messages.some(m => m.includes('Hello'))).toBeTruthy(); - expect(messages.some(m => m.includes('thank you'))).toBeTruthy(); + expect(messages.some(m => m.includes('thank'))).toBeTruthy(); }); it('should handle different vcon versions', () => { const vcons = getAllTestVcons(); const versions = new Set(vcons.map(v => v.vcon)); - + expect(versions.size).toBeGreaterThan(0); versions.forEach(version => { expect(version).toMatch(/^\d+\.\d+\.\d+$/); }); }); -}); \ No newline at end of file +}); + +describe('Vcon analysis with vcon-core-01 fields', () => { + it('should add analysis with product and schema', () => { + const vcon = Vcon.buildNew(); + + vcon.addAnalysis({ + type: 'transcription', + dialog: [0, 1], + vendor: 'whisper', + product: 'whisper-large-v3', + schema: 'urn:ietf:params:vcon:analysis:transcription', + body: { + text: 'Hello, how can I help you?', + confidence: 0.95 + }, + encoding: 'json' + }); + + expect(vcon.analysis.length).toBe(1); + expect(vcon.analysis[0].product).toBe('whisper-large-v3'); + expect(vcon.analysis[0].schema).toBe('urn:ietf:params:vcon:analysis:transcription'); + }); + + it('should add analysis with external reference', () => { + const vcon = Vcon.buildNew(); + + vcon.addAnalysis({ + type: 'transcription', + dialog: 0, + vendor: 'cloud-service', + url: 'https://example.com/transcription.json', + content_hash: 'sha512-abc123xyz', + mediatype: 'application/json' + }); + + expect(vcon.analysis[0].url).toBe('https://example.com/transcription.json'); + expect(vcon.analysis[0].content_hash).toBe('sha512-abc123xyz'); + }); +}); diff --git a/src/attachment.ts b/src/attachment.ts index 9147d82..72c0f39 100644 --- a/src/attachment.ts +++ b/src/attachment.ts @@ -1,27 +1,139 @@ import { Attachment as AttachmentType, Encoding } from './types'; -export class Attachment implements AttachmentType { - readonly type: string; - readonly body: any; - readonly encoding: Encoding; +/** + * Attachment class for representing attached files in a vCon. + * Compliant with IETF draft-ietf-vcon-vcon-core-01 + */ +export class Attachment implements Partial { + /** Valid encodings per vcon-core-01 */ + static readonly VALID_ENCODINGS: Encoding[] = ['base64url', 'json', 'none']; - static readonly VALID_ENCODINGS: Encoding[] = ['base64', 'base64url', 'none']; + type?: string; + purpose?: string; + start?: Date | string; + party?: number; + dialog?: number | number[]; + mediatype?: string; + filename?: string; + body?: any; + encoding?: Encoding | string; + url?: string; + content_hash?: string; + [key: string]: any; - constructor(type: string, body: any, encoding: Encoding = 'none') { - if (!Attachment.VALID_ENCODINGS.includes(encoding)) { - throw new Error(`Invalid encoding: ${encoding}. Must be one of ${Attachment.VALID_ENCODINGS.join(', ')}`); + constructor(params: Partial = {}) { + // Validate encoding if provided + if (params.encoding && !Attachment.VALID_ENCODINGS.includes(params.encoding as Encoding)) { + throw new Error( + `Invalid encoding: ${params.encoding}. Must be one of ${Attachment.VALID_ENCODINGS.join(', ')}` + ); } - this.type = type; - this.body = body; - this.encoding = encoding; + // Copy all properties + Object.assign(this, params); + + // Set default encoding for inline data + if (params.body !== undefined && !params.encoding) { + this.encoding = 'none'; + } } + /** + * Convert attachment to plain object + */ toDict(): AttachmentType { + const dict: AttachmentType = {}; + + Object.entries(this).forEach(([key, value]) => { + if (value !== undefined) { + // Convert Date objects to ISO strings + if (value instanceof Date) { + dict[key] = value.toISOString(); + } else { + dict[key] = value; + } + } + }); + + return dict; + } + + /** + * Add external data reference (url + content_hash) + */ + addExternalData(url: string, mediatype: string, options?: { + filename?: string; + content_hash?: string; + }): void { + this.url = url; + this.mediatype = mediatype; + if (options?.filename) { + this.filename = options.filename; + } + if (options?.content_hash) { + this.content_hash = options.content_hash; + } + // Clear inline data + this.body = undefined; + this.encoding = undefined; + } + + /** + * Add inline data (body + encoding) + */ + addInlineData(body: any, mediatype: string, options?: { + encoding?: Encoding; + filename?: string; + }): void { + this.body = body; + this.mediatype = mediatype; + this.encoding = options?.encoding || 'none'; + if (options?.filename) { + this.filename = options.filename; + } + // Clear external data + this.url = undefined; + this.content_hash = undefined; + } + + /** + * Check if attachment has external data reference + */ + isExternalData(): boolean { + return this.url !== undefined; + } + + /** + * Check if attachment has inline data + */ + isInlineData(): boolean { + return this.body !== undefined; + } + + /** + * Validate the attachment against vcon-core-01 requirements + */ + validate(): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Must have either type or purpose + if (!this.type && !this.purpose) { + errors.push('Attachment must have either type or purpose'); + } + + // Cannot have both inline and external data + if (this.body !== undefined && this.url !== undefined) { + errors.push('Attachment cannot have both inline (body) and external (url) data'); + } + + // Validate encoding if set + if (this.encoding && !Attachment.VALID_ENCODINGS.includes(this.encoding as Encoding)) { + errors.push(`Invalid encoding: ${this.encoding}. Must be one of: ${Attachment.VALID_ENCODINGS.join(', ')}`); + } + return { - type: this.type, - body: this.body, - encoding: this.encoding + valid: errors.length === 0, + errors }; } -} \ No newline at end of file +} diff --git a/src/dialog.ts b/src/dialog.ts index 2b03554..1fb5a13 100644 --- a/src/dialog.ts +++ b/src/dialog.ts @@ -1,7 +1,32 @@ -import { Dialog as DialogType, PartyHistory } from './types'; +import { + Dialog as DialogType, + PartyHistory, + DialogType as DialogTypeEnum, + DialogDisposition, + Encoding, + SessionId +} from './types'; import { PartyHistory as PartyHistoryClass } from './party'; -export class Dialog implements DialogType { +/** + * Dialog class representing a conversation segment. + * Compliant with IETF draft-ietf-vcon-vcon-core-01 + */ +export class Dialog implements Partial { + /** Valid dialog types per vcon-core-01 */ + static readonly DIALOG_TYPES: DialogTypeEnum[] = ['recording', 'text', 'transfer', 'incomplete']; + + /** Valid dispositions for incomplete dialogs */ + static readonly DISPOSITIONS: DialogDisposition[] = [ + 'no-answer', + 'congestion', + 'failed', + 'busy', + 'hung-up', + 'voicemail-no-message' + ]; + + /** Supported MIME types for media content */ static readonly MIME_TYPES = [ 'text/plain', 'audio/x-wav', @@ -16,23 +41,36 @@ export class Dialog implements DialogType { 'video/mp4', 'video/x-mp4', 'video/ogg', + 'video/webm', 'multipart/mixed', - 'message/rfc822' + 'message/rfc822', + 'application/json' ]; - readonly type: string; - readonly start: Date; - readonly parties: number[]; + /** Valid encodings per vcon-core-01 */ + static readonly VALID_ENCODINGS: Encoding[] = ['base64url', 'json', 'none']; + + readonly type: DialogTypeEnum | string; + readonly start: Date | string; + parties?: number | number[]; originator?: number; - mimetype?: string; + mediatype?: string; filename?: string; body?: string; - encoding?: string; + encoding?: Encoding | string; url?: string; + content_hash?: string; + duration?: number; + disposition?: DialogDisposition | string; + session_id?: SessionId | SessionId[]; + party_history?: PartyHistory[]; + application?: string; + + // Legacy/extension fields + /** @deprecated Use mediatype instead */ + mimetype?: string; alg?: string; signature?: string; - disposition?: string; - party_history?: PartyHistory[]; transferee?: number; transferor?: number; transfer_target?: number; @@ -42,75 +80,126 @@ export class Dialog implements DialogType { campaign?: string; interaction?: string; skill?: string; - duration?: number; meta?: Record; [key: string]: any; - constructor(params: Partial & { type: string; start: Date; parties: number[] }) { + constructor(params: Partial & { type: DialogTypeEnum | string; start: Date | string }) { this.type = params.type; this.start = params.start; - this.parties = params.parties; - + + // Copy parties - can be number or number[] per vcon-core-01 + if (params.parties !== undefined) { + this.parties = params.parties; + } + // Copy other properties Object.entries(params).forEach(([key, value]) => { if (value !== undefined && !['type', 'start', 'parties'].includes(key)) { (this as any)[key] = value; } }); + + // Handle mediatype/mimetype compatibility + if (params.mimetype && !params.mediatype) { + this.mediatype = params.mimetype; + } } toDict(): DialogType { const dict: DialogType = { type: this.type, - start: this.start, - parties: this.parties + start: this.start instanceof Date ? this.start.toISOString() : this.start }; + // Add parties if defined + if (this.parties !== undefined) { + dict.parties = this.parties; + } + // Only include properties that are not undefined Object.entries(this).forEach(([key, value]) => { if (value !== undefined && !['type', 'start', 'parties'].includes(key)) { - dict[key] = value; + // Convert Date objects to ISO strings + if (value instanceof Date) { + dict[key] = value.toISOString(); + } else { + dict[key] = value; + } } }); return dict; } - addExternalData(url: string, filename: string, mimetype: string): void { - if (!Dialog.MIME_TYPES.includes(mimetype)) { - throw new Error(`Invalid MIME type: ${mimetype}`); - } - + /** + * Add external data reference (url + content_hash) + */ + addExternalData(url: string, mediatype: string, options?: { + filename?: string; + content_hash?: string; + }): void { this.url = url; - this.filename = filename; - this.mimetype = mimetype; + this.mediatype = mediatype; + if (options?.filename) { + this.filename = options.filename; + } + if (options?.content_hash) { + this.content_hash = options.content_hash; + } + // Clear inline data this.body = undefined; this.encoding = undefined; } - addInlineData(body: string, filename: string, mimetype: string): void { - if (!Dialog.MIME_TYPES.includes(mimetype)) { - throw new Error(`Invalid MIME type: ${mimetype}`); - } - + /** + * Add inline data (body + encoding) + */ + addInlineData(body: string, mediatype: string, options?: { + encoding?: Encoding; + filename?: string; + }): void { this.body = body; - this.filename = filename; - this.mimetype = mimetype; + this.mediatype = mediatype; + this.encoding = options?.encoding || 'none'; + if (options?.filename) { + this.filename = options.filename; + } + // Clear external data this.url = undefined; + this.content_hash = undefined; } + /** + * Check if dialog has external data reference + */ isExternalData(): boolean { return this.url !== undefined; } + /** + * Check if dialog has inline data + */ isInlineData(): boolean { return this.body !== undefined; } + /** + * Check if dialog is text type + */ isText(): boolean { - return this.mimetype === 'text/plain'; + return this.type === 'text' || this.mediatype === 'text/plain'; + } + + /** + * Check if dialog is recording type (audio) + */ + isRecording(): boolean { + return this.type === 'recording'; } + /** + * Check if dialog is audio content + */ isAudio(): boolean { return [ 'audio/x-wav', @@ -122,14 +211,76 @@ export class Dialog implements DialogType { 'audio/webm', 'audio/x-m4a', 'audio/aac' - ].includes(this.mimetype || ''); + ].includes(this.mediatype || ''); } + /** + * Check if dialog is video content + */ isVideo(): boolean { - return ['video/mp4', 'video/x-mp4', 'video/ogg'].includes(this.mimetype || ''); + return ['video/mp4', 'video/x-mp4', 'video/ogg', 'video/webm'].includes(this.mediatype || ''); } + /** + * Check if dialog is email content + */ isEmail(): boolean { - return this.mimetype === 'message/rfc822'; + return this.mediatype === 'message/rfc822'; + } + + /** + * Check if dialog is a transfer type + */ + isTransfer(): boolean { + return this.type === 'transfer'; + } + + /** + * Check if dialog is incomplete + */ + isIncomplete(): boolean { + return this.type === 'incomplete'; + } + + /** + * Validate the dialog against vcon-core-01 requirements + */ + validate(): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Type is required + if (!this.type) { + errors.push('Dialog type is required'); + } + + // Start is required + if (!this.start) { + errors.push('Dialog start time is required'); + } + + // If incomplete, disposition should be set + if (this.type === 'incomplete' && !this.disposition) { + errors.push('Disposition is required for incomplete dialogs'); + } + + // Validate disposition value if set + if (this.disposition && !Dialog.DISPOSITIONS.includes(this.disposition as DialogDisposition)) { + errors.push(`Invalid disposition: ${this.disposition}. Must be one of: ${Dialog.DISPOSITIONS.join(', ')}`); + } + + // Cannot have both inline and external data + if (this.body !== undefined && this.url !== undefined) { + errors.push('Dialog cannot have both inline (body) and external (url) data'); + } + + // Validate encoding if set + if (this.encoding && !Dialog.VALID_ENCODINGS.includes(this.encoding as Encoding)) { + errors.push(`Invalid encoding: ${this.encoding}. Must be one of: ${Dialog.VALID_ENCODINGS.join(', ')}`); + } + + return { + valid: errors.length === 0, + errors + }; } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 0d81e73..c1cea5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,20 +9,41 @@ import { Dialog as DialogType, Analysis, Encoding, - CivicAddress + CivicAddress, + PartyHistory as PartyHistoryType, + Group, + Redacted, + Amended, + DialogType as DialogTypeEnum, + DialogDisposition, + SessionId, + ContentHash, + VCON_VERSION } from './types'; export { + // Classes Vcon, Attachment, Party, PartyHistory, Dialog, + // Types VconData, AttachmentType, PartyType, DialogType, Analysis, Encoding, - CivicAddress -}; \ No newline at end of file + CivicAddress, + PartyHistoryType, + Group, + Redacted, + Amended, + DialogTypeEnum, + DialogDisposition, + SessionId, + ContentHash, + // Constants + VCON_VERSION +}; diff --git a/src/party.ts b/src/party.ts index 4bfa54b..fa17207 100644 --- a/src/party.ts +++ b/src/party.ts @@ -1,27 +1,37 @@ -import { Party as PartyType, CivicAddress } from './types'; +import { Party as PartyType, CivicAddress, PartyHistory as PartyHistoryType } from './types'; +/** + * Party class representing a conversation participant. + * Compliant with IETF draft-ietf-vcon-vcon-core-01 + */ export class Party implements PartyType { tel?: string; - stir?: string; + sip?: string; mailto?: string; + stir?: string; + did?: string; name?: string; + uuid?: string; validation?: string; gmlpos?: string; civicaddress?: CivicAddress; - uuid?: string; + timezone?: string; role?: string; contact_list?: string; meta?: Record; + id?: string; [key: string]: any; constructor(params: Partial = {}) { Object.assign(this, params); } + /** + * Convert party to plain object, excluding undefined properties + */ toDict(): PartyType { const dict: PartyType = {}; - - // Only include properties that are not undefined + Object.entries(this).forEach(([key, value]) => { if (value !== undefined) { dict[key] = value; @@ -30,24 +40,69 @@ export class Party implements PartyType { return dict; } + + /** + * Check if party has any identifier set + */ + hasIdentifier(): boolean { + return !!(this.tel || this.sip || this.mailto || this.stir || this.did || this.uuid); + } + + /** + * Get the primary identifier for this party + */ + getPrimaryIdentifier(): string | undefined { + return this.tel || this.sip || this.mailto || this.did || this.uuid; + } + + /** + * Validate the party against vcon-core-01 recommendations + */ + validate(): { valid: boolean; warnings: string[] } { + const warnings: string[] = []; + + // Warn if no identifier is set + if (!this.hasIdentifier()) { + warnings.push('Party has no identifier (tel, sip, mailto, stir, did, or uuid)'); + } + + return { + valid: true, // Parties don't have strict requirements + warnings + }; + } } -export class PartyHistory { +/** + * PartyHistory class for tracking party state changes within a dialog. + * Compliant with IETF draft-ietf-vcon-vcon-core-01 + */ +export class PartyHistory implements PartyHistoryType { readonly party: number; readonly event: string; - readonly time: Date; + readonly time: Date | string; - constructor(party: number, event: string, time: Date) { + constructor(party: number, event: string, time: Date | string) { this.party = party; this.event = event; this.time = time; } - toDict() { + /** + * Create a PartyHistory from a plain object + */ + static fromDict(data: PartyHistoryType): PartyHistory { + return new PartyHistory(data.party, data.event, data.time); + } + + /** + * Convert to plain object with ISO timestamp + */ + toDict(): PartyHistoryType { return { party: this.party, event: this.event, - time: this.time.toISOString() + time: this.time instanceof Date ? this.time.toISOString() : this.time }; } -} \ No newline at end of file +} diff --git a/src/types.ts b/src/types.ts index b60847a..11a8918 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,75 +1,273 @@ -export type Encoding = 'base64' | 'base64url' | 'json' | 'none'; +/** + * vCon Type Definitions + * Compliant with IETF draft-ietf-vcon-vcon-core-01 + * https://datatracker.ietf.org/doc/html/draft-ietf-vcon-vcon-core-01 + */ -export interface Attachment { - type: string; - body: any; - encoding: Encoding; +/** Valid encoding types for inline content */ +export type Encoding = 'base64url' | 'json' | 'none'; + +/** Dialog types as defined in vcon-core-01 */ +export type DialogType = 'recording' | 'text' | 'transfer' | 'incomplete'; + +/** Disposition values for incomplete dialogs */ +export type DialogDisposition = 'no-answer' | 'congestion' | 'failed' | 'busy' | 'hung-up' | 'voicemail-no-message'; + +/** Content hash for externally referenced files (algorithm-hash format) */ +export interface ContentHash { + alg: string; + value: string; } +/** Session identifier object */ +export interface SessionId { + id: string; + type?: string; +} + +/** Party history event for tracking party state changes */ export interface PartyHistory { party: number; event: string; - time: Date; + time: Date | string; } +/** Civic address for geographic location per RFC 5139 */ export interface CivicAddress { - country: string; - locality: string; - region: string; - postcode: string; - street: string; + country?: string; + a1?: string; // state/province + a2?: string; // county + a3?: string; // city + a4?: string; // city division + a5?: string; // neighborhood + a6?: string; // street + prd?: string; // leading street direction + pod?: string; // trailing street suffix + sts?: string; // street suffix + hno?: string; // house number + hns?: string; // house number suffix + lmk?: string; // landmark + loc?: string; // additional location info + nam?: string; // name (residence/office) + pc?: string; // postal code + bld?: string; // building + unit?: string; // unit + flr?: string; // floor + room?: string; // room + plc?: string; // place type + pcn?: string; // postal community name + pobox?: string; // post office box + addcode?: string; // additional code + seat?: string; // seat + rd?: string; // road + rdsec?: string; // road section + rdbr?: string; // road branch + rdsubbr?: string; // road sub-branch + prm?: string; // primary road name + pom?: string; // post office name + // Legacy fields for backward compatibility + locality?: string; + region?: string; + postcode?: string; + street?: string; } +/** Party object representing a conversation participant */ export interface Party { + /** Telephone URL (TEL format) */ tel?: string; - stir?: string; + /** SIP address (addr-spec format) */ + sip?: string; + /** Email address */ mailto?: string; + /** STIR PASSporT (JWS Compact Serialization) */ + stir?: string; + /** Decentralized Identifier */ + did?: string; + /** Free-form name string */ name?: string; + /** Participant identifier */ + uuid?: string; + /** Identity validation method indicator */ validation?: string; + /** Geographic location (GML position format) */ gmlpos?: string; + /** Civic address object */ civicaddress?: CivicAddress; - uuid?: string; + /** Location timezone */ + timezone?: string; + /** Role in conversation (e.g., 'agent', 'contact', 'customer') */ role?: string; + /** Contact list reference */ contact_list?: string; + /** Additional metadata */ meta?: Record; + /** Party identifier (for contact center scenarios) */ + id?: string; + /** Allow additional properties for extensions */ [key: string]: any; } +/** Dialog object representing a conversation segment */ export interface Dialog { - type: string; - start: Date; - parties: number[]; + /** Dialog type: 'recording', 'text', 'transfer', or 'incomplete' */ + type: DialogType | string; + /** Start time of dialog segment (RFC3339 format) */ + start: Date | string; + /** Party indices contributing to dialog */ + parties?: number | number[]; + /** Originator party index if not first in parties list */ originator?: number; - mimetype?: string; + /** Media type string (MIME type) */ + mediatype?: string; + /** Original filename */ filename?: string; + /** Duration in seconds */ + duration?: number; + /** Disposition for incomplete type dialogs */ + disposition?: DialogDisposition | string; + /** Session identifier object(s) */ + session_id?: SessionId | SessionId[]; + /** Party event history */ + party_history?: PartyHistory[]; + /** Application identifier */ + application?: string; + + // Inline content (mutually exclusive with url/content_hash) + /** Inline content body */ body?: string; - encoding?: string; + /** Content encoding: 'base64url', 'json', or 'none' */ + encoding?: Encoding | string; + + // External content (mutually exclusive with body/encoding) + /** External URL reference */ url?: string; + /** Content hash for externally referenced files */ + content_hash?: string; + + // Legacy/extension fields + /** @deprecated Use mediatype instead */ + mimetype?: string; + /** Signature algorithm */ alg?: string; + /** Digital signature */ signature?: string; - disposition?: string; - party_history?: PartyHistory[]; + /** Transfer target party index */ transferee?: number; + /** Transfer source party index */ transferor?: number; + /** Transfer target reference */ transfer_target?: number; + /** Original dialog reference */ original?: number; + /** Consultation dialog reference */ consultation?: number; + /** Target dialog reference */ target_dialog?: number; + /** Campaign identifier (contact center extension) */ campaign?: string; + /** Interaction identifier (contact center extension) */ interaction?: string; + /** Skill identifier (contact center extension) */ skill?: string; - duration?: number; + /** Additional metadata */ meta?: Record; + /** Allow additional properties for extensions */ [key: string]: any; } +/** Analysis object for analytical results */ export interface Analysis { + /** Analysis type identifier */ type: string; + /** Dialog indices analyzed */ dialog: number | number[]; - vendor: string; - body: Record | any[] | string; - encoding?: Encoding; + /** Vendor name */ + vendor?: string; + /** Product name */ + product?: string; + /** Schema reference */ + schema?: string; + /** Media type */ + mediatype?: string; + /** Original filename */ + filename?: string; + + // Inline content (mutually exclusive with url/content_hash) + /** Analysis body content */ + body?: Record | any[] | string; + /** Content encoding */ + encoding?: Encoding | string; + + // External content (mutually exclusive with body/encoding) + /** External URL reference */ + url?: string; + /** Content hash for externally referenced files */ + content_hash?: string; + + /** Additional properties */ extra?: Record; + /** Allow additional properties for extensions */ + [key: string]: any; +} + +/** Attachment object for related files */ +export interface Attachment { + /** Attachment type/purpose (MIME type or category) */ + type?: string; + /** Purpose/category of attachment */ + purpose?: string; + /** Reference time */ + start?: Date | string; + /** Related party index */ + party?: number; + /** Related dialog indices */ + dialog?: number | number[]; + /** Media type */ + mediatype?: string; + /** Original filename */ + filename?: string; + + // Inline content (mutually exclusive with url/content_hash) + /** Attachment body content */ + body?: any; + /** Content encoding */ + encoding?: Encoding | string; + + // External content (mutually exclusive with body/encoding) + /** External URL reference */ + url?: string; + /** Content hash for externally referenced files */ + content_hash?: string; + + /** Allow additional properties for extensions */ + [key: string]: any; +} + +/** Group object for linking related vCons */ +export interface Group { + /** Group identifier */ + uuid: string; + /** Group type/purpose */ + type?: string; + /** Additional metadata */ + meta?: Record; +} + +/** Redacted object reference */ +export interface Redacted { + /** UUID of original unredacted vCon */ + uuid?: string; + /** Additional redaction metadata */ + [key: string]: any; +} + +/** Amended object reference */ +export interface Amended { + /** UUID of amended vCon */ + uuid?: string; + /** Additional amendment metadata */ + [key: string]: any; } /** @@ -94,19 +292,37 @@ export interface Signature { } export interface VconData { + /** Globally unique identifier (preferably UUID v8) */ uuid?: string; + /** vCon version string (deprecated in favor of extensions) */ vcon?: string; + /** Conversation subject */ subject?: string; - created_at?: Date; - updated_at?: Date; - redacted?: boolean; - appended?: boolean; - group?: string; - meta?: Record; + /** Creation timestamp (RFC3339 format, mandatory, immutable) */ + created_at?: Date | string; + /** Last modification timestamp (RFC3339 format) */ + updated_at?: Date | string; + /** Array of parties in the conversation (mandatory) */ parties?: Party[]; + /** Array of dialog segments */ dialog?: Dialog[]; + /** Array of attachments */ attachments?: Attachment[]; + /** Array of analysis results */ analysis?: Analysis[]; + /** Array of group references */ + group?: Group[] | string[]; + /** Reference to redacted version */ + redacted?: Redacted | boolean; + /** Reference to amended version */ + amended?: Amended | boolean; + /** Names of non-core extensions used */ + extensions?: string[]; + /** Incompatible extension names requiring explicit support */ + critical?: string[]; + /** Additional metadata */ + meta?: Record; + /** Tags for classification */ tags?: Record; /** @@ -128,4 +344,7 @@ export interface VconData { * Added when a vCon is signed using the sign() method */ payload?: string; -} \ No newline at end of file +} + +/** vCon version constant for vcon-core-01 */ +export const VCON_VERSION = '0.0.1'; diff --git a/src/vcon.ts b/src/vcon.ts index 638043b..87bd81f 100644 --- a/src/vcon.ts +++ b/src/vcon.ts @@ -1,18 +1,34 @@ import { v4 as uuidv4 } from 'uuid'; -import { VconData, Attachment, Party, Dialog, Analysis, Encoding } from './types'; +import { + VconData, + Attachment, + Party, + Dialog, + Analysis, + Encoding, + Group, + Redacted, + Amended, + VCON_VERSION +} from './types'; import { Attachment as AttachmentClass } from './attachment'; import { Party as PartyClass } from './party'; import { Dialog as DialogClass } from './dialog'; import * as crypto from 'crypto'; +/** + * Main Vcon class for creating and managing vCon conversation containers. + * Compliant with IETF draft-ietf-vcon-vcon-core-01 + */ export class Vcon { data: VconData; constructor(vconDict: Partial = {}) { this.data = { uuid: vconDict.uuid || uuidv4(), - created_at: vconDict.created_at || new Date(), - updated_at: vconDict.updated_at || new Date(), + vcon: vconDict.vcon || VCON_VERSION, + created_at: vconDict.created_at || new Date().toISOString(), + updated_at: vconDict.updated_at, parties: vconDict.parties || [], dialog: vconDict.dialog || [], attachments: vconDict.attachments || [], @@ -22,6 +38,9 @@ export class Vcon { }; } + /** + * Create a Vcon from a JSON string + */ static buildFromJson(jsonString: string): Vcon { try { const data = JSON.parse(jsonString); @@ -32,10 +51,15 @@ export class Vcon { } } + /** + * Create a new empty Vcon with default values + */ static buildNew(): Vcon { return new Vcon(); } + // Tag methods + get tags(): Record | undefined { return this.data.tags; } @@ -49,23 +73,50 @@ export class Vcon { this.data.tags = {}; } this.data.tags[tagName] = tagValue; - this.data.updated_at = new Date(); + this.data.updated_at = new Date().toISOString(); } + // Attachment methods + findAttachmentByType(type: string): Attachment | undefined { return this.data.attachments?.find(attachment => attachment.type === type); } - addAttachment(type: string, body: any, encoding: Encoding = 'none'): AttachmentClass { - const attachment = new AttachmentClass(type, body, encoding); + findAttachmentByPurpose(purpose: string): Attachment | undefined { + return this.data.attachments?.find(attachment => attachment.purpose === purpose); + } + + addAttachment(params: { + type?: string; + purpose?: string; + body?: any; + encoding?: Encoding; + url?: string; + content_hash?: string; + mediatype?: string; + filename?: string; + start?: Date | string; + party?: number; + dialog?: number | number[]; + }): AttachmentClass { + const attachment = new AttachmentClass(params); if (!this.data.attachments) { this.data.attachments = []; } this.data.attachments.push(attachment.toDict()); - this.data.updated_at = new Date(); + this.data.updated_at = new Date().toISOString(); return attachment; } + /** + * @deprecated Use addAttachment with params object instead + */ + addAttachmentLegacy(type: string, body: any, encoding: Encoding = 'none'): AttachmentClass { + return this.addAttachment({ type, body, encoding }); + } + + // Analysis methods + findAnalysisByType(type: string): Analysis | undefined { return this.data.analysis?.find(analysis => analysis.type === type); } @@ -73,18 +124,18 @@ export class Vcon { addAnalysis(params: { type: string; dialog: number | number[]; - vendor: string; - body: Record | any[] | string; + vendor?: string; + product?: string; + schema?: string; + body?: Record | any[] | string; encoding?: Encoding; + url?: string; + content_hash?: string; + mediatype?: string; + filename?: string; extra?: Record; }): void { - const analysis: Analysis = { - type: params.type, - dialog: params.dialog, - vendor: params.vendor, - body: params.body, - encoding: params.encoding || 'none' - }; + const analysis: Analysis = { ...params }; if (params.extra) { analysis.extra = params.extra; @@ -94,21 +145,26 @@ export class Vcon { this.data.analysis = []; } this.data.analysis.push(analysis); - this.data.updated_at = new Date(); + this.data.updated_at = new Date().toISOString(); } + // Party methods + addParty(party: PartyClass): void { if (!this.data.parties) { this.data.parties = []; } this.data.parties.push(party.toDict()); - this.data.updated_at = new Date(); + this.data.updated_at = new Date().toISOString(); } findPartyIndex(by: string, val: string): number | undefined { - return this.data.parties?.findIndex(party => party[by] === val); + const index = this.data.parties?.findIndex(party => party[by] === val); + return index !== undefined && index >= 0 ? index : undefined; } + // Dialog methods + findDialog(by: string, val: any): DialogClass | undefined { const dialog = this.data.dialog?.find(d => d[by] === val); return dialog ? new DialogClass(dialog) : undefined; @@ -119,202 +175,79 @@ export class Vcon { this.data.dialog = []; } this.data.dialog.push(dialog.toDict()); - this.data.updated_at = new Date(); + this.data.updated_at = new Date().toISOString(); } + // Serialization methods + + /** + * Convert vCon to JSON string + */ toJson(): string { return JSON.stringify(this.toDict()); } + /** + * Convert vCon to plain object + */ toDict(): VconData { return { ...this.data }; } + // Extension methods (vcon-core-01) + /** - * Helper method to encode a string in base64url format - * - * @param input - The string to encode - * @returns The base64url encoded string + * Add an extension name to the extensions array */ - private base64UrlEncode(input: string): string { - return Buffer.from(input) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + addExtension(name: string): void { + if (!this.data.extensions) { + this.data.extensions = []; + } + if (!this.data.extensions.includes(name)) { + this.data.extensions.push(name); + this.data.updated_at = new Date().toISOString(); + } } /** - * Sign the vCon using JWS (JSON Web Signature). - * - * This method signs the vCon using the provided private key, adding the signature - * information to the vCon. The signature can later be verified using the - * corresponding public key. - * - * @param privateKey - The RSA private key in PEM format or as a crypto.KeyObject - * @throws Error - If there is an error during the signing process - * - * @example - * ```typescript - * import * as crypto from 'crypto'; - * const { privateKey } = crypto.generateKeyPairSync('rsa', { - * modulusLength: 2048, - * publicKeyEncoding: { type: 'spki', format: 'pem' }, - * privateKeyEncoding: { type: 'pkcs8', format: 'pem' } - * }); - * const vcon = Vcon.buildNew(); - * vcon.sign(privateKey); - * ``` + * Add a critical extension name */ - sign(privateKeyInput: string | crypto.KeyObject): void { - try { - console.log("Signing vCon with JWS"); - - // Convert the vCon to a JSON string for signing - const payload = this.toJson(); - - // Convert private key to PEM format if it's a KeyObject - const privateKey = typeof privateKeyInput === 'string' - ? privateKeyInput - : privateKeyInput.export({ type: 'pkcs8', format: 'pem' }).toString(); - - // Create a header for JWS - const header = { - alg: 'RS256', - typ: 'JWS' - }; - - // Create base64url encoded versions - const headerBase64 = this.base64UrlEncode(JSON.stringify(header)); - const payloadBase64 = this.base64UrlEncode(payload); - - // Create the signature input - const signatureInput = `${headerBase64}.${payloadBase64}`; - - // Create signature - const signer = crypto.createSign('RSA-SHA256'); - signer.update(signatureInput); - const signature = signer.sign(privateKey, 'base64'); - - // Convert to base64url format - const signatureBase64Url = signature - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - // Update the vCon with the signature information - this.data.signatures = [{ protected: headerBase64, signature: signatureBase64Url }]; - this.data.payload = payloadBase64; - - // Remove the original vCon properties that are now in the payload - // to match the signed vCon format - const keysToKeep = ['signatures', 'payload']; - Object.keys(this.data).forEach(key => { - if (!keysToKeep.includes(key)) { - delete this.data[key as keyof VconData]; - } - }); - - console.log("Successfully signed vCon"); - } catch (error) { - console.error("Failed to sign vCon:", error); - throw error; + addCriticalExtension(name: string): void { + if (!this.data.critical) { + this.data.critical = []; + } + if (!this.data.critical.includes(name)) { + this.data.critical.push(name); + this.addExtension(name); } } /** - * Verify the JWS signature of the vCon. - * - * This method verifies the vCon's signature using the provided public key. - * The vCon must have been previously signed using the corresponding private key. - * - * @param publicKey - The RSA public key in PEM format or as a crypto.KeyObject - * @returns true if the signature is valid, false otherwise - * @throws Error - If the vCon is not signed - * - * @example - * ```typescript - * import * as crypto from 'crypto'; - * const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { - * modulusLength: 2048, - * publicKeyEncoding: { type: 'spki', format: 'pem' }, - * privateKeyEncoding: { type: 'pkcs8', format: 'pem' } - * }); - * const vcon = Vcon.buildNew(); - * vcon.sign(privateKey); - * const isValid = vcon.verify(publicKey); - * console.log(isValid); // Prints true - * ``` + * Check if an extension is used */ - verify(publicKeyInput: string | crypto.KeyObject): boolean { - if (!this.data.signatures || !this.data.payload) { - console.error("Cannot verify: vCon is not signed"); - throw new Error("vCon is not signed"); - } - - try { - console.log("Verifying vCon signature"); - - // Extract components - const { protected: protectedHeader, signature } = this.data.signatures[0]; - const payload = this.data.payload; - - // Convert public key to appropriate format - const publicKey = typeof publicKeyInput === 'string' - ? publicKeyInput - : publicKeyInput.export({ type: 'spki', format: 'pem' }).toString(); - - // Create signature input - const signatureInput = `${protectedHeader}.${payload}`; - - // Convert base64url signature to base64 - const signatureBase64 = signature - .replace(/-/g, '+') - .replace(/_/g, '/'); - - // Verify the signature - const verifier = crypto.createVerify('RSA-SHA256'); - verifier.update(signatureInput); - const isValid = verifier.verify(publicKey, signatureBase64, 'base64'); - - console.log("Signature verification result:", isValid); - return isValid; - } catch (error) { - console.warn("Invalid signature detected:", error); - return false; - } + hasExtension(name: string): boolean { + return this.data.extensions?.includes(name) ?? false; } /** - * Generate a new RSA key pair for signing vCons. - * - * This method generates a new RSA key pair that can be used for signing - * and verifying vCons. - * - * @returns A tuple containing the private key and public key as PEM strings - * - * @example - * ```typescript - * const [privateKey, publicKey] = Vcon.generateKeyPair(); - * const vcon = Vcon.buildNew(); - * vcon.sign(privateKey); - * const isValid = vcon.verify(publicKey); - * console.log(isValid); // Prints true - * ``` + * Check if an extension is critical */ - static generateKeyPair(): [string, string] { - console.log("Generating new RSA key pair"); - - const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' } - }); - - console.log("Successfully generated RSA key pair"); - return [privateKey, publicKey]; + isCriticalExtension(name: string): boolean { + return this.data.critical?.includes(name) ?? false; + } + + // Group methods + + addGroup(group: Group | string): void { + if (!this.data.group) { + this.data.group = []; + } + (this.data.group as (Group | string)[]).push(group); + this.data.updated_at = new Date().toISOString(); } + // Property getters + get parties(): Party[] { return this.data.parties || []; } @@ -336,34 +269,69 @@ export class Vcon { } get vcon(): string { - return this.data.vcon || ''; + return this.data.vcon || VCON_VERSION; } get subject(): string | undefined { return this.data.subject; } - get created_at(): Date { + set subject(value: string | undefined) { + this.data.subject = value; + this.data.updated_at = new Date().toISOString(); + } + + get created_at(): Date | string { return this.data.created_at!; } - get updated_at(): Date { - return this.data.updated_at!; + get updated_at(): Date | string | undefined { + return this.data.updated_at; } - get redacted(): boolean { - return this.data.redacted || false; + get redacted(): Redacted | boolean | undefined { + return this.data.redacted; } - get appended(): boolean { - return this.data.appended || false; + set redacted(value: Redacted | boolean | undefined) { + this.data.redacted = value; + this.data.updated_at = new Date().toISOString(); + } + + get amended(): Amended | boolean | undefined { + return this.data.amended; } - get group(): string | undefined { + set amended(value: Amended | boolean | undefined) { + this.data.amended = value; + this.data.updated_at = new Date().toISOString(); + } + + get group(): Group[] | string[] | undefined { return this.data.group; } + get extensions(): string[] | undefined { + return this.data.extensions; + } + + get critical(): string[] | undefined { + return this.data.critical; + } + get meta(): Record | undefined { return this.data.meta; } -} \ No newline at end of file + + set meta(value: Record | undefined) { + this.data.meta = value; + this.data.updated_at = new Date().toISOString(); + } + + /** + * @deprecated Use amended instead (vcon-core-01 uses amended, not appended) + */ + get appended(): boolean { + return !!this.data.amended; + } +}