From 2752d5fa03e12fbdf8db7cb1765376234056d137 Mon Sep 17 00:00:00 2001 From: Kristina Date: Tue, 3 Sep 2024 19:21:35 +0400 Subject: [PATCH] UBERF-7915: Support tg bot attachments (#6471) Signed-off-by: Kristina Fefelova --- .vscode/launch.json | 5 +- plugins/telegram/package.json | 9 +- plugins/telegram/src/index.ts | 5 +- .../telegram-resources/src/index.ts | 19 +- .../pod-telegram-bot/package.json | 2 + .../telegram-bot/pod-telegram-bot/src/bot.ts | 26 ++- .../pod-telegram-bot/src/server.ts | 46 +++-- .../pod-telegram-bot/src/start.ts | 7 +- .../pod-telegram-bot/src/types.ts | 15 ++ .../pod-telegram-bot/src/utils.ts | 129 ++++++++++++- .../pod-telegram-bot/src/worker.ts | 27 ++- .../pod-telegram-bot/src/workspace.ts | 176 ++++++++++++++---- 12 files changed, 387 insertions(+), 79 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 84d2a272c8..04e28d3981 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -435,7 +435,10 @@ "MONGO_DB": "telegram-bot", "SECRET": "secret", "ACCOUNTS_URL": "http://localhost:3000", - "SERVICE_ID": "telegram-bot-service" + "SERVICE_ID": "telegram-bot-service", + "MINIO_ACCESS_KEY": "minioadmin", + "MINIO_SECRET_KEY": "minioadmin", + "MINIO_ENDPOINT": "localhost" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeVersion": "20", diff --git a/plugins/telegram/package.json b/plugins/telegram/package.json index 2443d352fb..71312329a9 100644 --- a/plugins/telegram/package.json +++ b/plugins/telegram/package.json @@ -37,13 +37,14 @@ "@types/jest": "^29.5.5" }, "dependencies": { - "@hcengineering/platform": "^0.6.11", - "@hcengineering/core": "^0.6.32", - "@hcengineering/ui": "^0.6.15", + "@hcengineering/activity": "^0.6.0", "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/setting": "^0.6.17", "@hcengineering/templates": "^0.6.11", - "@hcengineering/setting": "^0.6.17" + "@hcengineering/ui": "^0.6.15" }, "repository": "https://github.com/hcengineering/platform", "publishConfig": { diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index 5594fa4be3..9e42c36888 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import { ActivityMessage } from '@hcengineering/activity' import { ChannelItem } from '@hcengineering/contact' import { Account, AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core' import { InboxNotification, NotificationProvider, NotificationType } from '@hcengineering/notification' @@ -58,8 +59,10 @@ export interface SharedTelegramMessages extends AttachedDoc { messages: SharedTelegramMessage[] } -export interface TelegramNotificationRecord { +export interface TelegramNotificationRequest { notificationId: Ref + messageId?: Ref + attachments: boolean workspace: string account: Ref title: string diff --git a/server-plugins/telegram-resources/src/index.ts b/server-plugins/telegram-resources/src/index.ts index e442fc31f1..e510e423cc 100644 --- a/server-plugins/telegram-resources/src/index.ts +++ b/server-plugins/telegram-resources/src/index.ts @@ -30,7 +30,7 @@ import { TxProcessor } from '@hcengineering/core' import { TriggerControl } from '@hcengineering/server-core' -import telegram, { TelegramMessage, TelegramNotificationRecord } from '@hcengineering/telegram' +import telegram, { TelegramMessage, TelegramNotificationRequest } from '@hcengineering/telegram' import { BaseNotificationType, InboxNotification, NotificationType } from '@hcengineering/notification' import setting, { Integration } from '@hcengineering/setting' import { NotificationProviderFunc, ReceiverInfo, SenderInfo } from '@hcengineering/server-notification' @@ -218,6 +218,19 @@ async function getTranslatedData ( } } +function hasAttachments (doc: ActivityMessage | undefined, hierarchy: Hierarchy): boolean { + if (doc === undefined) { + return false + } + + if (hierarchy.isDerived(doc._class, chunter.class.ChatMessage)) { + const chatMessage = doc as ChatMessage + return (chatMessage.attachments ?? 0) > 0 + } + + return false +} + const SendTelegramNotifications: NotificationProviderFunc = async ( control: TriggerControl, types: BaseNotificationType[], @@ -244,11 +257,13 @@ const SendTelegramNotifications: NotificationProviderFunc = async ( try { const { title, body, quote, link } = await getTranslatedData(data, doc, control, message) - const record: TelegramNotificationRecord = { + const record: TelegramNotificationRequest = { notificationId: data._id, + messageId: message?._id, account: receiver._id, workspace: toWorkspaceString(control.workspace), sender: data.intlParams?.senderName?.toString() ?? formatName(sender.person?.name ?? 'System'), + attachments: hasAttachments(message, control.hierarchy), title, quote, body, diff --git a/services/telegram-bot/pod-telegram-bot/package.json b/services/telegram-bot/pod-telegram-bot/package.json index 5697bde6d0..721c82ead9 100644 --- a/services/telegram-bot/pod-telegram-bot/package.json +++ b/services/telegram-bot/pod-telegram-bot/package.json @@ -57,6 +57,7 @@ "@hcengineering/activity": "^0.6.0", "@hcengineering/analytics": "^0.6.0", "@hcengineering/analytics-service": "^0.6.0", + "@hcengineering/attachment": "^0.6.14", "@hcengineering/chunter": "^0.6.20", "@hcengineering/client": "^0.6.18", "@hcengineering/client-resources": "^0.6.27", @@ -67,6 +68,7 @@ "@hcengineering/platform": "^0.6.11", "@hcengineering/server-client": "^0.6.0", "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-storage": "^0.6.0", "@hcengineering/server-token": "^0.6.11", "@hcengineering/setting": "^0.6.17", "@hcengineering/telegram": "^0.6.21", diff --git a/services/telegram-bot/pod-telegram-bot/src/bot.ts b/services/telegram-bot/pod-telegram-bot/src/bot.ts index 5fe713dd3f..d9e1fd2af1 100644 --- a/services/telegram-bot/pod-telegram-bot/src/bot.ts +++ b/services/telegram-bot/pod-telegram-bot/src/bot.ts @@ -19,12 +19,12 @@ import telegram from '@hcengineering/telegram' import { htmlToMarkup } from '@hcengineering/text' import { message } from 'telegraf/filters' import { toHTML } from '@telegraf/entity' -import { TextMessage } from '@telegraf/entity/types/types' +import { Message, Update } from 'telegraf/typings/core/types/typegram' import config from './config' import { PlatformWorker } from './worker' -import { getBotCommands, getCommandsHelp } from './utils' -import { NotificationRecord } from './types' +import { getBotCommands, getCommandsHelp, toTelegramFileInfo } from './utils' +import { NotificationRecord, TelegramFileInfo } from './types' async function onStart (ctx: Context, worker: PlatformWorker): Promise { const id = ctx.from?.id @@ -116,9 +116,15 @@ async function findNotificationRecord ( return await worker.getNotificationRecordById(reply.notificationId, email) } +type ReplyMessage = Update.New & +Update.NonChannel & +Message.TextMessage & +(Message.PhotoMessage | Message.VoiceMessage | Message.VideoMessage | Message.VideoNoteMessage) + async function onReply ( + ctx: Context, from: number, - message: TextMessage, + message: ReplyMessage, messageId: number, replyTo: number, worker: PlatformWorker, @@ -142,7 +148,10 @@ async function onReply ( await worker.saveReply({ replyId: messageId, telegramId: from, notificationId: notification.notificationId }) - return await worker.reply(notification, htmlToMarkup(toHTML(message))) + const file = await toTelegramFileInfo(ctx, message) + const files: TelegramFileInfo[] = file !== undefined ? [file] : [] + + return await worker.reply(notification, htmlToMarkup(toHTML(message)), files) } export async function setUpBot (worker: PlatformWorker): Promise { @@ -166,16 +175,17 @@ export async function setUpBot (worker: PlatformWorker): Promise { const replyTo = message.reply_to_message const isReplied = await onReply( + ctx, id, - message as TextMessage, + message as ReplyMessage, message.message_id, replyTo.message_id, worker, ctx.from.username ) - if (isReplied) { - await ctx.react('👍') + if (!isReplied) { + await ctx.reply('Cannot reply to this message.') } }) diff --git a/services/telegram-bot/pod-telegram-bot/src/server.ts b/services/telegram-bot/pod-telegram-bot/src/server.ts index 5430dbd828..6e656d53e5 100644 --- a/services/telegram-bot/pod-telegram-bot/src/server.ts +++ b/services/telegram-bot/pod-telegram-bot/src/server.ts @@ -19,14 +19,14 @@ import express, { type Express, type NextFunction, type Request, type Response } import { IncomingHttpHeaders, type Server } from 'http' import { MeasureContext } from '@hcengineering/core' import { Telegraf } from 'telegraf' -import telegram, { TelegramNotificationRecord } from '@hcengineering/telegram' +import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram' import { translate } from '@hcengineering/platform' import { ApiError } from './error' import { PlatformWorker } from './worker' import { Limiter } from './limiter' import config from './config' -import { toTelegramHtml } from './utils' +import { toTelegramHtml, toMediaGroups } from './utils' const extractCookieToken = (cookie?: string): Token | null => { if (cookie === undefined || cookie === null) { @@ -182,7 +182,7 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur throw new ApiError(400) } - const notificationRecords = req.body as TelegramNotificationRecord[] + const notificationRequests = req.body as TelegramNotificationRequest[] const userRecord = await worker.getUserRecordByEmail(token.email) if (userRecord === undefined) { @@ -193,21 +193,37 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur ctx.info('Received notification', { email: token.email, username: userRecord.telegramUsername, - ids: notificationRecords.map((it) => it.notificationId) + ids: notificationRequests.map((it) => it.notificationId) }) - for (const notificationRecord of notificationRecords) { + for (const request of notificationRequests) { void limiter.add(userRecord.telegramId, async () => { - const formattedMessage = toTelegramHtml(notificationRecord) - const message = await bot.telegram.sendMessage(userRecord.telegramId, formattedMessage, { - parse_mode: 'HTML' - }) - await worker.addNotificationRecord({ - notificationId: notificationRecord.notificationId, - email: userRecord.email, - workspace: notificationRecord.workspace, - telegramId: message.message_id - }) + const { full: fullMessage, short: shortMessage } = toTelegramHtml(request) + const files = await worker.getFiles(request) + const messageIds: number[] = [] + + if (files.length === 0) { + const message = await bot.telegram.sendMessage(userRecord.telegramId, fullMessage, { + parse_mode: 'HTML' + }) + + messageIds.push(message.message_id) + } else { + const groups = toMediaGroups(files, fullMessage, shortMessage) + for (const group of groups) { + const mediaGroup = await bot.telegram.sendMediaGroup(userRecord.telegramId, group) + messageIds.push(...mediaGroup.map((it) => it.message_id)) + } + } + + for (const messageId of messageIds) { + await worker.addNotificationRecord({ + notificationId: request.notificationId, + email: userRecord.email, + workspace: request.workspace, + telegramId: messageId + }) + } }) } diff --git a/services/telegram-bot/pod-telegram-bot/src/start.ts b/services/telegram-bot/pod-telegram-bot/src/start.ts index bff0d81416..896bce26a4 100644 --- a/services/telegram-bot/pod-telegram-bot/src/start.ts +++ b/services/telegram-bot/pod-telegram-bot/src/start.ts @@ -20,6 +20,8 @@ import serverClient from '@hcengineering/server-client' import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' import { Analytics } from '@hcengineering/analytics' import { join } from 'path' +import type { StorageConfiguration } from '@hcengineering/server-core' +import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' import config from './config' import { createServer, listen } from './server' @@ -47,7 +49,10 @@ export const start = async (): Promise => { setMetadata(serverClient.metadata.UserAgent, config.ServiceId) registerLoaders() - const worker = await PlatformWorker.create() + const storageConfig: StorageConfiguration = storageConfigFromEnv() + const storageAdapter = buildStorageFromConfig(storageConfig, config.MongoURL) + + const worker = await PlatformWorker.create(ctx, storageAdapter) const bot = await setUpBot(worker) const app = createServer(bot, worker, ctx) diff --git a/services/telegram-bot/pod-telegram-bot/src/types.ts b/services/telegram-bot/pod-telegram-bot/src/types.ts index b2721132d7..6b447ee778 100644 --- a/services/telegram-bot/pod-telegram-bot/src/types.ts +++ b/services/telegram-bot/pod-telegram-bot/src/types.ts @@ -42,3 +42,18 @@ export interface OtpRecord { expires: Timestamp createdOn: Timestamp } + +export interface PlatformFileInfo { + filename: string + type: string + buffer: Buffer +} + +export interface TelegramFileInfo { + type: string + url: string + width: number + height: number + name?: string + size?: number +} diff --git a/services/telegram-bot/pod-telegram-bot/src/utils.ts b/services/telegram-bot/pod-telegram-bot/src/utils.ts index 0ef383a09c..4344be868d 100644 --- a/services/telegram-bot/pod-telegram-bot/src/utils.ts +++ b/services/telegram-bot/pod-telegram-bot/src/utils.ts @@ -15,12 +15,15 @@ import { Collection } from 'mongodb' import otpGenerator from 'otp-generator' -import { BotCommand } from 'telegraf/typings/core/types/typegram' +import { BotCommand, Message } from 'telegraf/typings/core/types/typegram' import { translate } from '@hcengineering/platform' -import telegram, { TelegramNotificationRecord } from '@hcengineering/telegram' +import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram' import { Parser } from 'htmlparser2' +import { MediaGroup } from 'telegraf/typings/telegram-types' +import { InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo } from 'telegraf/src/core/types/typegram' +import { Context, Input } from 'telegraf' -import { OtpRecord } from './types' +import { OtpRecord, PlatformFileInfo, TelegramFileInfo } from './types' import config from './config' export async function getNewOtp (otpCollection: Collection): Promise { @@ -73,17 +76,27 @@ const maxQuoteLength = 500 const maxBodyLength = 2000 const maxSenderLength = 100 -export function toTelegramHtml (record: TelegramNotificationRecord): string { +export function toTelegramHtml (record: TelegramNotificationRequest): { + full: string + short: string +} { const title = record.title !== '' ? `${platformToTelegram(record.title, maxTitleLength)}` + '\n' : '' const quote = record.quote !== undefined && record.quote !== '' ? `
${platformToTelegram(record.quote, maxQuoteLength)}
` + '\n' : '' - const body = platformToTelegram(record.body, maxBodyLength) + const rawBody = platformToTelegram(record.body, maxBodyLength) + const body = rawBody === '' ? '' : rawBody + '\n' const sender = `— ${record.sender.slice(0, maxSenderLength)}` - return title + quote + body + '\n' + sender + const full = title + quote + body + sender + const short = title + sender + + return { + full, + short + } } const supportedTags = ['strong', 'em', 's', 'blockquote', 'code', 'a'] @@ -99,7 +112,7 @@ export function platformToTelegram (message: string, limit: number): string { >() const parser = new Parser({ - onopentag: (tag, attrs) => { + onopentag: (tag) => { if (tag === 'br' || tag === 'p') { return } @@ -178,3 +191,105 @@ export function platformToTelegram (message: string, limit: number): string { return newMessage.trim() } + +export function toTgMediaFile ( + file: PlatformFileInfo, + caption: string +): InputMediaPhoto | InputMediaVideo | InputMediaAudio | InputMediaDocument { + const { type, filename, buffer } = file + + if (type.startsWith('image/')) { + return { + type: 'photo', + caption, + parse_mode: 'HTML', + media: Input.fromBuffer(buffer, filename) + } + } else if (type.startsWith('video/')) { + return { + type: 'video', + caption, + parse_mode: 'HTML', + media: Input.fromBuffer(buffer, filename) + } + } else if (type.startsWith('audio/')) { + return { + type: 'audio', + caption, + parse_mode: 'HTML', + media: Input.fromBuffer(buffer, filename) + } + } else { + return { + type: 'document', + caption, + parse_mode: 'HTML', + media: Input.fromBuffer(buffer, filename) + } + } +} + +export function toMediaGroups (files: PlatformFileInfo[], fullMessage: string, shortMessage: string): MediaGroup[] { + const photos: (InputMediaPhoto | InputMediaVideo)[] = [] + const audios: InputMediaAudio[] = [] + const documents: InputMediaDocument[] = [] + + for (const file of files) { + const media = toTgMediaFile(file, shortMessage) + if (media.type === 'photo' || media.type === 'video') { + photos.push(media) + } else if (media.type === 'audio') { + audios.push(media) + } else { + documents.push(media) + } + } + + const result = [photos, audios, documents].filter((it) => it.length > 0) + + result[0][0].caption = fullMessage + + return result +} + +export async function toTelegramFileInfo ( + ctx: Context, + message: Message.PhotoMessage | Message.VideoMessage | Message.VoiceMessage | Message.VideoNoteMessage +): Promise { + try { + if ('photo' in message) { + const photos = message.photo + const photo = photos[photos.length - 1] + const { file_id: fileId, height, width, file_size: fileSize } = photo + const url = (await ctx.telegram.getFileLink(fileId)).toString() + const fileName = url.toString().split('/').pop() + return { url: url.toString(), width, height, name: fileName, size: fileSize, type: 'image/jpeg' } + } + + if ('video' in message) { + const video = message.video + const { file_id: fileId, height, width, file_size: fileSize, mime_type: type, file_name: fileName } = video + const url = (await ctx.telegram.getFileLink(fileId)).toString() + return { url: url.toString(), width, height, name: fileName, size: fileSize, type: type ?? 'video/mp4' } + } + + if ('video_note' in message) { + const videoNote = message.video_note + const { file_id: fileId, file_size: fileSize } = videoNote + const url = (await ctx.telegram.getFileLink(fileId)).toString() + return { url: url.toString(), width: 0, height: 0, size: fileSize, type: 'video/mp4' } + } + + if ('voice' in message) { + const voice = message.voice + const { file_id: fileId, file_size: fileSize, mime_type: type } = voice + const url = (await ctx.telegram.getFileLink(fileId)).toString() + return { url: url.toString(), width: 0, height: 0, size: fileSize, type: type ?? 'audio/ogg' } + } + } catch (e) { + console.error('Failed to get file info', e) + return undefined + } + + return undefined +} diff --git a/services/telegram-bot/pod-telegram-bot/src/worker.ts b/services/telegram-bot/pod-telegram-bot/src/worker.ts index e37237cc87..8d55cbf33d 100644 --- a/services/telegram-bot/pod-telegram-bot/src/worker.ts +++ b/services/telegram-bot/pod-telegram-bot/src/worker.ts @@ -14,10 +14,12 @@ // import type { Collection } from 'mongodb' -import { Account, Ref, SortingOrder } from '@hcengineering/core' +import { Account, MeasureContext, Ref, SortingOrder } from '@hcengineering/core' import { InboxNotification } from '@hcengineering/notification' +import { TelegramNotificationRequest } from '@hcengineering/telegram' +import { StorageAdapter } from '@hcengineering/server-core' -import { UserRecord, NotificationRecord, OtpRecord, ReplyRecord } from './types' +import { NotificationRecord, OtpRecord, PlatformFileInfo, ReplyRecord, TelegramFileInfo, UserRecord } from './types' import { getDB } from './storage' import { WorkspaceClient } from './workspace' import { getNewOtp } from './utils' @@ -31,6 +33,8 @@ export class PlatformWorker { private readonly intervalId: NodeJS.Timeout | undefined private constructor ( + readonly ctx: MeasureContext, + readonly storageAdapter: StorageAdapter, private readonly usersStorage: Collection, private readonly notificationsStorage: Collection, private readonly otpStorage: Collection, @@ -86,6 +90,14 @@ export class PlatformWorker { return (await this.usersStorage.findOne({ _id: insertResult.insertedId })) ?? undefined } + async getFiles (request: TelegramNotificationRequest): Promise { + if (request.messageId === undefined || !request.attachments) { + return [] + } + const wsClient = await this.getWorkspaceClient(request.workspace) + return await wsClient.getFiles(request.messageId) + } + async updateTelegramUsername (userRecord: UserRecord, telegramUsername?: string): Promise { await this.usersStorage.updateOne( { telegramId: userRecord.telegramId, email: userRecord.email }, @@ -133,7 +145,8 @@ export class PlatformWorker { } async getWorkspaceClient (workspace: string): Promise { - const wsClient = this.workspacesClients.get(workspace) ?? (await WorkspaceClient.create(workspace)) + const wsClient = + this.workspacesClients.get(workspace) ?? (await WorkspaceClient.create(workspace, this.ctx, this.storageAdapter)) if (!this.workspacesClients.has(workspace)) { this.workspacesClients.set(workspace, wsClient) @@ -154,9 +167,9 @@ export class PlatformWorker { return wsClient } - async reply (notification: NotificationRecord, text: string): Promise { + async reply (notification: NotificationRecord, text: string, files: TelegramFileInfo[]): Promise { const client = await this.getWorkspaceClient(notification.workspace) - return await client.reply(notification, text) + return await client.reply(notification, text, files) } async authorizeUser (code: string, email: string): Promise { @@ -205,9 +218,9 @@ export class PlatformWorker { return [userStorage, notificationsStorage, otpStorage, repliesStorage] } - static async create (): Promise { + static async create (ctx: MeasureContext, storageAdapter: StorageAdapter): Promise { const [userStorage, notificationsStorage, otpStorage, repliesStorage] = await PlatformWorker.createStorages() - return new PlatformWorker(userStorage, notificationsStorage, otpStorage, repliesStorage) + return new PlatformWorker(ctx, storageAdapter, userStorage, notificationsStorage, otpStorage, repliesStorage) } } diff --git a/services/telegram-bot/pod-telegram-bot/src/workspace.ts b/services/telegram-bot/pod-telegram-bot/src/workspace.ts index fbeaac4379..af65f8d8ab 100644 --- a/services/telegram-bot/pod-telegram-bot/src/workspace.ts +++ b/services/telegram-bot/pod-telegram-bot/src/workspace.ts @@ -13,34 +13,106 @@ // limitations under the License. // -import { Client, getWorkspaceId, systemAccountEmail, TxFactory, WorkspaceId } from '@hcengineering/core' +import { + Blob, + Client, + generateId, + getWorkspaceId, + MeasureContext, + Ref, + Space, + systemAccountEmail, + TxFactory +} from '@hcengineering/core' import { generateToken } from '@hcengineering/server-token' import notification, { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' import chunter, { ThreadMessage } from '@hcengineering/chunter' import contact, { PersonAccount } from '@hcengineering/contact' import { createClient, getTransactorEndpoint } from '@hcengineering/server-client' import activity, { ActivityMessage } from '@hcengineering/activity' +import attachment, { Attachment } from '@hcengineering/attachment' +import { StorageAdapter } from '@hcengineering/server-core' +import { isEmptyMarkup } from '@hcengineering/text' -import { NotificationRecord } from './types' +import { NotificationRecord, PlatformFileInfo, TelegramFileInfo } from './types' export class WorkspaceClient { private constructor ( + private readonly ctx: MeasureContext, + private readonly storageAdapter: StorageAdapter, private readonly client: Client, private readonly token: string, - private readonly workspace: WorkspaceId + private readonly workspace: string ) {} - static async create (workspace: string): Promise { + static async create ( + workspace: string, + ctx: MeasureContext, + storageAdapter: StorageAdapter + ): Promise { const workspaceId = getWorkspaceId(workspace) const token = generateToken(systemAccountEmail, workspaceId) const client = await connectPlatform(token) - return new WorkspaceClient(client, token, workspaceId) + return new WorkspaceClient(ctx, storageAdapter, client, token, workspace) } - async replyToMessage (message: ActivityMessage, account: PersonAccount, text: string): Promise { + async createAttachments ( + factory: TxFactory, + _id: Ref, + space: Ref, + files: TelegramFileInfo[] + ): Promise { + const wsId = getWorkspaceId(this.workspace) + + let attachments = 0 + + for (const file of files) { + try { + const response = await fetch(file.url) + const buffer = Buffer.from(await response.arrayBuffer()) + const uuid = generateId() + await this.storageAdapter.put(this.ctx, wsId, uuid, buffer, file.type, file.size) + const tx = factory.createTxCollectionCUD( + chunter.class.ThreadMessage, + _id, + space, + 'attachments', + factory.createTxCreateDoc(attachment.class.Attachment, space, { + name: file.name ?? uuid, + file: uuid as Ref, + type: file.type, + size: file.size ?? 0, + lastModified: Date.now(), + collection: 'attachments', + attachedTo: _id, + attachedToClass: chunter.class.ThreadMessage + }) + ) + await this.client.tx(tx) + attachments++ + } catch (e) { + this.ctx.error('Failed to create attachment', { error: e, ...file }) + } + } + return attachments + } + + async replyToMessage ( + message: ActivityMessage, + account: PersonAccount, + text: string, + files: TelegramFileInfo[] + ): Promise { const txFactory = new TxFactory(account._id) const hierarchy = this.client.getHierarchy() + const messageId = generateId() + const attachments = await this.createAttachments(txFactory, messageId, message.space, files) + + if (attachments === 0 && isEmptyMarkup(text)) { + return + } + if (hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { const thread = message as ThreadMessage const collectionTx = txFactory.createTxCollectionCUD( @@ -48,16 +120,21 @@ export class WorkspaceClient { thread.attachedTo, message.space, 'replies', - txFactory.createTxCreateDoc(chunter.class.ThreadMessage, message.space, { - attachedTo: thread.attachedTo, - attachedToClass: thread.attachedToClass, - objectId: thread.objectId, - objectClass: thread.objectClass, - message: text, - attachments: 0, - collection: 'replies', - provider: contact.channelProvider.Telegram - }) + txFactory.createTxCreateDoc( + chunter.class.ThreadMessage, + message.space, + { + attachedTo: thread.attachedTo, + attachedToClass: thread.attachedToClass, + objectId: thread.objectId, + objectClass: thread.objectClass, + message: text, + attachments, + collection: 'replies', + provider: contact.channelProvider.Telegram + }, + messageId + ) ) await this.client.tx(collectionTx) } else { @@ -66,16 +143,21 @@ export class WorkspaceClient { message._id, message.space, 'replies', - txFactory.createTxCreateDoc(chunter.class.ThreadMessage, message.space, { - attachedTo: message._id, - attachedToClass: message._class, - objectId: message.attachedTo, - objectClass: message.attachedToClass, - message: text, - attachments: 0, - collection: 'replies', - provider: contact.channelProvider.Telegram - }) + txFactory.createTxCreateDoc( + chunter.class.ThreadMessage, + message.space, + { + attachedTo: message._id, + attachedToClass: message._class, + objectId: message.attachedTo, + objectClass: message.attachedToClass, + message: text, + attachments, + collection: 'replies', + provider: contact.channelProvider.Telegram + }, + messageId + ) ) await this.client.tx(collectionTx) } @@ -84,19 +166,25 @@ export class WorkspaceClient { async replyToActivityNotification ( it: ActivityInboxNotification, account: PersonAccount, - text: string + text: string, + files: TelegramFileInfo[] ): Promise { const message = await this.client.findOne(it.attachedToClass, { _id: it.attachedTo }) if (message !== undefined) { - await this.replyToMessage(message, account, text) + await this.replyToMessage(message, account, text, files) return true } return false } - async replyToMention (it: MentionInboxNotification, account: PersonAccount, text: string): Promise { + async replyToMention ( + it: MentionInboxNotification, + account: PersonAccount, + text: string, + files: TelegramFileInfo[] + ): Promise { const hierarchy = this.client.getHierarchy() if (!hierarchy.isDerived(it.mentionedInClass, activity.class.ActivityMessage)) { @@ -106,14 +194,14 @@ export class WorkspaceClient { const message = (await this.client.findOne(it.mentionedInClass, { _id: it.mentionedIn })) as ActivityMessage if (message !== undefined) { - await this.replyToMessage(message, account, text) + await this.replyToMessage(message, account, text, files) return true } return false } - public async reply (record: NotificationRecord, text: string): Promise { + public async reply (record: NotificationRecord, text: string, files: TelegramFileInfo[]): Promise { const account = await this.client.getModel().findOne(contact.class.PersonAccount, { email: record.email }) if (account === undefined) { return false @@ -128,9 +216,14 @@ export class WorkspaceClient { } const hierarchy = this.client.getHierarchy() if (hierarchy.isDerived(inboxNotification._class, notification.class.ActivityInboxNotification)) { - return await this.replyToActivityNotification(inboxNotification as ActivityInboxNotification, account, text) + return await this.replyToActivityNotification( + inboxNotification as ActivityInboxNotification, + account, + text, + files + ) } else if (hierarchy.isDerived(inboxNotification._class, notification.class.MentionInboxNotification)) { - return await this.replyToMention(inboxNotification as MentionInboxNotification, account, text) + return await this.replyToMention(inboxNotification as MentionInboxNotification, account, text, files) } return false @@ -139,6 +232,23 @@ export class WorkspaceClient { async close (): Promise { await this.client.close() } + + async getFiles (_id: Ref): Promise { + const attachments = await this.client.findAll(attachment.class.Attachment, { attachedTo: _id }) + const res: PlatformFileInfo[] = [] + for (const attachment of attachments) { + const chunks = await this.storageAdapter.read(this.ctx, { name: this.workspace }, attachment.file) + const buffer = Buffer.concat(chunks) + if (buffer.length > 0) { + res.push({ + buffer, + type: attachment.type, + filename: attachment.name + }) + } + } + return res + } } async function connectPlatform (token: string): Promise {