Support updated OAuth (#6798)

This commit is contained in:
zubiden 2026-03-31 11:29:27 +02:00 committed by Alexander Zinchuk
parent 6770fff857
commit d101cdf0d3
19 changed files with 729 additions and 198 deletions

View File

@ -166,7 +166,10 @@ export function buildJson(json: GramJs.TypeJSONValue): any {
export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlAuthResult | undefined {
if (result instanceof GramJs.UrlAuthResultRequest) {
const { bot, domain, requestWriteAccess } = result;
const {
bot, domain, requestWriteAccess, requestPhoneNumber, browser, platform, ip, region, matchCodes,
matchCodesFirst, userIdHint,
} = result;
const user = buildApiUser(bot);
if (!user) return undefined;
@ -177,6 +180,14 @@ export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlA
domain,
shouldRequestWriteAccess: requestWriteAccess,
bot: user,
shouldRequestPhoneNumber: requestPhoneNumber,
browser,
platform,
ip,
region,
matchCodes,
matchCodesFirst,
userIdHint: userIdHint?.toString(),
};
}

View File

@ -1,4 +1,5 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors';
import { generateRandomBigInt } from '../../../lib/gramjs/Helpers';
import type {
@ -8,6 +9,7 @@ import type {
ApiInputMessageReplyInfo,
ApiPeer,
ApiThemeParameters,
ApiUrlAuthResult,
ApiUser,
} from '../../types';
@ -450,23 +452,11 @@ export async function requestBotUrlAuth({
buttonId: number;
messageId: number;
}) {
const result = await invokeRequest(new GramJs.messages.RequestUrlAuth({
return invokeUrlAuthRequest(new GramJs.messages.RequestUrlAuth({
peer: buildInputPeer(chat.id, chat.accessHash),
buttonId,
msgId: messageId,
}));
if (!result) return undefined;
const authResult = buildApiUrlAuthResult(result);
if (authResult?.type === 'request') {
sendApiUpdate({
'@type': 'updateUser',
id: authResult.bot.id,
user: authResult.bot,
});
}
return authResult;
}
export async function acceptBotUrlAuth({
@ -474,67 +464,71 @@ export async function acceptBotUrlAuth({
messageId,
buttonId,
isWriteAllowed,
wasPhoneShared,
matchCode,
}: {
chat: ApiChat;
messageId: number;
buttonId: number;
isWriteAllowed?: boolean;
wasPhoneShared?: boolean;
matchCode?: string;
}) {
const result = await invokeRequest(new GramJs.messages.AcceptUrlAuth({
return invokeUrlAuthRequest(new GramJs.messages.AcceptUrlAuth({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
buttonId,
writeAllowed: isWriteAllowed || undefined,
sharePhoneNumber: wasPhoneShared || undefined,
matchCode: matchCode || undefined,
}));
if (!result) return undefined;
const authResult = buildApiUrlAuthResult(result);
if (authResult?.type === 'request') {
sendApiUpdate({
'@type': 'updateUser',
id: authResult.bot.id,
user: authResult.bot,
});
}
return authResult;
}
export async function requestLinkUrlAuth({ url }: { url: string }) {
const result = await invokeRequest(new GramJs.messages.RequestUrlAuth({
return invokeUrlAuthRequest(new GramJs.messages.RequestUrlAuth({
url,
}));
if (!result) return undefined;
const authResult = buildApiUrlAuthResult(result);
if (authResult?.type === 'request') {
sendApiUpdate({
'@type': 'updateUser',
id: authResult.bot.id,
user: authResult.bot,
});
}
return authResult;
}
export async function acceptLinkUrlAuth({ url, isWriteAllowed }: { url: string; isWriteAllowed?: boolean }) {
const result = await invokeRequest(new GramJs.messages.AcceptUrlAuth({
export async function acceptLinkUrlAuth({
url, isWriteAllowed, wasPhoneShared, matchCode,
}: {
url: string;
isWriteAllowed?: boolean;
wasPhoneShared?: boolean;
matchCode?: string;
}) {
return invokeUrlAuthRequest(new GramJs.messages.AcceptUrlAuth({
url,
writeAllowed: isWriteAllowed || undefined,
sharePhoneNumber: wasPhoneShared || undefined,
matchCode: matchCode || undefined,
}));
}
if (!result) return undefined;
const authResult = buildApiUrlAuthResult(result);
if (authResult?.type === 'request') {
sendApiUpdate({
'@type': 'updateUser',
id: authResult.bot.id,
user: authResult.bot,
export async function checkUrlAuthMatchCode({ url, matchCode }: { url: string; matchCode: string }) {
try {
const result = await invokeRequest(new GramJs.messages.CheckUrlAuthMatchCode({
url,
matchCode,
}), {
shouldThrow: true,
});
if (!result) return { type: 'unmatched' };
return { type: 'matched' };
} catch (err) {
if (err instanceof RPCError && err.errorMessage === 'URL_EXPIRED') {
return { type: 'expired' };
}
throw err;
}
return authResult;
}
export async function declineUrlAuth({ url }: { url: string }) {
return invokeRequest(new GramJs.messages.DeclineUrlAuth({ url }), {
shouldReturnTrue: true,
});
}
export function fetchBotCanSendMessage({ bot }: { bot: ApiUser }) {
@ -720,3 +714,27 @@ export async function fetchBotsRecommendations({ user }: { user: ApiChat }) {
count: result instanceof GramJs.users.UsersSlice ? result.count : similarBots.length,
};
}
async function invokeUrlAuthRequest(
request: GramJs.messages.RequestUrlAuth | GramJs.messages.AcceptUrlAuth,
): Promise<ApiUrlAuthResult | undefined> {
try {
const result = await invokeRequest(request, { shouldThrow: true });
if (!result) return undefined;
const authResult = buildApiUrlAuthResult(result);
if (authResult?.type === 'request') {
sendApiUpdate({
'@type': 'updateUser',
id: authResult.bot.id,
user: authResult.bot,
});
}
return authResult;
} catch (err) {
if (err instanceof RPCError && err.errorMessage === 'URL_EXPIRED') {
return { type: 'expired' };
}
throw err;
}
}

View File

@ -1106,13 +1106,19 @@ export type ApiSearchPostsFlood = {
starsAmount: number;
};
export type LinkContext = {
export type LinkContextMessage = {
type: 'message';
threadId?: ThreadId;
chatId: string;
messageId: number;
};
export type LinkContextInner = {
type: 'inner';
};
export type LinkContext = LinkContextMessage | LinkContextInner;
export interface ApiTopic {
id: number;
isClosed?: boolean;

View File

@ -348,11 +348,19 @@ export interface ApiEmojiInteraction {
timestamps: number[];
}
type ApiUrlAuthResultRequest = {
export type ApiUrlAuthResultRequest = {
type: 'request';
bot: ApiUser;
domain: string;
shouldRequestWriteAccess?: boolean;
shouldRequestPhoneNumber?: boolean;
browser?: string;
platform?: string;
ip?: string;
region?: string;
matchCodes?: string[];
matchCodesFirst?: boolean;
userIdHint?: string;
};
type ApiUrlAuthResultAccepted = {
@ -360,11 +368,19 @@ type ApiUrlAuthResultAccepted = {
url?: string;
};
type ApiUrlAuthResultExpired = {
type: 'expired';
};
type ApiUrlAuthResultDefault = {
type: 'default';
};
export type ApiUrlAuthResult = ApiUrlAuthResultRequest | ApiUrlAuthResultAccepted | ApiUrlAuthResultDefault;
export type ApiUrlAuthResult =
ApiUrlAuthResultRequest
| ApiUrlAuthResultAccepted
| ApiUrlAuthResultExpired
| ApiUrlAuthResultDefault;
export interface ApiCollectibleInfo {
amount: number;

View File

@ -611,8 +611,19 @@
"OpenUrlTitle" = "Open Link";
"OpenUrlText" = "Do you want to open **{url}**?";
"OpenUrlConfirm" = "Open";
"ConversationOpenBotLinkLogin" = "Log in to **{url}** as {user}";
"ConversationOpenBotLinkAllowMessages" = "Allow **{bot}** to send me messages";
"BotAuthTitle" = "Log in to {url}";
"BotAuthSiteSubtitle" = "This site will receive your **name**, **username** and **profile photo**.";
"BotAuthAllowMessages" = "Allow Messages";
"BotAuthAllowMessagesInfo" = "This will allow **{bot}** to message you.";
"BotAuthInfo" = "This login attempt came from the device above.";
"BotAuthDevice" = "Device";
"BotAuthSelectEmoji" = "Tap the emoji shown on your other device";
"BotAuthPhoneNumber" = "Phone Number";
"BotAuthPhoneNumberText" = "**{domain}** wants to access your phone number **{phone}**.";
"BotAuthPhoneNumberQuestion" = "Allow access?";
"BotAuthPhoneNumberAccept" = "Allow";
"BotAuthPhoneNumberDeny" = "Deny";
"BotAuthLogin" = "Log in";
"BotWebViewOpenBot" = "Open Bot";
"BotChatMiniAppOpen" = "Open";
"WebAppReloadPage" = "Reload Page";
@ -725,6 +736,7 @@
"ErrorPasswordChanged" = "Password has been changed, please try again";
"ErrorPasswordMissing" = "You must set 2FA password to use this feature";
"ErrorPasskeyUnknown" = "This passkey is not assigned to any account";
"ErrorUrlExpired" = "This link has expired";
"ErrorUnspecified" = "Error";
"NoStickers" = "No stickers yet";
"ClearRecentEmoji" = "Clear recent emoji?";
@ -2749,6 +2761,7 @@
"ChatListAuctionView" = "View";
"BotAuthSuccessTitle" = "Login Successful";
"BotAuthSuccessText" = "You've successfully logged into **{url}**";
"BotAuthSuccessTextNoPhone" = "You're now logged in to **{url}**, but you didn't grant access to your phone number.";
"GiftPreviewSelectedTraits" = "Selected traits";
"GiftPreviewCountModels_one" = "This collection features **{count}** unique model";
"GiftPreviewCountModels_other" = "This collection features **{count}** unique models";

View File

@ -426,7 +426,7 @@ const Main = ({
const parsedInitialLocationHash = parseInitialLocationHash();
if (parsedInitialLocationHash?.tgaddr) {
processDeepLink(decodeURIComponent(parsedInitialLocationHash.tgaddr));
processDeepLink(decodeURIComponent(parsedInitialLocationHash.tgaddr), { type: 'inner' });
}
}, [isSynced]);
@ -434,7 +434,7 @@ const Main = ({
try {
const url = event.payload || '';
const decodedUrl = decodeURIComponent(url);
processDeepLink(decodedUrl);
processDeepLink(decodedUrl, { type: 'inner' });
} catch (e) {
if (DEBUG) {
// eslint-disable-next-line no-console

View File

@ -0,0 +1,71 @@
.content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.center {
align-self: center;
}
.textCenter {
text-align: center;
}
.title {
margin-bottom: 0;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.infoCard {
display: flex;
flex-direction: column;
min-width: 12rem;
padding: 0.5rem;
border-radius: 1rem;
background-color: var(--color-background-secondary);
}
.infoLabel, .note {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.allowMessages {
margin-top: 0.5rem;
}
.infoValue {
font-size: 0.9375rem;
color: var(--color-text);
}
.matchCodes {
display: flex;
gap: 0.75rem;
justify-content: space-between;
margin-bottom: 1rem;
}
.matchCodeButton {
--custom-emoji-size: 2rem;
font-size: 2rem;
}
.footnote {
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
}
.cancelButton {
margin-top: 0.75rem;
}

View File

@ -1,121 +1,366 @@
import type { FC } from '../../../lib/teact/teact';
import {
memo, useCallback, useEffect, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import { getActions, withGlobal } from '../../../global';
import type { ApiUser } from '../../../api/types';
import type { TabState } from '../../../global/types';
import type { GlobalState, TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers';
import { selectUser } from '../../../global/selectors';
import { ensureProtocol } from '../../../util/browser/url';
import { selectAnimatedEmoji, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatPhoneNumber } from '../../../util/phoneNumber';
import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText';
import { useShallowSelector } from '../../../hooks/data/useSelector';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useOldLang from '../../../hooks/useOldLang';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Checkbox from '../../ui/Checkbox';
import ConfirmDialog from '../../ui/ConfirmDialog';
import Avatar from '../../common/Avatar';
import CustomEmoji from '../../common/CustomEmoji';
import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button';
import ListItem from '../../ui/ListItem';
import Modal from '../../ui/Modal';
import Switcher from '../../ui/Switcher';
import styles from './UrlAuthModal.module.scss';
export type OwnProps = {
modal?: TabState['urlAuth'];
};
type StateProps = {
bot?: ApiUser;
currentUser?: ApiUser;
};
const UrlAuthModal: FC<OwnProps & StateProps> = ({
modal, currentUser,
}) => {
const { closeUrlAuthModal, acceptBotUrlAuth, acceptLinkUrlAuth } = getActions();
const [isLoginChecked, setLoginChecked] = useState(true);
const [isWriteAccessChecked, setWriteAccessChecked] = useState(true);
const currentAuth = useCurrentOrPrev(modal, false);
const { domain, botId, shouldRequestWriteAccess } = currentAuth?.request || {};
const bot = botId ? getGlobal().users.byId[botId] : undefined;
type AcceptParams = {
wasPhoneShared?: boolean;
matchCode?: string;
};
const lang = useOldLang();
type DialogState = 'closed' | 'match-confirm' | 'phone';
const MATCH_CODE_EMOJI_SIZE = 2 * REM;
const handleOpen = useCallback(() => {
if (modal?.url && isLoginChecked) {
const acceptAction = modal.button ? acceptBotUrlAuth : acceptLinkUrlAuth;
acceptAction({
isWriteAllowed: isWriteAccessChecked,
});
} else if (currentAuth?.url) {
window.open(ensureProtocol(currentAuth.url), '_blank', 'noopener');
}
closeUrlAuthModal();
}, [
modal, isLoginChecked, closeUrlAuthModal, acceptBotUrlAuth, acceptLinkUrlAuth, isWriteAccessChecked, currentAuth,
]);
const UrlAuthModal = ({
modal, bot, currentUser,
}: OwnProps & StateProps) => {
const {
acceptBotUrlAuth, acceptLinkUrlAuth, checkUrlAuthMatchCode, declineUrlAuth,
} = getActions();
const lang = useLang();
const handleDismiss = useCallback(() => {
closeUrlAuthModal();
}, [closeUrlAuthModal]);
const renderingModal = useCurrentOrPrev(modal, false);
const currentBot = useCurrentOrPrev(bot, false);
const modalRequest = modal?.request;
const renderingRequest = renderingModal?.request;
const confirmedMatchCode = renderingModal?.matchCode;
const botName = getUserFullName(currentBot) || currentBot?.firstName;
const isMatchCodePrecheckPending = Boolean(
modalRequest?.matchCodesFirst && modalRequest.matchCodes?.length && !modal?.matchCode,
);
const isOpen = Boolean(modal?.url && modal?.request) && !isMatchCodePrecheckPending;
const handleLoginChecked = useCallback((value: boolean) => {
setLoginChecked(value);
setWriteAccessChecked(value);
}, [setLoginChecked]);
const [isWriteAccessChecked, setWriteAccessChecked] = useState(
() => Boolean(modalRequest?.shouldRequestWriteAccess),
);
const [selectedMatchCode, setSelectedMatchCode] = useState<string | undefined>();
const [dialogState, setDialogState] = useState<DialogState>('closed');
const effectiveMatchCode = confirmedMatchCode || selectedMatchCode;
const isMatchDialogOpen = isMatchCodePrecheckPending || dialogState === 'match-confirm';
const isPhoneDialogOpen = dialogState === 'phone';
const matchCodeEmojisSelector = useCallback((global: GlobalState) => {
return renderingRequest?.matchCodes?.map((matchCode) => selectAnimatedEmoji(global, matchCode));
}, [renderingRequest?.matchCodes]);
const matchCodeEmojis = useShallowSelector(matchCodeEmojisSelector);
// Reset on re-open
useEffect(() => {
if (domain) {
setLoginChecked(true);
setWriteAccessChecked(Boolean(shouldRequestWriteAccess));
if (!modalRequest) {
setSelectedMatchCode(undefined);
setDialogState('closed');
return;
}
}, [shouldRequestWriteAccess, domain]);
setWriteAccessChecked(Boolean(modalRequest.shouldRequestWriteAccess));
setSelectedMatchCode(undefined);
setDialogState('closed');
}, [modalRequest]);
const handleDismiss = useLastCallback(() => {
setDialogState('closed');
declineUrlAuth();
});
const submitAuth = useLastCallback((params: AcceptParams = {}) => {
const acceptAction = renderingModal?.button ? acceptBotUrlAuth : acceptLinkUrlAuth;
acceptAction({
isWriteAllowed: renderingRequest?.shouldRequestWriteAccess ? isWriteAccessChecked : undefined,
wasPhoneShared: params.wasPhoneShared,
matchCode: params.matchCode ?? effectiveMatchCode,
});
});
const handleConfirm = useLastCallback(() => {
if (!renderingRequest) {
return;
}
if (renderingRequest.matchCodes?.length && !effectiveMatchCode) {
setDialogState('match-confirm');
return;
}
if (renderingRequest.shouldRequestPhoneNumber) {
setDialogState('phone');
return;
}
submitAuth();
});
const handleMatchDialogClose = useLastCallback(() => {
setDialogState('closed');
});
const handleMatchCodeSelect = useLastCallback((matchCode: string) => {
if (isMatchCodePrecheckPending) {
checkUrlAuthMatchCode({ matchCode });
return;
}
setSelectedMatchCode(matchCode);
setDialogState('closed');
if (renderingRequest?.shouldRequestPhoneNumber) {
setDialogState('phone');
return;
}
submitAuth({ matchCode });
});
const handlePhoneDialogClose = useLastCallback(() => {
setDialogState('closed');
});
const handlePhoneDecision = useLastCallback((wasPhoneShared: boolean) => {
setDialogState('closed');
submitAuth({ wasPhoneShared });
});
const handleTriggerWriteAccess = useLastCallback(() => {
setWriteAccessChecked(!isWriteAccessChecked);
});
if (!renderingRequest) {
return undefined;
}
const shouldRenderSessionInfo = Boolean(renderingRequest.platform
|| renderingRequest.browser || renderingRequest.ip || renderingRequest.region,
);
const requestDomain = renderingRequest.domain;
const formattedPhoneNumber = currentUser?.phoneNumber ? `+${formatPhoneNumber(currentUser.phoneNumber)}` : undefined;
const titleText = lang('BotAuthTitle', {
url: <SafeLink url={requestDomain} text={requestDomain} />,
}, {
withNodes: true,
});
const descriptionText = lang('BotAuthSiteSubtitle', undefined, {
withNodes: true,
withMarkdown: true,
});
function renderPhoneDialogText() {
return (
<>
<p>
{lang('BotAuthPhoneNumberText', {
domain: requestDomain,
phone: formattedPhoneNumber || lang('Phone'),
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
<p>{lang('BotAuthPhoneNumberQuestion')}</p>
</>
);
}
return (
<ConfirmDialog
isOpen={Boolean(modal?.url)}
onClose={handleDismiss}
title={lang('OpenUrlTitle')}
confirmLabel={lang('OpenUrlTitle')}
confirmHandler={handleOpen}
>
{renderText(lang('OpenUrlAlert2', currentAuth?.url), ['links'])}
{domain && (
<Checkbox
className="dialog-checkbox"
checked={isLoginChecked}
label={(
<>
{renderText(
lang('Conversation.OpenBotLinkLogin', [domain, getUserFullName(currentUser)]),
['simple_markdown'],
)}
</>
)}
onCheck={handleLoginChecked}
<>
<Modal
isOpen={isOpen}
contentClassName={styles.content}
className="tall"
onClose={handleDismiss}
hasAbsoluteCloseButton
isSlim
>
<Avatar
peer={currentBot}
size={96}
className={styles.center}
/>
)}
{shouldRequestWriteAccess && (
<Checkbox
className="dialog-checkbox"
checked={isWriteAccessChecked}
label={(
<>
{renderText(
lang('Conversation.OpenBotLinkAllowMessages', getUserFullName(bot)),
['simple_markdown'],
<h2 className={buildClassName(styles.center, styles.title)} dir="auto">{titleText}</h2>
<span className={styles.textCenter}>{descriptionText}</span>
{shouldRenderSessionInfo && (
<>
<div className={styles.infoCard}>
<span className={styles.infoLabel}>{lang('BotAuthDevice')}</span>
<span className={styles.infoValue}>
{[renderingRequest.platform, renderingRequest.browser].filter(Boolean).join(' · ')}
</span>
<span className={styles.infoLabel}>{lang('SessionPreviewIp')}</span>
<span className={styles.infoValue}>{renderingRequest.ip}</span>
<span className={styles.infoLabel}>{lang('SessionPreviewLocation')}</span>
<span className={styles.infoValue}>{renderingRequest.region}</span>
</div>
<span className={styles.note}>{lang('BotAuthInfo')}</span>
</>
)}
{renderingRequest.shouldRequestWriteAccess && (
<>
<ListItem
className={styles.allowMessages}
onClick={handleTriggerWriteAccess}
rightElement={(
<Switcher
id="url_auth_allow_messages"
label={lang('BotAuthAllowMessages')}
checked={isWriteAccessChecked}
/>
)}
</>
)}
onCheck={setWriteAccessChecked}
disabled={!isLoginChecked}
/>
)}
</ConfirmDialog>
>
{lang('BotAuthAllowMessages')}
</ListItem>
{botName && (
<span className={styles.note}>
{lang(
'BotAuthAllowMessagesInfo',
{ bot: botName },
{ withNodes: true, withMarkdown: true },
)}
</span>
)}
</>
)}
<div className={buildClassName(styles.actions, styles.center)}>
<Button
className={styles.actionButton}
color="gray"
isText
fluid
noForcedUpperCase
onClick={handleDismiss}
>
{lang('Cancel')}
</Button>
<Button
className={styles.actionButton}
color="primary"
fluid
noForcedUpperCase
onClick={handleConfirm}
>
{lang('BotAuthLogin')}
</Button>
</div>
</Modal>
<Modal
isOpen={isMatchDialogOpen}
title={lang('BotAuthSelectEmoji')}
onClose={isMatchCodePrecheckPending ? handleDismiss : handleMatchDialogClose}
className={buildClassName('confirm', styles.matchDialog)}
>
<div className={styles.matchCodes}>
{renderingRequest.matchCodes?.map((matchCode, index) => {
const animatedMatchCodeEmoji = matchCodeEmojis?.[index];
return (
<Button
key={matchCode}
fluid
color="adaptive"
className={styles.matchCodeButton}
onClick={() => handleMatchCodeSelect(matchCode)}
>
{animatedMatchCodeEmoji ? (
<CustomEmoji
sticker={animatedMatchCodeEmoji}
size={MATCH_CODE_EMOJI_SIZE}
/>
) : renderText(matchCode)}
</Button>
);
})}
</div>
<div className={styles.footnote}>
{lang('BotAuthTitle', {
url: <SafeLink url={requestDomain} text={requestDomain} />,
}, {
withNodes: true,
})}
</div>
<Button
color="danger"
className={styles.cancelButton}
isText
noForcedUpperCase
onClick={handleDismiss}
>
{lang('Cancel')}
</Button>
</Modal>
<Modal
isOpen={isPhoneDialogOpen}
title={lang('BotAuthPhoneNumber')}
onClose={handlePhoneDialogClose}
className={buildClassName('confirm', styles.phoneDialog)}
>
{renderPhoneDialogText()}
<div className="dialog-buttons">
<Button
className="confirm-dialog-button"
color="primary"
noForcedUpperCase
onClick={() => handlePhoneDecision(true)}
>
{lang('BotAuthPhoneNumberAccept')}
</Button>
<Button
className="confirm-dialog-button"
color="gray"
noForcedUpperCase
onClick={() => handlePhoneDecision(false)}
>
{lang('BotAuthPhoneNumberDeny')}
</Button>
</div>
</Modal>
</>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
(global, { modal }): Complete<StateProps> => {
const currentUser = selectUser(global, global.currentUserId!);
const bot = modal?.request?.botId ? selectUser(global, modal.request.botId) : undefined;
return {
bot,
currentUser,
};
},

View File

@ -33,7 +33,7 @@ import {
prepareMessageReplyInfo,
} from '../../helpers';
import {
addActionHandler, getGlobal, setGlobal,
addActionHandler, getActions, getGlobal, setGlobal,
} from '../../index';
import {
removeBlockedUser,
@ -1119,6 +1119,11 @@ addActionHandler('requestBotUrlAuth', async (global, actions, payload): Promise<
if (!result) return;
global = getGlobal();
if (result.type !== 'request') {
handleUrlAuthResult(global, { url, result }, tabId);
return;
}
global = updateTabState(global, {
urlAuth: {
url,
@ -1130,11 +1135,16 @@ addActionHandler('requestBotUrlAuth', async (global, actions, payload): Promise<
},
}, tabId);
setGlobal(global);
handleUrlAuthResult(global, actions, url, result, tabId);
handleUrlAuthResult(global, { url, result }, tabId);
});
addActionHandler('acceptBotUrlAuth', async (global, actions, payload): Promise<void> => {
const { isWriteAllowed, tabId = getCurrentTabId() } = payload;
const {
isWriteAllowed,
wasPhoneShared,
matchCode: providedMatchCode,
tabId = getCurrentTabId(),
} = payload;
const tabState = selectTabState(global, tabId);
if (!tabState.urlAuth?.button) return;
const {
@ -1152,10 +1162,12 @@ addActionHandler('acceptBotUrlAuth', async (global, actions, payload): Promise<v
messageId,
buttonId,
isWriteAllowed,
wasPhoneShared,
matchCode: providedMatchCode || tabState.urlAuth.matchCode,
});
if (!result) return;
global = getGlobal();
handleUrlAuthResult(global, actions, url, result, tabId);
handleUrlAuthResult(global, { url, result, wasPhoneShared }, tabId);
});
addActionHandler('requestLinkUrlAuth', async (global, actions, payload): Promise<void> => {
@ -1164,25 +1176,80 @@ addActionHandler('requestLinkUrlAuth', async (global, actions, payload): Promise
const result = await callApi('requestLinkUrlAuth', { url });
if (!result) return;
global = getGlobal();
if (result.type !== 'request') {
handleUrlAuthResult(global, { url, result }, tabId);
return;
}
global = updateTabState(global, {
urlAuth: {
url,
},
}, tabId);
setGlobal(global);
handleUrlAuthResult(global, actions, url, result, tabId);
handleUrlAuthResult(global, { url, result }, tabId);
});
addActionHandler('acceptLinkUrlAuth', async (global, actions, payload): Promise<void> => {
const { isWriteAllowed, tabId = getCurrentTabId() } = payload;
const {
isWriteAllowed,
wasPhoneShared,
matchCode: providedMatchCode,
tabId = getCurrentTabId(),
} = payload;
const tabState = selectTabState(global, tabId);
if (!tabState.urlAuth?.url) return;
const { url } = tabState.urlAuth;
const result = await callApi('acceptLinkUrlAuth', { url, isWriteAllowed });
const result = await callApi('acceptLinkUrlAuth', {
url,
isWriteAllowed,
wasPhoneShared,
matchCode: providedMatchCode || tabState.urlAuth.matchCode,
});
if (!result) return;
global = getGlobal();
handleUrlAuthResult(global, actions, url, result, tabId);
handleUrlAuthResult(global, { url, result, wasPhoneShared }, tabId);
});
addActionHandler('checkUrlAuthMatchCode', async (global, actions, payload): Promise<void> => {
const { matchCode, tabId = getCurrentTabId() } = payload;
const url = selectTabState(global, tabId).urlAuth?.url;
if (!url) return;
const { type } = await callApi('checkUrlAuthMatchCode', { url, matchCode });
if (type === 'unmatched') {
actions.closeUrlAuthModal({ tabId });
return;
}
if (type === 'expired') {
actions.closeUrlAuthModal({ tabId });
actions.showNotification({ message: { key: 'ErrorUrlExpired' }, tabId });
return;
}
global = getGlobal();
const tabState = selectTabState(global, tabId);
if (!tabState.urlAuth) return;
global = updateTabState(global, {
urlAuth: {
...tabState.urlAuth,
matchCode,
},
}, tabId);
setGlobal(global);
});
addActionHandler('declineUrlAuth', async (global, actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload || {};
const url = selectTabState(global, tabId).urlAuth?.url;
if (!url) return;
actions.closeUrlAuthModal({ tabId });
await callApi('declineUrlAuth', { url });
});
addActionHandler('closeUrlAuthModal', (global, actions, payload): ActionReturnType => {
@ -1194,22 +1261,33 @@ addActionHandler('closeUrlAuthModal', (global, actions, payload): ActionReturnTy
function handleUrlAuthResult<T extends GlobalState>(
global: T,
actions: RequiredGlobalActions,
url: string, result: ApiUrlAuthResult,
{ url, result, wasPhoneShared }: {
url: string;
result: ApiUrlAuthResult;
wasPhoneShared?: boolean;
},
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const actions = getActions();
if (result.type === 'expired') {
actions.closeUrlAuthModal({ tabId });
actions.showNotification({ message: { key: 'ErrorUrlExpired' }, tabId });
return;
}
const tabState = selectTabState(global, tabId);
if (result.type === 'request') {
global = getGlobal();
const tabState = selectTabState(global, tabId);
if (!tabState.urlAuth) return;
const { domain, bot, shouldRequestWriteAccess } = result;
global = getGlobal();
const { type, bot, ...request } = result;
global = updateTabState(global, {
urlAuth: {
...tabState.urlAuth,
matchCode: undefined,
request: {
domain,
...request,
botId: bot.id,
shouldRequestWriteAccess,
},
},
}, tabId);
@ -1218,22 +1296,41 @@ function handleUrlAuthResult<T extends GlobalState>(
}
if (result.type === 'accepted' && !result.url) {
actions.showNotification({
message: {
key: 'BotAuthSuccessText',
variables: {
url,
if (!wasPhoneShared && tabState.urlAuth?.request?.shouldRequestPhoneNumber) {
actions.showNotification({
message: {
key: 'BotAuthSuccessTextNoPhone',
variables: {
url: tabState.urlAuth.request?.domain || result.url,
},
options: {
withMarkdown: true,
withNodes: true,
},
},
options: {
withMarkdown: true,
withNodes: true,
title: {
key: 'BotAuthSuccessTitle',
},
},
title: {
key: 'BotAuthSuccessTitle',
},
tabId,
});
tabId,
});
} else {
actions.showNotification({
message: {
key: 'BotAuthSuccessText',
variables: {
url: tabState.urlAuth?.request?.domain || result.url,
},
options: {
withMarkdown: true,
withNodes: true,
},
},
title: {
key: 'BotAuthSuccessTitle',
},
tabId,
});
}
actions.closeUrlAuthModal({ tabId });
return;
}

View File

@ -3770,7 +3770,7 @@ async function openChatWithParams<T extends GlobalState>(
if (!isTopicProcessed) {
actions.focusMessage({
chatId: chat.id, threadId, messageId, timestamp, tabId,
replyMessageId: linkContext?.messageId,
replyMessageId: linkContext?.type === 'message' ? linkContext.messageId : undefined,
});
}
} else if (!isCurrentChat) {

View File

@ -2308,6 +2308,8 @@ export interface ActionPayloads {
acceptBotUrlAuth: {
isWriteAllowed?: boolean;
wasPhoneShared?: boolean;
matchCode?: string;
} & WithTabId;
requestLinkUrlAuth: {
@ -2316,8 +2318,16 @@ export interface ActionPayloads {
acceptLinkUrlAuth: {
isWriteAllowed?: boolean;
wasPhoneShared?: boolean;
matchCode?: string;
} & WithTabId;
checkUrlAuthMatchCode: {
matchCode: string;
} & WithTabId;
declineUrlAuth: WithTabId | undefined;
// Settings
loadAuthorizations: undefined;
terminateAuthorization: {

View File

@ -57,6 +57,7 @@ import type {
ApiTypePrepaidGiveaway,
ApiTypeStoryView,
ApiUniqueStarGiftValueInfo,
ApiUrlAuthResultRequest,
ApiUser,
ApiVideo,
} from '../../api/types';
@ -645,11 +646,8 @@ export type TabState = {
messageId: number;
buttonId: number;
};
request?: {
domain: string;
botId: string;
shouldRequestWriteAccess?: boolean;
};
matchCode?: string;
request?: Omit<ApiUrlAuthResultRequest, 'type' | 'bot'> & { botId: string };
url: string;
};

View File

@ -1795,6 +1795,8 @@ messages.summarizeText#9d4104e2 flags:# peer:InputPeer id:int to_lang:flags.0?st
messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates;
messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User;
messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates;
messages.declineUrlAuth#35436bbc url:string = Bool;
messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool;
updates.getState#edd4882a = updates.State;
updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -163,6 +163,8 @@
"messages.getEmojiKeywordsDifference",
"messages.requestUrlAuth",
"messages.acceptUrlAuth",
"messages.declineUrlAuth",
"messages.checkUrlAuthMatchCode",
"messages.hidePeerSettingsBar",
"messages.getScheduledHistory",
"messages.sendScheduledMessages",

View File

@ -112,7 +112,7 @@
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
--color-primary-opacity: rgba(var(--color-primary), 0.15);
--color-primary-opacity: rgba(var(--color-primary), 0.2);
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
--color-primary-tint: rgba(var(--color-primary), 0.1);
--color-green: #{$color-green};
@ -121,7 +121,7 @@
--accent-color: var(--color-primary);
--accent-background-color: var(--color-primary-tint);
--accent-background-active-color: var(--color-primary-opacity);
--accent-background-active-color: var(--color-primary-opacity-hover);
--color-error: #{$color-error};
--color-error-shade: #{color.mix($color-error, $color-black, 92%)};

View File

@ -548,6 +548,16 @@ export interface LangPair {
'AboutPremiumDescription2': undefined;
'OpenUrlTitle': undefined;
'OpenUrlConfirm': undefined;
'BotAuthSiteSubtitle': undefined;
'BotAuthAllowMessages': undefined;
'BotAuthInfo': undefined;
'BotAuthDevice': undefined;
'BotAuthSelectEmoji': undefined;
'BotAuthPhoneNumber': undefined;
'BotAuthPhoneNumberQuestion': undefined;
'BotAuthPhoneNumberAccept': undefined;
'BotAuthPhoneNumberDeny': undefined;
'BotAuthLogin': undefined;
'BotWebViewOpenBot': undefined;
'BotChatMiniAppOpen': undefined;
'WebAppReloadPage': undefined;
@ -642,6 +652,7 @@ export interface LangPair {
'ErrorPasswordChanged': undefined;
'ErrorPasswordMissing': undefined;
'ErrorPasskeyUnknown': undefined;
'ErrorUrlExpired': undefined;
'ErrorUnspecified': undefined;
'NoStickers': undefined;
'ClearRecentEmoji': undefined;
@ -2181,13 +2192,16 @@ export interface LangPairWithVariables<V = LangVariable> {
'OpenUrlText': {
'url': V;
};
'ConversationOpenBotLinkLogin': {
'BotAuthTitle': {
'url': V;
'user': V;
};
'ConversationOpenBotLinkAllowMessages': {
'BotAuthAllowMessagesInfo': {
'bot': V;
};
'BotAuthPhoneNumberText': {
'domain': V;
'phone': V;
};
'ForwardForStars': {
'price': V;
};
@ -3575,6 +3589,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'BotAuthSuccessText': {
'url': V;
};
'BotAuthSuccessTextNoPhone': {
'url': V;
};
'RankModalMemberText': {
'tag': V;
'author': V;

View File

@ -66,12 +66,8 @@ export const IS_EMOJI_SUPPORTED = PLATFORM_ENV && (IS_MAC_OS || IS_IOS) && isLas
export const IS_SERVICE_WORKER_SUPPORTED = 'serviceWorker' in navigator;
// Remove in mid-late 2025 when Chromium 132 is no longer a problem
// https://issues.chromium.org/issues/390581541
const chromeVersion = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)?.[2];
const hasBrokenServiceWorkerStreaming = chromeVersion && Number(chromeVersion) === 132;
// TODO Consider failed service worker
export const IS_PROGRESSIVE_SUPPORTED = IS_SERVICE_WORKER_SUPPORTED && !hasBrokenServiceWorkerStreaming;
export const IS_PROGRESSIVE_SUPPORTED = IS_SERVICE_WORKER_SUPPORTED;
export const IS_OPUS_SUPPORTED = Boolean((new Audio()).canPlayType('audio/ogg; codecs=opus'));
export const IS_CANVAS_FILTER_SUPPORTED = (
!IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {})

View File

@ -10,7 +10,7 @@ import { isUsernameValid } from './entities/username';
export type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' |
'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' |
'invoice' | 'addlist' | 'boost' | 'giftcode' | 'message' | 'premium_offer' | 'premium_multigift' | 'stars_topup'
| 'nft' | 'stars' | 'ton' | 'stargift_auction' | 'premium';
| 'nft' | 'stars' | 'ton' | 'stargift_auction' | 'premium' | 'oauth';
interface PublicMessageLink {
type: 'publicMessageLink';
@ -122,6 +122,11 @@ interface SettingsScreenLink {
screen?: 'devices' | 'folders' | 'language' | 'privacy' | 'editProfile' | 'theme';
}
interface OAuthLink {
type: 'oauth';
url: string;
}
type DeepLink =
TelegramPassportLink |
LoginCodeLink |
@ -139,7 +144,8 @@ type DeepLink =
GiftAuctionLink |
StarsModalLink |
TonModalLink |
SettingsScreenLink;
SettingsScreenLink |
OAuthLink;
type BuilderParams<T extends DeepLink> = Record<keyof Omit<T, 'type'>, string | undefined>;
type BuilderReturnType<T extends DeepLink> = T | undefined;
@ -157,6 +163,10 @@ type PublicUsernameOrBotLinkBuilderParams = Omit<BuilderParams<PublicUsernameOrB
direct?: string;
};
type OAuthLinkBuilderParams = Omit<BuilderParams<OAuthLink>, 'url'> & {
url: string;
};
const ELIGIBLE_HOSTNAMES = new Set(['t.me', 'telegram.me', 'telegram.dog']);
export function isDeepLink(link: string): boolean {
@ -283,6 +293,8 @@ function parseTgLink(url: URL) {
return { type: 'ton' } satisfies TonModalLink;
case 'settings':
return buildSettingsScreenLink({ screen: pathParams.length === 1 ? pathParams[0] : undefined });
case 'oauth':
return buildOAuthLink({ url: url.toString() });
default:
break;
}
@ -455,12 +467,14 @@ function getTgDeepLinkType(
switch (method) {
case 'resolve': {
const {
domain, post, bot_id, scope, public_key, nonce,
domain, post, bot_id, scope, public_key, nonce, startapp,
} = queryParams;
if (domain === 'telegrampassport' && bot_id && scope && public_key && nonce) {
return 'telegramPassportLink';
}
if (domain === 'oauth' && startapp) {
return 'oauth';
}
if (domain && post) {
return 'publicMessageLink';
}
@ -505,6 +519,8 @@ function getTgDeepLinkType(
case 'settings': {
return 'settings';
}
case 'oauth':
return 'oauth';
default:
break;
}
@ -773,6 +789,15 @@ function buildSettingsScreenLink(params: BuilderParams<SettingsScreenLink>): Bui
};
}
function buildOAuthLink(params: OAuthLinkBuilderParams): BuilderReturnType<OAuthLink> {
const {
url,
} = params;
return {
type: 'oauth',
url,
};
}
function buildPremiumReferrerLink(params: BuilderParams<PremiumReferrerLink>): BuilderReturnType<PremiumReferrerLink> {
const {
ref,

View File

@ -90,30 +90,34 @@ export const processDeepLink = (url: string, linkContext?: LinkContext): boolean
switch (parsedLink.screen) {
case 'editProfile':
actions.openSettingsScreen({ screen: SettingsScreens.EditProfile });
break;
return true;
case 'language':
actions.openSettingsScreen({ screen: SettingsScreens.Language });
break;
return true;
case 'devices':
actions.openSettingsScreen({ screen: SettingsScreens.ActiveSessions });
break;
return true;
case 'privacy':
actions.openSettingsScreen({ screen: SettingsScreens.Privacy });
break;
return true;
case 'folders':
actions.openSettingsScreen({ screen: SettingsScreens.Folders });
break;
return true;
case 'theme':
actions.openSettingsScreen({ screen: SettingsScreens.General });
break;
return true;
}
return true;
break;
case 'stars':
actions.openStarsBalanceModal({});
break;
return true;
case 'ton':
actions.openStarsBalanceModal({ currency: TON_CURRENCY_CODE });
break;
return true;
case 'oauth':
if (linkContext?.type !== 'inner') return false;
actions.requestLinkUrlAuth({ url: parsedLink.url });
return true;
default:
break;
}