@cognigy/chat-components-vue 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-components-vue.css +1 -1
- package/dist/chat-components-vue.js +12222 -5673
- package/dist/components/messages/AdaptiveCardRenderer.vue.d.ts +35 -0
- package/dist/types/index.d.ts +50 -1
- package/dist/utils/helpers.d.ts +3 -2
- package/dist/utils/matcher.d.ts +3 -3
- package/package.json +3 -2
- package/src/components/Message.vue +17 -6
- package/src/components/messages/AdaptiveCard.vue +322 -225
- package/src/components/messages/AdaptiveCardRenderer.vue +260 -0
- package/src/composables/useCollation.ts +28 -45
- package/src/types/index.ts +56 -2
- package/src/utils/helpers.ts +46 -24
- package/src/utils/matcher.ts +20 -6
- package/src/utils/sanitize.ts +1 -2
- package/src/utils/theme.ts +6 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="targetRef" data-testid="adaptive-card-renderer" />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* AdaptiveCardRenderer - Inner component that uses Microsoft's adaptivecards library
|
|
8
|
+
*
|
|
9
|
+
* This component handles the actual rendering of Adaptive Cards using the
|
|
10
|
+
* Microsoft adaptivecards library. It's designed for presentation-only mode
|
|
11
|
+
* (no action handling).
|
|
12
|
+
*
|
|
13
|
+
* Inspired by Microsoft's adaptivecards-react package:
|
|
14
|
+
* https://github.com/microsoft/AdaptiveCards/blob/5b66a52e0e0cee5074a42dcbe688d608e0327ae4/source/nodejs/adaptivecards-react/src/adaptive-card.tsx
|
|
15
|
+
*/
|
|
16
|
+
import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
17
|
+
import { AdaptiveCard as MSAdaptiveCard, HostConfig } from 'adaptivecards'
|
|
18
|
+
import MarkdownIt from 'markdown-it'
|
|
19
|
+
import { sanitizeHTMLWithConfig } from '../../utils/sanitize'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shared MarkdownIt instance at module scope.
|
|
23
|
+
*
|
|
24
|
+
* Why this is safe to share across component instances:
|
|
25
|
+
* - MarkdownIt's render() method is a pure function (input → output, no side effects)
|
|
26
|
+
* - No internal state is mutated during rendering
|
|
27
|
+
* - Configuration is set at construction time and doesn't change
|
|
28
|
+
* - This pattern is recommended by MarkdownIt documentation for performance
|
|
29
|
+
*
|
|
30
|
+
* Creating a new instance per component would be wasteful since there's no
|
|
31
|
+
* per-instance state to isolate.
|
|
32
|
+
*/
|
|
33
|
+
const md = new MarkdownIt()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set up Markdown processing for Adaptive Cards (module-level, runs once)
|
|
37
|
+
*
|
|
38
|
+
* This sets a static callback on MSAdaptiveCard that processes markdown text.
|
|
39
|
+
* We intentionally do this at module scope to:
|
|
40
|
+
* 1. Avoid race conditions from multiple components setting the callback
|
|
41
|
+
* 2. Ensure consistent markdown processing across all card instances
|
|
42
|
+
*
|
|
43
|
+
* Note: We use sanitizeHTML directly (not via useSanitize composable) because
|
|
44
|
+
* this runs at module scope without component context. For adaptive cards,
|
|
45
|
+
* sanitization is always applied for security - config overrides don't apply.
|
|
46
|
+
*/
|
|
47
|
+
MSAdaptiveCard.onProcessMarkdown = (text, result) => {
|
|
48
|
+
try {
|
|
49
|
+
const html = md.render(text)
|
|
50
|
+
// Use default sanitization (no custom allowed tags for adaptive card content)
|
|
51
|
+
result.outputHtml = sanitizeHTMLWithConfig(html)
|
|
52
|
+
result.didProcess = true
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('AdaptiveCardRenderer: Markdown processing failed', { error })
|
|
55
|
+
result.outputHtml = text
|
|
56
|
+
result.didProcess = true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Host config input type - plain object that HostConfig constructor parses.
|
|
62
|
+
* We don't use Partial<HostConfig> because HostConfig properties are class instances,
|
|
63
|
+
* but the constructor accepts plain objects and converts them internally.
|
|
64
|
+
*/
|
|
65
|
+
type HostConfigInput = Record<string, unknown>
|
|
66
|
+
|
|
67
|
+
interface Props {
|
|
68
|
+
/**
|
|
69
|
+
* The Adaptive Card payload to render
|
|
70
|
+
*/
|
|
71
|
+
payload?: Record<string, unknown>
|
|
72
|
+
/**
|
|
73
|
+
* Host configuration for styling the card.
|
|
74
|
+
* Accepts a plain object matching the HostConfig structure - the library
|
|
75
|
+
* parses this internally when constructing the HostConfig instance.
|
|
76
|
+
*/
|
|
77
|
+
hostConfig?: HostConfigInput
|
|
78
|
+
/**
|
|
79
|
+
* When true, disables all inputs and buttons after rendering
|
|
80
|
+
* Useful for displaying submitted cards in chat history
|
|
81
|
+
*/
|
|
82
|
+
readonly?: boolean
|
|
83
|
+
/**
|
|
84
|
+
* Data to pre-fill into input fields
|
|
85
|
+
* Keys should match input element IDs in the card
|
|
86
|
+
* Used to show submitted values in chat history
|
|
87
|
+
*/
|
|
88
|
+
inputData?: Record<string, unknown>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
92
|
+
readonly: false,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const targetRef = ref<HTMLDivElement | null>(null)
|
|
96
|
+
|
|
97
|
+
// Component-scoped card instance - using shallowRef for proper instance isolation
|
|
98
|
+
// Each component gets its own MSAdaptiveCard instance to prevent conflicts
|
|
99
|
+
const cardInstance = shallowRef<MSAdaptiveCard | null>(null)
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply input data to card payload by setting values on input elements
|
|
103
|
+
* Recursively walks the card structure to find inputs and set their values
|
|
104
|
+
*/
|
|
105
|
+
function applyInputData(
|
|
106
|
+
cardPayload: Record<string, unknown>,
|
|
107
|
+
inputData: Record<string, unknown>
|
|
108
|
+
): Record<string, unknown> {
|
|
109
|
+
if (!inputData || Object.keys(inputData).length === 0) {
|
|
110
|
+
return cardPayload
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Deep clone to avoid mutating original
|
|
114
|
+
const payload = structuredClone(cardPayload)
|
|
115
|
+
|
|
116
|
+
function processElement(element: Record<string, unknown>): void {
|
|
117
|
+
// Check if this is an input element with an id
|
|
118
|
+
const type = element.type as string | undefined
|
|
119
|
+
const id = element.id as string | undefined
|
|
120
|
+
|
|
121
|
+
if (type?.startsWith('Input.') && id && id in inputData) {
|
|
122
|
+
// Set the value for this input
|
|
123
|
+
const value = inputData[id]
|
|
124
|
+
|
|
125
|
+
if (type === 'Input.Toggle') {
|
|
126
|
+
// Toggle inputs use valueOn/valueOff - custom values take precedence
|
|
127
|
+
const matchesCustomOn = element.valueOn !== undefined && value === element.valueOn
|
|
128
|
+
const matchesCustomOff = element.valueOff !== undefined && value === element.valueOff
|
|
129
|
+
const isStandardOn = value === true || value === 'true'
|
|
130
|
+
|
|
131
|
+
const isToggleOn = matchesCustomOn || (!matchesCustomOff && isStandardOn)
|
|
132
|
+
|
|
133
|
+
element.value = isToggleOn
|
|
134
|
+
? (element.valueOn ?? 'true')
|
|
135
|
+
: (element.valueOff ?? 'false')
|
|
136
|
+
} else if (type === 'Input.ChoiceSet' && Array.isArray(value)) {
|
|
137
|
+
// Multi-select choice sets use comma-separated values
|
|
138
|
+
element.value = value.join(',')
|
|
139
|
+
} else {
|
|
140
|
+
element.value = value
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Recursively process child elements
|
|
145
|
+
if (Array.isArray(element.body)) {
|
|
146
|
+
element.body.forEach((child: Record<string, unknown>) => processElement(child))
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(element.items)) {
|
|
149
|
+
element.items.forEach((child: Record<string, unknown>) => processElement(child))
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(element.columns)) {
|
|
152
|
+
element.columns.forEach((col: Record<string, unknown>) => {
|
|
153
|
+
if (Array.isArray(col.items)) {
|
|
154
|
+
col.items.forEach((child: Record<string, unknown>) => processElement(child))
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(element.actions)) {
|
|
159
|
+
element.actions.forEach((action: Record<string, unknown>) => {
|
|
160
|
+
if (action.card) {
|
|
161
|
+
processElement(action.card as Record<string, unknown>)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
processElement(payload)
|
|
168
|
+
return payload
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Disable all interactive elements in the rendered card
|
|
173
|
+
* Used for displaying submitted cards in chat history
|
|
174
|
+
*/
|
|
175
|
+
function disableInteractiveElements(container: HTMLElement): void {
|
|
176
|
+
const interactiveElements = container.querySelectorAll(
|
|
177
|
+
'input, textarea, select, button'
|
|
178
|
+
)
|
|
179
|
+
interactiveElements.forEach((el) => {
|
|
180
|
+
el.setAttribute('disabled', 'true')
|
|
181
|
+
el.setAttribute('aria-disabled', 'true')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Add a class to the container for additional styling hooks
|
|
185
|
+
container.classList.add('ac-readonly')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render the adaptive card
|
|
190
|
+
*/
|
|
191
|
+
function renderCard(): void {
|
|
192
|
+
if (!targetRef.value || !props.payload) {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Create card instance if needed
|
|
197
|
+
if (!cardInstance.value) {
|
|
198
|
+
cardInstance.value = new MSAdaptiveCard()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Apply host config if provided
|
|
202
|
+
if (props.hostConfig) {
|
|
203
|
+
cardInstance.value.hostConfig = new HostConfig(props.hostConfig)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Apply input data to payload if provided
|
|
208
|
+
const payloadToRender = props.inputData
|
|
209
|
+
? applyInputData(props.payload, props.inputData)
|
|
210
|
+
: props.payload
|
|
211
|
+
|
|
212
|
+
// Parse and render the card
|
|
213
|
+
cardInstance.value.parse(payloadToRender)
|
|
214
|
+
const renderedCard = cardInstance.value.render()
|
|
215
|
+
|
|
216
|
+
if (renderedCard && targetRef.value) {
|
|
217
|
+
// Clear previous content and append new
|
|
218
|
+
targetRef.value.innerHTML = ''
|
|
219
|
+
targetRef.value.appendChild(renderedCard)
|
|
220
|
+
|
|
221
|
+
// Accessibility: Add aria-level to heading elements without it
|
|
222
|
+
const headings = targetRef.value.querySelectorAll("[role='heading']")
|
|
223
|
+
headings.forEach((heading) => {
|
|
224
|
+
if (heading.getAttribute('aria-level') === null) {
|
|
225
|
+
heading.setAttribute('aria-level', '4')
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Disable all interactive elements when in readonly mode
|
|
230
|
+
if (props.readonly) {
|
|
231
|
+
disableInteractiveElements(targetRef.value)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error('AdaptiveCardRenderer: Unable to render Adaptive Card', {
|
|
236
|
+
error,
|
|
237
|
+
payload: props.payload,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Render on mount (markdown processing is set up at module scope)
|
|
243
|
+
onMounted(() => {
|
|
244
|
+
renderCard()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// Re-render when payload, hostConfig, or inputData changes
|
|
248
|
+
watch(
|
|
249
|
+
() => [props.payload, props.hostConfig, props.inputData],
|
|
250
|
+
() => {
|
|
251
|
+
renderCard()
|
|
252
|
+
},
|
|
253
|
+
{ deep: true }
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
// Cleanup on unmount
|
|
257
|
+
onBeforeUnmount(() => {
|
|
258
|
+
cardInstance.value = null
|
|
259
|
+
})
|
|
260
|
+
</script>
|
|
@@ -44,35 +44,22 @@ export interface UseCollationReturn {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Check if two messages can be collated together
|
|
47
|
+
* Check if two messages can be collated together.
|
|
48
|
+
* Both must be bot messages with text, no rich content, attachments, or plugins.
|
|
48
49
|
*/
|
|
49
50
|
function canCollate(current: IMessage, previous: IMessage): boolean {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return false
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Don't collate if either has attachments
|
|
66
|
-
if (current.data?.attachments || previous.data?.attachments) {
|
|
67
|
-
return false
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Don't collate plugin messages
|
|
71
|
-
if (current.data?._plugin || previous.data?._plugin) {
|
|
72
|
-
return false
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return true
|
|
51
|
+
return (
|
|
52
|
+
current.source === 'bot' &&
|
|
53
|
+
previous.source === 'bot' &&
|
|
54
|
+
Boolean(current.text) &&
|
|
55
|
+
Boolean(previous.text) &&
|
|
56
|
+
!current.data?._cognigy?._webchat &&
|
|
57
|
+
!previous.data?._cognigy?._webchat &&
|
|
58
|
+
!current.data?.attachments &&
|
|
59
|
+
!previous.data?.attachments &&
|
|
60
|
+
!current.data?._plugin &&
|
|
61
|
+
!previous.data?._plugin
|
|
62
|
+
)
|
|
76
63
|
}
|
|
77
64
|
|
|
78
65
|
/**
|
|
@@ -109,37 +96,33 @@ export function useCollation(
|
|
|
109
96
|
|
|
110
97
|
const result: CollatedMessage[] = []
|
|
111
98
|
|
|
112
|
-
for (
|
|
113
|
-
const
|
|
99
|
+
for (const current of msgs) {
|
|
100
|
+
const lastIndex = result.length - 1
|
|
101
|
+
const lastCollated = result[lastIndex]
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
? lastCollated.collatedFrom[lastCollated.collatedFrom.length - 1]
|
|
103
|
+
if (lastCollated) {
|
|
104
|
+
const existingCollatedFrom = lastCollated.collatedFrom
|
|
105
|
+
const lastOriginal = existingCollatedFrom
|
|
106
|
+
? existingCollatedFrom[existingCollatedFrom.length - 1]
|
|
120
107
|
: lastCollated
|
|
121
108
|
|
|
122
109
|
if (canCollate(current, lastOriginal)) {
|
|
123
|
-
// Collate:
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const collatedFrom = lastCollated.collatedFrom
|
|
128
|
-
? [...lastCollated.collatedFrom, current]
|
|
110
|
+
// Collate: create new object with combined text (avoid mutating existing)
|
|
111
|
+
const collatedFrom = existingCollatedFrom
|
|
112
|
+
? [...existingCollatedFrom, current]
|
|
129
113
|
: [lastOriginal, current]
|
|
130
114
|
|
|
131
|
-
|
|
132
|
-
result[result.length - 1] = {
|
|
115
|
+
result[lastIndex] = {
|
|
133
116
|
...lastCollated,
|
|
134
|
-
text:
|
|
117
|
+
text: (lastCollated.text ?? '') + '\n' + (current.text ?? ''),
|
|
135
118
|
collatedFrom,
|
|
136
119
|
}
|
|
137
120
|
continue
|
|
138
121
|
}
|
|
139
122
|
}
|
|
140
123
|
|
|
141
|
-
// Can't collate, add as new message
|
|
142
|
-
result.push(current
|
|
124
|
+
// Can't collate, add as new message (shallow copy to avoid mutating original)
|
|
125
|
+
result.push({ ...current })
|
|
143
126
|
}
|
|
144
127
|
|
|
145
128
|
return result
|
package/src/types/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface IMessageWithId extends IMessage {
|
|
|
22
22
|
* Type guard to check if a message has an id property
|
|
23
23
|
*/
|
|
24
24
|
export function hasMessageId(message: IMessage): message is IMessageWithId & { id: string } {
|
|
25
|
-
return 'id' in message && typeof
|
|
25
|
+
return 'id' in message && typeof message.id === 'string'
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -120,6 +120,12 @@ export interface ChatSettings {
|
|
|
120
120
|
|
|
121
121
|
// Link color
|
|
122
122
|
textLinkColor?: string
|
|
123
|
+
|
|
124
|
+
// Adaptive Card colors
|
|
125
|
+
adaptiveCardTextColor?: string
|
|
126
|
+
adaptiveCardInputColor?: string
|
|
127
|
+
adaptiveCardInputBackground?: string
|
|
128
|
+
adaptiveCardInputBorder?: string
|
|
123
129
|
}
|
|
124
130
|
behavior?: {
|
|
125
131
|
renderMarkdown?: boolean
|
|
@@ -127,6 +133,15 @@ export interface ChatSettings {
|
|
|
127
133
|
messageDelay?: number
|
|
128
134
|
collateStreamedOutputs?: boolean
|
|
129
135
|
focusInputAfterPostback?: boolean
|
|
136
|
+
/**
|
|
137
|
+
* Controls adaptive card interactivity.
|
|
138
|
+
* - `true`: All cards are readonly (presentation only), regardless of submitted data
|
|
139
|
+
* - `false` or `undefined`: Smart default - readonly only if card has submitted data
|
|
140
|
+
*
|
|
141
|
+
* Use `true` for chat history/transcript displays where no interaction is needed.
|
|
142
|
+
* Use `false`/omit for interactive chat interfaces with smart auto-detection.
|
|
143
|
+
*/
|
|
144
|
+
adaptiveCardsReadonly?: boolean
|
|
130
145
|
}
|
|
131
146
|
widgetSettings?: {
|
|
132
147
|
enableDefaultPreview?: boolean
|
|
@@ -369,5 +384,44 @@ export interface IWebchatChannelPayload {
|
|
|
369
384
|
quick_replies?: QuickReply[]
|
|
370
385
|
attachment?: TemplateAttachment | AudioAttachment | ImageAttachment | VideoAttachment
|
|
371
386
|
}
|
|
372
|
-
adaptiveCard?: unknown
|
|
387
|
+
adaptiveCard?: Record<string, unknown>
|
|
388
|
+
/** Submitted adaptive card data */
|
|
389
|
+
adaptiveCardData?: Record<string, unknown>
|
|
390
|
+
data?: Record<string, unknown>
|
|
391
|
+
formData?: Record<string, unknown>
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Cognigy message data structure
|
|
396
|
+
* Shape of message.data._cognigy
|
|
397
|
+
*/
|
|
398
|
+
export interface ICognigyData {
|
|
399
|
+
_webchat?: IWebchatChannelPayload
|
|
400
|
+
_defaultPreview?: IWebchatChannelPayload
|
|
401
|
+
_facebook?: IWebchatChannelPayload
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Plugin payload structure for custom message types
|
|
406
|
+
*/
|
|
407
|
+
export interface IPluginPayload {
|
|
408
|
+
type?: string
|
|
409
|
+
payload?: Record<string, unknown>
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Extended message data with Cognigy-specific fields
|
|
414
|
+
* Use this when accessing message.data with Cognigy payload structures
|
|
415
|
+
*/
|
|
416
|
+
export interface IMessageDataExtended {
|
|
417
|
+
_cognigy?: ICognigyData
|
|
418
|
+
_plugin?: IPluginPayload
|
|
419
|
+
/** Adaptive card submission request data */
|
|
420
|
+
request?: {
|
|
421
|
+
value?: Record<string, unknown>
|
|
422
|
+
}
|
|
423
|
+
/** Direct adaptive card data fields (fallback locations) */
|
|
424
|
+
adaptiveCardData?: Record<string, unknown>
|
|
425
|
+
data?: Record<string, unknown>
|
|
426
|
+
formData?: Record<string, unknown>
|
|
373
427
|
}
|
package/src/utils/helpers.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import type {IWebchatButton, IWebchatQuickReply} from '../types'
|
|
6
6
|
|
|
7
|
+
// Pre-compiled regex patterns (compiled once at module load)
|
|
8
|
+
const TEMPLATE_PLACEHOLDER_REGEX = /{(\w+)}/g
|
|
9
|
+
const URL_MATCHER_REGEX =
|
|
10
|
+
/(^|\s)(\b(https?):\/\/([-A-Z0-9+&@$#/%?=~_|!:,.;\p{L}]*[-A-Z0-9+&$@#/%=~_|\p{L}]))/giu
|
|
11
|
+
const CONTROL_CHARS_REGEX = /[\r\n\f]/g
|
|
12
|
+
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
15
|
* Gets the label for a button
|
|
9
16
|
* Returns "Call" for phone_number buttons without title
|
|
@@ -29,7 +36,7 @@ export function interpolateString(
|
|
|
29
36
|
template: string,
|
|
30
37
|
replacements: Record<string, string>
|
|
31
38
|
): string {
|
|
32
|
-
return template.replace(
|
|
39
|
+
return template.replace(TEMPLATE_PLACEHOLDER_REGEX, (_, key) => {
|
|
33
40
|
return key in replacements ? replacements[key] : ''
|
|
34
41
|
})
|
|
35
42
|
}
|
|
@@ -55,19 +62,31 @@ export function moveFocusToMessageFocusTarget(dataMessageId: string): void {
|
|
|
55
62
|
}, 0)
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Escapes HTML special characters to prevent injection attacks
|
|
67
|
+
*/
|
|
68
|
+
function escapeHtmlAttribute(str: string): string {
|
|
69
|
+
return str
|
|
70
|
+
.replace(/&/g, '&')
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, ''')
|
|
73
|
+
.replace(/</g, '<')
|
|
74
|
+
.replace(/>/g, '>')
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* Helper function that replaces URLs in a string with HTML anchor elements
|
|
60
79
|
* - Works with URLs starting with http/https, www., or just domain/subdomain
|
|
61
80
|
* - Will only match URLs at the beginning or following whitespace
|
|
62
81
|
* - Will not work with emails
|
|
82
|
+
* - URLs are escaped to prevent injection attacks
|
|
63
83
|
*/
|
|
64
84
|
export function replaceUrlsWithHTMLanchorElem(text: string): string {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return `<a href="${url}" target="_blank">${url}</a>`
|
|
85
|
+
return text.replace(URL_MATCHER_REGEX, (_, prefix, url) => {
|
|
86
|
+
// Escape URL for safe insertion into HTML attributes and content
|
|
87
|
+
const escapedUrl = escapeHtmlAttribute(url)
|
|
88
|
+
// Preserve leading whitespace, but keep it outside the anchor
|
|
89
|
+
return `${prefix}<a href="${escapedUrl}" target="_blank">${escapedUrl}</a>`
|
|
71
90
|
})
|
|
72
91
|
}
|
|
73
92
|
|
|
@@ -79,13 +98,13 @@ export function getBackgroundImage(url: string): string | undefined {
|
|
|
79
98
|
if (!url) return undefined
|
|
80
99
|
|
|
81
100
|
// Remove control characters that could break CSS parsing
|
|
82
|
-
let sanitized = url.replace(
|
|
101
|
+
let sanitized = url.replace(CONTROL_CHARS_REGEX, '')
|
|
83
102
|
|
|
84
103
|
// If the string looks like an absolute URL (has a scheme), validate allowed protocols (http/https).
|
|
85
|
-
if (
|
|
104
|
+
if (URL_SCHEME_REGEX.test(sanitized)) {
|
|
86
105
|
try {
|
|
87
106
|
const parsed = new URL(sanitized)
|
|
88
|
-
if (
|
|
107
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
89
108
|
return undefined
|
|
90
109
|
}
|
|
91
110
|
// Normalize absolute URLs
|
|
@@ -117,13 +136,12 @@ const ONE_KB = 1000
|
|
|
117
136
|
* Example: "document.pdf" → "document."
|
|
118
137
|
*/
|
|
119
138
|
export function getFileName(fileNameWithExtension: string): string {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
return
|
|
123
|
-
} else {
|
|
124
|
-
// return full name here if it didn't have a file ending
|
|
125
|
-
return fileNameWithExtension
|
|
139
|
+
const lastDotIndex = fileNameWithExtension.lastIndexOf('.')
|
|
140
|
+
if (lastDotIndex > 0) {
|
|
141
|
+
return fileNameWithExtension.slice(0, lastDotIndex + 1)
|
|
126
142
|
}
|
|
143
|
+
// Return full name if no extension
|
|
144
|
+
return fileNameWithExtension
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
/**
|
|
@@ -131,12 +149,11 @@ export function getFileName(fileNameWithExtension: string): string {
|
|
|
131
149
|
* Example: "document.pdf" → "pdf"
|
|
132
150
|
*/
|
|
133
151
|
export function getFileExtension(fileNameWithExtension: string): string | null {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
return
|
|
137
|
-
} else {
|
|
138
|
-
return null
|
|
152
|
+
const lastDotIndex = fileNameWithExtension.lastIndexOf('.')
|
|
153
|
+
if (lastDotIndex > 0 && lastDotIndex < fileNameWithExtension.length - 1) {
|
|
154
|
+
return fileNameWithExtension.slice(lastDotIndex + 1)
|
|
139
155
|
}
|
|
156
|
+
return null
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
/**
|
|
@@ -152,13 +169,18 @@ export function getSizeLabel(size: number): string {
|
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
/**
|
|
155
|
-
* Valid image MIME types for file attachments
|
|
172
|
+
* Valid image MIME types for file attachments (Set for O(1) lookup)
|
|
156
173
|
*/
|
|
157
|
-
export const VALID_IMAGE_MIME_TYPES = [
|
|
174
|
+
export const VALID_IMAGE_MIME_TYPES = new Set([
|
|
175
|
+
'image/jpeg',
|
|
176
|
+
'image/png',
|
|
177
|
+
'image/gif',
|
|
178
|
+
'image/webp',
|
|
179
|
+
])
|
|
158
180
|
|
|
159
181
|
/**
|
|
160
182
|
* Checks if attachment is a valid image type
|
|
161
183
|
*/
|
|
162
184
|
export function isImageAttachment(mimeType: string): boolean {
|
|
163
|
-
return VALID_IMAGE_MIME_TYPES.
|
|
185
|
+
return VALID_IMAGE_MIME_TYPES.has(mimeType)
|
|
164
186
|
}
|
package/src/utils/matcher.ts
CHANGED
|
@@ -10,6 +10,9 @@ import type { IMessage } from '@cognigy/socket-client'
|
|
|
10
10
|
import type { ChatConfig, MatchRule, MessagePlugin, MatchResult } from '../types'
|
|
11
11
|
import { isAdaptiveCardPayload } from '../types'
|
|
12
12
|
|
|
13
|
+
// Pre-compiled regex for escape sequence check
|
|
14
|
+
const ESCAPE_SEQUENCE_REGEX = /^[\n\t\r\v\f\s]*$/
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Check if message has channel payload
|
|
15
18
|
*/
|
|
@@ -46,16 +49,17 @@ function isOnlyEscapeSequence(text: string | null | undefined): boolean {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const trimmed = text.trim()
|
|
49
|
-
return trimmed === '' ||
|
|
52
|
+
return trimmed === '' || ESCAPE_SEQUENCE_REGEX.test(trimmed)
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
/**
|
|
53
56
|
* Default match rules for internal message types.
|
|
54
57
|
* These rules map message data structures to component names.
|
|
55
58
|
* Components are resolved by name lookup in Message.vue.
|
|
59
|
+
*
|
|
60
|
+
* Created once at module load for performance - rules are static.
|
|
56
61
|
*/
|
|
57
|
-
|
|
58
|
-
return [
|
|
62
|
+
const DEFAULT_MATCH_RULES: MatchRule[] = [
|
|
59
63
|
// xApp submit
|
|
60
64
|
{
|
|
61
65
|
name: 'XAppSubmit',
|
|
@@ -240,6 +244,14 @@ export function createDefaultMatchRules(): MatchRule[] {
|
|
|
240
244
|
},
|
|
241
245
|
},
|
|
242
246
|
]
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Returns a copy of the default match rules.
|
|
250
|
+
* Exposed for testing and extension purposes.
|
|
251
|
+
* Returns a new array to prevent mutation of internal rules.
|
|
252
|
+
*/
|
|
253
|
+
export function createDefaultMatchRules(): MatchRule[] {
|
|
254
|
+
return [...DEFAULT_MATCH_RULES]
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
/**
|
|
@@ -254,9 +266,11 @@ export function match(
|
|
|
254
266
|
config?: ChatConfig,
|
|
255
267
|
externalPlugins: MessagePlugin[] = []
|
|
256
268
|
): MatchResult[] {
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
const allRules: MatchResult[] =
|
|
269
|
+
// External plugins are checked first, then default rules
|
|
270
|
+
// Always create a new array to prevent mutation of DEFAULT_MATCH_RULES
|
|
271
|
+
const allRules: MatchResult[] = externalPlugins.length > 0
|
|
272
|
+
? [...externalPlugins, ...DEFAULT_MATCH_RULES]
|
|
273
|
+
: [...DEFAULT_MATCH_RULES]
|
|
260
274
|
|
|
261
275
|
const matchedRules: MatchResult[] = []
|
|
262
276
|
|
package/src/utils/sanitize.ts
CHANGED
|
@@ -95,8 +95,7 @@ export function sanitizeHTMLWithConfig(
|
|
|
95
95
|
})
|
|
96
96
|
|
|
97
97
|
try {
|
|
98
|
-
|
|
99
|
-
return sanitized
|
|
98
|
+
return DOMPurify.sanitize(text, config).toString()
|
|
100
99
|
} catch (error) {
|
|
101
100
|
console.error('sanitizeHTMLWithConfig: Sanitization failed', {
|
|
102
101
|
error,
|
package/src/utils/theme.ts
CHANGED
|
@@ -49,6 +49,12 @@ export function configColorsToCssVariables(
|
|
|
49
49
|
|
|
50
50
|
// Link color
|
|
51
51
|
'--cc-text-link-color': colors.textLinkColor,
|
|
52
|
+
|
|
53
|
+
// Adaptive Card colors
|
|
54
|
+
'--cc-adaptive-card-text-color': colors.adaptiveCardTextColor,
|
|
55
|
+
'--cc-adaptive-card-input-color': colors.adaptiveCardInputColor,
|
|
56
|
+
'--cc-adaptive-card-input-background': colors.adaptiveCardInputBackground,
|
|
57
|
+
'--cc-adaptive-card-input-border': colors.adaptiveCardInputBorder,
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
// Filter out undefined values and return only defined CSS variables
|