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:
Alexey Aristov 2025-05-27 16:38:16 +02:00 committed by GitHub
parent 5ff2dafbc1
commit 29405cd043
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 253 additions and 115 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,8 @@
{
"string": {
"Loading": "Caricamento...",
"IntegrationConnected": "Collegato come {phone}",
"Disconnect": "Disconnettersi",
"SharedMessages": "messaggi Telegram condivisi",
"Next": "Avanti",
"Back": "Indietro",

View File

@ -1,5 +1,8 @@
{
"string": {
"Loading": "読み込み中...",
"IntegrationConnected": "{phone} として接続されています",
"Disconnect": "切断",
"SharedMessages": "共有されたTelegramメッセージ",
"Next": "次へ",
"Back": "戻る",

View File

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

View File

@ -1,5 +1,8 @@
{
"string": {
"Loading": "Загрузка...",
"IntegrationConnected": "Подключено как {phone}",
"Disconnect": "Отключить",
"SharedMessages": "опубликованные сообщения Telegram",
"Next": "Далее",
"Back": "Назад",
@ -58,4 +61,4 @@
"SyncAllChannels": "Синхронизировать все каналы",
"SyncStarredChannels": "Синхронизировать избранные каналы"
}
}
}

View File

@ -1,5 +1,8 @@
{
"string": {
"Loading": "加载中...",
"IntegrationConnected": "已连接为 {phone}",
"Disconnect": "断开连接",
"SharedMessages": "共享的 Telegram 消息",
"Next": "下一步",
"Back": "上一步",

View File

@ -1,6 +1,6 @@
import { makeLocalesTest } from '@hcengineering/platform'
it(
'Locales are equale',
'Locales are equal',
makeLocalesTest((lang) => import(`../../lang/${lang}.json`))
)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1478,7 +1478,7 @@ export async function setTimezoneIfNotDefined (
export const integrationServices = [
'github',
'telegram-bot',
'telegram',
'hulygram',
'mailbox',
'caldav',
'gmail',