Allow to send message from tg bot to channels (#6472)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-09-03 23:50:43 +04:00 committed by GitHub
parent f8cbd1c335
commit 3be2de033a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1107 additions and 224 deletions

View File

@ -681,7 +681,7 @@
$: void forceReadContext(isScrollAtBottom, notifyContext)
async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise<void> {
if (context === undefined || !isScrollAtBottom || forceRead || !separatorElement) return
if (context === undefined || !isScrollAtBottom || forceRead) return
const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context
if (lastViewedTimestamp >= lastUpdateTimestamp) return

View File

@ -48,6 +48,11 @@
"AccountConnectedHtml": "You have been successfully connected as <b>{email}</b>",
"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"
}
}

View File

@ -48,6 +48,11 @@
"AccountConnectedHtml": "Te has conectado correctamente como <b>{email}</b>",
"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"
}
}

View File

@ -48,6 +48,11 @@
"AccountConnectedHtml": "Vous avez été connecté avec succès en tant que <b>{email}</b>",
"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"
}
}

View File

@ -48,6 +48,11 @@
"AccountConnectedHtml": "Foi ligado com sucesso como <b>{email}</b>",
"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"
}
}

View File

@ -48,6 +48,11 @@
"AccountConnectedHtml": "Вы успешно подключены как <b>{email}</b>",
"AccountAlreadyConnected": "Аккаунт уже подключен",
"InvalidCode": "Неверный код",
"SomethingWentWrong": "Что-то пошло не так. Пожалуйста, попробуйте снова."
"SomethingWentWrong": "Что-то пошло не так. Пожалуйста, попробуйте снова.",
"Configure": "Настроить",
"ConnectTelegramBot": "Подключить Telegram бота",
"DisconnectMessage": "⚠️Вы отключены от сервиса. Пожалуйста, подключите свой аккаунт на {app} с помощью /{command}.",
"SyncAllChannels": "Синхронизировать все каналы",
"SyncStarredChannels": "Синхронизировать избранные каналы"
}
}

View File

@ -48,6 +48,11 @@
"AccountConnectedHtml": "您已成功连接为 <b>{email}</b>",
"AccountAlreadyConnected": "帐户已连接",
"InvalidCode": "无效代码",
"SomethingWentWrong": "出现问题。 请重试。"
"SomethingWentWrong": "出现问题。 请重试。",
"Configure": "配置",
"ConnectTelegramBot": "连接 Telegram 机器人",
"DisconnectMessage": "⚠️您已从服务中断开连接。 请使用 /{command} 在 {app} 上重新连接您的帐户。",
"SyncAllChannels": "同步所有频道",
"SyncStarredChannels": "同步标记的频道"
}
}

View File

@ -115,7 +115,7 @@
</script>
<Modal
label={getEmbeddedLabel('Connect Telegram Bot')}
label={telegram.string.ConnectTelegramBot}
type="type-popup"
okLabel={presentation.string.Ok}
okAction={() => {

View File

@ -15,20 +15,43 @@
<script lang="ts">
import { ModernButton, showPopup } from '@hcengineering/ui'
import { getEmbeddedLabel } from '@hcengineering/platform'
import telegram from '@hcengineering/telegram'
import presentation from '@hcengineering/presentation'
import { concatLink } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import ConfigureBotPopup from './ConfigureBotPopup.svelte'
export let enabled: boolean
const url = getMetadata(telegram.metadata.BotUrl) ?? ''
function configureBot (): void {
showPopup(ConfigureBotPopup, {})
}
$: void updateWorkspace(enabled)
async function updateWorkspace (enabled: boolean): Promise<void> {
if (url === '') return
try {
const link = concatLink(url, '/updateWorkspace')
await fetch(link, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token),
'Content-Type': 'application/json'
},
body: JSON.stringify({ enabled })
})
} catch (e) {}
}
</script>
{#if enabled}
<div class="configure mt-2">
<ModernButton label={getEmbeddedLabel('Configure')} kind="primary" size="small" on:click={configureBot} />
<ModernButton label={telegram.string.Configure} kind="primary" size="small" on:click={configureBot} />
</div>
{/if}

View File

@ -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
}
})

View File

@ -155,3 +155,25 @@ export async function workerHandshake (
})
})
}
export async function getWorkspaceInfo (token: string): Promise<BaseWorkspaceInfo | undefined> {
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
}

View File

@ -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<keyof Config>).filter((key) => params[key] === undefined)

View File

@ -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<TgContext>,
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

View File

@ -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<TgContext>,
worker: PlatformWorker,
limiter: Limiter
): Promise<void> {
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<void> => {
setMetadata(serverToken.metadata.Secret, config.Secret)
setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl)
@ -54,7 +82,8 @@ export const start = async (): Promise<void> => {
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<void> => {
res.status(200).send()
})
await requestReconnect(bot, worker, limiter)
const server = listen(app, ctx, config.Port)
const onClose = (): void => {

View File

@ -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<MessageRecord | undefined> {
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<boolean> {
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<Update.CallbackQueryUpdate<CallbackQuery>>,
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<TgContext, Update.MessageUpdate>,
worker: PlatformWorker,
userRecord: UserRecord,
workspace: string
): Promise<void> {
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<TgContext, Update.MessageUpdate>,
worker: PlatformWorker,
workspaces: string[]
): Promise<void> {
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<Telegraf<TgContext>> {
const bot = new Telegraf<TgContext>(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 <b>${info.name}</b>`, {
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 <b>${channel}</b>`, { 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<ChannelRecord>[], page: number): WithId<ChannelRecord>[] => {
return channels.slice(page * channelsPerPage, (page + 1) * channelsPerPage)
}
const editChannelKeyboard = async (
ctx: Context,
worker: PlatformWorker,
workspace: string,
page: number
): Promise<void> => {
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 <b>${ws?.name ?? workspace}</b>.\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('<Prev', getPrevActionId(workspace, page))] : []),
...(hasNext ? [Markup.button.callback('Next>', getNextActionId(workspace, page))] : [])
]
]
})
}

View File

@ -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<BotCommand[]> {
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<string> {
const myCommands = await getBotCommands(lang)
return myCommands.map(({ command, description }) => `/${command} - ${description}`).join('\n')
}
async function onStart (ctx: Context, worker: PlatformWorker): Promise<void> {
const id = ctx.from?.id
@ -57,7 +96,6 @@ async function onStart (ctx: Context, worker: PlatformWorker): Promise<void> {
async function onHelp (ctx: Context): Promise<void> {
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<void> {
await ctx.reply(message)
}
async function onSyncChannels (ctx: Context, worker: PlatformWorker, onlyStarred: boolean): Promise<void> {
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<void> {
const id = ctx.from?.id
const lang = ctx.from?.language_code ?? 'en'
@ -95,105 +153,14 @@ async function onConnect (ctx: Context, worker: PlatformWorker): Promise<void> {
await ctx.reply(`*${code}*`, { parse_mode: 'MarkdownV2' })
}
async function findNotificationRecord (
worker: PlatformWorker,
from: number,
replyTo: number,
email: string
): Promise<NotificationRecord | undefined> {
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<boolean> {
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<Telegraf> {
const bot = new Telegraf(config.BotToken)
export async function defineCommands (bot: Telegraf<TgContext>, worker: PlatformWorker): Promise<void> {
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))
}

View File

@ -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<number>
scene: Scenes.SceneContextScene<TgContext, Scenes.SceneSessionData>
}

View File

@ -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<InboxNotification>
export interface MessageRecord {
notificationId?: Ref<InboxNotification>
messageId?: Ref<ActivityMessage>
workspace: string
email: string
telegramId: number
}
export interface ChannelRecord {
workspace: string
channelId: Ref<ChunterSpace>
channelClass: Ref<Class<ChunterSpace>>
name: string
email: string
}
export interface ReplyRecord {
notificationId: Ref<InboxNotification>
notificationId?: Ref<InboxNotification>
messageId?: Ref<ActivityMessage>
telegramId: number
replyId: number
}
@ -57,3 +70,9 @@ export interface TelegramFileInfo {
name?: string
size?: number
}
export interface WorkspaceInfo {
name: string
url: string
id: string
}

View File

@ -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<OtpRecord>): Promise<string> {
let otp = otpGenerator.generate(6, {
@ -45,32 +43,6 @@ export async function getNewOtp (otpCollection: Collection<OtpRecord>): Promise<
return otp
}
export async function getBotCommands (lang: string = 'en'): Promise<BotCommand[]> {
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<string> {
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<TelegramFileInfo | undefined> {
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)

View File

@ -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<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>()
private readonly intervalId: NodeJS.Timeout | undefined
private readonly channelsMap = new Map<string, WithId<ChannelRecord>[]>()
private readonly workspaceInfoById = new Map<string, WorkspaceInfo>()
private constructor (
readonly ctx: MeasureContext,
readonly storageAdapter: StorageAdapter,
private readonly usersStorage: Collection<UserRecord>,
private readonly notificationsStorage: Collection<NotificationRecord>,
private readonly messagesStorage: Collection<MessageRecord>,
private readonly otpStorage: Collection<OtpRecord>,
private readonly repliesStorage: Collection<ReplyRecord>
private readonly repliesStorage: Collection<ReplyRecord>,
private readonly channelsStorage: Collection<ChannelRecord>
) {
this.intervalId = setInterval(
() => {
@ -48,6 +66,14 @@ export class PlatformWorker {
)
}
public async getUsersToDisconnect (): Promise<UserRecord[]> {
return await this.usersStorage.find({ workspaces: { $exists: false } }).toArray()
}
public async disconnectUsers (): Promise<void> {
await this.usersStorage.deleteMany({ workspaces: { $exists: false } })
}
async close (): Promise<void> {
if (this.intervalId !== undefined) {
clearInterval(this.intervalId)
@ -70,22 +96,42 @@ export class PlatformWorker {
}
}
async addUser (id: number, email: string, telegramUsername?: string): Promise<UserRecord | undefined> {
async addUser (
id: number,
email: string,
workspace: string,
telegramUsername?: string
): Promise<UserRecord | undefined> {
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<void> {
await this.notificationsStorage.insertOne(record)
async addNotificationRecord (record: MessageRecord): Promise<void> {
await this.messagesStorage.insertOne(record)
}
async removeUserByTelegramId (id: number): Promise<void> {
await this.usersStorage.deleteOne({ telegramId: id })
}
async removeUserByAccount (_id: Ref<Account>): Promise<void> {
await this.usersStorage.deleteOne({ account: _id })
}
async saveReply (record: ReplyRecord): Promise<void> {
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<NotificationRecord | undefined> {
return (await this.notificationsStorage.findOne({ telegramId: id, email })) ?? undefined
async getNotificationRecord (id: number, email: string): Promise<MessageRecord | undefined> {
return (await this.messagesStorage.findOne({ telegramId: id, email })) ?? undefined
}
async getNotificationRecordById (
notificationId: Ref<InboxNotification>,
email: string
): Promise<NotificationRecord | undefined> {
return (await this.notificationsStorage.findOne({ notificationId, email })) ?? undefined
async findMessageRecord (
email: string,
notificationId?: Ref<InboxNotification>,
messageId?: Ref<ActivityMessage>
): Promise<MessageRecord | undefined> {
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<UserRecord | undefined> {
@ -144,6 +195,14 @@ export class PlatformWorker {
return (await this.usersStorage.findOne({ email })) ?? undefined
}
async addWorkspace (email: string, workspace: string): Promise<void> {
await this.usersStorage.updateOne({ email }, { $push: { workspaces: workspace } })
}
async removeWorkspace (email: string, workspace: string): Promise<void> {
await this.usersStorage.updateOne({ email }, { $pull: { workspaces: workspace } })
}
async getWorkspaceClient (workspace: string): Promise<WorkspaceClient> {
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<boolean> {
const client = await this.getWorkspaceClient(notification.workspace)
return await client.reply(notification, text, files)
async reply (messageRecord: MessageRecord, text: string, files: TelegramFileInfo[]): Promise<boolean> {
const client = await this.getWorkspaceClient(messageRecord.workspace)
return await client.reply(messageRecord, text, files)
}
async authorizeUser (code: string, email: string): Promise<UserRecord | undefined> {
async getChannelName (client: WorkspaceClient, channel: ChunterSpace, email: string): Promise<string> {
if (client.hierarchy.isDerived(channel._class, chunter.class.DirectMessage)) {
const persons = await client.getPersons(channel.members as Ref<PersonAccount>[], 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<string[]> {
return (await this.usersStorage.findOne({ email }))?.workspaces ?? []
}
async getChannels (email: string, workspace: string): Promise<WithId<ChannelRecord>[]> {
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<boolean> {
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<void> {
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<ChannelRecord>[] = []
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<WorkspaceInfo | undefined> {
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<UserRecord | undefined> {
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<string> {
@ -207,20 +381,37 @@ export class PlatformWorker {
}
static async createStorages (): Promise<
[Collection<UserRecord>, Collection<NotificationRecord>, Collection<OtpRecord>, Collection<ReplyRecord>]
[
Collection<UserRecord>,
Collection<MessageRecord>,
Collection<OtpRecord>,
Collection<ReplyRecord>,
Collection<ChannelRecord>
]
> {
const db = await getDB()
const userStorage = db.collection<UserRecord>('users')
const notificationsStorage = db.collection<NotificationRecord>('notifications')
await db.dropCollection('notifications')
const messagesStorage = db.collection<MessageRecord>('messages')
const otpStorage = db.collection<OtpRecord>('otp')
const repliesStorage = db.collection<ReplyRecord>('replies')
const channelsStorage = db.collection<ChannelRecord>('channels')
return [userStorage, notificationsStorage, otpStorage, repliesStorage]
return [userStorage, messagesStorage, otpStorage, repliesStorage, channelsStorage]
}
static async create (ctx: MeasureContext, storageAdapter: StorageAdapter): Promise<PlatformWorker> {
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
)
}
}

View File

@ -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<ThreadMessage>,
_id: Ref<ChatMessage>,
_class: Ref<Class<ChatMessage>>,
space: Ref<Space>,
files: TelegramFileInfo[]
): Promise<number> {
@ -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<ThreadMessage, Attachment>(
chunter.class.ThreadMessage,
const tx = factory.createTxCollectionCUD<ChatMessage, Attachment>(
_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<Account>, message: ActivityMessage): Promise<boolean> {
const hierarchy = this.hierarchy
let objectId: Ref<Doc>
let objectClass: Ref<Class<Doc>>
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<Space>, 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<void> {
): Promise<boolean> {
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<ThreadMessage>()
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
await this.client.close()
}
@ -249,6 +342,95 @@ export class WorkspaceClient {
}
return res
}
async getChannels (email: string, onlyStarred: boolean): Promise<ChunterSpace[]> {
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<ChunterSpace>) },
members: account._id
})
}
async getPersons (_ids: Ref<PersonAccount>[], myEmail: string): Promise<Person[]> {
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<Ref<ChatMessage> | 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<ChatMessage>()
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<Client> {