Files
huly-platform/plugins/communication-resources/src/actions.ts
T
2026-03-02 19:18:11 +07:00

460 lines
14 KiB
TypeScript

// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import communication, {
type Applet,
type MessageAction,
type MessageActionFunction,
type MessageActionVisibilityTester
} from '@hcengineering/communication'
import { languageStore, showPopup } from '@hcengineering/ui'
import emojiPlugin from '@hcengineering/emoji'
import {
type AppletParams,
type AttachmentID,
type BlobAttachment,
type BlobParams,
type LinkPreviewParams,
linkPreviewType,
type Message,
type MessageID,
MessageType
} from '@hcengineering/communication-types'
import cardPlugin, { type Card, type MasterTag } from '@hcengineering/card'
import { addRefreshListener, deleteFile, getClient, getCommunicationClient } from '@hcengineering/presentation'
import {
AccountRole,
fillDefaults,
generateId,
getCurrentAccount,
hasAccountRole,
type MarkupBlobRef,
type Ref
} from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import { employeeByPersonIdStore } from '@hcengineering/contact-resources'
import { getEmployeeBySocialId } from '@hcengineering/contact'
import chat from '@hcengineering/chat'
import { markupToText } from '@hcengineering/text'
import { get } from 'svelte/store'
import { translate as aiTranslate } from '@hcengineering/ai-bot-resources'
import aiBot from '@hcengineering/ai-bot'
import CreateCardFromMessagePopup from './components/CreateCardFromMessagePopup.svelte'
import { Analytics } from '@hcengineering/analytics'
import { isAppletAttachment, isBlobAttachment, isLinkPreviewAttachment } from '@hcengineering/communication-shared'
import { isCardAllowedForCommunications, loadLinkPreviewParams, showForbidden, toggleReaction, toMarkup } from './utils'
import {
isMessageManualTranslating,
messageEditingStore,
showOriginalMessagesStore,
threadCreateMessageStore,
translateMessagesStore,
translateToStore
} from './stores'
import { type AppletDraft, type BlobDraft, type LinkPreviewDraft } from './types'
export const addReaction: MessageActionFunction = async (message, card: Card, evt, onOpen, onClose) => {
if (!isCardAllowedForCommunications(card)) {
await showForbidden()
return
}
if (onOpen !== undefined) onOpen()
showPopup(
emojiPlugin.component.EmojiPopup,
{},
evt?.target as HTMLElement,
async (result) => {
if (onClose !== undefined) onClose()
const emoji = result?.text
if (emoji == null) return
await toggleReaction(message, emoji)
},
() => {}
)
}
export const replyInThread: MessageActionFunction = async (message: Message, parentCard: Card): Promise<void> => {
if (!isCardAllowedForCommunications(parentCard)) {
await showForbidden()
return
}
const thread = message.threads[0]
if (thread != null) {
const client = getClient()
const _id = thread.threadId
const card = await client.findOne(cardPlugin.class.Card, { _id: _id as Ref<Card> })
if (card === undefined) return
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
await r(_id, card)
return
}
await attachCardToMessage(
message,
parentCard,
createThreadTitle(message, parentCard),
chat.masterTag.Thread,
`${parentCard._id}_${message.id}` as Ref<Card>
)
}
export async function attachCardToMessage (
message: Message,
parentCard: Card,
title: string,
type: Ref<MasterTag>,
_id?: Ref<Card>
): Promise<void> {
const client = getClient()
const communicationClient = getCommunicationClient()
const hierarchy = client.getHierarchy()
const threadCardID = _id ?? generateId<Card>()
const author =
get(employeeByPersonIdStore).get(message.creator) ?? (await getEmployeeBySocialId(client, message.creator))
const data = fillDefaults<Card>(
hierarchy,
{
title,
rank: '',
content: '' as MarkupBlobRef,
blobs: {},
parentInfo: [
{
_id: parentCard._id,
_class: parentCard._class,
title: parentCard.title
}
]
},
type
)
await client.createDoc(type, parentCard.space, data, threadCardID)
await communicationClient.attachThread(parentCard._id, message.id, threadCardID, type)
if (author?.active === true && author?.personUuid !== undefined) {
await communicationClient.addCollaborators(threadCardID, type, [author.personUuid])
}
const threadCard = await client.findOne(cardPlugin.class.Card, { _id: threadCardID })
if (threadCard === undefined) return
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
await r(threadCard._id, threadCard)
}
function createThreadTitle (message: Message, parent: Card): string {
const markup = toMarkup(message.content)
const messageText = markupToText(markup).trim()
return messageText.length > 0 ? messageText : `Thread from ${parent.title}`
}
export const canReplyInThread: MessageActionVisibilityTester = (message: Message): boolean => {
return message.type === MessageType.Text && message.extra?.threadRoot !== true
}
export const translateMessage: MessageActionFunction = async (message: Message): Promise<void> => {
const language = get(translateToStore) ?? get(languageStore)
if (isMessageManualTranslating(message.cardId, message.id)) return
const result = get(translateMessagesStore).find((it) => it.cardId === message.cardId && it.messageId === message.id)
showOriginalMessagesStore.update((store) =>
store.filter(([cId, mId]) => cId !== message.cardId || mId !== message.id)
)
if (result != null) return
translateMessagesStore.update((store) => {
store.push({ inProgress: true, messageId: message.id, cardId: message.cardId })
return store
})
const markup = toMarkup(message.content)
const currentTranslate = message?.translates?.[language] ?? ''
const response = currentTranslate !== '' ? toMarkup(currentTranslate) : (await aiTranslate(markup, language))?.text
if (response !== undefined) {
translateMessagesStore.update((store) => {
return store.map((it) => {
if (it.cardId === message.cardId && it.messageId === message.id) {
return {
...it,
inProgress: false,
result: response
}
}
return it
})
})
} else {
translateMessagesStore.update((store) => {
return store.filter((it) => it.cardId !== message.cardId || it.messageId !== message.id)
})
}
}
export const canTranslateMessage: MessageActionVisibilityTester = (message: Message): boolean => {
const url = getMetadata(aiBot.metadata.EndpointURL) ?? ''
if (url === '') return false
return message.type === MessageType.Text
}
export const showOriginalMessage: MessageActionFunction = async (message: Message): Promise<void> => {
const messageId = message.id
showOriginalMessagesStore.update((store) => {
store.push([message.cardId, messageId])
return store
})
}
export const canShowOriginalMessage: MessageActionVisibilityTester = (message: Message): boolean => {
return canTranslateMessage(message)
}
export const editMessage: MessageActionFunction = async (message: Message): Promise<void> => {
messageEditingStore.set(message.id)
}
export const canEditMessage: MessageActionVisibilityTester = (message: Message): boolean => {
if (message.type !== MessageType.Text) return false
const me = getCurrentAccount()
return me.socialIds.includes(message.creator)
}
export const removeMessage: MessageActionFunction = async (message: Message): Promise<void> => {
const communicationClient = getCommunicationClient()
await communicationClient.removeMessage(message.cardId, message.id)
}
export const canRemoveMessage: MessageActionVisibilityTester = (message: Message): boolean => {
if (message.type !== MessageType.Text) return false
const me = getCurrentAccount()
return me.socialIds.includes(message.creator)
}
export const createCard: MessageActionFunction = async (message: Message, card: Card): Promise<void> => {
if (!hasAccountRole(getCurrentAccount(), AccountRole.User)) {
await showForbidden()
return
}
threadCreateMessageStore.set(message)
showPopup(CreateCardFromMessagePopup, { message, card }, undefined, () => {
threadCreateMessageStore.set(undefined)
})
}
export const canCreateCard: MessageActionVisibilityTester = (message: Message): boolean => {
return canReplyInThread(message)
}
let allMessageActions: MessageAction[] | undefined
addRefreshListener(() => {
allMessageActions = undefined
})
export async function getMessageActions (message: Message): Promise<MessageAction[]> {
const client = getClient()
const actions: MessageAction[] =
allMessageActions ?? client.getModel().findAllSync(communication.class.MessageAction, {})
if (allMessageActions === undefined) {
allMessageActions = actions
}
const filteredActions = await filterActions(message, actions)
return filteredActions.sort((a, b) => a.order - b.order)
}
async function filterActions (message: Message, actions: MessageAction[]): Promise<MessageAction[]> {
const result: MessageAction[] = []
for (const action of actions) {
if (action.visibilityTester == null) {
result.push(action)
} else {
const visibilityTester = await getResource(action.visibilityTester)
if (visibilityTester(message)) {
result.push(action)
}
}
}
return result
}
export async function createMessage (
card: Card,
markdown: string,
blobs: BlobDraft[],
applets: AppletDraft[],
appletModels: Applet[],
links: LinkPreviewDraft[],
urlsToLoad: string[],
linksData: Map<string, LinkPreviewParams | null>
): Promise<void> {
const communicationClient = getCommunicationClient()
const client = getClient()
const { messageId } = await communicationClient.createMessage(card._id, card._class, markdown)
void client.update(card, {}, false, Date.now())
void attachApplets(card, messageId, applets, appletModels)
if (blobs.length > 0) {
void communicationClient.attachmentPatch<BlobParams>(card._id, messageId, {
add: blobs.map((it) => ({
id: it.blobId as any as AttachmentID,
mimeType: it.mimeType,
params: it
}))
})
}
if (links.length > 0) {
void communicationClient.attachmentPatch<LinkPreviewParams>(card._id, messageId, {
add: links.map((it) => ({
mimeType: linkPreviewType,
params: it
}))
})
}
for (const url of urlsToLoad) {
if (links.some((it) => it.url === url)) continue
const fetchedData = linksData.get(url)
if (fetchedData === null) continue
const params = fetchedData ?? (await loadLinkPreviewParams(url))
if (params === undefined) continue
void communicationClient.attachmentPatch<LinkPreviewParams>(card._id, messageId, {
add: [
{
mimeType: linkPreviewType,
params
}
]
})
}
}
export async function updateMessage (
card: Card,
message: Message,
markdown: string,
blobs: BlobDraft[],
applets: AppletDraft[],
appletModels: Applet[],
links: LinkPreviewDraft[]
): Promise<void> {
const communicationClient = getCommunicationClient()
await communicationClient.updateMessage(card._id, message.id, markdown)
const attachBlobs = blobs.filter(
(b) => !message.attachments.some((it) => isBlobAttachment(it) && it.params.blobId === b.blobId)
)
void attachApplets(
card,
message.id,
applets.filter((a) => !message.attachments.some((it) => it.id === a.id)),
appletModels
)
if (attachBlobs.length > 0) {
void communicationClient.attachmentPatch<BlobParams>(card._id, message.id, {
add: attachBlobs.map((it) => ({
id: it.blobId as any as AttachmentID,
mimeType: it.mimeType,
params: it
}))
})
}
const detachBlobs = message.attachments.filter(
(it) => isBlobAttachment(it) && !blobs.some((b) => b.blobId === it.params.blobId)
) as BlobAttachment[]
if (detachBlobs.length > 0) {
detachBlobs.forEach((it) => {
void deleteFile(it.params.blobId)
})
void communicationClient.attachmentPatch(card._id, message.id, {
remove: detachBlobs.map((it) => it.id)
})
}
const detachApplets = message.attachments.filter(
(it) => isAppletAttachment(it) && !applets.some((a) => a.id === it.id)
)
if (detachApplets.length > 0) {
void communicationClient.attachmentPatch(card._id, message.id, {
remove: detachApplets.map((it) => it.id)
})
}
const attachLinks = links.filter(
(l) => !message.attachments.some((it) => isLinkPreviewAttachment(it) && it.params.url === l.url)
)
void communicationClient.attachmentPatch(card._id, message.id, {
add: attachLinks.map((it) => ({
mimeType: linkPreviewType,
params: it
}))
})
}
async function attachApplets (
card: Card,
messageId: MessageID,
applets: AppletDraft[],
models: Applet[]
): Promise<void> {
if (applets.length === 0) return
const communicationClient = getCommunicationClient()
const toAttach: AppletDraft[] = []
for (const appletDraft of applets) {
try {
const model = models.find((it) => it._id === appletDraft.appletId)
if (model?.createFn == null) {
toAttach.push(appletDraft)
continue
}
const r = await getResource(model.createFn)
await r(card, messageId, appletDraft.params)
toAttach.push(appletDraft)
} catch (err: any) {
Analytics.handleError(err)
}
}
if (toAttach.length > 0) {
void communicationClient.attachmentPatch<AppletParams>(card._id, messageId, {
add: toAttach.map((it) => ({
mimeType: it.mimeType,
params: it.params
}))
})
}
}