diff --git a/server-plugins/ai-bot-resources/package.json b/server-plugins/ai-bot-resources/package.json index 9b181f48d0b..b7e176e9c4b 100644 --- a/server-plugins/ai-bot-resources/package.json +++ b/server-plugins/ai-bot-resources/package.json @@ -50,6 +50,7 @@ "@hcengineering/server-core": "^0.6.1", "@hcengineering/server-templates": "^0.6.0", "@hcengineering/server-token": "^0.6.11", - "@hcengineering/templates": "^0.6.11" + "@hcengineering/templates": "^0.6.11", + "@hcengineering/text-core": "^0.6.0" } } diff --git a/server-plugins/ai-bot-resources/src/__tests__/index.test.ts b/server-plugins/ai-bot-resources/src/__tests__/index.test.ts new file mode 100644 index 00000000000..70d642e6c50 --- /dev/null +++ b/server-plugins/ai-bot-resources/src/__tests__/index.test.ts @@ -0,0 +1,481 @@ +// +// Copyright © 2024-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 core, { + type Doc, + type Ref, + type Class, + type PersonId, + type Space, + type SocialIdType, + type TxCreateDoc +} from '@hcengineering/core' +import chunter, { type ChatMessage, type ThreadMessage } from '@hcengineering/chunter' +import { type ActivityMessage } from '../../../../plugins/activity/types' +import contact, { type Person, type SocialIdentity } from '@hcengineering/contact' +import { type TriggerControl } from '@hcengineering/server-core' +import { getAccountBySocialKey } from '@hcengineering/server-contact' + +import plugin from '../index' +import * as utils from '../utils' + +jest.mock('../utils', () => ({ + hasAiEndpoint: jest.fn(), + sendAIEvents: jest.fn() +})) + +jest.mock('@hcengineering/server-contact', () => ({ + getAccountBySocialKey: jest.fn() +})) + +const aiBotPersonId = '684d7786acf962fc450c9668' as Ref + +function createSocialIdentity (): SocialIdentity { + return { + _class: contact.class.SocialIdentity, + _id: '1080807657619881985' as Ref & PersonId, + attachedTo: aiBotPersonId, + attachedToClass: contact.class.Person, + collection: 'socialIds', + createdBy: '1080807657619881985' as PersonId, + createdOn: 1749907334330, + key: 'email:huly.ai.bot@hc.engineering', + value: 'email:huly.ai.bot@hc.engineering', + modifiedBy: '1080807657619881985' as PersonId, + modifiedOn: 1749907334330, + space: 'contact:space:Contacts' as Ref, + type: 'email' as SocialIdType + } +} + +function createAiBotPerson (): Doc { + return { + _class: contact.class.Person, + _id: aiBotPersonId, + createdBy: '1080807657619881985' as PersonId, + createdOn: 1749907334289, + modifiedBy: '1080809591990779905' as PersonId, + modifiedOn: 1750284961630, + space: contact.space.Contacts + } +} + +function createChannel (): any { + return { + _class: chunter.class.Channel, + _id: 'chunter:space:Random' as Ref, + archived: false, + autoJoin: true, + createdBy: 'core:account:System', + createdOn: 1749907332178, + description: 'Random Talks', + docUpdateMessages: 1, + members: ['f91a6c38-ba6a-40d6-9fc1-f025134f6041', '4583f5e4-20b6-4fa1-b929-84ea66a7d539'], + messages: 2, + modifiedBy: '1080809591990779905', + modifiedOn: 1750285289466, + name: 'random', + 'notification:mixin:Collaborators': { + collaborators: ['4583f5e4-20b6-4fa1-b929-84ea66a7d539', 'f91a6c38-ba6a-40d6-9fc1-f025134f6041'] + }, + private: false, + space: 'core:space:Space', + topic: 'Random Talks' + } +} + +function createChatMessage (params: { + _id: string + attachedTo: string + attachedToClass: Ref> + collection: string + message: string + space: Ref + createdBy?: PersonId + modifiedBy?: PersonId +}): ChatMessage { + return { + _class: chunter.class.ChatMessage, + _id: params._id as Ref, + attachedTo: params.attachedTo as Ref, + attachedToClass: params.attachedToClass, + attachments: 0, + collection: params.collection, + createdBy: params.createdBy ?? ('1080809591990779905' as PersonId), + createdOn: 1750284961630, + message: params.message, + modifiedBy: params.modifiedBy ?? ('1080809591990779905' as PersonId), + modifiedOn: 1750284961630, + space: params.space + } +} + +function createThreadMessage (params: { + _id: string + attachedTo: string + attachedToClass: Ref> + message: string + objectClass: Ref> + objectId: Ref + space: Ref + createdBy?: PersonId +}): ThreadMessage { + return { + _class: chunter.class.ThreadMessage, + _id: params._id as Ref, + attachedTo: params.attachedTo as Ref, + attachedToClass: params.attachedToClass, + attachments: 0, + collection: 'replies', + createdBy: params.createdBy ?? ('1080809591990779905' as PersonId), + createdOn: 1750322755416, + message: params.message, + modifiedBy: params.createdBy ?? ('1080809591990779905' as PersonId), + modifiedOn: 1750322755416, + objectClass: params.objectClass, + objectId: params.objectId, + space: params.space + } +} + +describe('OnMessageSend', () => { + const mockControl = { + ctx: { + contextData: { + account: { + socialIds: ['1080807657619881985' as PersonId] + } + } + }, + workspace: { + uuid: 'test-workspace-uuid' + }, + hierarchy: { + isDerived: jest.fn((messageClass, target) => messageClass === target) + }, + findAll: jest.fn(), + lowLevel: {}, + branding: {} as any, + txFactory: {} + } + + beforeEach(() => { + ;(utils.hasAiEndpoint as jest.Mock).mockReturnValue(true) + ;(utils.sendAIEvents as jest.Mock).mockImplementation(async () => {}) + ;(utils.sendAIEvents as jest.Mock).mockReset() + ;(getAccountBySocialKey as jest.Mock).mockResolvedValue(null) + ;(getAccountBySocialKey as jest.Mock).mockReset() + mockControl.findAll.mockReset() + }) + + it('should not process messages when AI endpoint is not available', async () => { + ;(utils.hasAiEndpoint as jest.Mock).mockReturnValue(false) + + const message = createChatMessage({ + _id: '68533a9590d9cd9f454b6087', + attachedTo: aiBotPersonId, + attachedToClass: contact.class.Person as Ref>, + collection: 'comments', + message: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Personal message to bot"}]}]}', + space: 'contact:space:Contacts' as Ref + }) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).not.toHaveBeenCalled() + expect(getAccountBySocialKey).not.toHaveBeenCalled() + }) + + it('should process personal message to AI bot', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createAiBotPerson() + const message = createChatMessage({ + _id: '68533a9590d9cd9f454b6087', + attachedTo: aiBotPersonId, + attachedToClass: contact.class.Person, + collection: 'comments', + message: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Personal message to bot"}]}]}', + space: contact.space.Contacts + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [messageDoc]) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + messageId: message._id, + message: message.message, + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: messageDoc.space, + collection: message.collection + }) + ], + expect.anything(), + expect.anything() + ) + }) + + it('AI bot should not reply to self message', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createAiBotPerson() + const message = createChatMessage({ + _id: '68533a9590d9cd9f454b6087', + attachedTo: aiBotPersonId, + attachedToClass: contact.class.Person, + collection: 'comments', + createdBy: socialIdentity._id, + modifiedBy: socialIdentity._id, + message: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"AI bot reply"}]}]}', + space: contact.space.Contacts + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [socialIdentity._id]) + .mockImplementationOnce(async () => [messageDoc]) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).not.toHaveBeenCalled() + }) + + it('should not process channel message without AI bot mention', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createChannel() + const message = createChatMessage({ + _id: '68533b3290d9cd9f454b609f', + attachedTo: 'chunter:space:Random', + attachedToClass: chunter.class.Channel as Ref>, + collection: 'messages', + message: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Channel message"}]}]}', + space: 'chunter:space:Random' as Ref + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [messageDoc]) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).not.toHaveBeenCalled() + }) + + it('should process channel message with AI bot mention', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createChannel() + const message = createChatMessage({ + _id: '68533bdd0be81f9a017418bc', + attachedTo: 'chunter:space:Random', + attachedToClass: chunter.class.Channel as Ref>, + collection: 'messages', + message: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"reference","attrs":{"id":"${aiBotPersonId}","objectclass":"contact:class:Person","label":"Jolie AI"}},{"type":"text","text":" with mention"}]}]}`, + space: 'chunter:space:Random' as Ref + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [messageDoc]) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + messageId: message._id, + message: message.message, + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: messageDoc.space, + collection: message.collection + }) + ], + expect.anything(), + expect.anything() + ) + }) + + it('should process thread message with AI bot mention', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createChannel() + const message = createThreadMessage({ + _id: '6853ce2be8428c3ce73c8a09', + attachedTo: '68533bdd0be81f9a017418bc', + attachedToClass: chunter.class.ChatMessage, + message: `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"reference","attrs":{"id":"${aiBotPersonId}","objectclass":"contact:class:Person","label":"Jolie AI"}},{"type":"text","text":" thread with mention"}]}]}`, + objectClass: chunter.class.Channel, + objectId: 'chunter:space:Random' as Ref>, + space: 'chunter:space:Random' as Ref + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [messageDoc]) + mockControl.hierarchy.isDerived.mockImplementation( + (messageClass: string) => messageClass === chunter.class.ThreadMessage + ) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + messageId: message._id, + message: message.message, + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: messageDoc.space, + collection: message.collection + }) + ], + expect.anything(), + expect.anything() + ) + }) + + it('should not process thread message without AI bot mention', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createChannel() + const message = createThreadMessage({ + _id: '6853cea7e8428c3ce73c8a8e', + attachedTo: '68533bdd0be81f9a017418bc', + attachedToClass: chunter.class.ChatMessage, + message: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"In channel thread"}]}]}', + objectClass: chunter.class.Channel, + objectId: 'chunter:space:Random' as Ref>, + space: 'chunter:space:Random' as Ref + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [messageDoc]) + mockControl.hierarchy.isDerived.mockImplementation( + (messageClass: string) => messageClass === chunter.class.ThreadMessage + ) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).not.toHaveBeenCalled() + }) + + it('should process message in personal AI bot thread', async () => { + const socialIdentity = createSocialIdentity() + const messageDoc = createAiBotPerson() + const message = createThreadMessage({ + _id: '6853cf39e8428c3ce73c8b09', + attachedTo: '68533a9590d9cd9f454b6087', + attachedToClass: chunter.class.ChatMessage, + message: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"In personal thread"}]}]}', + objectClass: contact.class.Person, + objectId: aiBotPersonId, + space: 'contact:space:Contacts' as Ref + }) + + mockControl.findAll + .mockImplementationOnce(async () => [socialIdentity]) + .mockImplementationOnce(async () => [messageDoc]) + mockControl.hierarchy.isDerived.mockImplementation( + (messageClass: string) => messageClass === chunter.class.ThreadMessage + ) + + const tx = { + _class: core.class.TxCreateDoc, + objectId: message._id, + attributes: message + } as unknown as TxCreateDoc + + const plugin_ = await plugin() + const result = await plugin_.trigger.OnMessageSend([tx], mockControl as unknown as TriggerControl) + + expect(result).toEqual([]) + expect(utils.sendAIEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + messageId: message._id, + message: message.message, + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: message.space, + collection: message.collection + }) + ], + expect.anything(), + expect.anything() + ) + }) +}) diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts index 2acf51dbf02..cd1f26887ff 100644 --- a/server-plugins/ai-bot-resources/src/index.ts +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -16,6 +16,7 @@ import core, { Doc, PersonId, + Ref, systemAccountUuid, Tx, TxCreateDoc, @@ -29,6 +30,8 @@ import { getAccountBySocialKey } from '@hcengineering/server-contact' import { aiBotEmailSocialKey, AIEventRequest } from '@hcengineering/ai-bot' import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' import contact from '@hcengineering/contact' +import { type SocialIdentity } from '@hcengineering/contact' +import { extractReferences, markupToJSON } from '@hcengineering/text-core' import { createAccountRequest, hasAiEndpoint, sendAIEvents } from './utils' @@ -63,10 +66,7 @@ async function OnUserStatus (txes: TxCUD[], control: TriggerControl) } } - const socialIdentity = ( - await control.findAll(control.ctx, contact.class.SocialIdentity, { key: aiBotEmailSocialKey }, { limit: 1 }) - )[0] - + const socialIdentity = await findAiBotSocialIdentity(control) if (socialIdentity === undefined) { await createAccountRequest(control.workspace.uuid, control.ctx) return [] @@ -80,44 +80,95 @@ async function OnMessageSend (originTxs: TxCreateDoc[], control: Tr if (!hasAiEndpoint()) { return [] } - const { account } = control.ctx.contextData - const primaryIdentity = ( + + const aiBotSocialIdentity = await findAiBotSocialIdentity(control) + if (aiBotSocialIdentity === undefined) { + return [] + } + + const allAiSocialIds = await findAiBotAllSocialIds(control, aiBotSocialIdentity) + const notAiBotTxes = originTxs.filter((it) => !allAiSocialIds.includes(it.modifiedBy)) + + for (const notAiBotTx of notAiBotTxes) { + const message = TxProcessor.createDoc2Doc(notAiBotTx) + const messageDoc = await getMessageDoc(message, control) + + if (messageDoc !== undefined) { + const aiBotShouldReply = await isAiBotShouldReply(control, message, messageDoc, aiBotSocialIdentity) + if (aiBotShouldReply) { + await OnAiBotShouldReply(control, message, messageDoc) + } + } + } + + return [] +} + +async function findAiBotSocialIdentity (control: TriggerControl): Promise { + return ( await control.findAll(control.ctx, contact.class.SocialIdentity, { key: aiBotEmailSocialKey }, { limit: 1 }) )[0] +} - if (primaryIdentity === undefined) return [] +async function findAiBotAllSocialIds (control: TriggerControl, socialIdentity: SocialIdentity): Promise { + const { account } = control.ctx.contextData + if (account.socialIds.includes(socialIdentity._id)) { + return account.socialIds + } - const allAiSocialIds: PersonId[] = account.socialIds.includes(primaryIdentity._id) - ? account.socialIds - : ( - await control.findAll(control.ctx, contact.class.SocialIdentity, { - attachedTo: primaryIdentity.attachedTo - }) - ).map((it) => it._id) + return ( + await control.findAll(control.ctx, contact.class.SocialIdentity, { attachedTo: socialIdentity.attachedTo }) + ).map((it) => it._id) +} - const { hierarchy } = control - const txes = originTxs.filter((it) => !allAiSocialIds.includes(it.modifiedBy)) +function isThreadMessage (control: TriggerControl, message: ChatMessage): boolean { + return control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage) +} - if (txes.length === 0) { - return [] +async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise { + if (isThreadMessage(control, message)) { + const thread = message as ThreadMessage + const _id = thread.objectId + const _class = thread.objectClass + + return (await control.findAll(control.ctx, _class, { _id }))[0] } + const _id = message.attachedTo + const _class = message.attachedToClass - for (const tx of txes) { - const message = TxProcessor.createDoc2Doc(tx) + return (await control.findAll(control.ctx, _class, { _id }))[0] +} - const isThread = hierarchy.isDerived(tx.objectClass, chunter.class.ThreadMessage) - const docClass = isThread ? (message as ThreadMessage).objectClass : message.attachedToClass +async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise { + const account = await getAccountBySocialKey(control, aiBotEmailSocialKey) + if (account == null) { + return false + } - if (!hierarchy.isDerived(docClass, chunter.class.DirectMessage)) { - continue - } + return direct.members.length === 2 && direct.members.includes(account) +} - if (docClass === chunter.class.DirectMessage) { - await onBotDirectMessageSend(control, message) - } - } +async function isAiBotShouldReply ( + control: TriggerControl, + message: ChatMessage, + messageDoc: Doc, + aiBotSocialIdentity: SocialIdentity +): Promise { + const aiBotPersonId = aiBotSocialIdentity.attachedTo - return [] + const isDirect = + messageDoc._class === chunter.class.DirectMessage && (await isDirectAvailable(messageDoc as DirectMessage, control)) + const isAiBotPersonalChat = messageDoc._id === aiBotPersonId + const isAiBotMentioned = isDocMentioned(aiBotPersonId, message.message) + + return isDirect || isAiBotPersonalChat || isAiBotMentioned +} + +export function isDocMentioned (doc: Ref, content: string): boolean { + const node = markupToJSON(content) + const references = extractReferences(node) + + return references.some((ref) => ref.objectId === doc) } function getMessageData (doc: Doc, message: ChatMessage): AIEventRequest { @@ -148,51 +199,11 @@ function getThreadMessageData (message: ThreadMessage): AIEventRequest { } } -async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise { - if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { - const thread = message as ThreadMessage - const _id = thread.objectId - const _class = thread.objectClass - - return (await control.findAll(control.ctx, _class, { _id }))[0] - } else { - const _id = message.attachedTo - const _class = message.attachedToClass - - return (await control.findAll(control.ctx, _class, { _id }))[0] - } -} - -async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise { - const { members } = direct - const account = await getAccountBySocialKey(control, aiBotEmailSocialKey) - - if (account == null) { - return false - } +async function OnAiBotShouldReply (control: TriggerControl, message: ChatMessage, messageDoc: Doc): Promise { + const messageEvent = isThreadMessage(control, message) + ? getThreadMessageData(message as ThreadMessage) + : getMessageData(messageDoc, message) - if (!members.includes(account)) { - return false - } - - return members.length === 2 -} - -async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { - const direct = (await getMessageDoc(message, control)) as DirectMessage - if (direct === undefined) { - return - } - const isAvailable = await isDirectAvailable(direct, control) - if (!isAvailable) { - return - } - let messageEvent: AIEventRequest - if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { - messageEvent = getThreadMessageData(message as ThreadMessage) - } else { - messageEvent = getMessageData(direct, message) - } await sendAIEvents([messageEvent], control.workspace.uuid, control.ctx) }