Compare commits

..

2 commits

Author SHA1 Message Date
e3bbb0c061
feat: support custom api id/hash in imported sessions
All checks were successful
Docs / build (push) Successful in 2m29s
2025-01-24 20:21:41 +03:00
ce33cfac9a
feat: custom api id/hash/etc 2025-01-24 20:18:44 +03:00
13 changed files with 446 additions and 56 deletions

View file

@ -1,24 +1,26 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu' import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import type { TooltipTriggerProps } from '@kobalte/core/tooltip' import type { TooltipTriggerProps } from '@kobalte/core/tooltip'
import type { TelegramAccount } from 'mtcute-repl-worker/client' import type { CustomApiFields, TelegramAccount } from 'mtcute-repl-worker/client'
import type { LoginStep } from './login/Login.tsx' import type { LoginStep } from './login/Login.tsx'
import { timers, unknownToError } from '@fuman/utils' import { timers, unknownToError } from '@fuman/utils'
import { import {
LucideBot, LucideBot,
LucideChevronDown,
LucideChevronRight, LucideChevronRight,
LucideEllipsis, LucideEllipsis,
LucideFlaskConical,
LucideFolderUp, LucideFolderUp,
LucideLogIn, LucideLogIn,
LucidePlus,
LucideRefreshCw, LucideRefreshCw,
LucideSearch, LucideSearch,
LucideServerCog,
LucideTrash, LucideTrash,
LucideTriangleAlert, LucideTriangleAlert,
LucideUser, LucideUser,
LucideX, LucideX,
} from 'lucide-solid' } from 'lucide-solid'
import { workerInvoke } from 'mtcute-repl-worker/client'
import { workerInvoke } from 'mtcute-repl-worker/client'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
import { toast } from 'solid-sonner' import { toast } from 'solid-sonner'
@ -36,11 +38,13 @@ import { useStore } from '../../store/use-store.ts'
import { AccountAvatar } from '../AccountAvatar.tsx' import { AccountAvatar } from '../AccountAvatar.tsx'
import { ImportDropdown } from './import/ImportDropdown.tsx' import { ImportDropdown } from './import/ImportDropdown.tsx'
import { StringSessionDefs } from './import/StringSessionImportDialog.tsx' import { StringSessionDefs } from './import/StringSessionImportDialog.tsx'
import { CustomApiDialog } from './login/CustomApiDialog.tsx'
import { LoginForm } from './login/Login.tsx' import { LoginForm } from './login/Login.tsx'
function AddAccountDialog(props: { function AddAccountDialog(props: {
show: boolean show: boolean
testMode: boolean testMode: boolean
apiOptions?: CustomApiFields
onClose: () => void onClose: () => void
}) { }) {
const [accountId, setAccountId] = createSignal<string | undefined>(undefined) const [accountId, setAccountId] = createSignal<string | undefined>(undefined)
@ -81,6 +85,7 @@ function AddAccountDialog(props: {
await workerInvoke('telegram', 'createClient', { await workerInvoke('telegram', 'createClient', {
accountId: accountId()!, accountId: accountId()!,
testMode: props.testMode, testMode: props.testMode,
apiOptions: props.apiOptions,
}) })
})) }))
@ -281,17 +286,71 @@ function AccountRow(props: {
) )
} }
type AddAccountMode = 'test' | 'custom-api' | 'normal'
function LoginButton(props: {
size: 'xs' | 'sm'
onAddAccount: (mode: AddAccountMode) => void
}) {
const [showDropdown, setShowDropdown] = createSignal(false)
return (
<div class="flex flex-row items-center">
<Button
variant="outline"
size={props.size}
onClick={() => props.onAddAccount('normal')}
class="rounded-r-none"
>
Log in
</Button>
<DropdownMenu
open={showDropdown()}
onOpenChange={setShowDropdown}
>
<DropdownMenuTrigger>
<Button
variant="outline"
size={props.size}
class="w-6 rounded-l-none border-l-0"
>
<LucideChevronDown class="size-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem class="py-1 text-xs" onClick={() => props.onAddAccount('test')}>
<LucideFlaskConical class="mr-2 size-3.5 stroke-[1.5px]" />
Use test server
</DropdownMenuItem>
<DropdownMenuItem class="py-1 text-xs" onClick={() => props.onAddAccount('custom-api')}>
<LucideServerCog class="mr-2 size-3.5 stroke-[1.5px]" />
Use custom API
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function AccountsTab() { export function AccountsTab() {
const accounts = useStore($accounts) const accounts = useStore($accounts)
const activeAccountId = useStore($activeAccountId) const activeAccountId = useStore($activeAccountId)
const [showAddAccount, setShowAddAccount] = createSignal(false) const [showAddAccount, setShowAddAccount] = createSignal(false)
const [addAccountTestMode, setAddAccountTestMode] = createSignal(false) const [addAccountTestMode, setAddAccountTestMode] = createSignal(false)
const [addAccountOptions, setAddAccountOptions] = createSignal<CustomApiFields>()
const [searchQuery, setSearchQuery] = createSignal('') const [searchQuery, setSearchQuery] = createSignal('')
function handleAddAccount(e: MouseEvent) { const [showCustomApi, setShowCustomApi] = createSignal(false)
function handleAddAccount(mode: AddAccountMode) {
if (mode === 'custom-api') {
setShowCustomApi(true)
return
}
setShowAddAccount(true) setShowAddAccount(true)
setAddAccountTestMode(e.ctrlKey || e.metaKey) setAddAccountTestMode(mode === 'test')
setAddAccountOptions(undefined)
} }
const filteredAccounts = createMemo(() => { const filteredAccounts = createMemo(() => {
@ -329,14 +388,7 @@ export function AccountsTab() {
No accounts yet No accounts yet
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Button <LoginButton size="sm" onAddAccount={handleAddAccount} />
variant="outline"
size="sm"
onClick={handleAddAccount}
>
<LucidePlus class="mr-2 size-4" />
Log in
</Button>
<ImportDropdown size="sm" /> <ImportDropdown size="sm" />
</div> </div>
</div> </div>
@ -363,15 +415,7 @@ export function AccountsTab() {
</TextFieldFrame> </TextFieldFrame>
</TextFieldRoot> </TextFieldRoot>
<Button <LoginButton size="xs" onAddAccount={handleAddAccount} />
variant="outline"
size="xs"
onClick={handleAddAccount}
>
<LucidePlus class="mr-2 size-3" />
Log in
</Button>
<ImportDropdown size="xs" /> <ImportDropdown size="xs" />
</div> </div>
<div class="flex max-w-full flex-col gap-1 overflow-hidden"> <div class="flex max-w-full flex-col gap-1 overflow-hidden">
@ -391,8 +435,19 @@ export function AccountsTab() {
<AddAccountDialog <AddAccountDialog
show={showAddAccount()} show={showAddAccount()}
testMode={addAccountTestMode()} testMode={addAccountTestMode()}
apiOptions={addAccountOptions()}
onClose={() => setShowAddAccount(false)} onClose={() => setShowAddAccount(false)}
/> />
<CustomApiDialog
visible={showCustomApi()}
setVisible={setShowCustomApi}
onSubmit={(options) => {
setShowCustomApi(false)
setAddAccountOptions(options)
setShowAddAccount(true)
}}
/>
</> </>
) )
} }

View file

@ -1,9 +1,11 @@
import { workerInvoke } from 'mtcute-repl-worker/client' import { workerInvoke } from 'mtcute-repl-worker/client'
import { createEffect, createSignal, on } from 'solid-js' import { createEffect, createSignal, on, Show } from 'solid-js'
import { unwrap } from 'solid-js/store'
import { Button } from '../../../lib/components/ui/button.tsx' import { Button } from '../../../lib/components/ui/button.tsx'
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx' import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx' import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx' import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { CustomApiForm, useCustomApiFormState } from '../login/CustomApiDialog.tsx'
export function AuthKeyImportDialog(props: { export function AuthKeyImportDialog(props: {
open: boolean open: boolean
@ -15,6 +17,9 @@ export function AuthKeyImportDialog(props: {
const [error, setError] = createSignal<string | undefined>() const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [useCustomApi, setUseCustomApi] = createSignal(false)
const [customApi, setCustomApi] = useCustomApiFormState()
let abortController: AbortController | undefined let abortController: AbortController | undefined
const handleSubmit = async () => { const handleSubmit = async () => {
if (!['1', '2', '4', '5'].includes(dcId())) { if (!['1', '2', '4', '5'].includes(dcId())) {
@ -32,6 +37,7 @@ export function AuthKeyImportDialog(props: {
dcId: Number(dcId()), dcId: Number(dcId()),
testMode: testMode(), testMode: testMode(),
abortSignal: abortController.signal, abortSignal: abortController.signal,
apiOptions: useCustomApi() ? unwrap(customApi) : undefined,
}) })
props.onClose() props.onClose()
@ -66,7 +72,7 @@ export function AuthKeyImportDialog(props: {
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
<TextFieldRoot> <TextFieldRoot>
<TextFieldLabel class="text-foreground"> <TextFieldLabel>
Datacenter ID Datacenter ID
</TextFieldLabel> </TextFieldLabel>
<TextFieldFrame> <TextFieldFrame>
@ -79,7 +85,7 @@ export function AuthKeyImportDialog(props: {
</TextFieldRoot> </TextFieldRoot>
<TextFieldRoot class="mt-2" validationState={error() ? 'invalid' : 'valid'}> <TextFieldRoot class="mt-2" validationState={error() ? 'invalid' : 'valid'}>
<TextFieldLabel class="flex flex-row items-center justify-between text-foreground"> <TextFieldLabel class="flex flex-row items-center justify-between">
Hex-encoded auth key Hex-encoded auth key
<a <a
href="#" href="#"
@ -97,7 +103,7 @@ export function AuthKeyImportDialog(props: {
</TextFieldLabel> </TextFieldLabel>
<TextFieldFrame class="h-auto"> <TextFieldFrame class="h-auto">
<TextField <TextField
class="size-full h-40 resize-none font-mono" class="size-full h-20 resize-none font-mono"
as="textarea" as="textarea"
ref={setAuthKeyInputRef} ref={setAuthKeyInputRef}
onInput={() => setError(undefined)} onInput={() => setError(undefined)}
@ -114,11 +120,30 @@ export function AuthKeyImportDialog(props: {
onChange={setTestMode} onChange={setTestMode}
> >
<CheckboxControl /> <CheckboxControl />
<CheckboxLabel class="text-foreground"> <CheckboxLabel>
Use test servers Use test servers
</CheckboxLabel> </CheckboxLabel>
</Checkbox> </Checkbox>
<Checkbox
class="mt-2 flex flex-row items-center gap-2"
checked={useCustomApi()}
onChange={setUseCustomApi}
>
<CheckboxControl />
<CheckboxLabel>
Use custom connection options
</CheckboxLabel>
</Checkbox>
<Show when={useCustomApi()}>
<CustomApiForm
class="mt-2"
state={customApi}
setState={setCustomApi}
/>
</Show>
<Button <Button
class="mt-6 w-full" class="mt-6 w-full"
size="sm" size="sm"

View file

@ -1,9 +1,12 @@
import { type StringSessionLibName, workerInvoke } from 'mtcute-repl-worker/client' import { type StringSessionLibName, workerInvoke } from 'mtcute-repl-worker/client'
import { createEffect, createSignal, on } from 'solid-js' import { createEffect, createSignal, on, Show } from 'solid-js'
import { unwrap } from 'solid-js/store'
import { Button } from '../../../lib/components/ui/button.tsx' import { Button } from '../../../lib/components/ui/button.tsx'
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx' import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../lib/components/ui/select.tsx' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../lib/components/ui/select.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx' import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { CustomApiForm, useCustomApiFormState } from '../login/CustomApiDialog.tsx'
export const StringSessionDefs: { export const StringSessionDefs: {
name: StringSessionLibName name: StringSessionLibName
@ -26,6 +29,9 @@ export function StringSessionImportDialog(props: {
const [error, setError] = createSignal<string | undefined>() const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [useCustomApi, setUseCustomApi] = createSignal(false)
const [customApi, setCustomApi] = useCustomApiFormState()
let abortController: AbortController | undefined let abortController: AbortController | undefined
const handleSubmit = async () => { const handleSubmit = async () => {
abortController?.abort() abortController?.abort()
@ -37,6 +43,7 @@ export function StringSessionImportDialog(props: {
libraryName: props.chosenLibName, libraryName: props.chosenLibName,
session: inputRef()!.value, session: inputRef()!.value,
abortSignal: abortController.signal, abortSignal: abortController.signal,
apiOptions: useCustomApi() ? unwrap(customApi) : undefined,
}) })
props.onClose() props.onClose()
} catch (e) { } catch (e) {
@ -108,7 +115,7 @@ export function StringSessionImportDialog(props: {
</TextFieldLabel> </TextFieldLabel>
<TextFieldFrame class="h-auto"> <TextFieldFrame class="h-auto">
<TextField <TextField
class="size-full h-40 resize-none font-mono" class="size-full h-20 resize-none font-mono"
as="textarea" as="textarea"
ref={setInputRef} ref={setInputRef}
onInput={() => setError(undefined)} onInput={() => setError(undefined)}
@ -119,6 +126,25 @@ export function StringSessionImportDialog(props: {
</TextFieldErrorMessage> </TextFieldErrorMessage>
</TextFieldRoot> </TextFieldRoot>
<Checkbox
class="mt-4 flex flex-row items-center gap-2"
checked={useCustomApi()}
onChange={setUseCustomApi}
>
<CheckboxControl />
<CheckboxLabel>
Use custom connection options
</CheckboxLabel>
</Checkbox>
<Show when={useCustomApi()}>
<CustomApiForm
class="mt-2"
state={customApi}
setState={setCustomApi}
/>
</Show>
<Button <Button
class="mt-6 w-full" class="mt-6 w-full"
size="sm" size="sm"

View file

@ -2,10 +2,13 @@ import type { Tdata } from '@mtcute/convert'
import { hex } from '@fuman/utils' import { hex } from '@fuman/utils'
import { workerInvoke } from 'mtcute-repl-worker/client' import { workerInvoke } from 'mtcute-repl-worker/client'
import { createEffect, createSignal, on, Show } from 'solid-js' import { createEffect, createSignal, on, Show } from 'solid-js'
import { unwrap } from 'solid-js/store'
import { Button } from '../../../../lib/components/ui/button.tsx' import { Button } from '../../../../lib/components/ui/button.tsx'
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../../lib/components/ui/checkbox.tsx'
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../../lib/components/ui/dialog.tsx' import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../../lib/components/ui/dialog.tsx'
import { Spinner } from '../../../../lib/components/ui/spinner.tsx' import { Spinner } from '../../../../lib/components/ui/spinner.tsx'
import { $accounts } from '../../../../store/accounts.ts' import { $accounts } from '../../../../store/accounts.ts'
import { CustomApiForm, useCustomApiFormState } from '../../login/CustomApiDialog.tsx'
import { TdataDataTable } from './TdataTable.tsx' import { TdataDataTable } from './TdataTable.tsx'
interface TdataAccount { interface TdataAccount {
@ -25,6 +28,9 @@ export function TdataImportDialog(props: {
const [error, setError] = createSignal<string | undefined>('') const [error, setError] = createSignal<string | undefined>('')
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [useCustomApi, setUseCustomApi] = createSignal(false)
const [customApi, setCustomApi] = useCustomApiFormState()
const accountExists = (id: number) => $accounts.get()?.some(it => it.telegramId === id) const accountExists = (id: number) => $accounts.get()?.some(it => it.telegramId === id)
let abortController: AbortController | undefined let abortController: AbortController | undefined
@ -43,6 +49,7 @@ export function TdataImportDialog(props: {
dcId: account.dcId, dcId: account.dcId,
testMode: false, testMode: false,
abortSignal: abortController.signal, abortSignal: abortController.signal,
apiOptions: unwrap(customApi),
}) })
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
@ -212,12 +219,31 @@ export function TdataImportDialog(props: {
}} }}
/> />
{error() && ( {error() && (
<div class="text-error-foreground mt-2 text-sm"> <div class="mt-2 text-sm text-error-foreground">
{error()} {error()}
</div> </div>
)} )}
</Show> </Show>
<Checkbox
class="mt-2 flex flex-row items-center gap-2"
checked={useCustomApi()}
onChange={setUseCustomApi}
>
<CheckboxControl />
<CheckboxLabel>
Use custom connection options
</CheckboxLabel>
</Checkbox>
<Show when={useCustomApi()}>
<CustomApiForm
class="mt-2"
state={customApi}
setState={setCustomApi}
/>
</Show>
<Button <Button
class="mt-4 w-full" class="mt-4 w-full"
size="sm" size="sm"

View file

@ -0,0 +1,179 @@
import type { CustomApiFields } from 'mtcute-repl-worker/client'
import type { SetStoreFunction } from 'solid-js/store'
import { createSignal, Show } from 'solid-js'
import { createStore, unwrap } from 'solid-js/store'
import { Button } from '../../../lib/components/ui/button.tsx'
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
import { Dialog, DialogContent, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
import { TextField, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { cn } from '../../../lib/utils.ts'
export function useCustomApiFormState() {
// eslint-disable-next-line solid/reactivity
return createStore<CustomApiFields>({
apiId: '',
apiHash: '',
deviceModel: '',
systemVersion: '',
appVersion: '',
systemLangCode: '',
langPack: '',
langCode: '',
extraJson: '',
})
}
export function CustomApiForm(props: {
class?: string
state: CustomApiFields
setState: SetStoreFunction<CustomApiFields>
}) {
const [showAdvanced, setShowAdvanced] = createSignal(false)
return (
<div class={cn('flex flex-col gap-2', props.class)}>
<div class="flex flex-row gap-2">
<TextFieldRoot class="flex-1">
<TextFieldLabel>API ID</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="2040"
value={props.state.apiId}
onInput={e => props.setState('apiId', e.currentTarget.value.replace(/\D/g, ''))}
/>
</TextFieldFrame>
</TextFieldRoot>
<TextFieldRoot class="flex-[2]">
<TextFieldLabel>API Hash</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="b18441..."
value={props.state.apiHash}
onInput={e => props.setState('apiHash', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
</div>
<Checkbox
checked={showAdvanced()}
onChange={setShowAdvanced}
class="my-2 flex flex-row items-center gap-2 text-sm"
>
<CheckboxControl />
<CheckboxLabel>
Show advanced fields
</CheckboxLabel>
</Checkbox>
<Show when={showAdvanced()}>
<div class="flex w-full flex-row gap-2">
<TextFieldRoot class="w-full">
<TextFieldLabel>Device model</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="iPhone14,5"
value={props.state.deviceModel}
onInput={e => props.setState('deviceModel', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
<TextFieldRoot class="w-full">
<TextFieldLabel>Language pack</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="ios"
value={props.state.langPack}
onInput={e => props.setState('langPack', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
</div>
<div class="flex w-full flex-row gap-2">
<TextFieldRoot class="w-full">
<TextFieldLabel>System version</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="15.4"
value={props.state.systemVersion}
onInput={e => props.setState('systemVersion', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
<TextFieldRoot class="w-full">
<TextFieldLabel>App version</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="4.0.1"
value={props.state.appVersion}
onInput={e => props.setState('appVersion', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
</div>
<div class="flex w-full flex-row gap-2">
<TextFieldRoot class="w-full">
<TextFieldLabel>System language code</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="en"
value={props.state.systemLangCode}
onInput={e => props.setState('systemLangCode', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
<TextFieldRoot class="w-full">
<TextFieldLabel>Language code</TextFieldLabel>
<TextFieldFrame>
<TextField
placeholder="en"
value={props.state.langCode}
onInput={e => props.setState('langCode', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
</div>
<TextFieldRoot class="w-full">
<TextFieldLabel>Extra options (JSON)</TextFieldLabel>
<TextFieldFrame class="h-auto">
<TextField
as="textarea"
class="h-20 resize-none font-mono"
placeholder={'{"tz_offset": 3600}'}
value={props.state.extraJson}
onInput={e => props.setState('extraJson', e.currentTarget.value)}
/>
</TextFieldFrame>
</TextFieldRoot>
</Show>
</div>
)
}
export function CustomApiDialog(props: {
visible: boolean
setVisible: (visible: boolean) => void
onSubmit: (options: CustomApiFields) => void
}) {
const [state, setState] = useCustomApiFormState()
return (
<Dialog open={props.visible} onOpenChange={props.setVisible}>
<DialogContent class="flex flex-col gap-2">
<DialogHeader class="font-medium">
Custom connection options
</DialogHeader>
<CustomApiForm state={state} setState={setState} />
<Button
size="sm"
disabled={!state.apiId || !state.apiHash}
onClick={() => props.onSubmit(unwrap(state))}
>
Submit
</Button>
</DialogContent>
</Dialog>
)
}

View file

@ -4,6 +4,7 @@ import { unknownToError } from '@fuman/utils'
import { LucideChevronRight, LucideLockKeyhole, MessageSquareMore } from 'lucide-solid' import { LucideChevronRight, LucideLockKeyhole, MessageSquareMore } from 'lucide-solid'
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client' import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js' import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
import { toast } from 'solid-sonner'
import { Button } from '../../../lib/components/ui/button.tsx' import { Button } from '../../../lib/components/ui/button.tsx'
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../../lib/components/ui/otp-field.tsx' import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../../lib/components/ui/otp-field.tsx'
import { Spinner } from '../../../lib/components/ui/spinner.tsx' import { Spinner } from '../../../lib/components/ui/spinner.tsx'
@ -55,14 +56,18 @@ function QrLoginStep(props: StepProps<'qr'>) {
cleanup2() cleanup2()
}) })
const result = await workerInvoke('telegram', 'signInQr', { try {
accountId: props.accountId, const result = await workerInvoke('telegram', 'signInQr', {
abortSignal: abortController.signal, accountId: props.accountId,
}) abortSignal: abortController.signal,
if (result === 'need_password') { })
props.setStep('password') if (result === 'need_password') {
} else { props.setStep('password')
props.setStep('done', { account: result }) } else {
props.setStep('done', { account: result })
}
} catch (e) {
toast.error(unknownToError(e).message)
} }
}) })
onCleanup(() => abortController.abort()) onCleanup(() => abortController.abort())

View file

@ -1,11 +1,23 @@
import type { CheckboxControlProps } from '@kobalte/core/checkbox' import type { CheckboxControlProps } from '@kobalte/core/checkbox'
import type { PolymorphicProps } from '@kobalte/core/polymorphic' import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { ValidComponent, VoidProps } from 'solid-js' import type { ComponentProps, ValidComponent, VoidProps } from 'solid-js'
import { Checkbox as CheckboxPrimitive } from '@kobalte/core/checkbox' import { Checkbox as CheckboxPrimitive } from '@kobalte/core/checkbox'
import { splitProps } from 'solid-js' import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts' import { cn } from '../../utils.ts'
export const CheckboxLabel = CheckboxPrimitive.Label export function CheckboxLabel(props: ComponentProps<typeof CheckboxPrimitive.Label>) {
const [local, others] = splitProps(props, ['class'])
return (
<CheckboxPrimitive.Label
class={cn(
'text-foreground',
local.class,
)}
{...others}
/>
)
}
export const Checkbox = CheckboxPrimitive export const Checkbox = CheckboxPrimitive
export const CheckboxErrorMessage = CheckboxPrimitive.ErrorMessage export const CheckboxErrorMessage = CheckboxPrimitive.ErrorMessage
export const CheckboxDescription = CheckboxPrimitive.Description export const CheckboxDescription = CheckboxPrimitive.Description

View file

@ -38,7 +38,7 @@ export function DialogContent<T extends ValidComponent = 'div'>(props: Polymorph
/> />
<DialogPrimitive.Content <DialogPrimitive.Content
class={cn( class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[closed]:duration-200 data-[expanded]:duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg md:w-full', 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] max-h-screen overflow-auto gap-4 border bg-background p-6 shadow-lg data-[closed]:duration-200 data-[expanded]:duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg md:w-full',
local.class, local.class,
)} )}
{...rest} {...rest}

View file

@ -25,7 +25,7 @@ export function TextFieldRoot<T extends ValidComponent = 'div'>(props: Polymorph
} }
export const textfieldLabel = cva( export const textfieldLabel = cva(
'text-sm font-medium data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70', 'text-sm font-medium text-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70',
{ {
variants: { variants: {
label: { label: {
@ -131,7 +131,7 @@ export function TextField<T extends ValidComponent = 'input'>(props: Polymorphic
return ( return (
<TextFieldPrimitive.Input <TextFieldPrimitive.Input
class={cn('border-none outline-none placeholder:text-muted-foreground bg-transparent', local.class)} class={cn('border-none outline-none placeholder:text-muted-foreground bg-transparent min-w-0 w-full', local.class)}
{...rest} {...rest}
/> />
) )

View file

@ -3,7 +3,7 @@ import type { ReplWorker } from './worker/main.ts'
import type { ReplWorkerEvents } from './worker/utils.ts' import type { ReplWorkerEvents } from './worker/utils.ts'
import { Deferred, unknownToError } from '@fuman/utils' import { Deferred, unknownToError } from '@fuman/utils'
export type { TelegramAccount } from './store/accounts.ts' export type { CustomApiFields, TelegramAccount } from './store/accounts.ts'
export type { StringSessionLibName } from './worker/telegram.ts' export type { StringSessionLibName } from './worker/telegram.ts'
// eslint-disable-next-line ts/no-namespace // eslint-disable-next-line ts/no-namespace

View file

@ -9,6 +9,19 @@ export interface TelegramAccount {
testMode: boolean testMode: boolean
telegramId: number telegramId: number
dcId: number dcId: number
apiOptions?: CustomApiFields
}
export interface CustomApiFields {
apiId: string
apiHash: string
deviceModel: string
systemVersion: string
appVersion: string
systemLangCode: string
langPack: string
langCode: string
extraJson: string
} }
const AccountSchema = v.object({ const AccountSchema = v.object({
@ -19,6 +32,17 @@ const AccountSchema = v.object({
name: v.string(), name: v.string(),
testMode: v.boolean(), testMode: v.boolean(),
dcId: v.number(), dcId: v.number(),
apiOptions: v.object({
apiId: v.string(),
apiHash: v.string(),
deviceModel: v.string(),
systemVersion: v.string(),
appVersion: v.string(),
systemLangCode: v.string(),
langPack: v.string(),
langCode: v.string(),
extraJson: v.string(),
}).optional(),
}) })
export const $accounts = persistentAtom<TelegramAccount[]>('repl:accounts', [], { export const $accounts = persistentAtom<TelegramAccount[]>('repl:accounts', [], {

View file

@ -1,17 +1,49 @@
import type { InputStringSessionData } from '@mtcute/web/utils.js' import type { tl } from '@mtcute/web'
import type { TelegramAccount } from '../store/accounts.ts' import type { CustomApiFields, TelegramAccount } from '../store/accounts.ts'
import { asNonNull } from '@fuman/utils' import { asNonNull } from '@fuman/utils'
import { BaseTelegramClient, IdbStorage, TransportError } from '@mtcute/web' import { BaseTelegramClient, IdbStorage, TransportError } from '@mtcute/web'
import { getMe } from '@mtcute/web/methods.js' import { getMe } from '@mtcute/web/methods.js'
import { type InputStringSessionData, jsonToTlJson } from '@mtcute/web/utils.js'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
export function createInternalClient(accountId: string, testMode?: boolean) { export function createInternalClient(
accountId: string,
testMode?: boolean,
apiOptions?: CustomApiFields,
) {
let initConnectionOptions: Partial<tl.RawInitConnectionRequest> | undefined
if (apiOptions) {
initConnectionOptions = {}
if (apiOptions.deviceModel) {
initConnectionOptions.deviceModel = apiOptions.deviceModel
}
if (apiOptions.systemVersion) {
initConnectionOptions.systemVersion = apiOptions.systemVersion
}
if (apiOptions.appVersion) {
initConnectionOptions.appVersion = apiOptions.appVersion
}
if (apiOptions.langCode) {
initConnectionOptions.langCode = apiOptions.langCode
}
if (apiOptions.langPack) {
initConnectionOptions.langPack = apiOptions.langPack
}
if (apiOptions.systemLangCode) {
initConnectionOptions.systemLangCode = apiOptions.systemLangCode
}
if (apiOptions.extraJson) {
initConnectionOptions.params = jsonToTlJson(JSON.parse(apiOptions.extraJson))
}
}
return new BaseTelegramClient({ return new BaseTelegramClient({
apiId: Number(import.meta.env.VITE_API_ID), apiId: Number(apiOptions?.apiId ?? import.meta.env.VITE_API_ID),
apiHash: import.meta.env.VITE_API_HASH, apiHash: apiOptions?.apiHash ?? import.meta.env.VITE_API_HASH,
storage: new IdbStorage(`mtcute:${accountId}`), storage: new IdbStorage(`mtcute:${accountId}`),
testMode, testMode,
logLevel: import.meta.env.DEV ? 5 : 2, logLevel: import.meta.env.DEV ? 5 : 2,
initConnectionOptions,
}) })
} }
@ -26,9 +58,10 @@ export async function deleteAccount(accountId: string) {
export async function importAccount( export async function importAccount(
session: InputStringSessionData, session: InputStringSessionData,
abortSignal: AbortSignal, abortSignal: AbortSignal,
apiOptions?: CustomApiFields,
): Promise<TelegramAccount> { ): Promise<TelegramAccount> {
const accountId = nanoid() const accountId = nanoid()
const client = createInternalClient(accountId, session.primaryDcs?.main.testMode) const client = createInternalClient(accountId, session.primaryDcs?.main.testMode, apiOptions)
let is404 = false let is404 = false

View file

@ -1,6 +1,6 @@
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web' import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
import type { StringSessionData } from '@mtcute/web/utils.js' import type { StringSessionData } from '@mtcute/web/utils.js'
import type { TelegramAccount } from '../store/accounts.ts' import type { CustomApiFields, TelegramAccount } from '../store/accounts.ts'
import { assert, hex } from '@fuman/utils' import { assert, hex } from '@fuman/utils'
import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert' import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert'
import { tl } from '@mtcute/web' import { tl } from '@mtcute/web'
@ -31,7 +31,9 @@ function getClient(accountId: string) {
function getTmpClient(accountId: string): [BaseTelegramClient, () => Promise<void>] { function getTmpClient(accountId: string): [BaseTelegramClient, () => Promise<void>] {
const client = clients.get(accountId) const client = clients.get(accountId)
if (!client) { if (!client) {
const tmpClient = createInternalClient(accountId) const accountInfo = $accounts.get().find(it => it.id === accountId)
if (!accountInfo) throw new Error('Account not found')
const tmpClient = createInternalClient(accountId, accountInfo.testMode, accountInfo.apiOptions)
return [tmpClient, () => tmpClient.close()] return [tmpClient, () => tmpClient.close()]
} else { } else {
return [client, () => Promise.resolve()] return [client, () => Promise.resolve()]
@ -73,8 +75,9 @@ export class ReplWorkerTelegram {
async createClient(params: { async createClient(params: {
accountId: string accountId: string
testMode?: boolean testMode?: boolean
apiOptions?: CustomApiFields
}) { }) {
const client = createInternalClient(params.accountId, params.testMode) const client = createInternalClient(params.accountId, params.testMode, params.apiOptions)
clients.set(params.accountId, client) clients.set(params.accountId, client)
} }
@ -232,8 +235,9 @@ export class ReplWorkerTelegram {
dcId: number dcId: number
testMode: boolean testMode: boolean
abortSignal: AbortSignal abortSignal: AbortSignal
apiOptions?: CustomApiFields
}) { }) {
const { hexAuthKey, dcId, testMode, abortSignal } = params const { hexAuthKey, dcId, testMode, abortSignal, apiOptions } = params
const authKey = hex.decode(hexAuthKey) const authKey = hex.decode(hexAuthKey)
if (authKey.length !== 256) { if (authKey.length !== 256) {
@ -244,7 +248,7 @@ export class ReplWorkerTelegram {
authKey, authKey,
testMode, testMode,
primaryDcs: (testMode ? DC_MAPPING_TEST : DC_MAPPING_PROD)[dcId], primaryDcs: (testMode ? DC_MAPPING_TEST : DC_MAPPING_PROD)[dcId],
}, abortSignal) }, abortSignal, apiOptions)
if ($accounts.get().some(it => it.telegramId === account.telegramId)) { if ($accounts.get().some(it => it.telegramId === account.telegramId)) {
await deleteAccount(account.id) await deleteAccount(account.id)
@ -264,6 +268,7 @@ export class ReplWorkerTelegram {
libraryName: StringSessionLibName libraryName: StringSessionLibName
session: string session: string
abortSignal: AbortSignal abortSignal: AbortSignal
apiOptions?: CustomApiFields
}) { }) {
let session: StringSessionData let session: StringSessionData
switch (params.libraryName) { switch (params.libraryName) {
@ -297,7 +302,7 @@ export class ReplWorkerTelegram {
throw new Error(`Account already exists (user ID: ${session.self.userId})`) throw new Error(`Account already exists (user ID: ${session.self.userId})`)
} }
const account = await importAccount(session, params.abortSignal) const account = await importAccount(session, params.abortSignal, params.apiOptions)
// check if account already exists once again // check if account already exists once again
if ($accounts.get().some(it => it.telegramId === account.telegramId)) { if ($accounts.get().some(it => it.telegramId === account.telegramId)) {