mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +00:00
Allow to send message from tg bot to channels (#6472)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
parent
f8cbd1c335
commit
3be2de033a
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -48,6 +48,11 @@
|
||||
"AccountConnectedHtml": "Вы успешно подключены как <b>{email}</b>",
|
||||
"AccountAlreadyConnected": "Аккаунт уже подключен",
|
||||
"InvalidCode": "Неверный код",
|
||||
"SomethingWentWrong": "Что-то пошло не так. Пожалуйста, попробуйте снова."
|
||||
"SomethingWentWrong": "Что-то пошло не так. Пожалуйста, попробуйте снова.",
|
||||
"Configure": "Настроить",
|
||||
"ConnectTelegramBot": "Подключить Telegram бота",
|
||||
"DisconnectMessage": "⚠️Вы отключены от сервиса. Пожалуйста, подключите свой аккаунт на {app} с помощью /{command}.",
|
||||
"SyncAllChannels": "Синхронизировать все каналы",
|
||||
"SyncStarredChannels": "Синхронизировать избранные каналы"
|
||||
}
|
||||
}
|
@ -48,6 +48,11 @@
|
||||
"AccountConnectedHtml": "您已成功连接为 <b>{email}</b>",
|
||||
"AccountAlreadyConnected": "帐户已连接",
|
||||
"InvalidCode": "无效代码",
|
||||
"SomethingWentWrong": "出现问题。 请重试。"
|
||||
"SomethingWentWrong": "出现问题。 请重试。",
|
||||
"Configure": "配置",
|
||||
"ConnectTelegramBot": "连接 Telegram 机器人",
|
||||
"DisconnectMessage": "⚠️您已从服务中断开连接。 请使用 /{command} 在 {app} 上重新连接您的帐户。",
|
||||
"SyncAllChannels": "同步所有频道",
|
||||
"SyncStarredChannels": "同步标记的频道"
|
||||
}
|
||||
}
|
||||
|
@ -115,7 +115,7 @@
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
label={getEmbeddedLabel('Connect Telegram Bot')}
|
||||
label={telegram.string.ConnectTelegramBot}
|
||||
type="type-popup"
|
||||
okLabel={presentation.string.Ok}
|
||||
okAction={() => {
|
||||
|
@ -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}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 => {
|
||||
|
367
services/telegram-bot/pod-telegram-bot/src/telegraf/bot.ts
Normal file
367
services/telegram-bot/pod-telegram-bot/src/telegraf/bot.ts
Normal 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))] : [])
|
||||
]
|
||||
]
|
||||
})
|
||||
}
|
@ -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))
|
||||
}
|
27
services/telegram-bot/pod-telegram-bot/src/telegraf/types.ts
Normal file
27
services/telegram-bot/pod-telegram-bot/src/telegraf/types.ts
Normal 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>
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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> {
|
||||
|
Loading…
Reference in New Issue
Block a user