mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +00:00
hulygram UI integration (#9103)
* hulygram UI integration Signed-off-by: Alexey Aristov <aav@acm.org> wip Signed-off-by: Alexey Aristov <aav@acm.org> * consider AI robot suggestions Signed-off-by: Alexey Aristov <aav@acm.org> * add missing translations Signed-off-by: Alexey Aristov <aav@acm.org> * corrections after code review Signed-off-by: Alexey Aristov <aav@acm.org> * move api handling to the api.ts module Signed-off-by: Alexey Aristov <aav@acm.org> * account service: register hulygram service id Signed-off-by: Alexey Aristov <aav@acm.org> * correct source code formatting Signed-off-by: Alexey Aristov <aav@acm.org> * correct accounts tests (service name changed) Signed-off-by: Alexey Aristov <aav@acm.org> * minor change (for gh troubleshooting) Signed-off-by: Alexey Aristov <aav@acm.org> --------- Signed-off-by: Alexey Aristov <aav@acm.org>
This commit is contained in:
parent
5ff2dafbc1
commit
29405cd043
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Načítání...",
|
||||
"IntegrationConnected": "Připojeno jako {phone}",
|
||||
"Disconnect": "Odpojit",
|
||||
"SharedMessages": "Sdílené zprávy Telegramu",
|
||||
"Next": "Další",
|
||||
"Back": "Zpět",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Synchronizovat všechny kanály",
|
||||
"SyncStarredChannels": "Synchronizovat označené kanály"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Laden...",
|
||||
"IntegrationConnected": "Verbunden als {phone}",
|
||||
"Disconnect": "Trennen",
|
||||
"SharedMessages": "Geteilte Telegram-Nachrichten",
|
||||
"Next": "Weiter",
|
||||
"Back": "Zurück",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Alle Kanäle synchronisieren",
|
||||
"SyncStarredChannels": "Markierte Kanäle synchronisieren"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Loading...",
|
||||
"IntegrationConnected": "Connected as {phone}",
|
||||
"Disconnect": "Disconnect",
|
||||
"SharedMessages": "shared Telegram messages",
|
||||
"Next": "Next",
|
||||
"Back": "Back",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Sync all channels",
|
||||
"SyncStarredChannels": "Sync starred channels"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Cargando...",
|
||||
"IntegrationConnected": "Conectado como {phone}",
|
||||
"Disconnect": "Desconectar",
|
||||
"SharedMessages": "mensajes compartidos de Telegram",
|
||||
"Next": "Siguiente",
|
||||
"Back": "Atrás",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Sincronizar todos los canales",
|
||||
"SyncStarredChannels": "Sincronizar canales marcados"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Chargement...",
|
||||
"IntegrationConnected": "Connecté en tant que {phone}",
|
||||
"Disconnect": "Déconnecter",
|
||||
"SharedMessages": "messages Telegram partagés",
|
||||
"Next": "Suivant",
|
||||
"Back": "Retour",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Synchroniser tous les canaux",
|
||||
"SyncStarredChannels": "Synchroniser les chaînes marquées"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Caricamento...",
|
||||
"IntegrationConnected": "Collegato come {phone}",
|
||||
"Disconnect": "Disconnettersi",
|
||||
"SharedMessages": "messaggi Telegram condivisi",
|
||||
"Next": "Avanti",
|
||||
"Back": "Indietro",
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "読み込み中...",
|
||||
"IntegrationConnected": "{phone} として接続されています",
|
||||
"Disconnect": "切断",
|
||||
"SharedMessages": "共有されたTelegramメッセージ",
|
||||
"Next": "次へ",
|
||||
"Back": "戻る",
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "A carregar...",
|
||||
"IntegrationConnected": "Ligado como {phone}",
|
||||
"Disconnect": "Desconectar",
|
||||
"SharedMessages": "mensagens partilhadas do Telegram",
|
||||
"Next": "Seguinte",
|
||||
"Back": "Voltar",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Sincronizar todos os canais",
|
||||
"SyncStarredChannels": "Sincronizar canais marcados"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "Загрузка...",
|
||||
"IntegrationConnected": "Подключено как {phone}",
|
||||
"Disconnect": "Отключить",
|
||||
"SharedMessages": "опубликованные сообщения Telegram",
|
||||
"Next": "Далее",
|
||||
"Back": "Назад",
|
||||
@ -58,4 +61,4 @@
|
||||
"SyncAllChannels": "Синхронизировать все каналы",
|
||||
"SyncStarredChannels": "Синхронизировать избранные каналы"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"Loading": "加载中...",
|
||||
"IntegrationConnected": "已连接为 {phone}",
|
||||
"Disconnect": "断开连接",
|
||||
"SharedMessages": "共享的 Telegram 消息",
|
||||
"Next": "下一步",
|
||||
"Back": "上一步",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { makeLocalesTest } from '@hcengineering/platform'
|
||||
|
||||
it(
|
||||
'Locales are equale',
|
||||
'Locales are equal',
|
||||
makeLocalesTest((lang) => import(`../../lang/${lang}.json`))
|
||||
)
|
||||
|
53
plugins/telegram-resources/src/api.ts
Normal file
53
plugins/telegram-resources/src/api.ts
Normal file
@ -0,0 +1,53 @@
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
//
|
||||
// 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 { concatLink } from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import telegram from './plugin'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
|
||||
export type Integration = { status: 'authorized' | 'wantcode' | 'wantpassword', number: string } | 'Loading' | 'Missing'
|
||||
|
||||
const url = getMetadata(telegram.metadata.TelegramURL) ?? ''
|
||||
|
||||
async function request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: any): Promise<any> {
|
||||
const base = concatLink(url, 'api/integrations')
|
||||
|
||||
const response = await fetch(concatLink(base, path ?? ''), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
return await response.json()
|
||||
} else {
|
||||
throw new Error(`Unexpected response: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function list (): Promise<Integration[]> {
|
||||
return await request('GET')
|
||||
}
|
||||
|
||||
export async function command (
|
||||
phone: string,
|
||||
command: 'start' | 'next' | 'cancel',
|
||||
input?: string
|
||||
): Promise<Integration> {
|
||||
return await request('POST', phone, { command, input })
|
||||
}
|
@ -13,151 +13,198 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { Button, EditBox, IconClose, Label } from '@hcengineering/ui'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import ui, { Button, EditBox, IconClose, Label } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import PinPad from './PinPad.svelte'
|
||||
import telegram from '../plugin'
|
||||
import { concatLink } from '@hcengineering/core'
|
||||
import { command, list, type Integration } from '../api'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let requested = false
|
||||
let secondFactor = false
|
||||
let connecting = false
|
||||
let phone: string = ''
|
||||
let code: string = ''
|
||||
let password: string = ''
|
||||
let error: string | undefined = undefined
|
||||
const url = getMetadata(telegram.metadata.TelegramURL) ?? ''
|
||||
let error: string = ''
|
||||
|
||||
async function requestCode (): Promise<void> {
|
||||
const res = await sendRequest('/signin', { phone })
|
||||
if (res.next === 'code') {
|
||||
requested = true
|
||||
}
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
if (res.next === 'end') {
|
||||
dispatch('close', { value: phone })
|
||||
function close (): void {
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
mode: 'Loading' | 'WantPhone' | 'WantCode' | 'WantPassword' | 'Authorized' | 'Unauthorized' | 'Error'
|
||||
hint?: string
|
||||
|
||||
buttons?: {
|
||||
primary?: { label: IntlString, handler?: () => any, disabled?: boolean }
|
||||
secondary?: { label: IntlString, handler?: () => any }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPassword (): Promise<void> {
|
||||
const res = await sendRequest('/signin/pass', { phone, pass: password })
|
||||
if (res.next === 'end') {
|
||||
dispatch('close', { value: phone })
|
||||
let integration: Integration = 'Loading'
|
||||
let state: UIState = { mode: 'Loading' }
|
||||
|
||||
function h (handler: () => Promise<Integration>) {
|
||||
return () => {
|
||||
handler()
|
||||
.then((i) => {
|
||||
integration = i
|
||||
})
|
||||
.catch((error) => {
|
||||
state = {
|
||||
mode: 'Error',
|
||||
hint: error.message,
|
||||
buttons: {
|
||||
primary: { label: ui.string.Ok, handler: close }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCode (): Promise<void> {
|
||||
const res = await sendRequest('/signin/code', { phone, code })
|
||||
if (res.next === 'pass') {
|
||||
secondFactor = true
|
||||
} else if (res.next === 'end') {
|
||||
dispatch('close', { value: phone })
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest (path: string, data: any): Promise<any> {
|
||||
connecting = true
|
||||
const response = await fetch(concatLink(url, path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
const res = await response.json()
|
||||
connecting = false
|
||||
|
||||
if (Math.trunc(response.status / 100) !== 2) {
|
||||
if (res.code === 'PHONE_CODE_INVALID') {
|
||||
error = 'Invalid code'
|
||||
$: {
|
||||
if (integration === 'Loading') {
|
||||
state = { mode: 'Loading' }
|
||||
} else if (integration === 'Missing') {
|
||||
state = {
|
||||
mode: 'WantPhone',
|
||||
buttons: {
|
||||
primary: {
|
||||
label: ui.string.Next,
|
||||
handler: h(() => command(phone, 'start')),
|
||||
disabled: phone.match(/^\+\d{9,15}$/) == null
|
||||
},
|
||||
secondary: { label: telegram.string.Cancel, handler: close }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (integration.status) {
|
||||
case 'authorized': {
|
||||
state = {
|
||||
mode: 'Authorized',
|
||||
hint: integration.number,
|
||||
buttons: {
|
||||
primary: { label: ui.string.Ok, handler: close }
|
||||
// secondary: { label: telegram.string.Disconnect }
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
throw new Error(res.message)
|
||||
case 'wantcode': {
|
||||
const number = integration.number
|
||||
|
||||
state = {
|
||||
mode: 'WantCode',
|
||||
buttons: {
|
||||
primary: {
|
||||
label: ui.string.Next,
|
||||
handler: h(() => command(number, 'next', code)),
|
||||
disabled: code.match(/^\d{5}$/) == null
|
||||
},
|
||||
secondary: { label: telegram.string.Cancel, handler: close }
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'wantpassword': {
|
||||
const number = integration.number
|
||||
|
||||
state = {
|
||||
mode: 'WantPassword',
|
||||
buttons: {
|
||||
primary: {
|
||||
label: ui.string.Next,
|
||||
handler: h(() => command(number, 'next', password)),
|
||||
disabled: password.length === 0
|
||||
},
|
||||
secondary: { label: telegram.string.Cancel, handler: close }
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
function back () {
|
||||
password = ''
|
||||
code = ''
|
||||
phone = ''
|
||||
requested = false
|
||||
secondFactor = false
|
||||
}
|
||||
async function init (): Promise<void> {
|
||||
try {
|
||||
const integrations = await list()
|
||||
|
||||
$: label = connecting
|
||||
? telegram.string.Connecting
|
||||
: requested || secondFactor
|
||||
? telegram.string.Connect
|
||||
: telegram.string.Next
|
||||
if (integrations.length === 0) {
|
||||
integration = 'Missing'
|
||||
} else {
|
||||
integration = integrations[0]
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(ex)
|
||||
state = {
|
||||
mode: 'Error',
|
||||
hint: ex.message,
|
||||
|
||||
$: disabled = checkDisabled(connecting, secondFactor, password, requested, error, code, phone)
|
||||
|
||||
function checkDisabled (
|
||||
connecting: boolean,
|
||||
secondFactor: boolean,
|
||||
password: string,
|
||||
requested: boolean,
|
||||
error: string | undefined,
|
||||
code: string,
|
||||
phone: string
|
||||
): boolean {
|
||||
if (connecting) return true
|
||||
if (secondFactor) return password.length === 0
|
||||
if (requested) {
|
||||
if (error !== undefined) return true
|
||||
return !code.match(/^\d{5}$/)
|
||||
buttons: {
|
||||
primary: { label: ui.string.Ok, handler: close }
|
||||
}
|
||||
}
|
||||
}
|
||||
return !phone.match(/^\+\d{9,15}$/)
|
||||
}
|
||||
|
||||
function click () {
|
||||
if (secondFactor) return sendPassword()
|
||||
if (requested) return sendCode()
|
||||
return requestCode()
|
||||
}
|
||||
void init()
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex-between header">
|
||||
<div class="overflow-label fs-title"><Label label={telegram.string.ConnectFull} /></div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="tool"
|
||||
on:click={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="tool" on:click={close}>
|
||||
<IconClose size={'small'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if secondFactor}
|
||||
<p><Label label={telegram.string.PasswordDescr} /></p>
|
||||
{#if state.mode === 'Loading'}
|
||||
<Label label={telegram.string.Loading} />
|
||||
{:else if state.mode === 'WantPhone'}
|
||||
<Label label={telegram.string.PhoneDescr} />
|
||||
|
||||
<EditBox label={telegram.string.Phone} placeholder={telegram.string.PhonePlaceholder} bind:value={phone} />
|
||||
{:else if state.mode === 'WantCode'}
|
||||
<Label label={telegram.string.CodeDescr} />
|
||||
|
||||
<PinPad length={5} bind:value={code} bind:error />
|
||||
{:else if state.mode === 'WantPassword'}
|
||||
<Label label={telegram.string.PasswordDescr} />
|
||||
|
||||
<EditBox
|
||||
label={telegram.string.Password}
|
||||
format="password"
|
||||
placeholder={telegram.string.Password}
|
||||
bind:value={password}
|
||||
/>
|
||||
{:else if requested}
|
||||
<p><Label label={telegram.string.CodeDescr} /></p>
|
||||
<PinPad length={5} bind:value={code} bind:error />
|
||||
{:else}
|
||||
<p><Label label={telegram.string.PhoneDescr} /></p>
|
||||
<EditBox label={telegram.string.Phone} placeholder={telegram.string.PhonePlaceholder} bind:value={phone} />
|
||||
{:else if state.mode === 'Authorized'}
|
||||
<Label label={telegram.string.IntegrationConnected} params={{ phone: state.hint }} />
|
||||
{:else if state.mode === 'Error'}
|
||||
<p>Error: {state.hint}</p>
|
||||
{/if}
|
||||
|
||||
<div class="footer">
|
||||
<Button {label} kind={'primary'} {disabled} on:click={click} />
|
||||
{#if requested || secondFactor}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="link over-underline" on:click={back}><Label label={telegram.string.Back} /></div>
|
||||
{#if state.buttons?.primary}
|
||||
<Button
|
||||
label={state.buttons.primary.label}
|
||||
kind={'primary'}
|
||||
disabled={state.buttons.primary.disabled}
|
||||
on:click={state.buttons.primary.handler}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if state.buttons?.secondary}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="link over-underline" on:click={state.buttons.secondary.handler}>
|
||||
<Label label={state.buttons.secondary.label} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,6 +21,10 @@ import { type AnyComponent } from '@hcengineering/ui/src/types'
|
||||
|
||||
export default mergeIds(telegramId, telegram, {
|
||||
string: {
|
||||
Loading: '' as IntlString,
|
||||
IntegrationConnected: '' as IntlString,
|
||||
|
||||
Disconnect: '' as IntlString,
|
||||
Next: '' as IntlString,
|
||||
Back: '' as IntlString,
|
||||
Connect: '' as IntlString,
|
||||
|
@ -201,7 +201,7 @@ describe('integration methods', () => {
|
||||
const mockBranding = null
|
||||
const mockToken = 'test-token'
|
||||
|
||||
const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox']
|
||||
const integrationServices = ['github', 'telegram-bot', 'hulygram', 'mailbox']
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
@ -1641,7 +1641,11 @@ export async function ensurePerson (
|
||||
}
|
||||
): Promise<{ uuid: PersonUuid, socialId: PersonId }> {
|
||||
const { account, workspace, extra } = decodeTokenVerbose(ctx, token)
|
||||
const allowedService = verifyAllowedServices(['tool', 'workspace', 'schedule', 'mail', 'github'], extra, false)
|
||||
const allowedService = verifyAllowedServices(
|
||||
['tool', 'workspace', 'schedule', 'mail', 'github', 'hulygram'],
|
||||
extra,
|
||||
false
|
||||
)
|
||||
|
||||
if (!allowedService) {
|
||||
const callerRole = await getWorkspaceRole(db, account, workspace)
|
||||
|
@ -551,7 +551,7 @@ export async function addSocialIdToPerson (
|
||||
const { person, type, value, confirmed, displayValue } = params
|
||||
const { extra } = decodeTokenVerbose(ctx, token)
|
||||
|
||||
verifyAllowedServices(['github', 'telegram-bot', 'gmail', 'tool', 'workspace'], extra)
|
||||
verifyAllowedServices(['github', 'telegram-bot', 'gmail', 'tool', 'workspace', 'hulygram'], extra)
|
||||
|
||||
return await addSocialId(db, person, type, value, confirmed, displayValue)
|
||||
}
|
||||
|
@ -1478,7 +1478,7 @@ export async function setTimezoneIfNotDefined (
|
||||
export const integrationServices = [
|
||||
'github',
|
||||
'telegram-bot',
|
||||
'telegram',
|
||||
'hulygram',
|
||||
'mailbox',
|
||||
'caldav',
|
||||
'gmail',
|
||||
|
Loading…
Reference in New Issue
Block a user