diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index 0b8864c698..c8c40bb2a4 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -681,7 +681,7 @@ $: void forceReadContext(isScrollAtBottom, notifyContext) async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise { - if (context === undefined || !isScrollAtBottom || forceRead || !separatorElement) return + if (context === undefined || !isScrollAtBottom || forceRead) return const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context if (lastViewedTimestamp >= lastUpdateTimestamp) return diff --git a/plugins/telegram-assets/lang/en.json b/plugins/telegram-assets/lang/en.json index d786f25c1d..b6040472b7 100644 --- a/plugins/telegram-assets/lang/en.json +++ b/plugins/telegram-assets/lang/en.json @@ -48,6 +48,11 @@ "AccountConnectedHtml": "You have been successfully connected as {email}", "AccountAlreadyConnected": "Account already connected", "InvalidCode": "Invalid code", - "SomethingWentWrong": "Something went wrong. Please try again." + "SomethingWentWrong": "Something went wrong. Please try again.", + "Configure": "Configure", + "ConnectTelegramBot": "Connect Telegram Bot", + "DisconnectMessage": "⚠️You have been disconnected from the service. Please reconnect your account on {app} using /{command}.", + "SyncAllChannels": "Sync all channels", + "SyncStarredChannels": "Sync starred channels" } } \ No newline at end of file diff --git a/plugins/telegram-assets/lang/es.json b/plugins/telegram-assets/lang/es.json index 24a8368674..66044a7f70 100644 --- a/plugins/telegram-assets/lang/es.json +++ b/plugins/telegram-assets/lang/es.json @@ -48,6 +48,11 @@ "AccountConnectedHtml": "Te has conectado correctamente como {email}", "AccountAlreadyConnected": "Cuenta ya conectada", "InvalidCode": "Código no válido", - "SomethingWentWrong": "Algo salió mal. Por favor, inténtalo de nuevo." + "SomethingWentWrong": "Algo salió mal. Por favor, inténtalo de nuevo.", + "Configure": "Configurar", + "ConnectTelegramBot": "Conectar bot de Telegram", + "DisconnectMessage": "⚠️Te has desconectado del servicio. Por favor, vuelve a conectar tu cuenta en {app} usando /{command}.", + "SyncAllChannels": "Sincronizar todos los canales", + "SyncStarredChannels": "Sincronizar canales marcados" } } \ No newline at end of file diff --git a/plugins/telegram-assets/lang/fr.json b/plugins/telegram-assets/lang/fr.json index d7e3a087ba..405f0c1905 100644 --- a/plugins/telegram-assets/lang/fr.json +++ b/plugins/telegram-assets/lang/fr.json @@ -48,6 +48,11 @@ "AccountConnectedHtml": "Vous avez été connecté avec succès en tant que {email}", "AccountAlreadyConnected": "Compte déjà connecté", "InvalidCode": "Code invalide", - "SomethingWentWrong": "Quelque chose s'est mal passé. Veuillez réessayer." + "SomethingWentWrong": "Quelque chose s'est mal passé. Veuillez réessayer.", + "Configure": "Configurer", + "ConnectTelegramBot": "Connecter le bot Telegram", + "DisconnectMessage": "⚠️Vous avez été déconnecté du service. Veuillez reconnecter votre compte sur {app} en utilisant /{command}.", + "SyncAllChannels": "Synchroniser tous les canaux", + "SyncStarredChannels": "Synchroniser les chaînes marquées" } } \ No newline at end of file diff --git a/plugins/telegram-assets/lang/pt.json b/plugins/telegram-assets/lang/pt.json index fb9e6ff12c..de6925a8fa 100644 --- a/plugins/telegram-assets/lang/pt.json +++ b/plugins/telegram-assets/lang/pt.json @@ -48,6 +48,11 @@ "AccountConnectedHtml": "Foi ligado com sucesso como {email}", "AccountAlreadyConnected": "Conta já ligada", "InvalidCode": "Código inválido", - "SomethingWentWrong": "Algo correu mal. Por favor, tente novamente." + "SomethingWentWrong": "Algo correu mal. Por favor, tente novamente.", + "Configure": "Configurar", + "ConnectTelegramBot": "Ligar bot do Telegram", + "DisconnectMessage": "⚠️Foi desconectado do serviço. Por favor, reconecte a sua conta em {app} usando /{command}.", + "SyncAllChannels": "Sincronizar todos os canais", + "SyncStarredChannels": "Sincronizar canais marcados" } } \ No newline at end of file diff --git a/plugins/telegram-assets/lang/ru.json b/plugins/telegram-assets/lang/ru.json index cea3288b84..e7cfca50fc 100644 --- a/plugins/telegram-assets/lang/ru.json +++ b/plugins/telegram-assets/lang/ru.json @@ -48,6 +48,11 @@ "AccountConnectedHtml": "Вы успешно подключены как {email}", "AccountAlreadyConnected": "Аккаунт уже подключен", "InvalidCode": "Неверный код", - "SomethingWentWrong": "Что-то пошло не так. Пожалуйста, попробуйте снова." + "SomethingWentWrong": "Что-то пошло не так. Пожалуйста, попробуйте снова.", + "Configure": "Настроить", + "ConnectTelegramBot": "Подключить Telegram бота", + "DisconnectMessage": "⚠️Вы отключены от сервиса. Пожалуйста, подключите свой аккаунт на {app} с помощью /{command}.", + "SyncAllChannels": "Синхронизировать все каналы", + "SyncStarredChannels": "Синхронизировать избранные каналы" } } \ No newline at end of file diff --git a/plugins/telegram-assets/lang/zh.json b/plugins/telegram-assets/lang/zh.json index d5e781c9f5..0553c203c7 100644 --- a/plugins/telegram-assets/lang/zh.json +++ b/plugins/telegram-assets/lang/zh.json @@ -48,6 +48,11 @@ "AccountConnectedHtml": "您已成功连接为 {email}", "AccountAlreadyConnected": "帐户已连接", "InvalidCode": "无效代码", - "SomethingWentWrong": "出现问题。 请重试。" + "SomethingWentWrong": "出现问题。 请重试。", + "Configure": "配置", + "ConnectTelegramBot": "连接 Telegram 机器人", + "DisconnectMessage": "⚠️您已从服务中断开连接。 请使用 /{command} 在 {app} 上重新连接您的帐户。", + "SyncAllChannels": "同步所有频道", + "SyncStarredChannels": "同步标记的频道" } } diff --git a/plugins/telegram-resources/src/components/ConfigureBotPopup.svelte b/plugins/telegram-resources/src/components/ConfigureBotPopup.svelte index 928bf1a8eb..3b9e7059fb 100644 --- a/plugins/telegram-resources/src/components/ConfigureBotPopup.svelte +++ b/plugins/telegram-resources/src/components/ConfigureBotPopup.svelte @@ -115,7 +115,7 @@ { diff --git a/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte b/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte index 3b79589836..aa96d2361a 100644 --- a/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte +++ b/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte @@ -15,20 +15,43 @@ {#if enabled}
- +
{/if} diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index 9e42c36888..0c5e46e8f1 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -127,6 +127,11 @@ export default plugin(telegramId, { AccountConnectedHtml: '' as IntlString, AccountAlreadyConnected: '' as IntlString, InvalidCode: '' as IntlString, - SomethingWentWrong: '' as IntlString + SomethingWentWrong: '' as IntlString, + Configure: '' as IntlString, + ConnectTelegramBot: '' as IntlString, + DisconnectMessage: '' as IntlString, + SyncAllChannels: '' as IntlString, + SyncStarredChannels: '' as IntlString } }) diff --git a/server/client/src/account.ts b/server/client/src/account.ts index 018af4491c..94b765c647 100644 --- a/server/client/src/account.ts +++ b/server/client/src/account.ts @@ -155,3 +155,25 @@ export async function workerHandshake ( }) }) } + +export async function getWorkspaceInfo (token: string): Promise { + const accountsUrl = getMetadata(plugin.metadata.Endpoint) + if (accountsUrl == null) { + throw new PlatformError(unknownError('No account endpoint specified')) + } + const workspaceInfo = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'getWorkspaceInfo', + params: [] + }) + }) + ).json() + + return workspaceInfo.result as BaseWorkspaceInfo | undefined +} diff --git a/services/telegram-bot/pod-telegram-bot/src/config.ts b/services/telegram-bot/pod-telegram-bot/src/config.ts index 49d3b6a640..afcb368f98 100644 --- a/services/telegram-bot/pod-telegram-bot/src/config.ts +++ b/services/telegram-bot/pod-telegram-bot/src/config.ts @@ -27,6 +27,7 @@ export interface Config { OtpRetryDelaySec: number AccountsUrl: string SentryDSN: string + AccountsURL: string } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) @@ -46,7 +47,8 @@ const config: Config = (() => { App: process.env.APP ?? 'Huly', OtpTimeToLiveSec: parseNumber(process.env.OTP_TIME_TO_LIVE_SEC) ?? 60, OtpRetryDelaySec: parseNumber(process.env.OTP_RETRY_DELAY_SEC) ?? 60, - SentryDSN: process.env.SENTRY_DSN ?? '' + SentryDSN: process.env.SENTRY_DSN ?? '', + AccountsURL: process.env.ACCOUNTS_URL } const missingEnv = (Object.keys(params) as Array).filter((key) => params[key] === undefined) diff --git a/services/telegram-bot/pod-telegram-bot/src/server.ts b/services/telegram-bot/pod-telegram-bot/src/server.ts index 6e656d53e5..d2fb2274cc 100644 --- a/services/telegram-bot/pod-telegram-bot/src/server.ts +++ b/services/telegram-bot/pod-telegram-bot/src/server.ts @@ -27,6 +27,7 @@ import { PlatformWorker } from './worker' import { Limiter } from './limiter' import config from './config' import { toTelegramHtml, toMediaGroups } from './utils' +import { TgContext } from './telegraf/types' const extractCookieToken = (cookie?: string): Token | null => { if (cookie === undefined || cookie === null) { @@ -95,8 +96,12 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n void handleRequest(fn, req, res, next) } -export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: MeasureContext): Express { - const limiter = new Limiter() +export function createServer ( + bot: Telegraf, + worker: PlatformWorker, + ctx: MeasureContext, + limiter: Limiter +): Express { const app = express() app.use(cors()) @@ -121,6 +126,33 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur }) ) + app.post( + '/updateWorkspace', + wrapRequest(async (req, res, token) => { + if (req.body == null || typeof req.body !== 'object' || req.body.enabled == null) { + throw new ApiError(400) + } + + const enabled: boolean = req.body.enabled + const record = await worker.getUserRecordByEmail(token.email) + + if (record === undefined) { + return + } + + if (enabled && !record.workspaces.includes(token.workspace.name)) { + await worker.addWorkspace(token.email, token.workspace.name) + } + + if (!enabled && record.workspaces.includes(token.workspace.name)) { + await worker.removeWorkspace(token.email, token.workspace.name) + } + + res.status(200) + res.json({}) + }) + ) + app.post( '/auth', wrapRequest(async (req, res, token) => { @@ -140,7 +172,7 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur throw new ApiError(409, 'User already authorized') } - const newRecord = await worker.authorizeUser(code, token.email) + const newRecord = await worker.authorizeUser(code, token.email, token.workspace.name) if (newRecord === undefined) { throw new ApiError(500) @@ -159,7 +191,7 @@ export function createServer (bot: Telegraf, worker: PlatformWorker, ctx: Measur app.get( '/info', - wrapRequest(async (_, res, token) => { + wrapRequest(async (_, res) => { const me = await bot.telegram.getMe() const profilePhotos = await bot.telegram.getUserProfilePhotos(me.id) const photoId = profilePhotos.photos[0]?.[0]?.file_id diff --git a/services/telegram-bot/pod-telegram-bot/src/start.ts b/services/telegram-bot/pod-telegram-bot/src/start.ts index 896bce26a4..ec6a15092c 100644 --- a/services/telegram-bot/pod-telegram-bot/src/start.ts +++ b/services/telegram-bot/pod-telegram-bot/src/start.ts @@ -14,7 +14,7 @@ // import { MeasureMetricsContext, newMetrics } from '@hcengineering/core' -import { setMetadata } from '@hcengineering/platform' +import { setMetadata, translate } from '@hcengineering/platform' import serverToken from '@hcengineering/server-token' import serverClient from '@hcengineering/server-client' import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' @@ -22,12 +22,17 @@ import { Analytics } from '@hcengineering/analytics' import { join } from 'path' import type { StorageConfiguration } from '@hcengineering/server-core' import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' +import telegram from '@hcengineering/telegram' +import { Telegraf } from 'telegraf' import config from './config' import { createServer, listen } from './server' -import { setUpBot } from './bot' +import { setUpBot } from './telegraf/bot' import { PlatformWorker } from './worker' import { registerLoaders } from './loaders' +import { TgContext } from './telegraf/types' +import { Command } from './telegraf/commands' +import { Limiter } from './limiter' const ctx = new MeasureMetricsContext( 'telegram-bot-service', @@ -43,6 +48,29 @@ const ctx = new MeasureMetricsContext( configureAnalytics(config.SentryDSN, config) Analytics.setTag('application', 'telegram-bot-service') +export async function requestReconnect ( + bot: Telegraf, + worker: PlatformWorker, + limiter: Limiter +): Promise { + const toReconnect = await worker.getUsersToDisconnect() + + if (toReconnect.length > 0) { + ctx.info('Disconnecting users', { users: toReconnect.map((it) => it.email) }) + const message = await translate(telegram.string.DisconnectMessage, { app: config.App, command: Command.Connect }) + for (const userRecord of toReconnect) { + try { + await limiter.add(userRecord.telegramId, async () => { + await bot.telegram.sendMessage(userRecord.telegramId, message) + }) + } catch (e) { + ctx.error('Failed to send message', { user: userRecord.email, error: e }) + } + } + await worker.disconnectUsers() + } +} + export const start = async (): Promise => { setMetadata(serverToken.metadata.Secret, config.Secret) setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl) @@ -54,7 +82,8 @@ export const start = async (): Promise => { const worker = await PlatformWorker.create(ctx, storageAdapter) const bot = await setUpBot(worker) - const app = createServer(bot, worker, ctx) + const limiter = new Limiter() + const app = createServer(bot, worker, ctx, limiter) if (config.Domain === '') { ctx.info('Starting bot with polling') @@ -76,6 +105,7 @@ export const start = async (): Promise => { res.status(200).send() }) + await requestReconnect(bot, worker, limiter) const server = listen(app, ctx, config.Port) const onClose = (): void => { diff --git a/services/telegram-bot/pod-telegram-bot/src/telegraf/bot.ts b/services/telegram-bot/pod-telegram-bot/src/telegraf/bot.ts new file mode 100644 index 0000000000..c85bb6548f --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/telegraf/bot.ts @@ -0,0 +1,367 @@ +// +// 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 { Context, Markup, NarrowedContext, session, Telegraf } from 'telegraf' +import { message } from 'telegraf/filters' +import telegram from '@hcengineering/telegram' +import { htmlToMarkup, isEmptyMarkup, jsonToMarkup, MarkupNodeType } from '@hcengineering/text' +import { toHTML } from '@telegraf/entity' +import { CallbackQuery, Message, Update } from 'telegraf/typings/core/types/typegram' +import { translate } from '@hcengineering/platform' +import { WithId } from 'mongodb' + +import config from '../config' +import { PlatformWorker } from '../worker' +import { TgContext, ReplyMessage } from './types' +import { toTelegramFileInfo } from '../utils' +import { Command, defineCommands } from './commands' +import { ChannelRecord, MessageRecord, TelegramFileInfo, UserRecord, WorkspaceInfo } from '../types' + +function encodeChannelId (workspace: string, channelId: string): string { + return `${workspace}_${channelId}` +} + +function decodeChannelId (id: string): { workspace: string, channelId: string } { + const [workspace, channelId] = id.split('_') + return { workspace, channelId } +} + +const getNextActionId = (workspace: string, page: number): string => `next_${workspace}_${page}` +const getPrevActionId = (workspace: string, page: number): string => `prev_${workspace}_${page}` + +async function findMessageRecord ( + worker: PlatformWorker, + from: number, + replyTo: number, + email: string +): Promise { + const record = await worker.getNotificationRecord(replyTo, email) + + if (record !== undefined) { + return record + } + + const reply = await worker.getReply(from, replyTo) + + if (reply === undefined) { + return undefined + } + + return await worker.findMessageRecord(email, reply.notificationId, reply.messageId) +} + +async function onReply ( + ctx: Context, + from: number, + message: ReplyMessage, + messageId: number, + replyTo: number, + worker: PlatformWorker, + username?: string +): Promise { + const userRecord = await worker.getUserRecord(from) + + if (userRecord === undefined) { + return false + } + + if (userRecord.telegramUsername !== username) { + await worker.updateTelegramUsername(userRecord, username) + } + + const messageRecord = await findMessageRecord(worker, from, replyTo, userRecord.email) + + if (messageRecord === undefined) { + return false + } + + await worker.saveReply({ + replyId: messageId, + telegramId: from, + notificationId: messageRecord.notificationId, + messageId: messageRecord.messageId + }) + + const file = await toTelegramFileInfo(ctx, message) + const files: TelegramFileInfo[] = file !== undefined ? [file] : [] + + return await worker.reply(messageRecord, htmlToMarkup(toHTML(message)), files) +} + +async function handleSelectChannel ( + ctx: Context>, + worker: PlatformWorker, + match: string +): Promise<[string, boolean]> { + const userMessage = (ctx.callbackQuery.message as ReplyMessage)?.reply_to_message + if (userMessage === undefined) return ['', false] + + const id = ctx.chat?.id + if (id === undefined) return ['', false] + + const userRecord = await worker.getUserRecord(id) + if (userRecord === undefined) return ['', false] + + const { workspace, channelId } = decodeChannelId(match) + + const channels = await worker.getChannels(userRecord.email, workspace) + const channel = channels.find((it) => it._id.toString() === channelId) + + if (channel === undefined) return ['', false] + + const file = await toTelegramFileInfo(ctx as TgContext, userMessage) + let text = htmlToMarkup(toHTML(userMessage as Message.TextMessage)) + + if (isEmptyMarkup(text) && 'caption' in userMessage) { + text = jsonToMarkup({ + type: MarkupNodeType.text, + text: userMessage.caption + }) + } + + return [channel.name, await worker.sendMessage(channel, userMessage.message_id, text, file)] +} + +async function createSelectChannelKeyboard ( + ctx: NarrowedContext, + worker: PlatformWorker, + userRecord: UserRecord, + workspace: string +): Promise { + const channels = await worker.getChannels(userRecord.email, workspace) + const hasNext = channels.length > channelsPerPage + const pageChannels = getPageChannels(channels, 0) + + await ctx.reply('Please select the channel to send message', { + reply_parameters: { message_id: ctx.message.message_id }, + ...Markup.inlineKeyboard( + [ + ...pageChannels.map((channel) => + Markup.button.callback(channel.name, encodeChannelId(channel.workspace, channel._id.toString())) + ), + ...(hasNext ? [Markup.button.callback('Next>', getNextActionId(workspace, 0))] : []) + ], + { columns: 1 } + ) + }) +} + +async function createSelectWorkspaceKeyboard ( + ctx: NarrowedContext, + worker: PlatformWorker, + workspaces: string[] +): Promise { + const info: WorkspaceInfo[] = [] + + for (const workspace of workspaces) { + const workspaceInfo = await worker.getWorkspaceInfo(workspace) + if (workspaceInfo === undefined) continue + info.push(workspaceInfo) + } + + info.sort((a, b) => a.name.localeCompare(b.name)) + + await ctx.reply('Please select workspace', { + reply_parameters: { message_id: ctx.message.message_id }, + ...Markup.inlineKeyboard( + info.map(({ id, name }) => Markup.button.callback(name, `workspace_${id}`)), + { columns: 1 } + ) + }) +} + +export async function setUpBot (worker: PlatformWorker): Promise> { + const bot = new Telegraf(config.BotToken) + + bot.use(session()) + bot.use((ctx, next) => { + ctx.processingKeyboards = ctx.processingKeyboards ?? new Set() + return next() + }) + + await defineCommands(bot, worker) + + bot.on(message('reply_to_message'), async (ctx) => { + const id = ctx.chat?.id + const message = ctx.message + + if (id === undefined || message.reply_to_message === undefined) { + return + } + + const replyTo = message.reply_to_message + const isReplied = await onReply( + ctx, + id, + message as ReplyMessage, + message.message_id, + replyTo.message_id, + worker, + ctx.from.username + ) + + if (!isReplied) { + await ctx.reply('Cannot reply to this message.') + } + }) + + bot.on(message(), async (ctx) => { + const id = ctx.chat?.id + if (id === undefined) return + if ('reply_to_message' in ctx.message) return + + const userRecord = await worker.getUserRecord(id) + if (userRecord === undefined) return + + const workspaces = userRecord.workspaces + + if (workspaces.length === 0) { + await ctx.reply("You don't have any connected workspaces") + return + } + if (workspaces.length === 1) { + await createSelectChannelKeyboard(ctx, worker, userRecord, workspaces[0]) + } else { + await createSelectWorkspaceKeyboard(ctx, worker, workspaces) + } + }) + + bot.action(/next_.+_.+/, async (ctx) => { + const [, workspace, page] = ctx.match[0].split('_') + + if (workspace == null || page == null) return + if (workspace === '' || page === '') return + + const newPage = parseInt(page) + 1 + + await editChannelKeyboard(ctx, worker, workspace, newPage) + }) + + bot.action(/prev_.+_.+/, async (ctx) => { + const [, workspace, page] = ctx.match[0].split('_') + + if (workspace == null || page == null) return + if (workspace === '' || page === '') return + + const newPage = parseInt(page) - 1 + + await editChannelKeyboard(ctx, worker, workspace, newPage) + }) + + bot.action(/workspace_.+/, async (ctx) => { + const messageId = ctx.callbackQuery.message?.message_id + if (messageId === undefined) return + if (ctx.processingKeyboards.has(messageId)) return + + ctx.processingKeyboards.add(messageId) + + try { + const wsId = ctx.match[0].split('_')[1] + if (wsId == null || wsId === '') return + const info = await worker.getWorkspaceInfo(wsId) + if (info === undefined) return + await ctx.editMessageText(`Please select the channel to send message in workspace ${info.name}`, { + parse_mode: 'HTML' + }) + await editChannelKeyboard(ctx, worker, wsId, 0) + } catch (e) { + await ctx.answerCbQuery('❌Failed to select workspace') + } + + ctx.processingKeyboards.delete(messageId) + }) + + bot.action(/.+_.+/, async (ctx) => { + const messageId = ctx.callbackQuery.message?.message_id + if (messageId === undefined) return + if (ctx.processingKeyboards.has(messageId)) return + + ctx.processingKeyboards.add(messageId) + + try { + const [channel, isMessageSent] = await handleSelectChannel(ctx, worker, ctx.match[0]) + + if (isMessageSent) { + // TODO: add link to the channel + await ctx.editMessageText(`✅Message sent to ${channel}`, { parse_mode: 'HTML' }) + } else { + if (channel !== '') { + await ctx.answerCbQuery(`❌Failed to send message to ${channel}`) + } else { + await ctx.answerCbQuery('❌Failed to send message') + } + } + } catch (e) { + console.error('Failed to send message', e) + await ctx.answerCbQuery('❌Failed to send message') + } + + ctx.processingKeyboards.delete(messageId) + }) + + const description = await translate(telegram.string.BotDescription, { app: config.App }) + const shortDescription = await translate(telegram.string.BotShortDescription, { app: config.App }) + + await bot.telegram.setMyDescription(description, 'en') + await bot.telegram.setMyShortDescription(shortDescription, 'en') + + return bot +} + +const channelsPerPage = 10 + +const getPageChannels = (channels: WithId[], page: number): WithId[] => { + return channels.slice(page * channelsPerPage, (page + 1) * channelsPerPage) +} + +const editChannelKeyboard = async ( + ctx: Context, + worker: PlatformWorker, + workspace: string, + page: number +): Promise => { + const id = ctx.chat?.id + if (id === undefined) return + + const userRecord = await worker.getUserRecord(id) + if (userRecord === undefined) return + + const channels = await worker.getChannels(userRecord.email, workspace) + + if (channels.length === 0) { + const ws = await worker.getWorkspaceInfo(workspace) + await ctx.editMessageText( + `No channels found in workspace ${ws?.name ?? workspace}.\nTo add channels call /${Command.SyncAllChannels} or /${Command.SyncStarredChannels}`, + { parse_mode: 'HTML' } + ) + return + } + + const pageChannels = getPageChannels(channels, page) + const hasNext = channels.length > (page + 1) * channelsPerPage + const hasPrev = page > 0 + + await ctx.editMessageReplyMarkup({ + inline_keyboard: [ + ...pageChannels.map((channel) => [ + Markup.button.callback(channel.name, encodeChannelId(channel.workspace, channel._id.toString())) + ]), + [ + ...(hasPrev ? [Markup.button.callback('', getNextActionId(workspace, page))] : []) + ] + ] + }) +} diff --git a/services/telegram-bot/pod-telegram-bot/src/bot.ts b/services/telegram-bot/pod-telegram-bot/src/telegraf/commands.ts similarity index 50% rename from services/telegram-bot/pod-telegram-bot/src/bot.ts rename to services/telegram-bot/pod-telegram-bot/src/telegraf/commands.ts index d9e1fd2af1..e7b4bfa14e 100644 --- a/services/telegram-bot/pod-telegram-bot/src/bot.ts +++ b/services/telegram-bot/pod-telegram-bot/src/telegraf/commands.ts @@ -13,18 +13,57 @@ // limitations under the License. // -import { Context, Telegraf } from 'telegraf' +import { BotCommand } from 'telegraf/typings/core/types/typegram' import { translate } from '@hcengineering/platform' import telegram from '@hcengineering/telegram' -import { htmlToMarkup } from '@hcengineering/text' -import { message } from 'telegraf/filters' -import { toHTML } from '@telegraf/entity' -import { Message, Update } from 'telegraf/typings/core/types/typegram' +import { Context, Telegraf } from 'telegraf' -import config from './config' -import { PlatformWorker } from './worker' -import { getBotCommands, getCommandsHelp, toTelegramFileInfo } from './utils' -import { NotificationRecord, TelegramFileInfo } from './types' +import config from '../config' +import { PlatformWorker } from '../worker' +import { TgContext } from './types' + +export enum Command { + Start = 'start', + Connect = 'connect', + SyncAllChannels = 'sync_all_channels', + SyncStarredChannels = 'sync_starred_channels', + Help = 'help', + Stop = 'stop' +} + +export async function getBotCommands (lang: string = 'en'): Promise { + return [ + { + command: Command.Start, + description: await translate(telegram.string.StartBot, { app: config.App }, lang) + }, + { + command: Command.Connect, + description: await translate(telegram.string.ConnectAccount, { app: config.App }, lang) + }, + { + command: Command.SyncAllChannels, + description: await translate(telegram.string.SyncAllChannels, { app: config.App }, lang) + }, + { + command: Command.SyncStarredChannels, + description: await translate(telegram.string.SyncStarredChannels, { app: config.App }, lang) + }, + { + command: Command.Help, + description: await translate(telegram.string.ShowCommandsDetails, { app: config.App }, lang) + }, + { + command: Command.Stop, + description: await translate(telegram.string.TurnNotificationsOff, { app: config.App }, lang) + } + ] +} + +export async function getCommandsHelp (lang: string): Promise { + const myCommands = await getBotCommands(lang) + return myCommands.map(({ command, description }) => `/${command} - ${description}`).join('\n') +} async function onStart (ctx: Context, worker: PlatformWorker): Promise { const id = ctx.from?.id @@ -57,7 +96,6 @@ async function onStart (ctx: Context, worker: PlatformWorker): Promise { async function onHelp (ctx: Context): Promise { const lang = ctx.from?.language_code ?? 'en' const commandsHelp = await getCommandsHelp(lang) - await ctx.reply(commandsHelp) } @@ -71,6 +109,26 @@ async function onStop (ctx: Context, worker: PlatformWorker): Promise { await ctx.reply(message) } +async function onSyncChannels (ctx: Context, worker: PlatformWorker, onlyStarred: boolean): Promise { + const id = ctx.from?.id + + if (id === undefined) { + return + } + + const record = await worker.getUserRecord(id) + + if (record === undefined) return + + const workspaces = record.workspaces + + for (const workspace of workspaces) { + await worker.syncChannels(record.email, workspace, onlyStarred) + } + + await ctx.reply('List of channels updated') +} + async function onConnect (ctx: Context, worker: PlatformWorker): Promise { const id = ctx.from?.id const lang = ctx.from?.language_code ?? 'en' @@ -95,105 +153,14 @@ async function onConnect (ctx: Context, worker: PlatformWorker): Promise { await ctx.reply(`*${code}*`, { parse_mode: 'MarkdownV2' }) } -async function findNotificationRecord ( - worker: PlatformWorker, - from: number, - replyTo: number, - email: string -): Promise { - const record = await worker.getNotificationRecord(replyTo, email) - - if (record !== undefined) { - return record - } - - const reply = await worker.getReply(from, replyTo) - - if (reply === undefined) { - return undefined - } - - 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: ReplyMessage, - messageId: number, - replyTo: number, - worker: PlatformWorker, - username?: string -): Promise { - const userRecord = await worker.getUserRecord(from) - - if (userRecord === undefined) { - return false - } - - if (userRecord.telegramUsername !== username) { - await worker.updateTelegramUsername(userRecord, username) - } - - const notification = await findNotificationRecord(worker, from, replyTo, userRecord.email) - - if (notification === undefined) { - return false - } - - await worker.saveReply({ replyId: messageId, telegramId: from, notificationId: notification.notificationId }) - - 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 { - const bot = new Telegraf(config.BotToken) - +export async function defineCommands (bot: Telegraf, worker: PlatformWorker): Promise { await bot.telegram.setMyCommands(await getBotCommands()) bot.start((ctx) => onStart(ctx, worker)) bot.help(onHelp) - bot.command('stop', (ctx) => onStop(ctx, worker)) - bot.command('connect', (ctx) => onConnect(ctx, worker)) - - bot.on(message('reply_to_message'), async (ctx) => { - const id = ctx.chat?.id - const message = ctx.message - - if (id === undefined || message.reply_to_message === undefined) { - return - } - - const replyTo = message.reply_to_message - const isReplied = await onReply( - ctx, - id, - message as ReplyMessage, - message.message_id, - replyTo.message_id, - worker, - ctx.from.username - ) - - if (!isReplied) { - await ctx.reply('Cannot reply to this message.') - } - }) - - const description = await translate(telegram.string.BotDescription, { app: config.App }) - const shortDescription = await translate(telegram.string.BotShortDescription, { app: config.App }) - - await bot.telegram.setMyDescription(description, 'en') - await bot.telegram.setMyShortDescription(shortDescription, 'en') - - return bot + bot.command(Command.Stop, (ctx) => onStop(ctx, worker)) + bot.command(Command.Connect, (ctx) => onConnect(ctx, worker)) + bot.command(Command.SyncAllChannels, (ctx) => onSyncChannels(ctx, worker, false)) + bot.command(Command.SyncStarredChannels, (ctx) => onSyncChannels(ctx, worker, true)) } diff --git a/services/telegram-bot/pod-telegram-bot/src/telegraf/types.ts b/services/telegram-bot/pod-telegram-bot/src/telegraf/types.ts new file mode 100644 index 0000000000..adb0416364 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/telegraf/types.ts @@ -0,0 +1,27 @@ +// +// 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 { Message, Update } from 'telegraf/typings/core/types/typegram' +import { Context, Scenes } from 'telegraf' + +export type ReplyMessage = Update.New & +Update.NonChannel & +Message.TextMessage & +(Message.PhotoMessage | Message.VoiceMessage | Message.VideoMessage | Message.VideoNoteMessage) + +export interface TgContext extends Context { + processingKeyboards: Set + scene: Scenes.SceneContextScene +} diff --git a/services/telegram-bot/pod-telegram-bot/src/types.ts b/services/telegram-bot/pod-telegram-bot/src/types.ts index 6b447ee778..9c14caf087 100644 --- a/services/telegram-bot/pod-telegram-bot/src/types.ts +++ b/services/telegram-bot/pod-telegram-bot/src/types.ts @@ -13,24 +13,37 @@ // limitations under the License. // -import { Ref, Timestamp } from '@hcengineering/core' +import { Class, Ref, Timestamp } from '@hcengineering/core' import { InboxNotification } from '@hcengineering/notification' +import { ChunterSpace } from '@hcengineering/chunter' +import { ActivityMessage } from '@hcengineering/activity' export interface UserRecord { telegramId: number telegramUsername?: string email: string + workspaces: string[] } -export interface NotificationRecord { - notificationId: Ref +export interface MessageRecord { + notificationId?: Ref + messageId?: Ref workspace: string email: string telegramId: number } +export interface ChannelRecord { + workspace: string + channelId: Ref + channelClass: Ref> + name: string + email: string +} + export interface ReplyRecord { - notificationId: Ref + notificationId?: Ref + messageId?: Ref telegramId: number replyId: number } @@ -57,3 +70,9 @@ export interface TelegramFileInfo { name?: string size?: number } + +export interface WorkspaceInfo { + name: string + url: string + id: string +} diff --git a/services/telegram-bot/pod-telegram-bot/src/utils.ts b/services/telegram-bot/pod-telegram-bot/src/utils.ts index 4344be868d..f3eca3c2db 100644 --- a/services/telegram-bot/pod-telegram-bot/src/utils.ts +++ b/services/telegram-bot/pod-telegram-bot/src/utils.ts @@ -15,16 +15,14 @@ import { Collection } from 'mongodb' import otpGenerator from 'otp-generator' -import { BotCommand, Message } from 'telegraf/typings/core/types/typegram' -import { translate } from '@hcengineering/platform' -import telegram, { TelegramNotificationRequest } from '@hcengineering/telegram' +import { Message } from 'telegraf/typings/core/types/typegram' +import { 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, PlatformFileInfo, TelegramFileInfo } from './types' -import config from './config' export async function getNewOtp (otpCollection: Collection): Promise { let otp = otpGenerator.generate(6, { @@ -45,32 +43,6 @@ export async function getNewOtp (otpCollection: Collection): Promise< return otp } -export async function getBotCommands (lang: string = 'en'): Promise { - return [ - { - command: 'start', - description: await translate(telegram.string.StartBot, { app: config.App }, lang) - }, - { - command: 'connect', - description: await translate(telegram.string.ConnectAccount, { app: config.App }, lang) - }, - { - command: 'help', - description: await translate(telegram.string.ShowCommandsDetails, { app: config.App }, lang) - }, - { - command: 'stop', - description: await translate(telegram.string.TurnNotificationsOff, { app: config.App }, lang) - } - ] -} - -export async function getCommandsHelp (lang: string): Promise { - const myCommands = await getBotCommands(lang) - return myCommands.map(({ command, description }) => `/${command} - ${description}`).join('\n') -} - const maxTitleLength = 300 const maxQuoteLength = 500 const maxBodyLength = 2000 @@ -99,7 +71,7 @@ export function toTelegramHtml (record: TelegramNotificationRequest): { } } -const supportedTags = ['strong', 'em', 's', 'blockquote', 'code', 'a'] +const supportedTags = ['b', 'strong', 'em', 's', 'strike', 'del', 'blockquote', 'code', 'a', 'pre', 'u', 'i'] export function platformToTelegram (message: string, limit: number): string { let textLength = 0 @@ -254,7 +226,13 @@ export function toMediaGroups (files: PlatformFileInfo[], fullMessage: string, s export async function toTelegramFileInfo ( ctx: Context, - message: Message.PhotoMessage | Message.VideoMessage | Message.VoiceMessage | Message.VideoNoteMessage + message: + | Message.CommonMessage + | Message.PhotoMessage + | Message.VideoMessage + | Message.VoiceMessage + | Message.VideoNoteMessage + | Message.DocumentMessage ): Promise { try { if ('photo' in message) { @@ -262,29 +240,37 @@ export async function toTelegramFileInfo ( 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' } + const fileName = url.split('/').pop() + return { url, 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' } + return { url, 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' } + return { url, 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' } + return { url, width: 0, height: 0, size: fileSize, type: type ?? 'audio/ogg' } + } + + if ('document' in message) { + const document = message.document + const { file_id: fileId, file_size: fileSize, mime_type: type, file_name: fileName } = document + if (type == null) return + const url = (await ctx.telegram.getFileLink(fileId)).toString() + return { url, width: 0, height: 0, size: fileSize, name: fileName, type } } } catch (e) { console.error('Failed to get file info', e) diff --git a/services/telegram-bot/pod-telegram-bot/src/worker.ts b/services/telegram-bot/pod-telegram-bot/src/worker.ts index 8d55cbf33d..d5f60cf909 100644 --- a/services/telegram-bot/pod-telegram-bot/src/worker.ts +++ b/services/telegram-bot/pod-telegram-bot/src/worker.ts @@ -13,13 +13,27 @@ // limitations under the License. // -import type { Collection } from 'mongodb' -import { Account, MeasureContext, Ref, SortingOrder } from '@hcengineering/core' +import type { Collection, WithId } from 'mongodb' +import { MeasureContext, Ref, SortingOrder, systemAccountEmail } from '@hcengineering/core' import { InboxNotification } from '@hcengineering/notification' import { TelegramNotificationRequest } from '@hcengineering/telegram' import { StorageAdapter } from '@hcengineering/server-core' +import chunter, { ChunterSpace } from '@hcengineering/chunter' +import { formatName, PersonAccount } from '@hcengineering/contact' +import { generateToken } from '@hcengineering/server-token' +import { getWorkspaceInfo } from '@hcengineering/server-client' +import { ActivityMessage } from '@hcengineering/activity' -import { NotificationRecord, OtpRecord, PlatformFileInfo, ReplyRecord, TelegramFileInfo, UserRecord } from './types' +import { + ChannelRecord, + MessageRecord, + OtpRecord, + PlatformFileInfo, + ReplyRecord, + TelegramFileInfo, + UserRecord, + WorkspaceInfo +} from './types' import { getDB } from './storage' import { WorkspaceClient } from './workspace' import { getNewOtp } from './utils' @@ -32,13 +46,17 @@ export class PlatformWorker { private readonly closeWorkspaceTimeouts: Map = new Map() private readonly intervalId: NodeJS.Timeout | undefined + private readonly channelsMap = new Map[]>() + private readonly workspaceInfoById = new Map() + private constructor ( readonly ctx: MeasureContext, readonly storageAdapter: StorageAdapter, private readonly usersStorage: Collection, - private readonly notificationsStorage: Collection, + private readonly messagesStorage: Collection, private readonly otpStorage: Collection, - private readonly repliesStorage: Collection + private readonly repliesStorage: Collection, + private readonly channelsStorage: Collection ) { this.intervalId = setInterval( () => { @@ -48,6 +66,14 @@ export class PlatformWorker { ) } + public async getUsersToDisconnect (): Promise { + return await this.usersStorage.find({ workspaces: { $exists: false } }).toArray() + } + + public async disconnectUsers (): Promise { + await this.usersStorage.deleteMany({ workspaces: { $exists: false } }) + } + async close (): Promise { if (this.intervalId !== undefined) { clearInterval(this.intervalId) @@ -70,22 +96,42 @@ export class PlatformWorker { } } - async addUser (id: number, email: string, telegramUsername?: string): Promise { + async addUser ( + id: number, + email: string, + workspace: string, + telegramUsername?: string + ): Promise { const emailRes = await this.usersStorage.findOne({ email }) if (emailRes !== null) { - console.log('Account is already registered', { id, email }) + if (emailRes.workspaces.includes(workspace)) { + return + } + if (!emailRes.workspaces.includes(workspace)) { + await this.usersStorage.updateOne({ email }, { $push: { workspaces: workspace } }) + } return } const tRes = await this.usersStorage.findOne({ telegramId: id }) if (tRes !== null) { - console.log('Account is already registered', { id, email }) + if (tRes.email !== email) { + this.ctx.error('Account is already registered', { id, email: tRes.email, newEmail: email }) + } + if (tRes.email === email && !tRes.workspaces.includes(workspace)) { + await this.usersStorage.updateOne({ email }, { $push: { workspaces: workspace } }) + } return } - const insertResult = await this.usersStorage.insertOne({ telegramId: id, email, telegramUsername }) + const insertResult = await this.usersStorage.insertOne({ + telegramId: id, + email, + workspaces: [workspace], + telegramUsername + }) return (await this.usersStorage.findOne({ _id: insertResult.insertedId })) ?? undefined } @@ -105,18 +151,14 @@ export class PlatformWorker { ) } - async addNotificationRecord (record: NotificationRecord): Promise { - await this.notificationsStorage.insertOne(record) + async addNotificationRecord (record: MessageRecord): Promise { + await this.messagesStorage.insertOne(record) } async removeUserByTelegramId (id: number): Promise { await this.usersStorage.deleteOne({ telegramId: id }) } - async removeUserByAccount (_id: Ref): Promise { - await this.usersStorage.deleteOne({ account: _id }) - } - async saveReply (record: ReplyRecord): Promise { await this.repliesStorage.insertOne(record) } @@ -125,15 +167,24 @@ export class PlatformWorker { return (await this.repliesStorage.findOne({ telegramId: id, replyId: replyTo })) ?? undefined } - async getNotificationRecord (id: number, email: string): Promise { - return (await this.notificationsStorage.findOne({ telegramId: id, email })) ?? undefined + async getNotificationRecord (id: number, email: string): Promise { + return (await this.messagesStorage.findOne({ telegramId: id, email })) ?? undefined } - async getNotificationRecordById ( - notificationId: Ref, - email: string - ): Promise { - return (await this.notificationsStorage.findOne({ notificationId, email })) ?? undefined + async findMessageRecord ( + email: string, + notificationId?: Ref, + messageId?: Ref + ): Promise { + if (notificationId !== undefined) { + return (await this.messagesStorage.findOne({ notificationId, email })) ?? undefined + } + + if (messageId !== undefined) { + return (await this.messagesStorage.findOne({ messageId, email })) ?? undefined + } + + return undefined } async getUserRecord (id: number): Promise { @@ -144,6 +195,14 @@ export class PlatformWorker { return (await this.usersStorage.findOne({ email })) ?? undefined } + async addWorkspace (email: string, workspace: string): Promise { + await this.usersStorage.updateOne({ email }, { $push: { workspaces: workspace } }) + } + + async removeWorkspace (email: string, workspace: string): Promise { + await this.usersStorage.updateOne({ email }, { $pull: { workspaces: workspace } }) + } + async getWorkspaceClient (workspace: string): Promise { const wsClient = this.workspacesClients.get(workspace) ?? (await WorkspaceClient.create(workspace, this.ctx, this.storageAdapter)) @@ -167,12 +226,127 @@ export class PlatformWorker { return wsClient } - async reply (notification: NotificationRecord, text: string, files: TelegramFileInfo[]): Promise { - const client = await this.getWorkspaceClient(notification.workspace) - return await client.reply(notification, text, files) + async reply (messageRecord: MessageRecord, text: string, files: TelegramFileInfo[]): Promise { + const client = await this.getWorkspaceClient(messageRecord.workspace) + return await client.reply(messageRecord, text, files) } - async authorizeUser (code: string, email: string): Promise { + async getChannelName (client: WorkspaceClient, channel: ChunterSpace, email: string): Promise { + if (client.hierarchy.isDerived(channel._class, chunter.class.DirectMessage)) { + const persons = await client.getPersons(channel.members as Ref[], email) + return persons + .map(({ name }) => formatName(name)) + .sort((a, b) => a.localeCompare(b)) + .join(', ') + } + + if (client.hierarchy.isDerived(channel._class, chunter.class.Channel)) { + return `#${channel.name}` + } + + return channel.name + } + + async getWorkspaces (email: string): Promise { + return (await this.usersStorage.findOne({ email }))?.workspaces ?? [] + } + + async getChannels (email: string, workspace: string): Promise[]> { + const key = `${email}:${workspace}` + + if (this.channelsMap.has(key)) { + return this.channelsMap.get(key) ?? [] + } + const res = await this.channelsStorage + .find({ workspace, email }, { sort: { name: SortingOrder.Ascending } }) + .toArray() + + this.channelsMap.set(key, res) + return res + } + + async sendMessage ( + channel: ChannelRecord, + telegramId: number, + text: string, + file?: TelegramFileInfo + ): Promise { + const client = await this.getWorkspaceClient(channel.workspace) + const _id = await client.sendMessage(channel, text, file) + + await this.messagesStorage.insertOne({ + email: channel.email, + workspace: channel.workspace, + telegramId, + messageId: _id + }) + + return _id !== undefined + } + + async syncChannels (email: string, workspace: string, onlyStarred: boolean): Promise { + const client = await this.getWorkspaceClient(workspace) + const channels = await client.getChannels(email, onlyStarred) + const existingChannels = await this.channelsStorage.find({ workspace, email }).toArray() + + const toInsert: ChannelRecord[] = [] + const toDelete: WithId[] = [] + + for (const channel of channels) { + const existingChannel = existingChannels.find((c) => c.channelId === channel._id) + const name = await this.getChannelName(client, channel, email) + if (existingChannel === undefined) { + toInsert.push({ workspace, email, channelId: channel._id, channelClass: channel._class, name }) + } else if (existingChannel.name !== name) { + await this.channelsStorage.updateOne({ workspace, email, _id: channel._id }, { $set: { name } }) + } + } + + for (const existingChannel of existingChannels) { + const channel = channels.find(({ _id }) => _id === existingChannel.channelId) + if (channel === undefined) { + toDelete.push(existingChannel) + } + } + + if (toInsert.length > 0) { + await this.channelsStorage.insertMany(toInsert) + } + + if (toDelete.length > 0) { + await this.channelsStorage.deleteMany({ _id: { $in: toDelete.map((c) => c._id) } }) + } + + this.channelsMap.delete(`${email}:${workspace}`) + } + + async getWorkspaceInfo (workspaceId: string): Promise { + if (this.workspaceInfoById.has(workspaceId)) { + return this.workspaceInfoById.get(workspaceId) + } + + try { + const token = generateToken(systemAccountEmail, { name: workspaceId }) + const result = await getWorkspaceInfo(token) + + if (result === undefined) { + this.ctx.error('Failed to get workspace info', { workspaceId }) + return undefined + } + + const info: WorkspaceInfo = { + name: result.workspaceName ?? result.workspace, + url: result.workspaceUrl ?? result.workspace, + id: workspaceId + } + this.workspaceInfoById.set(workspaceId, info) + return info + } catch (e) { + return undefined + } + } + + async authorizeUser (code: string, email: string, workspace: string): Promise { const otpData = (await this.otpStorage.findOne({ code })) ?? undefined const isExpired = otpData !== undefined && otpData.expires < Date.now() const isValid = otpData !== undefined && !isExpired && code === otpData.code @@ -181,7 +355,7 @@ export class PlatformWorker { throw new Error('Invalid OTP') } - return await this.addUser(otpData.telegramId, email, otpData.telegramUsername) + return await this.addUser(otpData.telegramId, email, workspace, otpData.telegramUsername) } async generateCode (telegramId: number, telegramUsername?: string): Promise { @@ -207,20 +381,37 @@ export class PlatformWorker { } static async createStorages (): Promise< - [Collection, Collection, Collection, Collection] + [ + Collection, + Collection, + Collection, + Collection, + Collection + ] > { const db = await getDB() const userStorage = db.collection('users') - const notificationsStorage = db.collection('notifications') + await db.dropCollection('notifications') + const messagesStorage = db.collection('messages') const otpStorage = db.collection('otp') const repliesStorage = db.collection('replies') + const channelsStorage = db.collection('channels') - return [userStorage, notificationsStorage, otpStorage, repliesStorage] + return [userStorage, messagesStorage, otpStorage, repliesStorage, channelsStorage] } static async create (ctx: MeasureContext, storageAdapter: StorageAdapter): Promise { - const [userStorage, notificationsStorage, otpStorage, repliesStorage] = await PlatformWorker.createStorages() + const [userStorage, messagesStorage, otpStorage, repliesStorage, channelsStorage] = + await PlatformWorker.createStorages() - return new PlatformWorker(ctx, storageAdapter, userStorage, notificationsStorage, otpStorage, repliesStorage) + return new PlatformWorker( + ctx, + storageAdapter, + userStorage, + messagesStorage, + otpStorage, + repliesStorage, + channelsStorage + ) } } diff --git a/services/telegram-bot/pod-telegram-bot/src/workspace.ts b/services/telegram-bot/pod-telegram-bot/src/workspace.ts index af65f8d8ab..5661fc94b2 100644 --- a/services/telegram-bot/pod-telegram-bot/src/workspace.ts +++ b/services/telegram-bot/pod-telegram-bot/src/workspace.ts @@ -13,11 +13,16 @@ // limitations under the License. // -import { +import core, { + Account, Blob, + Class, Client, + Doc, generateId, getWorkspaceId, + Hierarchy, + Markup, MeasureContext, Ref, Space, @@ -26,24 +31,27 @@ import { } 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 chunter, { ChatMessage, ChunterSpace, ThreadMessage } from '@hcengineering/chunter' +import contact, { Person, 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, PlatformFileInfo, TelegramFileInfo } from './types' +import { ChannelRecord, MessageRecord, PlatformFileInfo, TelegramFileInfo } from './types' export class WorkspaceClient { + hierarchy: Hierarchy private constructor ( private readonly ctx: MeasureContext, private readonly storageAdapter: StorageAdapter, private readonly client: Client, private readonly token: string, private readonly workspace: string - ) {} + ) { + this.hierarchy = client.getHierarchy() + } static async create ( workspace: string, @@ -59,7 +67,8 @@ export class WorkspaceClient { async createAttachments ( factory: TxFactory, - _id: Ref, + _id: Ref, + _class: Ref>, space: Ref, files: TelegramFileInfo[] ): Promise { @@ -73,8 +82,8 @@ export class WorkspaceClient { 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, + const tx = factory.createTxCollectionCUD( + _class, _id, space, 'attachments', @@ -86,7 +95,7 @@ export class WorkspaceClient { lastModified: Date.now(), collection: 'attachments', attachedTo: _id, - attachedToClass: chunter.class.ThreadMessage + attachedToClass: _class }) ) await this.client.tx(tx) @@ -98,19 +107,71 @@ export class WorkspaceClient { return attachments } - async replyToMessage ( + async isReplyAvailable (account: Ref, message: ActivityMessage): Promise { + const hierarchy = this.hierarchy + + let objectId: Ref + let objectClass: Ref> + + if (hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + const thread = message as ThreadMessage + objectId = thread.objectId + objectClass = thread.objectClass + } else { + objectId = message.attachedTo + objectClass = message.attachedToClass + } + + if (hierarchy.isDerived(objectClass, core.class.Space)) { + const space = await this.client.findOne(objectClass, { _id: objectId as Ref, members: account }) + return space !== undefined + } + + const doc = await this.client.findOne(objectClass, { _id: objectId }) + + if (doc === undefined) { + return false + } + + const space = await this.client.findOne(core.class.Space, { _id: doc.space }) + + if (space === undefined) { + return false + } + + if (hierarchy.isDerived(space._class, core.class.SystemSpace)) { + return true + } + + return space.members.includes(account) + } + + async createThreadMessage ( message: ActivityMessage, account: PersonAccount, text: string, files: TelegramFileInfo[] - ): Promise { + ): Promise { const txFactory = new TxFactory(account._id) - const hierarchy = this.client.getHierarchy() + const hierarchy = this.hierarchy + + const isAvailable = await this.isReplyAvailable(account._id, message) + + if (!isAvailable) { + return false + } + const messageId = generateId() - const attachments = await this.createAttachments(txFactory, messageId, message.space, files) + const attachments = await this.createAttachments( + txFactory, + messageId, + chunter.class.ThreadMessage, + message.space, + files + ) if (attachments === 0 && isEmptyMarkup(text)) { - return + return false } if (hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { @@ -161,6 +222,8 @@ export class WorkspaceClient { ) await this.client.tx(collectionTx) } + + return true } async replyToActivityNotification ( @@ -172,8 +235,7 @@ export class WorkspaceClient { const message = await this.client.findOne(it.attachedToClass, { _id: it.attachedTo }) if (message !== undefined) { - await this.replyToMessage(message, account, text, files) - return true + return await this.createThreadMessage(message, account, text, files) } return false @@ -185,7 +247,7 @@ export class WorkspaceClient { text: string, files: TelegramFileInfo[] ): Promise { - const hierarchy = this.client.getHierarchy() + const hierarchy = this.hierarchy if (!hierarchy.isDerived(it.mentionedInClass, activity.class.ActivityMessage)) { return false @@ -194,19 +256,18 @@ 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, files) - return true + return await this.createThreadMessage(message, account, text, files) } return false } - 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 - } - + async replyToNotification ( + account: PersonAccount, + record: MessageRecord, + text: string, + files: TelegramFileInfo[] + ): Promise { const inboxNotification = await this.client.findOne(notification.class.InboxNotification, { _id: record.notificationId }) @@ -214,7 +275,7 @@ export class WorkspaceClient { if (inboxNotification === undefined) { return false } - const hierarchy = this.client.getHierarchy() + const hierarchy = this.hierarchy if (hierarchy.isDerived(inboxNotification._class, notification.class.ActivityInboxNotification)) { return await this.replyToActivityNotification( inboxNotification as ActivityInboxNotification, @@ -229,6 +290,38 @@ export class WorkspaceClient { return false } + async replyToMessage ( + account: PersonAccount, + record: MessageRecord, + text: string, + files: TelegramFileInfo[] + ): Promise { + const message = await this.client.findOne(activity.class.ActivityMessage, { _id: record.messageId }) + + if (message === undefined) { + return false + } + + return await this.createThreadMessage(message, account, text, files) + } + + public async reply (record: MessageRecord, text: string, files: TelegramFileInfo[]): Promise { + const account = await this.client.getModel().findOne(contact.class.PersonAccount, { email: record.email }) + if (account === undefined) { + return false + } + + if (record.notificationId !== undefined) { + return await this.replyToNotification(account, record, text, files) + } + + if (record.messageId !== undefined) { + return await this.replyToMessage(account, record, text, files) + } + + return false + } + async close (): Promise { await this.client.close() } @@ -249,6 +342,95 @@ export class WorkspaceClient { } return res } + + async getChannels (email: string, onlyStarred: boolean): Promise { + const account = await this.client.findOne(contact.class.PersonAccount, { email }) + if (account === undefined) return [] + + if (!onlyStarred) { + return await this.client.findAll(chunter.class.ChunterSpace, { + members: account._id + }) + } + + const contexts = await this.client.findAll(notification.class.DocNotifyContext, { + objectClass: { $in: [chunter.class.Channel, chunter.class.DirectMessage] }, + isPinned: true, + user: account._id + }) + + if (contexts.length === 0) { + return [] + } + + return await this.client.findAll(chunter.class.ChunterSpace, { + _id: { $in: contexts.map((context) => context.objectId as Ref) }, + members: account._id + }) + } + + async getPersons (_ids: Ref[], myEmail: string): Promise { + const me = await this.client.findOne(contact.class.PersonAccount, { email: myEmail }) + const accounts = this.client.getModel().findAllSync(contact.class.PersonAccount, { _id: { $in: _ids } }) + const persons = accounts.filter((account) => account.person !== me?.person).map(({ person }) => person) + return await this.client.findAll(contact.class.Person, { _id: { $in: persons } }) + } + + async sendMessage ( + channel: ChannelRecord, + text: Markup, + file?: TelegramFileInfo + ): Promise | undefined> { + const account = await this.client.getModel().findOne(contact.class.PersonAccount, { email: channel.email }) + + if (account === undefined) { + return undefined + } + + const doc = await this.client.findOne(channel.channelClass, { _id: channel.channelId, members: account._id }) + + if (doc === undefined) { + return undefined + } + + const txFactory = new TxFactory(account._id) + const messageId = generateId() + const attachments = await this.createAttachments( + txFactory, + messageId, + chunter.class.ChatMessage, + channel.channelId, + file !== undefined ? [file] : [] + ) + + if (attachments === 0 && isEmptyMarkup(text)) { + return undefined + } + + const collectionTx = txFactory.createTxCollectionCUD( + channel.channelClass, + channel.channelId, + channel.channelId, + 'messages', + txFactory.createTxCreateDoc( + chunter.class.ChatMessage, + channel.channelId, + { + message: text, + attachments, + attachedTo: channel.channelId, + attachedToClass: channel.channelClass, + collection: 'messages', + provider: contact.channelProvider.Telegram + }, + messageId + ) + ) + + await this.client.tx(collectionTx) + + return messageId + } } async function connectPlatform (token: string): Promise {