UBERF-7915: Support tg bot attachments (#6471)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-09-03 19:21:35 +04:00 committed by GitHub
parent e7a893cd4b
commit 2752d5fa03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 387 additions and 79 deletions

5
.vscode/launch.json vendored
View File

@ -435,7 +435,10 @@
"MONGO_DB": "telegram-bot", "MONGO_DB": "telegram-bot",
"SECRET": "secret", "SECRET": "secret",
"ACCOUNTS_URL": "http://localhost:3000", "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"], "runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeVersion": "20", "runtimeVersion": "20",

View File

@ -37,13 +37,14 @@
"@types/jest": "^29.5.5" "@types/jest": "^29.5.5"
}, },
"dependencies": { "dependencies": {
"@hcengineering/platform": "^0.6.11", "@hcengineering/activity": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/contact": "^0.6.24", "@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/notification": "^0.6.23", "@hcengineering/notification": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/setting": "^0.6.17",
"@hcengineering/templates": "^0.6.11", "@hcengineering/templates": "^0.6.11",
"@hcengineering/setting": "^0.6.17" "@hcengineering/ui": "^0.6.15"
}, },
"repository": "https://github.com/hcengineering/platform", "repository": "https://github.com/hcengineering/platform",
"publishConfig": { "publishConfig": {

View File

@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import { ActivityMessage } from '@hcengineering/activity'
import { ChannelItem } from '@hcengineering/contact' import { ChannelItem } from '@hcengineering/contact'
import { Account, AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core' import { Account, AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core'
import { InboxNotification, NotificationProvider, NotificationType } from '@hcengineering/notification' import { InboxNotification, NotificationProvider, NotificationType } from '@hcengineering/notification'
@ -58,8 +59,10 @@ export interface SharedTelegramMessages extends AttachedDoc {
messages: SharedTelegramMessage[] messages: SharedTelegramMessage[]
} }
export interface TelegramNotificationRecord { export interface TelegramNotificationRequest {
notificationId: Ref<InboxNotification> notificationId: Ref<InboxNotification>
messageId?: Ref<ActivityMessage>
attachments: boolean
workspace: string workspace: string
account: Ref<Account> account: Ref<Account>
title: string title: string

View File

@ -30,7 +30,7 @@ import {
TxProcessor TxProcessor
} from '@hcengineering/core' } from '@hcengineering/core'
import { TriggerControl } from '@hcengineering/server-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 { BaseNotificationType, InboxNotification, NotificationType } from '@hcengineering/notification'
import setting, { Integration } from '@hcengineering/setting' import setting, { Integration } from '@hcengineering/setting'
import { NotificationProviderFunc, ReceiverInfo, SenderInfo } from '@hcengineering/server-notification' 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 ( const SendTelegramNotifications: NotificationProviderFunc = async (
control: TriggerControl, control: TriggerControl,
types: BaseNotificationType[], types: BaseNotificationType[],
@ -244,11 +257,13 @@ const SendTelegramNotifications: NotificationProviderFunc = async (
try { try {
const { title, body, quote, link } = await getTranslatedData(data, doc, control, message) const { title, body, quote, link } = await getTranslatedData(data, doc, control, message)
const record: TelegramNotificationRecord = { const record: TelegramNotificationRequest = {
notificationId: data._id, notificationId: data._id,
messageId: message?._id,
account: receiver._id, account: receiver._id,
workspace: toWorkspaceString(control.workspace), workspace: toWorkspaceString(control.workspace),
sender: data.intlParams?.senderName?.toString() ?? formatName(sender.person?.name ?? 'System'), sender: data.intlParams?.senderName?.toString() ?? formatName(sender.person?.name ?? 'System'),
attachments: hasAttachments(message, control.hierarchy),
title, title,
quote, quote,
body, body,

View File

@ -57,6 +57,7 @@
"@hcengineering/activity": "^0.6.0", "@hcengineering/activity": "^0.6.0",
"@hcengineering/analytics": "^0.6.0", "@hcengineering/analytics": "^0.6.0",
"@hcengineering/analytics-service": "^0.6.0", "@hcengineering/analytics-service": "^0.6.0",
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/chunter": "^0.6.20", "@hcengineering/chunter": "^0.6.20",
"@hcengineering/client": "^0.6.18", "@hcengineering/client": "^0.6.18",
"@hcengineering/client-resources": "^0.6.27", "@hcengineering/client-resources": "^0.6.27",
@ -67,6 +68,7 @@
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
"@hcengineering/server-client": "^0.6.0", "@hcengineering/server-client": "^0.6.0",
"@hcengineering/server-core": "^0.6.1", "@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-storage": "^0.6.0",
"@hcengineering/server-token": "^0.6.11", "@hcengineering/server-token": "^0.6.11",
"@hcengineering/setting": "^0.6.17", "@hcengineering/setting": "^0.6.17",
"@hcengineering/telegram": "^0.6.21", "@hcengineering/telegram": "^0.6.21",

View File

@ -19,12 +19,12 @@ import telegram from '@hcengineering/telegram'
import { htmlToMarkup } from '@hcengineering/text' import { htmlToMarkup } from '@hcengineering/text'
import { message } from 'telegraf/filters' import { message } from 'telegraf/filters'
import { toHTML } from '@telegraf/entity' 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 config from './config'
import { PlatformWorker } from './worker' import { PlatformWorker } from './worker'
import { getBotCommands, getCommandsHelp } from './utils' import { getBotCommands, getCommandsHelp, toTelegramFileInfo } from './utils'
import { NotificationRecord } from './types' import { NotificationRecord, TelegramFileInfo } from './types'
async function onStart (ctx: Context, worker: PlatformWorker): Promise<void> { async function onStart (ctx: Context, worker: PlatformWorker): Promise<void> {
const id = ctx.from?.id const id = ctx.from?.id
@ -116,9 +116,15 @@ async function findNotificationRecord (
return await worker.getNotificationRecordById(reply.notificationId, email) 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 ( async function onReply (
ctx: Context,
from: number, from: number,
message: TextMessage, message: ReplyMessage,
messageId: number, messageId: number,
replyTo: number, replyTo: number,
worker: PlatformWorker, worker: PlatformWorker,
@ -142,7 +148,10 @@ async function onReply (
await worker.saveReply({ replyId: messageId, telegramId: from, notificationId: notification.notificationId }) 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<Telegraf> { export async function setUpBot (worker: PlatformWorker): Promise<Telegraf> {
@ -166,16 +175,17 @@ export async function setUpBot (worker: PlatformWorker): Promise<Telegraf> {
const replyTo = message.reply_to_message const replyTo = message.reply_to_message
const isReplied = await onReply( const isReplied = await onReply(
ctx,
id, id,
message as TextMessage, message as ReplyMessage,
message.message_id, message.message_id,
replyTo.message_id, replyTo.message_id,
worker, worker,
ctx.from.username ctx.from.username
) )
if (isReplied) { if (!isReplied) {
await ctx.react('👍') await ctx.reply('Cannot reply to this message.')
} }
}) })

View File

@ -19,14 +19,14 @@ import express, { type Express, type NextFunction, type Request, type Response }
import { IncomingHttpHeaders, type Server } from 'http' import { IncomingHttpHeaders, type Server } from 'http'
import { MeasureContext } from '@hcengineering/core' import { MeasureContext } from '@hcengineering/core'
import { Telegraf } from 'telegraf' import { Telegraf } from 'telegraf'
import telegram, { TelegramNotificationRecord } from '@hcengineering/telegram' import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram'
import { translate } from '@hcengineering/platform' import { translate } from '@hcengineering/platform'
import { ApiError } from './error' import { ApiError } from './error'
import { PlatformWorker } from './worker' import { PlatformWorker } from './worker'
import { Limiter } from './limiter' import { Limiter } from './limiter'
import config from './config' import config from './config'
import { toTelegramHtml } from './utils' import { toTelegramHtml, toMediaGroups } from './utils'
const extractCookieToken = (cookie?: string): Token | null => { const extractCookieToken = (cookie?: string): Token | null => {
if (cookie === undefined || cookie === null) { if (cookie === undefined || cookie === null) {
@ -182,7 +182,7 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur
throw new ApiError(400) throw new ApiError(400)
} }
const notificationRecords = req.body as TelegramNotificationRecord[] const notificationRequests = req.body as TelegramNotificationRequest[]
const userRecord = await worker.getUserRecordByEmail(token.email) const userRecord = await worker.getUserRecordByEmail(token.email)
if (userRecord === undefined) { if (userRecord === undefined) {
@ -193,21 +193,37 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur
ctx.info('Received notification', { ctx.info('Received notification', {
email: token.email, email: token.email,
username: userRecord.telegramUsername, 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 () => { void limiter.add(userRecord.telegramId, async () => {
const formattedMessage = toTelegramHtml(notificationRecord) const { full: fullMessage, short: shortMessage } = toTelegramHtml(request)
const message = await bot.telegram.sendMessage(userRecord.telegramId, formattedMessage, { const files = await worker.getFiles(request)
parse_mode: 'HTML' const messageIds: number[] = []
})
await worker.addNotificationRecord({ if (files.length === 0) {
notificationId: notificationRecord.notificationId, const message = await bot.telegram.sendMessage(userRecord.telegramId, fullMessage, {
email: userRecord.email, parse_mode: 'HTML'
workspace: notificationRecord.workspace, })
telegramId: message.message_id
}) 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
})
}
}) })
} }

View File

@ -20,6 +20,8 @@ import serverClient from '@hcengineering/server-client'
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import { join } from 'path' import { join } from 'path'
import type { StorageConfiguration } from '@hcengineering/server-core'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import config from './config' import config from './config'
import { createServer, listen } from './server' import { createServer, listen } from './server'
@ -47,7 +49,10 @@ export const start = async (): Promise<void> => {
setMetadata(serverClient.metadata.UserAgent, config.ServiceId) setMetadata(serverClient.metadata.UserAgent, config.ServiceId)
registerLoaders() 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 bot = await setUpBot(worker)
const app = createServer(bot, worker, ctx) const app = createServer(bot, worker, ctx)

View File

@ -42,3 +42,18 @@ export interface OtpRecord {
expires: Timestamp expires: Timestamp
createdOn: 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
}

View File

@ -15,12 +15,15 @@
import { Collection } from 'mongodb' import { Collection } from 'mongodb'
import otpGenerator from 'otp-generator' 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 { translate } from '@hcengineering/platform'
import telegram, { TelegramNotificationRecord } from '@hcengineering/telegram' import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram'
import { Parser } from 'htmlparser2' 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' import config from './config'
export async function getNewOtp (otpCollection: Collection<OtpRecord>): Promise<string> { export async function getNewOtp (otpCollection: Collection<OtpRecord>): Promise<string> {
@ -73,17 +76,27 @@ const maxQuoteLength = 500
const maxBodyLength = 2000 const maxBodyLength = 2000
const maxSenderLength = 100 const maxSenderLength = 100
export function toTelegramHtml (record: TelegramNotificationRecord): string { export function toTelegramHtml (record: TelegramNotificationRequest): {
full: string
short: string
} {
const title = const title =
record.title !== '' ? `<a href='${record.link}'>${platformToTelegram(record.title, maxTitleLength)}</a>` + '\n' : '' record.title !== '' ? `<a href='${record.link}'>${platformToTelegram(record.title, maxTitleLength)}</a>` + '\n' : ''
const quote = const quote =
record.quote !== undefined && record.quote !== '' record.quote !== undefined && record.quote !== ''
? `<blockquote>${platformToTelegram(record.quote, maxQuoteLength)}</blockquote>` + '\n' ? `<blockquote>${platformToTelegram(record.quote, maxQuoteLength)}</blockquote>` + '\n'
: '' : ''
const body = platformToTelegram(record.body, maxBodyLength) const rawBody = platformToTelegram(record.body, maxBodyLength)
const body = rawBody === '' ? '' : rawBody + '\n'
const sender = `<i>— ${record.sender.slice(0, maxSenderLength)}</i>` const sender = `<i>— ${record.sender.slice(0, maxSenderLength)}</i>`
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'] const supportedTags = ['strong', 'em', 's', 'blockquote', 'code', 'a']
@ -99,7 +112,7 @@ export function platformToTelegram (message: string, limit: number): string {
>() >()
const parser = new Parser({ const parser = new Parser({
onopentag: (tag, attrs) => { onopentag: (tag) => {
if (tag === 'br' || tag === 'p') { if (tag === 'br' || tag === 'p') {
return return
} }
@ -178,3 +191,105 @@ export function platformToTelegram (message: string, limit: number): string {
return newMessage.trim() 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<TelegramFileInfo | undefined> {
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
}

View File

@ -14,10 +14,12 @@
// //
import type { Collection } from 'mongodb' 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 { 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 { getDB } from './storage'
import { WorkspaceClient } from './workspace' import { WorkspaceClient } from './workspace'
import { getNewOtp } from './utils' import { getNewOtp } from './utils'
@ -31,6 +33,8 @@ export class PlatformWorker {
private readonly intervalId: NodeJS.Timeout | undefined private readonly intervalId: NodeJS.Timeout | undefined
private constructor ( private constructor (
readonly ctx: MeasureContext,
readonly storageAdapter: StorageAdapter,
private readonly usersStorage: Collection<UserRecord>, private readonly usersStorage: Collection<UserRecord>,
private readonly notificationsStorage: Collection<NotificationRecord>, private readonly notificationsStorage: Collection<NotificationRecord>,
private readonly otpStorage: Collection<OtpRecord>, private readonly otpStorage: Collection<OtpRecord>,
@ -86,6 +90,14 @@ export class PlatformWorker {
return (await this.usersStorage.findOne({ _id: insertResult.insertedId })) ?? undefined return (await this.usersStorage.findOne({ _id: insertResult.insertedId })) ?? undefined
} }
async getFiles (request: TelegramNotificationRequest): Promise<PlatformFileInfo[]> {
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<void> { async updateTelegramUsername (userRecord: UserRecord, telegramUsername?: string): Promise<void> {
await this.usersStorage.updateOne( await this.usersStorage.updateOne(
{ telegramId: userRecord.telegramId, email: userRecord.email }, { telegramId: userRecord.telegramId, email: userRecord.email },
@ -133,7 +145,8 @@ export class PlatformWorker {
} }
async getWorkspaceClient (workspace: string): Promise<WorkspaceClient> { async getWorkspaceClient (workspace: string): Promise<WorkspaceClient> {
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)) { if (!this.workspacesClients.has(workspace)) {
this.workspacesClients.set(workspace, wsClient) this.workspacesClients.set(workspace, wsClient)
@ -154,9 +167,9 @@ export class PlatformWorker {
return wsClient return wsClient
} }
async reply (notification: NotificationRecord, text: string): Promise<boolean> { async reply (notification: NotificationRecord, text: string, files: TelegramFileInfo[]): Promise<boolean> {
const client = await this.getWorkspaceClient(notification.workspace) 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<UserRecord | undefined> { async authorizeUser (code: string, email: string): Promise<UserRecord | undefined> {
@ -205,9 +218,9 @@ export class PlatformWorker {
return [userStorage, notificationsStorage, otpStorage, repliesStorage] return [userStorage, notificationsStorage, otpStorage, repliesStorage]
} }
static async create (): Promise<PlatformWorker> { static async create (ctx: MeasureContext, storageAdapter: StorageAdapter): Promise<PlatformWorker> {
const [userStorage, notificationsStorage, otpStorage, repliesStorage] = await PlatformWorker.createStorages() const [userStorage, notificationsStorage, otpStorage, repliesStorage] = await PlatformWorker.createStorages()
return new PlatformWorker(userStorage, notificationsStorage, otpStorage, repliesStorage) return new PlatformWorker(ctx, storageAdapter, userStorage, notificationsStorage, otpStorage, repliesStorage)
} }
} }

View File

@ -13,34 +13,106 @@
// limitations under the License. // 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 { generateToken } from '@hcengineering/server-token'
import notification, { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' import notification, { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification'
import chunter, { ThreadMessage } from '@hcengineering/chunter' import chunter, { ThreadMessage } from '@hcengineering/chunter'
import contact, { PersonAccount } from '@hcengineering/contact' import contact, { PersonAccount } from '@hcengineering/contact'
import { createClient, getTransactorEndpoint } from '@hcengineering/server-client' import { createClient, getTransactorEndpoint } from '@hcengineering/server-client'
import activity, { ActivityMessage } from '@hcengineering/activity' 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 { export class WorkspaceClient {
private constructor ( private constructor (
private readonly ctx: MeasureContext,
private readonly storageAdapter: StorageAdapter,
private readonly client: Client, private readonly client: Client,
private readonly token: string, private readonly token: string,
private readonly workspace: WorkspaceId private readonly workspace: string
) {} ) {}
static async create (workspace: string): Promise<WorkspaceClient> { static async create (
workspace: string,
ctx: MeasureContext,
storageAdapter: StorageAdapter
): Promise<WorkspaceClient> {
const workspaceId = getWorkspaceId(workspace) const workspaceId = getWorkspaceId(workspace)
const token = generateToken(systemAccountEmail, workspaceId) const token = generateToken(systemAccountEmail, workspaceId)
const client = await connectPlatform(token) 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<void> { async createAttachments (
factory: TxFactory,
_id: Ref<ThreadMessage>,
space: Ref<Space>,
files: TelegramFileInfo[]
): Promise<number> {
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<ThreadMessage, Attachment>(
chunter.class.ThreadMessage,
_id,
space,
'attachments',
factory.createTxCreateDoc<Attachment>(attachment.class.Attachment, space, {
name: file.name ?? uuid,
file: uuid as Ref<Blob>,
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<void> {
const txFactory = new TxFactory(account._id) const txFactory = new TxFactory(account._id)
const hierarchy = this.client.getHierarchy() const hierarchy = this.client.getHierarchy()
const messageId = generateId<ThreadMessage>()
const attachments = await this.createAttachments(txFactory, messageId, message.space, files)
if (attachments === 0 && isEmptyMarkup(text)) {
return
}
if (hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { if (hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) {
const thread = message as ThreadMessage const thread = message as ThreadMessage
const collectionTx = txFactory.createTxCollectionCUD( const collectionTx = txFactory.createTxCollectionCUD(
@ -48,16 +120,21 @@ export class WorkspaceClient {
thread.attachedTo, thread.attachedTo,
message.space, message.space,
'replies', 'replies',
txFactory.createTxCreateDoc(chunter.class.ThreadMessage, message.space, { txFactory.createTxCreateDoc(
attachedTo: thread.attachedTo, chunter.class.ThreadMessage,
attachedToClass: thread.attachedToClass, message.space,
objectId: thread.objectId, {
objectClass: thread.objectClass, attachedTo: thread.attachedTo,
message: text, attachedToClass: thread.attachedToClass,
attachments: 0, objectId: thread.objectId,
collection: 'replies', objectClass: thread.objectClass,
provider: contact.channelProvider.Telegram message: text,
}) attachments,
collection: 'replies',
provider: contact.channelProvider.Telegram
},
messageId
)
) )
await this.client.tx(collectionTx) await this.client.tx(collectionTx)
} else { } else {
@ -66,16 +143,21 @@ export class WorkspaceClient {
message._id, message._id,
message.space, message.space,
'replies', 'replies',
txFactory.createTxCreateDoc(chunter.class.ThreadMessage, message.space, { txFactory.createTxCreateDoc(
attachedTo: message._id, chunter.class.ThreadMessage,
attachedToClass: message._class, message.space,
objectId: message.attachedTo, {
objectClass: message.attachedToClass, attachedTo: message._id,
message: text, attachedToClass: message._class,
attachments: 0, objectId: message.attachedTo,
collection: 'replies', objectClass: message.attachedToClass,
provider: contact.channelProvider.Telegram message: text,
}) attachments,
collection: 'replies',
provider: contact.channelProvider.Telegram
},
messageId
)
) )
await this.client.tx(collectionTx) await this.client.tx(collectionTx)
} }
@ -84,19 +166,25 @@ export class WorkspaceClient {
async replyToActivityNotification ( async replyToActivityNotification (
it: ActivityInboxNotification, it: ActivityInboxNotification,
account: PersonAccount, account: PersonAccount,
text: string text: string,
files: TelegramFileInfo[]
): Promise<boolean> { ): Promise<boolean> {
const message = await this.client.findOne(it.attachedToClass, { _id: it.attachedTo }) const message = await this.client.findOne(it.attachedToClass, { _id: it.attachedTo })
if (message !== undefined) { if (message !== undefined) {
await this.replyToMessage(message, account, text) await this.replyToMessage(message, account, text, files)
return true return true
} }
return false return false
} }
async replyToMention (it: MentionInboxNotification, account: PersonAccount, text: string): Promise<boolean> { async replyToMention (
it: MentionInboxNotification,
account: PersonAccount,
text: string,
files: TelegramFileInfo[]
): Promise<boolean> {
const hierarchy = this.client.getHierarchy() const hierarchy = this.client.getHierarchy()
if (!hierarchy.isDerived(it.mentionedInClass, activity.class.ActivityMessage)) { 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 const message = (await this.client.findOne(it.mentionedInClass, { _id: it.mentionedIn })) as ActivityMessage
if (message !== undefined) { if (message !== undefined) {
await this.replyToMessage(message, account, text) await this.replyToMessage(message, account, text, files)
return true return true
} }
return false return false
} }
public async reply (record: NotificationRecord, text: string): Promise<boolean> { public async reply (record: NotificationRecord, text: string, files: TelegramFileInfo[]): Promise<boolean> {
const account = await this.client.getModel().findOne(contact.class.PersonAccount, { email: record.email }) const account = await this.client.getModel().findOne(contact.class.PersonAccount, { email: record.email })
if (account === undefined) { if (account === undefined) {
return false return false
@ -128,9 +216,14 @@ export class WorkspaceClient {
} }
const hierarchy = this.client.getHierarchy() const hierarchy = this.client.getHierarchy()
if (hierarchy.isDerived(inboxNotification._class, notification.class.ActivityInboxNotification)) { 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)) { } 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 return false
@ -139,6 +232,23 @@ export class WorkspaceClient {
async close (): Promise<void> { async close (): Promise<void> {
await this.client.close() await this.client.close()
} }
async getFiles (_id: Ref<ActivityMessage>): Promise<PlatformFileInfo[]> {
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<Client> { async function connectPlatform (token: string): Promise<Client> {