Merge branch 'feat/live-chat-channel' into help-articles-and-ai-responses

This commit is contained in:
Abhinav Raut
2025-08-24 02:12:32 +05:30
30 changed files with 537 additions and 235 deletions

16
.github/ISSUE_TEMPLATE/confirmed-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Confirmed Bug Report
about: Report a confirmed bug in Libredesk
title: "[Bug] <brief summary>"
labels: bug
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

16
.github/ISSUE_TEMPLATE/possible-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Possible Bug Report
about: Something in Libredesk might be broken but needs confirmation
title: "[Possible Bug] <brief summary>"
labels: bug, needs-investigation
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

View File

@@ -15,7 +15,7 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
## Features
- **Multi Shared Inbox**
Libredesk supports multiple shares inboxes, letting you manage conversations across teams effortlessly.
Libredesk supports multiple shared inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
@@ -85,6 +85,11 @@ __________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status
Libredesk is under active development.
Track roadmap and progress on the GitHub Project Board: [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

View File

@@ -723,7 +723,7 @@ func handleCreateConversation(r *fastglue.Request) error {
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)

View File

@@ -3,7 +3,6 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -175,13 +175,16 @@ func handleSendMessage(r *fastglue.Request) error {
}
if req.Private {
if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}
return r.SendEnvelope(true)
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}

View File

@@ -0,0 +1,30 @@
# API getting started
You can access the Libredesk API to interact with your instance programmatically.
## Generating API keys
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
3. **Save the credentials**: Keep both the API Key and API Secret secure
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
## Using the API
LibreDesk supports two authentication schemes:
### Basic authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic <base64_encoded_key:secret>"
```
### Token authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API Documentation
Complete API documentation with available endpoints and examples coming soon.

View File

@@ -210,7 +210,7 @@ Triggered when an existing message is updated.
## Delivery and Retries
- Webhooks are delivered with a 10-second timeout
- Webhooks requests timeout can be configured in the `config.toml` file
- Failed deliveries are not automatically retried
- Webhook delivery runs in a background worker pool for better performance
- If the webhook queue is full (configurable in config.toml file), new events may be dropped

View File

@@ -32,6 +32,7 @@ nav:
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

View File

@@ -1,10 +1,13 @@
<template>
<div ref="codeEditor" id="code-editor" class="code-editor" />
<div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import CodeFlask from 'codeflask'
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { html } from '@codemirror/lang-html'
import { oneDark } from '@codemirror/theme-one-dark'
import { useColorMode } from '@vueuse/core'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -13,45 +16,38 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const codeEditor = ref(null)
const data = ref('')
const flask = ref(null)
let editorView = null
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const el = document.createElement('code-flask')
el.attachShadow({ mode: 'open' })
el.shadowRoot.innerHTML = `
<style>
.codeflask .codeflask__flatten {
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.codeflask .codeflask__lines { background: #fafafa; z-index: 10; }
.codeflask .token.tag { font-weight: bold; }
.codeflask .token.attr-name { color: #111; }
.codeflask .token.attr-value { color: #000 !important; }
</style>
<div id="area"></div>
`
codeEditor.value.appendChild(el)
const isDark = useColorMode().value === 'dark'
flask.value = new CodeFlask(el.shadowRoot.getElementById('area'), {
language: props.language,
lineNumbers: false,
styleParent: el.shadowRoot,
readonly: props.disabled
editorView = new EditorView({
doc: body,
extensions: [
basicSetup,
html(),
...(isDark ? [oneDark] : []),
EditorView.editable.of(!props.disabled),
EditorView.theme({
'&': { height: '100%' },
'.cm-editor': { height: '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
})
flask.value.onUpdate((v) => {
emit('update:modelValue', v)
data.value = v
})
flask.value.updateCode(body)
nextTick(() => {
document.querySelector('code-flask').shadowRoot.querySelector('textarea').focus()
editorView?.focus()
})
}
@@ -61,7 +57,9 @@ onMounted(() => {
watch(() => props.modelValue, (newVal) => {
if (newVal !== data.value) {
flask.value.updateCode(newVal)
editorView?.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
})
}
})
</script>

View File

@@ -62,7 +62,7 @@
:checked="!!selectedDays[day]"
@update:checked="handleDayToggle(day, $event)"
/>
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
<Label :for="day" class="font-medium">{{ day }}</Label>
</div>
<div class="flex space-x-2 items-center">
<div class="flex flex-col items-start">
@@ -156,7 +156,7 @@
</div>
<DialogFooter>
<Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
{{ t('globals.messages.saveChanges') }}
{{ t('globals.messages.add') }}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -20,7 +20,7 @@ export const createColumns = (t) => [
},
cell: function ({ row }) {
const url = row.getValue('url')
return h('div', { class: 'text-center font-mono text-sm max-w-sm truncate' }, url)
return h('div', { class: 'text-center font-mono mt-1 max-w-sm truncate' }, url)
}
},
{

View File

@@ -69,11 +69,11 @@
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
{{ format(message.updated_at, 'h:mm a') }}
{{ formatMessageTimestamp(message.created_at) }}
</span>
</TooltipTrigger>
<TooltipContent>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
{{ formatFullTimestamp(message.created_at) }}
</TooltipContent>
</Tooltip>
</div>
@@ -82,17 +82,17 @@
<script setup>
import { computed } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '../../../stores/conversation'
import { Lock, RotateCcw, Check } from 'lucide-vue-next'
import { revertCIDToImageSrc } from '../../../utils/strings'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { useConversationStore } from '@/stores/conversation'
import { revertCIDToImageSrc } from '@/utils/strings'
import { formatMessageTimestamp, formatFullTimestamp } from '@/utils/datetime'
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
import MessageEnvelope from './MessageEnvelope.vue'
import CSATResponseDisplay from './CSATResponseDisplay.vue'
import api from '../../../api'
import api from '@/api'
const props = defineProps({
message: Object

View File

@@ -60,12 +60,12 @@
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
{{ format(message.updated_at, 'h:mm a') }}
{{ formatMessageTimestamp(message.created_at) }}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{{ format(message.updated_at, "MMMM dd, yyyy 'at' HH:mm") }}
{{ formatFullTimestamp(message.created_at) }}
</p>
</TooltipContent>
</Tooltip>
@@ -75,12 +75,12 @@
<script setup>
import { computed, ref } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '../../../stores/conversation'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Letter } from 'vue-letter'
import { useAppSettingsStore } from '../../../stores/appSettings'
import { useConversationStore } from '@/stores/conversation'
import { formatMessageTimestamp, formatFullTimestamp } from '@/utils/datetime'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useI18n } from 'vue-i18n'
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
import MessageEnvelope from './MessageEnvelope.vue'

View File

@@ -1,105 +1,139 @@
<template>
<div class="max-w-5xl mx-auto p-6 min-h-screen">
<div class="space-y-8">
<div
v-for="(items, type) in results"
:key="type"
class="bg-card rounded shadow overflow-hidden"
>
<!-- Header for each section -->
<h2
class="bg-primary dark:bg-primary text-lg font-bold text-white dark:text-primary-foreground py-2 px-6 capitalize"
>
{{ type }}
</h2>
<Tabs :default-value="defaultTab" v-model="activeTab">
<TabsList class="grid w-full mb-6" :class="tabsGridClass">
<TabsTrigger v-for="(items, type) in results" :key="type" :value="type" class="capitalize">
{{ type }} ({{ items.length }})
</TabsTrigger>
</TabsList>
<!-- No results message -->
<div v-if="items.length === 0" class="p-6 text-gray-500 dark:text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: type
})
}}
</div>
<TabsContent v-for="(items, type) in results" :key="type" :value="type" class="mt-0">
<div class="bg-background rounded border overflow-hidden">
<!-- No results message -->
<div v-if="items.length === 0" class="p-8 text-center text-muted-foreground">
<div class="text-lg font-medium mb-2">
{{
$t('globals.messages.noResults', {
name: type
})
}}
</div>
<div class="text-sm">{{ $t('search.adjustSearchTerms') }}</div>
</div>
<!-- Results list -->
<div class="divide-y divide-gray-200 dark:divide-border">
<div
v-for="item in items"
:key="item.id || item.uuid"
class="p-6 hover:bg-gray-100 dark:hover:bg-accent transition duration-300 ease-in-out group"
>
<router-link
:to="{
name: 'inbox-conversation',
params: {
uuid: type === 'conversations' ? item.uuid : item.conversation_uuid,
type: 'assigned'
}
}"
class="block"
<!-- Results list -->
<div v-else class="divide-y divide-border">
<div
v-for="item in items"
:key="item.id || item.uuid"
class="p-6 hover:bg-accent/50 transition duration-200 ease-in-out group"
>
<div class="flex justify-between items-start">
<div class="flex-grow">
<!-- Reference number -->
<div
class="text-sm font-semibold mb-2 group-hover:text-primary dark:group-hover:text-primary transition duration-300"
>
#{{
type === 'conversations'
? item.reference_number
: item.conversation_reference_number
}}
<router-link
:to="{
name: 'inbox-conversation',
params: {
uuid: type === 'conversations' ? item.uuid : item.conversation_uuid,
type: 'assigned'
}
}"
class="block"
>
<div class="flex justify-between items-start">
<div class="flex-grow">
<!-- Reference number -->
<div
class="text-sm font-semibold mb-2 text-muted-foreground group-hover:text-primary transition duration-200"
>
#{{
type === 'conversations'
? item.reference_number
: item.conversation_reference_number
}}
</div>
<!-- Content -->
<div
class="text-foreground font-medium mb-2 text-lg group-hover:text-primary transition duration-200"
>
{{
truncateText(
type === 'conversations' ? item.subject : item.text_content,
100
)
}}
</div>
<!-- Timestamp -->
<div class="text-sm text-muted-foreground flex items-center">
<ClockIcon class="h-4 w-4 mr-1" />
{{
formatDate(
type === 'conversations' ? item.created_at : item.conversation_created_at
)
}}
</div>
</div>
<!-- Content -->
<!-- Right arrow icon -->
<div
class="text-gray-900 dark:text-card-foreground font-medium mb-2 text-lg group-hover:text-gray-950 dark:group-hover:text-foreground transition duration-300"
class="bg-secondary rounded-full p-2 group-hover:bg-primary transition duration-200"
>
{{
truncateText(type === 'conversations' ? item.subject : item.text_content, 100)
}}
</div>
<!-- Timestamp -->
<div class="text-sm text-gray-500 dark:text-muted-foreground flex items-center">
<ClockIcon class="h-4 w-4 mr-1" />
{{
formatDate(
type === 'conversations' ? item.created_at : item.conversation_created_at
)
}}
<ChevronRightIcon
class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground"
aria-hidden="true"
/>
</div>
</div>
<!-- Right arrow icon -->
<div
class="bg-gray-200 dark:bg-secondary rounded-full p-2 group-hover:bg-primary dark:group-hover:bg-primary transition duration-300"
>
<ChevronRightIcon
class="h-5 w-5 text-gray-700 dark:text-secondary-foreground group-hover:text-white dark:group-hover:text-primary-foreground"
aria-hidden="true"
/>
</div>
</div>
</router-link>
</router-link>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
import { format, parseISO } from 'date-fns'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
defineProps({
const props = defineProps({
results: {
type: Object,
required: true
}
})
// Get the first available tab as default
const defaultTab = computed(() => {
const types = Object.keys(props.results)
return types.length > 0 ? types[0] : ''
})
const activeTab = ref('')
// Watch for changes in results and set the first tab as active
watch(
() => props.results,
(newResults) => {
const types = Object.keys(newResults)
if (types.length > 0 && !activeTab.value) {
activeTab.value = types[0]
}
},
{ immediate: true }
)
// Dynamic grid class based on number of tabs
const tabsGridClass = computed(() => {
const tabCount = Object.keys(props.results).length
if (tabCount <= 2) return 'grid-cols-2'
if (tabCount <= 3) return 'grid-cols-3'
if (tabCount <= 4) return 'grid-cols-4'
return 'grid-cols-5'
})
const formatDate = (dateString) => {
const date = parseISO(dateString)
return format(date, 'MMM d, yyyy HH:mm')

View File

@@ -25,4 +25,12 @@ export const formatDuration = (seconds, showSeconds = true) => {
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
return `${hours}h ${mins}m ${showSeconds ? `${secs}s` : ''}`
}
export const formatMessageTimestamp = (time) => {
return format(time, 'd MMM, hh:mm a')
}
export const formatFullTimestamp = (time) => {
return format(time, 'd MMM yyyy, hh:mm a')
}

View File

@@ -3,7 +3,7 @@
<div v-if="showForm" class="flex-1 flex flex-col max-h-full">
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50 p-4 space-y-4">
<!-- Form title -->
<div v-if="formTitle" class="text-lg font-semibold text-foreground mb-2">
<div v-if="formTitle" class="text-xl text-foreground mb-2 text-center">
{{ formTitle }}
</div>

View File

@@ -94,6 +94,15 @@ export const useChatStore = defineStore('chat', () => {
// Update conversations list with pending message
updateConversationListLastMessage(conversationUUID, pendingMessage)
// Auto-remove after 10 seconds if still has temp ID
const tempId = pendingMessage.uuid
setTimeout(() => {
const messages = messageCache.getAllPagesMessages(conversationUUID)
if (messages.some(msg => msg.uuid === tempId)) {
removeMessage(conversationUUID, tempId)
}
}, 10000)
return pendingMessage.uuid
}

View File

@@ -21,6 +21,8 @@
"format": "prettier --write src/"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
@@ -43,7 +45,7 @@
"axios": "^1.8.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
"codemirror": "^6.0.2",
"date-fns": "^3.6.0",
"lucide-vue-next": "^0.525.0",
"mitt": "^3.0.1",

210
frontend/pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@codemirror/lang-html':
specifier: ^6.4.9
version: 6.4.9
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@formkit/auto-animate':
specifier: ^0.8.2
version: 0.8.2
@@ -74,9 +80,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
codeflask:
specifier: ^1.4.1
version: 1.4.1
codemirror:
specifier: ^6.0.2
version: 6.0.2
date-fns:
specifier: ^3.6.0
version: 3.6.0
@@ -234,6 +240,39 @@ packages:
'@bassist/utils@0.4.0':
resolution: {integrity: sha512-aoFTl0jUjm8/tDZodP41wnEkvB+C5O9NFCuYN/ztL6jSUSsuBkXq90/1ifBm1XhV/zySHgLYlU1+tgo3XtQ+nA==}
'@codemirror/autocomplete@6.18.6':
resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.9':
resolution: {integrity: sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/language@6.11.1':
resolution: {integrity: sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==}
'@codemirror/lint@6.8.5':
resolution: {integrity: sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==}
'@codemirror/search@6.5.11':
resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.37.2':
resolution: {integrity: sha512-XD3LdgQpxQs5jhOOZ2HRVT+Rj59O4Suc7g2ULvZ+Yi8eCkickrkZ5JFuoDhs2ST1mNI5zSsNYgR3NGa4OUrbnw==}
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -508,6 +547,24 @@ packages:
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/css@1.2.1':
resolution: {integrity: sha512-2F5tOqzKEKbCUNraIXc0f6HKeyKlmMWJnBB0i4XW6dJgssrZO/YlZ2pY5xgyqDleqqhiNJ3dQhbrV2aClZQMvg==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/html@1.3.10':
resolution: {integrity: sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==}
'@lezer/javascript@1.5.1':
resolution: {integrity: sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==}
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
@@ -535,6 +592,9 @@ packages:
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
engines: {node: '>=6.0.0'}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1109,9 +1169,6 @@ packages:
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/sinonjs__fake-timers@8.1.1':
resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==}
@@ -1532,8 +1589,8 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codeflask@1.4.1:
resolution: {integrity: sha512-4vb2IbE/iwvP0Uubhd2ixVeysm3KNC2pl7SoDaisxq1juhZzvap3qbaX7B2CtpQVvv5V9sjcQK8hO0eTcY0V9Q==}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
@@ -2780,10 +2837,6 @@ packages:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'}
prismjs@1.29.0:
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
engines: {node: '>=6'}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -3093,6 +3146,9 @@ packages:
striptags@3.2.0:
resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==}
style-mod@4.1.2:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
@@ -3550,6 +3606,89 @@ snapshots:
dependencies:
'@withtypes/mime': 0.1.2
'@codemirror/autocomplete@6.18.6':
dependencies:
'@codemirror/language': 6.11.1
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
'@lezer/common': 1.2.3
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.11.1
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
'@lezer/common': 1.2.3
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.11.1
'@codemirror/state': 6.5.2
'@lezer/common': 1.2.3
'@lezer/css': 1.2.1
'@codemirror/lang-html@6.4.9':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.11.1
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
'@lezer/common': 1.2.3
'@lezer/css': 1.2.1
'@lezer/html': 1.3.10
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.11.1
'@codemirror/lint': 6.8.5
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
'@lezer/common': 1.2.3
'@lezer/javascript': 1.5.1
'@codemirror/language@6.11.1':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
style-mod: 4.1.2
'@codemirror/lint@6.8.5':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
crelt: 1.0.6
'@codemirror/search@6.5.11':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
crelt: 1.0.6
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.11.1
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
'@lezer/highlight': 1.2.1
'@codemirror/view@6.37.2':
dependencies:
'@codemirror/state': 6.5.2
crelt: 1.0.6
style-mod: 4.1.2
w3c-keyname: 2.2.8
'@colors/colors@1.5.0':
optional: true
@@ -3815,6 +3954,34 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
'@lezer/common@1.2.3': {}
'@lezer/css@1.2.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/html@1.3.10':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/javascript@1.5.1':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/lr@1.4.2':
dependencies:
'@lezer/common': 1.2.3
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
@@ -3836,6 +4003,8 @@ snapshots:
'@mapbox/whoots-js@3.1.0': {}
'@marijn/find-cluster-break@1.0.2': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4378,8 +4547,6 @@ snapshots:
'@types/pbf@3.0.5': {}
'@types/prismjs@1.26.5': {}
'@types/sinonjs__fake-timers@8.1.1': {}
'@types/sizzle@2.3.9': {}
@@ -4906,10 +5073,15 @@ snapshots:
clsx@2.1.1: {}
codeflask@1.4.1:
codemirror@6.0.2:
dependencies:
'@types/prismjs': 1.26.5
prismjs: 1.29.0
'@codemirror/autocomplete': 6.18.6
'@codemirror/commands': 6.8.1
'@codemirror/language': 6.11.1
'@codemirror/lint': 6.8.5
'@codemirror/search': 6.5.11
'@codemirror/state': 6.5.2
'@codemirror/view': 6.37.2
color-convert@2.0.1:
dependencies:
@@ -6200,8 +6372,6 @@ snapshots:
pretty-bytes@5.6.0: {}
prismjs@1.29.0: {}
process@0.11.10: {}
prosemirror-changeset@2.2.1:
@@ -6601,6 +6771,8 @@ snapshots:
striptags@3.2.0: {}
style-mod@4.1.2: {}
stylis@4.2.0: {}
stylus@0.57.0:

View File

@@ -233,10 +233,6 @@
}
// End Scrollbar
.code-editor {
@apply rounded border shadow h-[65vh] min-h-[250px] w-full relative;
}
.show-quoted-text {
blockquote {
@apply block;

2
go.mod
View File

@@ -43,7 +43,7 @@ require (
golang.org/x/crypto v0.38.0
golang.org/x/mod v0.24.0
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.21.0
golang.org/x/oauth2 v0.27.0
)
require (

4
go.sum
View File

@@ -253,8 +253,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@@ -34,7 +34,7 @@
"globals.terms.setting": "Setting | Settings",
"globals.terms.template": "Template | Templates",
"globals.terms.rule": "Rule | Rules",
"globals.terms.businessHour": "Business Hour | Business Hours",
"globals.terms.businessHour": "Business hour | Business hours",
"globals.terms.priority": "Priority | Priorities",
"globals.terms.status": "Status | Statuses",
"globals.terms.secret": "Secret | Secrets",
@@ -662,7 +662,7 @@
"admin.automation.event.message.incoming": "Incoming message",
"admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.",
"admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.",
"admin.template.outgoingEmailTemplates": "Outgoing Email Templates",
"admin.template.outgoingEmailTemplates": "Outgoing email templates",
"admin.template.emailNotificationTemplates": "Email notification templates",
"admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.",
"admin.template.onlyOneDefaultOutgoingTemplate": "You can have only one default outgoing email template.",
@@ -697,6 +697,7 @@
"search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.",
"search.minQueryLength": " Please enter at least {length} characters to search.",
"search.searchBy": "Search by reference number, contact email address or messages in conversations.",
"search.adjustSearchTerms": "Try adjusting your search terms or filters.",
"sla.overdueBy": "Overdue by",
"sla.met": "SLA met",
"view.form.description": "Create and save custom filter views for quick access to your conversations.",

View File

@@ -331,7 +331,7 @@ func (e *Engine) handleNewConversation(conversation cmodels.Conversation) {
e.lo.Debug("handling new conversation for automation rule evaluation", "uuid", conversation.UUID)
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
if len(rules) == 0 {
e.lo.Warn("no rules to evaluate for new conversation rule evaluation", "uuid", conversation.UUID)
e.lo.Info("no rules to evaluate for new conversation rule evaluation", "uuid", conversation.UUID)
return
}
e.evalConversationRules(rules, conversation)
@@ -342,7 +342,7 @@ func (e *Engine) handleUpdateConversation(conversation cmodels.Conversation, eve
e.lo.Debug("handling update conversation for automation rule evaluation", "uuid", conversation.UUID, "event_type", eventType)
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
if len(rules) == 0 {
e.lo.Warn("no rules to evaluate for conversation update", "uuid", conversation.UUID, "event_type", eventType)
e.lo.Info("no rules to evaluate for conversation update", "uuid", conversation.UUID, "event_type", eventType)
return
}
e.evalConversationRules(rules, conversation)
@@ -359,7 +359,7 @@ func (e *Engine) handleTimeTrigger() {
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
if len(rules) == 0 {
e.lo.Warn("no rules to evaluate for time trigger")
e.lo.Info("no rules to evaluate for time trigger")
return
}
e.lo.Info("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))

View File

@@ -1014,14 +1014,17 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
}
return m.UpdateConversationStatus(conv.UUID, statusID, "", "", user)
case amodels.ActionSendPrivateNote:
return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
_, err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
if err != nil {
return fmt.Errorf("sending private note: %w", err)
}
case amodels.ActionReply:
// Make recipient list.
to, cc, bcc, err := m.makeRecipients(conv.ID, conv.Contact.Email.String, conv.InboxMail)
if err != nil {
return fmt.Errorf("making recipients for reply action: %w", err)
}
return m.SendReply(
_, err = m.SendReply(
[]mmodels.Media{},
conv.InboxID,
user.ID,
@@ -1033,6 +1036,9 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
bcc,
map[string]any{}, /**meta**/
)
if err != nil {
return fmt.Errorf("sending reply: %w", err)
}
case amodels.ActionSetSLA:
slaID, err := strconv.Atoi(action.Value[0])
if err != nil {
@@ -1046,6 +1052,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
default:
return fmt.Errorf("unknown action: %s", action.Type)
}
return nil
}
// RemoveConversationAssignee removes the assignee from the conversation.
@@ -1089,10 +1096,16 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
// Make recipient list.
to, cc, bcc, err := m.makeRecipients(conversation.ID, conversation.Contact.Email.String, conversation.InboxMail)
if err != nil {
return fmt.Errorf("making recipients for CSAT reply: %w", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
}
return m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta)
// Send CSAT reply.
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta)
if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
}
return nil
}
// DeleteConversation deletes a conversation.
@@ -1356,7 +1369,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati
var assignee umodels.User
if conversation.AssignedUserID.Int > 0 {
var err error
assignee, err = m.userStore.GetAgent(conversation.AssignedUserID.Int, "")
assignee, err = m.userStore.Get(conversation.AssignedUserID.Int, "", "")
if err != nil {
m.lo.Error("error fetching conversation assignee", "conversation_uuid", conversation.UUID, "error", err)
} else {

View File

@@ -369,8 +369,8 @@ func (m *Manager) MarkMessageAsPending(uuid string) error {
return nil
}
// SendPrivateNote inserts a private message for a conversation.
func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) error {
// SendPrivateNote inserts a private message in a conversation.
func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) (models.Message, error) {
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
@@ -382,18 +382,21 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
Private: true,
Media: media,
}
return m.InsertMessage(&message)
if err := m.InsertMessage(&message); err != nil {
return models.Message{}, err
}
return message, nil
}
// SendReply inserts a reply message for a conversation.
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]any) error {
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]any) (models.Message, error) {
inboxRecord, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil {
return err
return models.Message{}, err
}
if !inboxRecord.Enabled {
return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil)
return models.Message{}, envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil)
}
var sourceID = ""
@@ -413,18 +416,18 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID
metaMap["bcc"] = bcc
}
if len(to) == 0 {
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil)
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil)
}
sourceID, err = stringutil.GenerateEmailMessageID(conversationUUID, inboxRecord.From)
if err != nil {
m.lo.Error("error generating source message id", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil)
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil)
}
case inbox.ChannelLiveChat:
sourceID, err = stringutil.RandomAlphanumeric(35)
if err != nil {
m.lo.Error("error generating random source id", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil)
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil)
}
sourceID = "livechat-" + sourceID
}
@@ -433,7 +436,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID
metaJSON, err := json.Marshal(metaMap)
if err != nil {
m.lo.Error("error marshalling message meta map to JSON", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil)
return models.Message{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil)
}
// Insert the message into the database
@@ -452,19 +455,16 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID
Meta: metaJSON,
}
if err := m.InsertMessage(&message); err != nil {
return err
return models.Message{}, err
}
return nil
return message, nil
}
// InsertMessage inserts a message and attaches the media to the message.
func (m *Manager) InsertMessage(message *models.Message) error {
// Private message is always sent.
if message.Private {
message.Status = models.MessageStatusSent
}
if len(message.Meta) == 0 || string(message.Meta) == "null" {
message.Meta = json.RawMessage(`{}`)
}
@@ -479,7 +479,7 @@ func (m *Manager) InsertMessage(message *models.Message) error {
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil)
}
// Attach message to the media in DB.
// Attach just inserted message to the media.
for _, media := range message.Media {
m.mediaStore.Attach(media.ID, mmodels.ModelMessages, message.ID)
}
@@ -546,16 +546,22 @@ func (m *Manager) InsertMessage(message *models.Message) error {
// Broadcast new message.
m.BroadcastNewMessage(message)
// Refetch message and send webhook event for message created.
updatedMessage, err := m.GetMessage(message.UUID)
if err != nil {
m.lo.Error("error fetching updated message for webhook event", "uuid", message.UUID, "error", err)
} else {
m.webhookStore.TriggerEvent(wmodels.EventMessageCreated, updatedMessage)
// Refetch message if this message has media attachments, as media gets linked after inserting the message.
if len(message.Media) > 0 {
refetchedMessage, err := m.GetMessage(message.UUID)
if err != nil {
m.lo.Error("error fetching message after insert", "error", err)
} else {
// Replace the message in the struct with the refetched message.
*message = refetchedMessage
}
}
// Handle AI completion for AI assistant conversations.
m.enqueueMessageForAICompletion(updatedMessage, message)
// Enqueue for AI completion.
m.enqueueMessageForAICompletion(*message)
// Trigger webhook for new message created.
m.webhookStore.TriggerEvent(wmodels.EventMessageCreated, message)
return nil
}
@@ -614,7 +620,7 @@ func (m *Manager) InsertConversationActivity(activityType, conversationUUID, new
content, err := m.getMessageActivityContent(activityType, newValue, actor.FullName())
if err != nil {
m.lo.Error("error could not generate activity content", "error", err)
return err
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.activityMessage}"), nil)
}
message := models.Message{
@@ -1061,22 +1067,21 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
return nil
}
// enqueueMessageForAICompletion enqueues message for AI completion if the conversation is assigned to an AI assistant
// and if the inbox has help center attached.
func (m *Manager) enqueueMessageForAICompletion(updatedMessage models.Message, message *models.Message) {
// enqueueMessageForAICompletion enqueues message for AI completion if the conversation is assigned to an AI assistant and if the inbox has help center attached.
func (m *Manager) enqueueMessageForAICompletion(message models.Message) {
if m.aiStore == nil {
m.lo.Warn("AI store not configured, skipping AI completion request")
return
}
// Only process incoming messages from contacts.
if updatedMessage.Type != models.MessageIncoming || updatedMessage.SenderType != models.SenderTypeContact {
if message.Type != models.MessageIncoming || message.SenderType != models.SenderTypeContact {
return
}
conversation, err := m.GetConversation(updatedMessage.ConversationID, "")
conversation, err := m.GetConversation(message.ConversationID, "")
if err != nil {
m.lo.Error("error fetching conversation for AI completion", "conversation_id", updatedMessage.ConversationID, "error", err)
m.lo.Error("error fetching conversation for AI completion", "conversation_id", message.ConversationID, "error", err)
return
}

View File

@@ -526,6 +526,7 @@ SELECT
m.sender_type,
m.sender_id,
m.meta,
c.uuid as conversation_uuid,
COALESCE(
json_agg(
json_build_object(
@@ -540,10 +541,11 @@ SELECT
'[]'::json
) AS attachments
FROM conversation_messages m
INNER JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN media ON media.model_type = 'messages' AND media.model_id = m.id
WHERE m.uuid = $1
GROUP BY
m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type
m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type, c.uuid
ORDER BY m.created_at;
-- name: get-messages
@@ -602,9 +604,9 @@ inserted_msg AS (
$1, $2, (SELECT id FROM conversation_id),
$5, $6, $7, $8, $9, $10, $11, $12
)
RETURNING id, uuid, created_at, conversation_id
RETURNING *
)
SELECT id, uuid, created_at FROM inserted_msg;
SELECT * FROM inserted_msg;
-- name: message-exists-by-source-id
SELECT conversation_id

View File

@@ -89,7 +89,7 @@ func New(opts Opts) (*Manager, error) {
db: opts.DB,
deliveryQueue: make(chan DeliveryTask, opts.QueueSize),
httpClient: &http.Client{
Timeout: 10 * time.Second,
Timeout: opts.Timeout,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,

View File

@@ -92,7 +92,7 @@
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
transition: transform 0.3s ease;
`;
@@ -174,8 +174,8 @@
this.iframe.style.cssText = `
position: fixed;
border: none;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
border-radius: 12px;
box-shadow: 0 5px 80px rgba(0,0,0,0.3);
z-index: 9999;
width: 400px;
height: 700px;
@@ -293,17 +293,17 @@
this.iframe.style.display = 'block';
this.iframe.style.position = 'fixed';
this.iframe.style.width = '400px';
this.iframe.style.borderRadius = '10px';
this.iframe.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)';
this.iframe.style.borderRadius = '12px';
this.iframe.style.boxShadow = '0 5px 40px rgba(0,0,0,0.2)';
this.iframe.style.top = '';
this.iframe.style.left = '';
this.widgetButtonWrapper.style.display = '';
// Apply expanded or normal height based on current state
if (this.isExpanded) {
this.iframe.style.height = '100vh';
this.iframe.style.bottom = '0';
this.iframe.style.top = '0';
this.iframe.style.width = '650px';
this.iframe.style.height = 'calc(100vh - 110px)';
this.iframe.style.bottom = '90px';
} else {
this.iframe.style.height = '700px';
this.setLauncherPosition();
@@ -323,7 +323,6 @@
if (this.iframe) {
this.iframe.style.display = 'none';
this.isChatVisible = false;
this.isExpanded = false;
this.toggleButton.style.transform = 'scale(1)';
this.widgetButtonWrapper.style.display = '';
@@ -348,13 +347,11 @@
if (this.iframe && this.isChatVisible && !this.isMobile) {
this.isExpanded = true;
// Expand to a larger size (wider and taller)
this.iframe.style.width = '600px';
this.iframe.style.height = '80vh';
this.iframe.style.maxHeight = '800px';
// Set launcher position to avoid covering it
this.setLauncherPosition();
// Expand to nearly full viewport height with gaps and wider
this.iframe.style.width = '650px';
this.iframe.style.height = 'calc(100vh - 110px)';
this.iframe.style.bottom = '90px';
this.iframe.style.maxHeight = '';
// Send expanded state to iframe
this.iframe.contentWindow.postMessage({
@@ -368,12 +365,13 @@
if (this.iframe && this.isChatVisible && !this.isMobile) {
this.isExpanded = false;
// Reset to original size
// Reset to original size and position
this.iframe.style.width = '400px';
this.iframe.style.height = '700px';
this.iframe.style.maxHeight = 'none';
this.iframe.style.maxHeight = '';
this.iframe.style.top = '';
// Set launcher position to avoid covering it
// Restore launcher position
this.setLauncherPosition();
// Send collapsed state to iframe