TelegramPWA/src/util/deepLinkParser.ts
2023-12-23 22:17:52 +01:00

425 lines
10 KiB
TypeScript

import { RE_TG_LINK, RE_TME_LINK } from '../config';
import { isUsernameValid } from './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';
interface PublicMessageLink {
type: 'publicMessageLink';
username: string;
messageId: number;
isSingle?: boolean;
threadId?: number;
commentId?: number;
mediaTimestamp?: string;
}
interface PrivateMessageLink {
type: 'privateMessageLink';
channelId: string;
messageId: number;
isSingle?: boolean;
threadId?: number;
commentId?: number;
mediaTimestamp?: string;
}
interface ShareLink {
type: 'shareLink';
url: string;
text?: string;
}
interface ChatFolderLink {
type: 'chatFolderLink';
slug: string;
}
interface LoginCodeLink {
type: 'loginCodeLink';
code: string;
}
interface TelegramPassportLink {
type: 'telegramPassportLink';
botId: number;
scope: string;
publicKey: string;
nonce: string;
callbackUrl?: string;
payload?: string;
}
type DeepLink =
TelegramPassportLink |
LoginCodeLink |
PublicMessageLink |
PrivateMessageLink |
ShareLink |
ChatFolderLink;
type BuilderParams<T extends DeepLink> = Record<keyof Omit<T, 'type'>, string>;
type BuilderReturnType<T extends DeepLink> = T | undefined;
type DeepLinkType = DeepLink['type'] | 'unknown';
const ELIGIBLE_HOSTNAMES = new Set(['t.me', 'telegram.me', 'telegram.dog']);
export function isDeepLink(link: string): boolean {
return Boolean(link.match(RE_TME_LINK) || link.match(RE_TG_LINK));
}
export function tryParseDeepLink(link: string): DeepLink | undefined {
try {
return parseDeepLink(link);
} catch (err) {
return undefined;
}
}
function parseDeepLink(url: string) {
if (url.startsWith('https:')) {
const urlParsed = new URL(url);
return handleHttpLink(urlParsed);
}
if (url.startsWith('tg:')) {
// Chrome parse url with tg: protocol incorrectly
const urlParsed = new URL(url.replace(/^tg:/, 'http:'));
return handleTgLink(urlParsed);
}
return undefined;
}
function handleTgLink(url: URL) {
const { hostname } = url;
const queryParams = getQueryParams(url);
const pathParams = getPathParams(url);
const method = hostname as DeepLinkMethod;
const deepLinkType = getTgDeepLinkType(queryParams, pathParams, method);
switch (deepLinkType) {
case 'publicMessageLink': {
const {
domain, post, single, thread, comment, t,
} = queryParams;
return buildPublicMessageLink({
username: domain,
messageId: post,
isSingle: single,
threadId: thread,
commentId: comment,
mediaTimestamp: t,
});
}
case 'privateMessageLink': {
const {
channel, post, single, thread, comment, t,
} = queryParams;
return buildPrivateMessageLink({
channelId: channel,
messageId: post,
isSingle: single,
threadId: thread,
commentId: comment,
mediaTimestamp: t,
});
}
case 'shareLink':
return buildShareLink({ text: queryParams.text, url: queryParams.url });
case 'chatFolderLink':
return buildChatFolderLink({ slug: queryParams.slug });
case 'loginCodeLink':
return buildLoginCodeLink({ code: queryParams.code });
case 'telegramPassportLink':
return buildTelegramPassportLink({
botId: queryParams.bot_id,
scope: queryParams.scope,
publicKey: queryParams.public_key,
nonce: queryParams.nonce,
callbackUrl: queryParams.callback_url,
payload: queryParams.payload,
});
default:
break;
}
return undefined;
}
function handleHttpLink(url: URL) {
if (!ELIGIBLE_HOSTNAMES.has(url.hostname)) {
return undefined;
}
const queryParams = getQueryParams(url);
const pathParams = getPathParams(url);
const deepLinkType = getHttpDeepLinkType(queryParams, pathParams);
switch (deepLinkType) {
case 'publicMessageLink': {
const {
single, comment, t,
} = queryParams;
const {
username,
thread,
messageId,
} = pathParams.length === 2 ? {
username: pathParams[0],
thread: queryParams.thread,
messageId: pathParams[1],
} : {
username: pathParams[0],
thread: pathParams[1],
messageId: pathParams[2],
};
return buildPublicMessageLink({
username,
messageId,
isSingle: single,
threadId: thread,
commentId: comment,
mediaTimestamp: t,
});
}
case 'privateMessageLink': {
const {
single, comment, t,
} = queryParams;
const {
channelId,
thread,
messageId,
} = pathParams.length === 3 ? {
channelId: pathParams[1],
thread: queryParams.thread,
messageId: pathParams[2],
} : {
channelId: pathParams[1],
thread: pathParams[2],
messageId: pathParams[3],
};
return buildPrivateMessageLink({
channelId,
messageId,
isSingle: single,
threadId: thread,
commentId: comment,
mediaTimestamp: t,
});
}
case 'shareLink': {
return buildShareLink({ text: queryParams.text, url: queryParams.url });
}
case 'chatFolderLink':
return buildChatFolderLink({ slug: pathParams[1] });
case 'loginCodeLink':
return buildLoginCodeLink({ code: pathParams[1] });
default:
break;
}
return undefined;
}
function getHttpDeepLinkType(
queryParams: Record<string, string>,
pathParams: string[],
): DeepLinkType {
const len = pathParams.length;
const method = pathParams[0];
if (len === 1) {
if (method === 'share') {
return 'shareLink';
}
} else if (len === 2) {
if (method === 'addlist') {
return 'chatFolderLink';
}
if (method === 'login') {
return 'loginCodeLink';
}
if (isUsernameValid(pathParams[0]) && isNumber(pathParams[1])) {
return 'publicMessageLink';
}
} else if (len === 3) {
if (method === 'c' && pathParams.slice(1).every(isNumber)) {
return 'privateMessageLink';
}
if (isUsernameValid(pathParams[0]) && pathParams.slice(1).every(isNumber)) {
return 'publicMessageLink';
}
} else if (len === 4) {
if (method === 'c' && pathParams.slice(1).every(isNumber)) {
return 'privateMessageLink';
}
}
return 'unknown';
}
function getTgDeepLinkType(
queryParams: Record<string, string>,
pathParams: string[],
method: DeepLinkMethod,
): DeepLinkType {
switch (method) {
case 'resolve': {
const {
// eslint-disable-next-line @typescript-eslint/naming-convention
domain, post, bot_id, scope, public_key, nonce,
} = queryParams;
if (domain === 'telegrampassport' && bot_id && scope && public_key && nonce) {
return 'telegramPassportLink';
}
if (domain && post) {
return 'publicMessageLink';
}
break;
}
case 'privatepost': {
const { channel, post } = queryParams;
if (channel && post) {
return 'privateMessageLink';
}
break;
}
case 'msg_url':
return 'shareLink';
case 'addlist':
return 'chatFolderLink';
case 'login':
return 'loginCodeLink';
case 'passport':
return 'telegramPassportLink';
default:
break;
}
return 'unknown';
}
function buildShareLink(params: BuilderParams<ShareLink>): BuilderReturnType<ShareLink> {
const { url, text } = params;
if (!url) {
return undefined;
}
return {
type: 'shareLink',
url,
text,
};
}
function buildPublicMessageLink(params: BuilderParams<PublicMessageLink>): BuilderReturnType<PublicMessageLink> {
const {
messageId, threadId, commentId, username, isSingle, mediaTimestamp,
} = params;
if (!username || !isUsernameValid(username)) {
return undefined;
}
if (!messageId || !isNumber(messageId)) {
return undefined;
}
if (threadId && !isNumber(threadId)) {
return undefined;
}
if (commentId && !isNumber(commentId)) {
return undefined;
}
return {
type: 'publicMessageLink',
username,
messageId: Number(messageId),
isSingle: isSingle === '',
threadId: threadId ? Number(threadId) : undefined,
commentId: commentId ? Number(commentId) : undefined,
mediaTimestamp,
};
}
function buildPrivateMessageLink(params: BuilderParams<PrivateMessageLink>): BuilderReturnType<PrivateMessageLink> {
const {
messageId, threadId, commentId, channelId, isSingle, mediaTimestamp,
} = params;
if (!channelId || !isNumber(channelId)) {
return undefined;
}
if (!messageId || !isNumber(messageId)) {
return undefined;
}
if (threadId && !isNumber(threadId)) {
return undefined;
}
if (commentId && !isNumber(commentId)) {
return undefined;
}
return {
type: 'privateMessageLink',
channelId,
messageId: Number(messageId),
isSingle: isSingle === '',
threadId: threadId ? Number(threadId) : undefined,
commentId: commentId ? Number(commentId) : undefined,
mediaTimestamp,
};
}
function buildChatFolderLink(params: BuilderParams<ChatFolderLink>): BuilderReturnType<ChatFolderLink> {
const {
slug,
} = params;
if (!slug) {
return undefined;
}
return {
type: 'chatFolderLink',
slug,
};
}
function buildLoginCodeLink(params: BuilderParams<LoginCodeLink>): BuilderReturnType<LoginCodeLink> {
const {
code,
} = params;
if (!code) {
return undefined;
}
return {
type: 'loginCodeLink',
code,
};
}
function buildTelegramPassportLink(
params: BuilderParams<TelegramPassportLink>,
): BuilderReturnType<TelegramPassportLink> {
const {
botId,
scope,
publicKey,
nonce,
callbackUrl,
payload,
} = params;
if (!botId || !isNumber(botId) || !scope || !publicKey || !nonce) {
return undefined;
}
return {
type: 'telegramPassportLink',
botId: Number(botId),
scope,
publicKey,
nonce,
callbackUrl,
payload,
};
}
function isNumber(s: string) {
return /^-?\d+$/.test(s);
}
function getPathParams(url: URL) {
return url.pathname.split('/').filter(Boolean).map(decodeURI);
}
function getQueryParams(url: URL) {
return Object.fromEntries(url.searchParams);
}