From d26a03f814e0a03350d732055e1b824716a4d0ad Mon Sep 17 00:00:00 2001 From: Kristina Date: Fri, 16 Aug 2024 11:02:24 +0400 Subject: [PATCH] Fix analytics collector and ai-bot services (#6331) Signed-off-by: Kristina Fefelova --- .vscode/launch.json | 22 ----- dev/prod/config.json | 3 +- models/all/src/migration.ts | 4 +- models/analytics-collector/package.json | 2 + models/analytics-collector/src/index.ts | 1 + models/analytics-collector/src/migration.ts | 56 +++++++++++ models/server-ai-bot/src/index.ts | 1 + plugins/ai-bot/src/index.ts | 1 + .../src/channelDataProvider.ts | 16 +++- .../src/components/ChannelScrollView.svelte | 22 +++-- plugins/chunter-resources/src/utils.ts | 4 +- .../src/inboxNotificationsClient.ts | 8 +- plugins/view-resources/src/middleware.ts | 1 + .../logs/server-combined-2024-08-14.log.gz | Bin 0 -> 19808 bytes server-plugins/ai-bot-resources/src/index.ts | 79 ++++++++------- .../src/utils.ts | 11 ++- services/ai-bot/pod-ai-bot/src/controller.ts | 12 ++- services/ai-bot/pod-ai-bot/src/loaders.ts | 25 +++++ services/ai-bot/pod-ai-bot/src/start.ts | 2 + .../ai-bot/pod-ai-bot/src/workspaceClient.ts | 90 ++++++++++++------ .../pod-analytics-collector/package.json | 2 + .../pod-analytics-collector/src/account.ts | 22 ++++- .../pod-analytics-collector/src/collector.ts | 90 ++++++++++++++---- .../pod-analytics-collector/src/config.ts | 4 +- .../pod-analytics-collector/src/loaders.ts | 15 +++ .../pod-analytics-collector/src/main.ts | 31 ++++-- .../src/workspaceClient.ts | 81 +++++++++++++--- 27 files changed, 447 insertions(+), 158 deletions(-) create mode 100644 models/analytics-collector/src/migration.ts create mode 100644 pods/server/logs/server-combined-2024-08-14.log.gz create mode 100644 services/ai-bot/pod-ai-bot/src/loaders.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index ac44c4ca15f..89756526432 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -347,28 +347,6 @@ "cwd": "${workspaceRoot}/services/rekoni", "protocol": "inspector" }, - { - "name": "Debug AI bot", - "type": "node", - "request": "launch", - "args": ["src/index.ts"], - "env": { - "ACCOUNTS_URL": "http://localhost:3000", - "MONGO_URL": "mongodb://localhost:27017", - "PORT": "4008", - "SERVER_SECRET": "secret", - "SUPPORT_WORKSPACE": "support", - "FIRST_NAME": "Jolie", - "LAST_NAME": "AI", - "AVATAR_PATH": "./assets/avatar.png", - "AVATAR_CONTENT_TYPE": ".png" - }, - "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], - "sourceMaps": true, - "cwd": "${workspaceRoot}/services/ai-bot/pod-ai-bot", - "protocol": "inspector", - "outputCapture": "std" - }, { "name": "Debug analytics collector", "type": "node", diff --git a/dev/prod/config.json b/dev/prod/config.json index d5d5272eb85..f292e076a83 100644 --- a/dev/prod/config.json +++ b/dev/prod/config.json @@ -5,5 +5,6 @@ "UPLOAD_URL":"/files", "REKONI_URL": "http://localhost:4004", "PRINT_URL": "http://localhost:4005", - "SIGN_URL": "http://localhost:4006" + "SIGN_URL": "http://localhost:4006", + "ANALYTICS_COLLECTOR_URL":"http://localhost:4007" } \ No newline at end of file diff --git a/models/all/src/migration.ts b/models/all/src/migration.ts index 22b183a33bb..c919fd53342 100644 --- a/models/all/src/migration.ts +++ b/models/all/src/migration.ts @@ -50,6 +50,7 @@ import { trainingOperation } from '@hcengineering/model-training' import { documentsOperation } from '@hcengineering/model-controlled-documents' import { productsOperation } from '@hcengineering/model-products' import { requestOperation } from '@hcengineering/model-request' +import { analyticsCollectorOperation } from '@hcengineering/model-analytics-collector' export const migrateOperations: [string, MigrateOperation][] = [ ['core', coreOperation], @@ -88,5 +89,6 @@ export const migrateOperations: [string, MigrateOperation][] = [ ['activityServer', activityServerOperation], ['textEditorOperation', textEditorOperation], // We should call notification migration after activityServer and chunter - ['notification', notificationOperation] + ['notification', notificationOperation], + ['analyticsCollector', analyticsCollectorOperation] ] diff --git a/models/analytics-collector/package.json b/models/analytics-collector/package.json index 43728ae52c0..68c30d5268b 100644 --- a/models/analytics-collector/package.json +++ b/models/analytics-collector/package.json @@ -32,6 +32,8 @@ "@hcengineering/chunter": "^0.6.20", "@hcengineering/core": "^0.6.32", "@hcengineering/model": "^0.6.11", + "@hcengineering/model-activity": "^0.6.0", + "@hcengineering/model-notification": "^0.6.0", "@hcengineering/model-chunter": "^0.6.0", "@hcengineering/model-core": "^0.6.0", "@hcengineering/model-view": "^0.6.0", diff --git a/models/analytics-collector/src/index.ts b/models/analytics-collector/src/index.ts index e46917052b8..999ef785d4b 100644 --- a/models/analytics-collector/src/index.ts +++ b/models/analytics-collector/src/index.ts @@ -21,6 +21,7 @@ import { TChannel } from '@hcengineering/model-chunter' import analyticsCollector from './plugin' export { analyticsCollectorId } from '@hcengineering/analytics-collector' +export { analyticsCollectorOperation } from './migration' export default analyticsCollector @Mixin(analyticsCollector.mixin.AnalyticsChannel, chunter.class.Channel) diff --git a/models/analytics-collector/src/migration.ts b/models/analytics-collector/src/migration.ts new file mode 100644 index 00000000000..ee80e23af20 --- /dev/null +++ b/models/analytics-collector/src/migration.ts @@ -0,0 +1,56 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// +// 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 { + type MigrateOperation, + type MigrationClient, + type MigrationUpgradeClient, + tryMigrate +} from '@hcengineering/model' +import analyticsCollector, { analyticsCollectorId } from '@hcengineering/analytics-collector' +import { DOMAIN_SPACE } from '@hcengineering/model-core' +import { DOMAIN_DOC_NOTIFY, DOMAIN_NOTIFICATION } from '@hcengineering/model-notification' +import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity' + +async function removeAnalyticsChannels (client: MigrationClient): Promise { + const channels = await client.find(DOMAIN_SPACE, { + [`${analyticsCollector.mixin.AnalyticsChannel}`]: { $exists: true } + }) + + if (channels.length === 0) { + return + } + + const channelsIds = channels.map((it) => it._id) + const contexts = await client.find(DOMAIN_DOC_NOTIFY, { objectId: { $in: channelsIds } }) + const contextsIds = contexts.map((it) => it._id) + + await client.deleteMany(DOMAIN_ACTIVITY, { attachedTo: { $in: channelsIds } }) + await client.deleteMany(DOMAIN_NOTIFICATION, { docNotifyContext: { $in: contextsIds } }) + await client.deleteMany(DOMAIN_DOC_NOTIFY, { _id: { $in: contextsIds } }) + await client.deleteMany(DOMAIN_SPACE, { [`${analyticsCollector.mixin.AnalyticsChannel}`]: { $exists: true } }) +} + +export const analyticsCollectorOperation: MigrateOperation = { + async migrate (client: MigrationClient): Promise { + await tryMigrate(client, analyticsCollectorId, [ + { + state: 'remove-analytics-channels-v1', + func: removeAnalyticsChannels + } + ]) + }, + async upgrade (state: Map>, client: () => Promise): Promise {} +} diff --git a/models/server-ai-bot/src/index.ts b/models/server-ai-bot/src/index.ts index 25bf6d947f4..077c9f9d160 100644 --- a/models/server-ai-bot/src/index.ts +++ b/models/server-ai-bot/src/index.ts @@ -65,6 +65,7 @@ export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEve toEmail!: string toWorkspace!: string fromWorkspace!: string + fromWorkspaceUrl!: string messageId!: Ref parentMessageId?: Ref } diff --git a/plugins/ai-bot/src/index.ts b/plugins/ai-bot/src/index.ts index 4a58934ee88..376004992de 100644 --- a/plugins/ai-bot/src/index.ts +++ b/plugins/ai-bot/src/index.ts @@ -39,6 +39,7 @@ export interface AIBotTransferEvent extends AIBotEvent { toEmail: string toWorkspace: string fromWorkspace: string + fromWorkspaceUrl: string messageId: Ref parentMessageId?: Ref } diff --git a/plugins/chunter-resources/src/channelDataProvider.ts b/plugins/chunter-resources/src/channelDataProvider.ts index db3126ba622..722d2ce49ef 100644 --- a/plugins/chunter-resources/src/channelDataProvider.ts +++ b/plugins/chunter-resources/src/channelDataProvider.ts @@ -244,7 +244,7 @@ export class ChannelDataProvider implements IChannelDataProvider { this.isTailLoading.set(true) const tailStart = metadata[startIndex]?.createdOn this.loadTail(tailStart) - this.backwardNextPromise = this.loadNext('backward', metadata[startIndex]?.createdOn, this.limit) + this.backwardNextPromise = this.loadNext('backward', metadata[startIndex]?.createdOn, this.limit, false) } else { const newStart = Math.max(startPosition - this.limit / 2, 0) await this.loadMore('forward', metadata[newStart]?.createdOn, this.limit) @@ -309,7 +309,7 @@ export class ChannelDataProvider implements IChannelDataProvider { return index !== -1 ? metadata.length - index : -1 } - async loadChunk (isBackward: boolean, loadAfter: Timestamp, limit?: number): Promise { + async loadChunk (isBackward: boolean, loadAfter: Timestamp, limit?: number, equal = true): Promise { const client = getClient() const skipIds = this.getChunkSkipIds(loadAfter) @@ -319,7 +319,13 @@ export class ChannelDataProvider implements IChannelDataProvider { attachedTo: this.chatId, space: this.space, _id: { $nin: skipIds }, - createdOn: isBackward ? { $lte: loadAfter } : { $gte: loadAfter } + createdOn: equal + ? isBackward + ? { $lte: loadAfter } + : { $gte: loadAfter } + : isBackward + ? { $lt: loadAfter } + : { $gt: loadAfter } }, { limit: limit ?? this.limit, @@ -359,7 +365,7 @@ export class ChannelDataProvider implements IChannelDataProvider { .map(({ _id }) => _id) } - async loadNext (mode: LoadMode, loadAfter?: Timestamp, limit?: number): Promise { + async loadNext (mode: LoadMode, loadAfter?: Timestamp, limit?: number, equal = true): Promise { if (this.chatId === undefined || loadAfter === undefined) { return } @@ -384,7 +390,7 @@ export class ChannelDataProvider implements IChannelDataProvider { return } - const chunk = await this.loadChunk(isBackward, loadAfter, limit) + const chunk = await this.loadChunk(isBackward, loadAfter, limit, equal) if (chunk !== undefined && isBackward) { this.backwardNextStore.set(chunk) diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index 01897341af0..1686337a8dd 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -25,7 +25,7 @@ canGroupMessages, messageInFocus } from '@hcengineering/activity-resources' - import { Class, Doc, getDay, Ref, Timestamp } from '@hcengineering/core' + import { Class, Doc, generateId, getDay, Ref, Timestamp } from '@hcengineering/core' import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources' import { getResource } from '@hcengineering/platform' import { getClient } from '@hcengineering/presentation' @@ -657,7 +657,7 @@ } } - function handleScrollDown (): void { + async function handleScrollDown (): Promise { selectedMessageId = undefined messageInFocus.set(undefined) @@ -665,8 +665,6 @@ const lastMetadata = metadata[metadata.length - 1] const lastMessage = displayMessages[displayMessages.length - 1] - void inboxClient.readDoc(client, objectId) - if (lastMetadata._id !== lastMessage._id) { separatorIndex = -1 provider.jumpToEnd(true) @@ -674,12 +672,17 @@ } else { scrollToBottom() } + + const op = client.apply(generateId(), 'chunter.scrollDown') + await inboxClient.readDoc(op, objectId) + await op.commit() } - $: forceReadContext(isScrollAtBottom, notifyContext) + let forceRead = false + $: void forceReadContext(isScrollAtBottom, notifyContext) - function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): void { - if (context === undefined || !isScrollAtBottom) return + async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise { + if (context === undefined || !isScrollAtBottom || forceRead || !separatorElement) return const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context if (lastViewedTimestamp >= lastUpdateTimestamp) return @@ -688,7 +691,10 @@ const unViewed = notifications.filter(({ isViewed }) => !isViewed) if (unViewed.length === 0) { - void inboxClient.readDoc(client, objectId) + forceRead = true + const op = client.apply(generateId(), 'chunter.forceReadContext') + await inboxClient.readDoc(op, objectId) + await op.commit() } } diff --git a/plugins/chunter-resources/src/utils.ts b/plugins/chunter-resources/src/utils.ts index 67738b86728..0888155898b 100644 --- a/plugins/chunter-resources/src/utils.ts +++ b/plugins/chunter-resources/src/utils.ts @@ -417,7 +417,7 @@ export function recheckNotifications (context: DocNotifyContext): void { const toReadData = Array.from(toRead) toRead.clear() void (async () => { - const _client = client.apply(generateId()) + const _client = client.apply(generateId(), 'recheckNotifications') await inboxClient.readNotifications(_client, toReadData) await _client.commit() })() @@ -434,7 +434,7 @@ export async function readChannelMessages ( const inboxClient = InboxNotificationsClientImpl.getClient() - const client = getClient().apply(generateId()) + const client = getClient().apply(generateId(), 'readViewportMessages') try { const readMessages = get(chatReadMessagesStore) const allIds = getAllIds(messages).filter((id) => !readMessages.has(id)) diff --git a/plugins/notification-resources/src/inboxNotificationsClient.ts b/plugins/notification-resources/src/inboxNotificationsClient.ts index 6e7a2b1b7ec..e666ba7dff2 100644 --- a/plugins/notification-resources/src/inboxNotificationsClient.ts +++ b/plugins/notification-resources/src/inboxNotificationsClient.ts @@ -157,12 +157,14 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { return } - const inboxNotifications = (get(this.inboxNotifications) ?? []).filter( - (notification) => notification.docNotifyContext === docNotifyContext._id && !notification.isViewed + const inboxNotifications = await client.findAll( + notification.class.InboxNotification, + { docNotifyContext: docNotifyContext._id, isViewed: false }, + { projection: { _id: 1, _class: 1, space: 1 } } ) for (const notification of inboxNotifications) { - await client.update(notification, { isViewed: true }) + await client.updateDoc(notification._class, notification.space, notification._id, { isViewed: true }) } await client.update(docNotifyContext, { lastViewedTimestamp: Date.now() }) } diff --git a/plugins/view-resources/src/middleware.ts b/plugins/view-resources/src/middleware.ts index 5f13406683e..8825bce73df 100644 --- a/plugins/view-resources/src/middleware.ts +++ b/plugins/view-resources/src/middleware.ts @@ -308,6 +308,7 @@ export class AnalyticsMiddleware extends BasePresentationMiddleware implements P } if (TxProcessor.isExtendsCUD(etx._class)) { const cud = etx as TxCUD + if (cud.objectClass === core.class.BenchmarkDoc) continue const _class = this.client.getHierarchy().getClass(cud.objectClass) if (_class.label !== undefined) { const label = await translate(_class.label, {}, 'en') diff --git a/pods/server/logs/server-combined-2024-08-14.log.gz b/pods/server/logs/server-combined-2024-08-14.log.gz new file mode 100644 index 0000000000000000000000000000000000000000..96e0332f96b9e7dbe212dcec306ec8686090bba8 GIT binary patch literal 19808 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 Latxp7zU2b|u|t!O literal 0 HcmV?d00001 diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts index 8e89b65ee77..888f0bbbd38 100644 --- a/server-plugins/ai-bot-resources/src/index.ts +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -182,31 +182,31 @@ function getSupportWorkspaceId (): string | undefined { return supportWorkspaceId } -async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { +async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { const supportWorkspaceId = getSupportWorkspaceId() if (supportWorkspaceId === undefined) { - return [] + return + } + + const account = control.modelDb.findAllSync(contact.class.PersonAccount, { + _id: (message.createdBy ?? message.modifiedBy) as Ref + })[0] + + if (account === undefined || account.role !== AccountRole.Owner) { + return } const direct = (await getMessageDoc(message, control)) as DirectMessage if (direct === undefined) { - return [] + return } const isAvailable = await isDirectAvailable(direct, control) if (!isAvailable) { - return [] - } - - const account = control.modelDb.findAllSync(contact.class.PersonAccount, { - _id: (message.createdBy ?? message.modifiedBy) as Ref - })[0] - - if (account === undefined || account.role !== AccountRole.Owner) { - return [] + return } let data: Data | undefined @@ -218,7 +218,7 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes } if (data === undefined) { - return [] + return } const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { @@ -228,34 +228,34 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes toWorkspace: supportWorkspaceId, toEmail: account.email, fromWorkspace: toWorkspaceString(control.workspace), + fromWorkspaceUrl: control.workspace.workspaceUrl, messageId: message._id, parentMessageId: await getThreadParent(control, message) }) + await control.apply([eventTx]) await processWorkspace(control) - - return [eventTx] } -async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise { +async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise { const supportWorkspaceId = getSupportWorkspaceId() if (supportWorkspaceId === undefined) { - return [] + return } if (toWorkspaceString(control.workspace) !== supportWorkspaceId) { - return [] + return } const channel = await getMessageDoc(message, control) if (channel === undefined) { - return [] + return } if (!control.hierarchy.hasMixin(channel, analytics.mixin.AnalyticsChannel)) { - return [] + return } const mixin = control.hierarchy.as(channel, analytics.mixin.AnalyticsChannel) @@ -270,23 +270,24 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat } if (data === undefined) { - return [] + return } - await processWorkspace(control) + const tx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + messageClass: data.messageClass, + message: message.message, + collection: data.collection, + toEmail: email, + toWorkspace: workspace, + fromWorkspace: toWorkspaceString(control.workspace), + fromWorkspaceUrl: control.workspace.workspaceUrl, + messageId: message._id, + parentMessageId: await getThreadParent(control, message) + }) - return [ - control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { - messageClass: data.messageClass, - message: message.message, - collection: data.collection, - toEmail: email, - toWorkspace: workspace, - fromWorkspace: toWorkspaceString(control.workspace), - messageId: message._id, - parentMessageId: await getThreadParent(control, message) - }) - ] + await control.apply([tx]) + + await processWorkspace(control) } export async function OnMessageSend ( @@ -312,19 +313,15 @@ export async function OnMessageSend ( return [] } - const res: Tx[] = [] - if (docClass === chunter.class.DirectMessage) { - const txes = await onBotDirectMessageSend(control, message) - res.push(...txes) + await onBotDirectMessageSend(control, message) } if (docClass === chunter.class.Channel) { - const txes = await onSupportWorkspaceMessage(control, message) - res.push(...txes) + await onSupportWorkspaceMessage(control, message) } - return res + return [] } export async function OnMention (tx: TxCreateDoc, control: TriggerControl): Promise { diff --git a/server-plugins/analytics-collector-resources/src/utils.ts b/server-plugins/analytics-collector-resources/src/utils.ts index 6a2e2e24f63..a9d157ab636 100644 --- a/server-plugins/analytics-collector-resources/src/utils.ts +++ b/server-plugins/analytics-collector-resources/src/utils.ts @@ -13,15 +13,17 @@ // limitations under the License. // import chunter, { Channel } from '@hcengineering/chunter' -import core, { AccountRole, Ref, TxOperations } from '@hcengineering/core' +import core, { AccountRole, MeasureContext, Ref, TxOperations } from '@hcengineering/core' import analyticsCollector, { getAnalyticsChannelName } from '@hcengineering/analytics-collector' import contact, { Person } from '@hcengineering/contact' import { translate } from '@hcengineering/platform' export async function getOrCreateAnalyticsChannel ( + ctx: MeasureContext, client: TxOperations, email: string, workspace: string, + workspaceUrl: string, person?: Person ): Promise | undefined> { const channel = await client.findOne(chunter.class.Channel, { @@ -33,13 +35,14 @@ export async function getOrCreateAnalyticsChannel ( return channel._id } - const accounts = await client.findAll(contact.class.PersonAccount, { role: { $ne: AccountRole.Guest } }) + ctx.info('Creating analytics channel', { email, workspace }) + const accounts = await client.findAll(contact.class.PersonAccount, { role: { $ne: AccountRole.Guest } }) const _id = await client.createDoc(chunter.class.Channel, core.space.Space, { - name: getAnalyticsChannelName(workspace, email), + name: getAnalyticsChannelName(workspaceUrl, email), topic: await translate(analyticsCollector.string.AnalyticsChannelDescription, { user: person?.name ?? email, - workspace + workspace: workspaceUrl }), description: '', private: false, diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts index 0df29539ef7..9568d13fdea 100644 --- a/services/ai-bot/pod-ai-bot/src/controller.ts +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -32,6 +32,7 @@ const MAX_ASSIGN_ATTEMPTS = 5 export class AIBotController { private readonly workspaces: Map = new Map() private readonly closeWorkspaceTimeouts: Map = new Map() + private readonly connectingWorkspaces: Set = new Set() private readonly db: Db private readonly ctx: MeasureContext @@ -58,7 +59,13 @@ export class AIBotController { for (const record of activeRecords) { const id: WorkspaceId = { name: record.workspace, productId: record.productId } - if (this.workspaces.has(toWorkspaceString(id))) { + const ws = toWorkspaceString(id) + + if (this.workspaces.has(ws)) { + continue + } + + if (this.connectingWorkspaces.has(ws)) { continue } @@ -89,6 +96,7 @@ export class AIBotController { await client.close() this.workspaces.delete(workspace) } + this.connectingWorkspaces.delete(workspace) } private async getWorkspaceInfo (ws: WorkspaceId): Promise { @@ -146,6 +154,7 @@ export class AIBotController { async initWorkspaceClient (workspaceId: WorkspaceId, info: WorkspaceInfoRecord): Promise { const workspace = toWorkspaceString(workspaceId) + this.connectingWorkspaces.add(workspace) if (!this.workspaces.has(workspace)) { this.ctx.info('Listen workspace: ', { workspace }) @@ -169,6 +178,7 @@ export class AIBotController { }, CLOSE_INTERVAL_MS) this.closeWorkspaceTimeouts.set(workspace, newTimeoutId) + this.connectingWorkspaces.delete(workspace) } async transfer (event: AIBotTransferEvent): Promise { diff --git a/services/ai-bot/pod-ai-bot/src/loaders.ts b/services/ai-bot/pod-ai-bot/src/loaders.ts new file mode 100644 index 00000000000..657d2d90610 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/loaders.ts @@ -0,0 +1,25 @@ +// +// Copyright © 2024 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 { coreId } from '@hcengineering/core' +import coreEng from '@hcengineering/core/lang/en.json' +import platformEng from '@hcengineering/platform/lang/en.json' + +import { addStringsLoader, platformId } from '@hcengineering/platform' + +export function registerLoaders (): void { + addStringsLoader(coreId, async (lang: string) => coreEng) + addStringsLoader(platformId, async (lang: string) => platformEng) +} diff --git a/services/ai-bot/pod-ai-bot/src/start.ts b/services/ai-bot/pod-ai-bot/src/start.ts index b006e26448a..e19a6e4d702 100644 --- a/services/ai-bot/pod-ai-bot/src/start.ts +++ b/services/ai-bot/pod-ai-bot/src/start.ts @@ -23,6 +23,7 @@ import config from './config' import { closeDB, getDB } from './storage' import { AIBotController } from './controller' import { createBotAccount } from './account' +import { registerLoaders } from './loaders' export const start = async (): Promise => { setMetadata(serverToken.metadata.Secret, config.ServerSecret) @@ -30,6 +31,7 @@ export const start = async (): Promise => { setMetadata(serverClient.metadata.UserAgent, config.ServiceID) setMetadata(serverClient.metadata.Endpoint, config.AccountsURL) + registerLoaders() const ctx = new MeasureMetricsContext('ai-bot-service', {}) ctx.info('AI Bot Service started', { firstName: config.FirstName, lastName: config.LastName }) diff --git a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts index 26554567485..ce9199585ab 100644 --- a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts +++ b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts @@ -26,10 +26,11 @@ import core, { TxOperations, TxProcessor, WorkspaceId, - Blob + Blob, + RateLimiter } from '@hcengineering/core' import aiBot, { AIBotEvent, aiBotAccountEmail, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot' -import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' +import chunter, { Channel, ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' import contact, { AvatarType, combineName, getFirstName, getLastName, PersonAccount } from '@hcengineering/contact' import { generateToken } from '@hcengineering/server-token' import notification from '@hcengineering/notification' @@ -57,7 +58,11 @@ export class WorkspaceClient { initializePromise: Promise | undefined = undefined + channelByKey = new Map>() aiAccount: PersonAccount | undefined + rate = new RateLimiter(1) + + directByEmail = new Map>() constructor ( readonly transactorUrl: string, @@ -176,15 +181,13 @@ export class WorkspaceClient { return } this.opClient = new TxOperations(this.client, aiBot.account.AIBot) - void this.opClient.findAll(aiBot.class.AIBotEvent, {}).then((res) => { - void this.processEvents(res) - }) + await this.uploadAvatarFile(this.opClient) + const events = await this.opClient.findAll(aiBot.class.AIBotTransferEvent, {}) + void this.processEvents(events) this.client.notify = (...txes: Tx[]) => { void this.txHandler(txes) } - - await this.uploadAvatarFile(this.opClient) this.ctx.info('Initialized workspace', this.workspace) } @@ -302,7 +305,7 @@ export class WorkspaceClient { await this.opClient.remove(event) } - async getAccount (email: string): Promise { + async getAccount (email: string): Promise { if (this.opClient === undefined) { return } @@ -310,12 +313,12 @@ export class WorkspaceClient { return await this.opClient.findOne(contact.class.PersonAccount, { email }) } - async getDirect (_id: Ref): Promise | undefined> { + async getDirect (email: string): Promise | undefined> { if (this.opClient === undefined) { return } - const personAccount = await this.opClient.findOne(contact.class.PersonAccount, { _id: _id as Ref }) + const personAccount = await this.getAccount(email) if (personAccount === undefined) { return @@ -331,7 +334,7 @@ export class WorkspaceClient { } } - const id = await this.opClient.createDoc(chunter.class.DirectMessage, core.space.Space, { + const dmId = await this.opClient.createDoc(chunter.class.DirectMessage, core.space.Space, { name: '', description: '', private: true, @@ -339,31 +342,40 @@ export class WorkspaceClient { members: accIds }) - if (this.aiAccount === undefined) return id + if (this.aiAccount === undefined) return dmId const space = await this.opClient.findOne(contact.class.PersonSpace, { person: this.aiAccount.person }) - if (space === undefined) return id + if (space === undefined) return dmId await this.opClient.createDoc(notification.class.DocNotifyContext, space._id, { user: aiBot.account.AIBot, - objectId: id, + objectId: dmId, objectClass: chunter.class.DirectMessage, objectSpace: core.space.Space, isPinned: false }) - return id + return dmId } - async transferToSupport (event: AIBotTransferEvent): Promise { - if (this.opClient === undefined) { - return - } - - const channel = await getOrCreateAnalyticsChannel(this.opClient, event.toEmail, event.fromWorkspace) + async transferToSupport (event: AIBotTransferEvent, channelRef?: Ref): Promise { + if (this.opClient === undefined) return + const key = `${event.toEmail}-${event.fromWorkspace}` + const channel = + channelRef ?? + this.channelByKey.get(key) ?? + (await getOrCreateAnalyticsChannel( + this.ctx, + this.opClient, + event.toEmail, + event.fromWorkspace, + event.fromWorkspaceUrl + )) if (channel === undefined) { return } + this.channelByKey.set(key, channel) + await this.createTransferMessage(this.opClient, event, channel, chunter.class.Channel, channel, event.message) } @@ -372,30 +384,48 @@ export class WorkspaceClient { return } - const account = await this.getAccount(event.toEmail) - - if (account === undefined) { - return - } - - const direct = await this.getDirect(account._id) + const direct = this.directByEmail.get(event.toEmail) ?? (await this.getDirect(event.toEmail)) if (direct === undefined) { return } + this.directByEmail.set(event.toEmail, direct) + await this.createTransferMessage(this.opClient, event, direct, chunter.class.DirectMessage, direct, event.message) } + getChannelRef (email: string, workspace: string): Ref | undefined { + const key = `${email}-${workspace}` + + return this.channelByKey.get(key) + } + async transfer (event: AIBotTransferEvent): Promise { if (this.initializePromise instanceof Promise) { await this.initializePromise } if (event.toWorkspace === config.SupportWorkspace) { - await this.transferToSupport(event) + const channel = this.getChannelRef(event.toEmail, event.fromWorkspace) + + if (channel !== undefined) { + await this.transferToSupport(event, channel) + } else { + // If we dont have AnalyticsChannel we should call it sync to prevent multiple channel for the same user and workspace + await this.rate.add(async () => { + await this.transferToSupport(event) + }) + } } else { - await this.transferToUserDirect(event) + if (this.directByEmail.has(event.toEmail)) { + await this.transferToUserDirect(event) + } else { + // If we dont have Direct with user we should call it sync to prevent multiple directs for the same user + await this.rate.add(async () => { + await this.transferToUserDirect(event) + }) + } } } diff --git a/services/analytics-collector/pod-analytics-collector/package.json b/services/analytics-collector/pod-analytics-collector/package.json index b1ea87c06bb..97c782ec6e7 100644 --- a/services/analytics-collector/pod-analytics-collector/package.json +++ b/services/analytics-collector/pod-analytics-collector/package.json @@ -53,6 +53,8 @@ "typescript": "^5.3.3" }, "dependencies": { + "@hcengineering/analytics": "^0.6.0", + "@hcengineering/analytics-service": "^0.6.0", "@hcengineering/account": "^0.6.0", "@hcengineering/chunter": "^0.6.20", "@hcengineering/chunter-assets": "^0.6.18", diff --git a/services/analytics-collector/pod-analytics-collector/src/account.ts b/services/analytics-collector/pod-analytics-collector/src/account.ts index bc4174648f8..81aa0370b3b 100644 --- a/services/analytics-collector/pod-analytics-collector/src/account.ts +++ b/services/analytics-collector/pod-analytics-collector/src/account.ts @@ -1,7 +1,23 @@ +// +// Copyright © 2024 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 { WorkspaceInfo } from '@hcengineering/account' + import config from './config' -import { WorkspaceLoginInfo } from '@hcengineering/account' -export async function getWorkspaceInfo (token: string): Promise { +export async function getWorkspaceInfo (token: string): Promise { const accountsUrl = config.AccountsUrl const workspaceInfo = await ( await fetch(accountsUrl, { @@ -17,5 +33,5 @@ export async function getWorkspaceInfo (token: string): Promise = new Map() private readonly closeWorkspaceTimeouts: Map = new Map() private readonly createdWorkspaces: Set = new Set() + private readonly workspaceUrlById = new Map() supportClient: WorkspaceClient | undefined = undefined eventsByEmail = new Map() periodicTimer: NodeJS.Timeout - constructor () { + persons = new Map() + + constructor (private readonly ctx: MeasureContext) { this.periodicTimer = setInterval(() => { void this.clearEvents() }, clearEventsTimeout) @@ -41,6 +66,7 @@ export class Collector { } async closeWorkspaceClient (workspaceId: WorkspaceId): Promise { + this.ctx.info('Closing workspace client', { workspace: toWorkspaceString(workspaceId) }) const workspace = toWorkspaceString(workspaceId) const timeoutId = this.closeWorkspaceTimeouts.get(workspace) @@ -57,11 +83,12 @@ export class Collector { } } - async getWorkspaceClient (workspaceId: WorkspaceId): Promise { + getWorkspaceClient (workspaceId: WorkspaceId): WorkspaceClient { const workspace = toWorkspaceString(workspaceId) - const wsClient = this.workspaces.get(workspace) ?? new WorkspaceClient(workspaceId) + const wsClient = this.workspaces.get(workspace) ?? new WorkspaceClient(this.ctx, workspaceId) if (!this.workspaces.has(workspace)) { + this.ctx.info('Creating workspace client', { workspace, allClients: Array.from(this.workspaces.keys()) }) this.workspaces.set(workspace, wsClient) } @@ -93,7 +120,7 @@ export class Collector { getSupportWorkspaceClient (): WorkspaceClient { if (this.supportClient === undefined) { - this.supportClient = new WorkspaceClient(getWorkspaceId(config.SupportWorkspace)) + this.supportClient = new WorkspaceClient(this.ctx, getWorkspaceId(config.SupportWorkspace)) } return this.supportClient @@ -106,14 +133,17 @@ export class Collector { return true } - console.info('isWorkspaceCreated', token.email, token.workspace.name) const info = await getWorkspaceInfo(generateToken(token.email, token.workspace, token.extra)) - - console.log('workspace info', info?.workspace, info?.email, info?.endpoint) + this.ctx.info('workspace info', info) if (info === undefined) { return false } + + if (info?.workspaceUrl != null) { + this.workspaceUrlById.set(ws, info.workspaceUrl) + } + if (info?.creating === true) { return false } @@ -122,18 +152,19 @@ export class Collector { return true } - async pushEvents (events: AnalyticEvent[], token: Token): Promise { - const isCreated = await this.isWorkspaceCreated(token) + async getPerson (email: string, workspace: WorkspaceId): Promise { + const wsString = toWorkspaceString(workspace) + const key = `${email}-${wsString}` - if (!isCreated) { - return + if (this.persons.has(key)) { + return this.persons.get(key) } - const fromWsClient = await this.getWorkspaceClient(token.workspace) - const account = await fromWsClient.getAccount(token.email) + const fromWsClient = this.getWorkspaceClient(workspace) + const account = await fromWsClient.getAccount(email) if (account === undefined) { - console.error('Cannnot found account', { email: token.email, workspace: toWorkspaceString(token.workspace) }) + this.ctx.error('Cannnot found account', { email, workspace: wsString }) return } @@ -142,9 +173,36 @@ export class Collector { } const person = await fromWsClient.getPerson(account) + + if (person !== undefined) { + this.persons.set(key, person) + } + + return person + } + + async pushEvents (events: AnalyticEvent[], token: Token): Promise { + const isCreated = await this.isWorkspaceCreated(token) + + if (!isCreated) { + return + } + + const person = await this.getPerson(token.email, token.workspace) + + if (person === undefined) { + return + } + const client = this.getSupportWorkspaceClient() - await client.pushEvents(events, token.email, token.workspace, person) + await client.pushEvents( + events, + token.email, + token.workspace, + person, + this.workspaceUrlById.get(toWorkspaceString(token.workspace)) + ) } getEvents (start?: Timestamp, end?: Timestamp): AnalyticEvent[] { diff --git a/services/analytics-collector/pod-analytics-collector/src/config.ts b/services/analytics-collector/pod-analytics-collector/src/config.ts index b9704aa3c82..f0f63759697 100644 --- a/services/analytics-collector/pod-analytics-collector/src/config.ts +++ b/services/analytics-collector/pod-analytics-collector/src/config.ts @@ -20,6 +20,7 @@ export interface Config { ServiceID: string SupportWorkspace: string AccountsUrl: string + SentryDSN?: string } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) @@ -31,7 +32,8 @@ const config: Config = (() => { Secret: process.env.SECRET, ServiceID: process.env.SERVICE_ID ?? 'analytics-collector-service', SupportWorkspace: process.env.SUPPORT_WORKSPACE, - AccountsUrl: process.env.ACCOUNTS_URL + AccountsUrl: process.env.ACCOUNTS_URL, + SentryDSN: process.env.SENTRY_DSN ?? '' } const missingEnv = (Object.keys(params) as Array).filter((key) => params[key] === undefined) diff --git a/services/analytics-collector/pod-analytics-collector/src/loaders.ts b/services/analytics-collector/pod-analytics-collector/src/loaders.ts index 527bd6e1e8a..bb9ad3a0568 100644 --- a/services/analytics-collector/pod-analytics-collector/src/loaders.ts +++ b/services/analytics-collector/pod-analytics-collector/src/loaders.ts @@ -1,3 +1,18 @@ +// +// Copyright © 2024 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 { analyticsCollectorId } from '@hcengineering/analytics-collector' import { chunterId } from '@hcengineering/chunter' import { contactId } from '@hcengineering/contact' diff --git a/services/analytics-collector/pod-analytics-collector/src/main.ts b/services/analytics-collector/pod-analytics-collector/src/main.ts index 41de678498c..d4073b4b381 100644 --- a/services/analytics-collector/pod-analytics-collector/src/main.ts +++ b/services/analytics-collector/pod-analytics-collector/src/main.ts @@ -15,26 +15,45 @@ import { setMetadata } from '@hcengineering/platform' import serverToken from '@hcengineering/server-token' +import { Analytics } from '@hcengineering/analytics' +import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' +import serverClient from '@hcengineering/server-client' +import { MeasureMetricsContext, newMetrics } from '@hcengineering/core' +import { join } from 'path' import config from './config' import { createServer, listen } from './server' import { Collector } from './collector' import { registerLoaders } from './loaders' -import serverClient from '@hcengineering/server-client' + +const ctx = new MeasureMetricsContext( + 'analytics-collector-service', + {}, + {}, + newMetrics(), + new SplitLogger('analytics-collector-service', { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) +) + +configureAnalytics(config.SentryDSN, config) +Analytics.setTag('application', 'analytics-collector-service') export const main = async (): Promise => { setMetadata(serverToken.metadata.Secret, config.Secret) setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl) setMetadata(serverClient.metadata.UserAgent, config.ServiceID) - console.log('Analytics service') - console.log(config.AccountsUrl) - console.log(config.DbURL) - console.log(config.SupportWorkspace) + ctx.info('Analytics service started', { + accountsUrl: config.AccountsUrl, + dbUrl: config.DbURL, + supportWorkspace: config.SupportWorkspace + }) registerLoaders() - const collector = new Collector() + const collector = new Collector(ctx) const app = createServer(collector) const server = listen(app, config.Port) diff --git a/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts b/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts index 4e2df0bf82e..d25648cf46d 100644 --- a/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts +++ b/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts @@ -15,6 +15,8 @@ import core, { Client, + MeasureContext, + RateLimiter, Ref, systemAccountEmail, toWorkspaceString, @@ -38,7 +40,12 @@ export class WorkspaceClient { channelIdByKey = new Map>() - constructor (readonly workspace: WorkspaceId) { + rate = new RateLimiter(1) + + constructor ( + readonly ctx: MeasureContext, + readonly workspace: WorkspaceId + ) { this.initializePromise = this.initClient().then(() => { this.initializePromise = undefined }) @@ -79,35 +86,51 @@ export class WorkspaceClient { return await this.opClient.findOne(contact.class.Person, { _id: account.person }) } - async pushEvents (events: AnalyticEvent[], email: string, workspace: WorkspaceId, person?: Person): Promise { - if (this.initializePromise instanceof Promise) { - await this.initializePromise + async getChannel ( + client: TxOperations, + workspace: string, + workspaceName: string, + email: string, + person?: Person + ): Promise | undefined> { + const key = `${email}-${workspace}` + if (this.channelIdByKey.has(key)) { + return this.channelIdByKey.get(key) } - if (this.opClient === undefined) { - return + const channel = await getOrCreateAnalyticsChannel(this.ctx, client, email, workspace, workspaceName, person) + + if (channel !== undefined) { + this.channelIdByKey.set(key, channel) } - const wsString = toWorkspaceString(workspace) - const channelKey = `${email}-${wsString}` + return channel + } - const channel = - this.channelIdByKey.get(channelKey) ?? (await getOrCreateAnalyticsChannel(this.opClient, email, wsString, person)) + async processEvents ( + client: TxOperations, + events: AnalyticEvent[], + email: string, + workspace: WorkspaceId, + person?: Person, + wsUrl?: string, + channelRef?: Ref + ): Promise { + const wsString = toWorkspaceString(workspace) + const channel = channelRef ?? (await this.getChannel(client, wsString, wsUrl ?? wsString, email, person)) if (channel === undefined) { return } - this.channelIdByKey.set(channelKey, channel) - for (const event of events) { - const markup = await eventToMarkup(event, this.opClient.getHierarchy()) + const markup = await eventToMarkup(event, client.getHierarchy()) if (markup === undefined) { continue } - await this.opClient.addCollection( + await client.addCollection( chunter.class.ChatMessage, channel, channel, @@ -120,6 +143,36 @@ export class WorkspaceClient { } } + async pushEvents ( + events: AnalyticEvent[], + email: string, + workspace: WorkspaceId, + person?: Person, + wsUrl?: string + ): Promise { + if (this.initializePromise instanceof Promise) { + await this.initializePromise + } + + if (this.opClient === undefined) { + return + } + + const wsString = toWorkspaceString(workspace) + const channelKey = `${email}-${wsString}` + + if (this.channelIdByKey.has(channelKey)) { + const channel = this.channelIdByKey.get(channelKey) + await this.processEvents(this.opClient, events, email, workspace, person, wsUrl, channel) + } else { + // If we dont have AnalyticsChannel we should call it sync to prevent multiple channels for the same user and workspace + await this.rate.add(async () => { + if (this.opClient === undefined) return + await this.processEvents(this.opClient, events, email, workspace, person, wsUrl) + }) + } + } + async close (): Promise { if (this.client === undefined) { return