From 0054491665446350047bdcf3fc7998f3c960ba3e Mon Sep 17 00:00:00 2001 From: Alina Sireneva Date: Sun, 22 Oct 2023 23:42:10 +0300 Subject: [PATCH] feat(core): helpers for deeplinks --- packages/core/src/utils/index.ts | 1 + packages/core/src/utils/links/bots.ts | 309 ++++++++++++++++++ packages/core/src/utils/links/bundle.ts | 7 + packages/core/src/utils/links/calls.ts | 69 ++++ packages/core/src/utils/links/chat.ts | 203 ++++++++++++ packages/core/src/utils/links/common.ts | 107 ++++++ packages/core/src/utils/links/index.ts | 2 + packages/core/src/utils/links/misc.ts | 84 +++++ packages/core/src/utils/links/proxy.ts | 72 ++++ packages/core/src/utils/links/stickers.ts | 28 ++ packages/core/src/utils/links/user.ts | 80 +++++ packages/core/tests/links/bots-links.spec.ts | 206 ++++++++++++ packages/core/tests/links/chat-links.spec.ts | 119 +++++++ packages/core/tests/links/misc-links.spec.ts | 189 +++++++++++ packages/core/tests/links/proxy-links.spec.ts | 89 +++++ packages/core/tests/links/user-links.spec.ts | 78 +++++ 16 files changed, 1643 insertions(+) create mode 100644 packages/core/src/utils/links/bots.ts create mode 100644 packages/core/src/utils/links/bundle.ts create mode 100644 packages/core/src/utils/links/calls.ts create mode 100644 packages/core/src/utils/links/chat.ts create mode 100644 packages/core/src/utils/links/common.ts create mode 100644 packages/core/src/utils/links/index.ts create mode 100644 packages/core/src/utils/links/misc.ts create mode 100644 packages/core/src/utils/links/proxy.ts create mode 100644 packages/core/src/utils/links/stickers.ts create mode 100644 packages/core/src/utils/links/user.ts create mode 100644 packages/core/tests/links/bots-links.spec.ts create mode 100644 packages/core/tests/links/chat-links.spec.ts create mode 100644 packages/core/tests/links/misc-links.spec.ts create mode 100644 packages/core/tests/links/proxy-links.spec.ts create mode 100644 packages/core/tests/links/user-links.spec.ts diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index b8aa09b7..77254166 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './early-timer.js' export * from './flood-control.js' export * from './function-utils.js' export * from './linked-list.js' +export * from './links/index.js' export * from './logger.js' export * from './long-utils.js' export * from './lru-map.js' diff --git a/packages/core/src/utils/links/bots.ts b/packages/core/src/utils/links/bots.ts new file mode 100644 index 00000000..700ea99b --- /dev/null +++ b/packages/core/src/utils/links/bots.ts @@ -0,0 +1,309 @@ +import { tl } from '@mtcute/tl' + +import { isPresent } from '../type-assertions.js' +import { deeplinkBuilder } from './common.js' + +/** + * Bot deeplinks + * + * Used to link to bots with a start parameter + */ +export const botStart = deeplinkBuilder<{ + /** Bot username */ + username: string + /** Start parameter */ + parameter: string +}>({ + internalBuild: ({ username, parameter }) => ['resolve', { domain: username, start: parameter }], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const username = query.get('domain') + const parameter = query.get('start') + + if (!username || !parameter) return null + + return { username, parameter } + }, + externalBuild: ({ username, parameter }) => [username, { start: parameter }], + externalParse: (path, query) => { + if (path.includes('/') || path[0] === '+' || path.length <= 2) return null + + const username = path + const parameter = query.get('start') + + if (!parameter) return null + + return { username, parameter } + }, +}) + +type BotAdminRight = Exclude + +function normalizeBotAdmin(rights?: BotAdminRight[]): string | undefined { + if (!rights?.length) return + + return rights + .map((it) => { + switch (it) { + case 'changeInfo': + return 'change_info' + case 'postMessages': + return 'post_messages' + case 'editMessages': + return 'edit_messages' + case 'deleteMessages': + return 'delete_messages' + case 'banUsers': + return 'restrict_members' + case 'inviteUsers': + return 'invite_users' + case 'pinMessages': + return 'pin_messages' + case 'manageTopics': + return 'manage_topics' + case 'addAdmins': + return 'promote_members' + case 'manageCall': + return 'manage_video_chats' + case 'anonymous': + return 'anonymous' + case 'other': + return 'manage_chat' + case 'postStories': + return 'post_stories' + case 'editStories': + return 'edit_stories' + case 'deleteStories': + return 'delete_stories' + } + }) + .join('+') +} + +function parseBotAdmin(rights: string | null): BotAdminRight[] | undefined { + if (!rights) return + + return rights + .split('+') + .map((it) => { + switch (it) { + case 'change_info': + return 'changeInfo' + case 'post_messages': + return 'postMessages' + case 'edit_messages': + return 'editMessages' + case 'delete_messages': + return 'deleteMessages' + case 'restrict_members': + return 'banUsers' + case 'invite_users': + return 'inviteUsers' + case 'pin_messages': + return 'pinMessages' + case 'manage_topics': + return 'manageTopics' + case 'promote_members': + return 'addAdmins' + case 'manage_video_chats': + return 'manageCall' + case 'anonymous': + return 'anonymous' + case 'manage_chat': + return 'other' + case 'post_stories': + return 'postStories' + case 'edit_stories': + return 'editStories' + case 'delete_stories': + return 'deleteStories' + default: + return null + } + }) + .filter(isPresent) +} + +/** + * Bot add to group links + * + * Used to ask the user to add a bot to a group, optionally asking for admin rights. + * Note that the user is still free to choose which rights to grant, and + * whether to grant them at all. + */ +export const botAddToGroup = deeplinkBuilder<{ + /** Bot username */ + bot: string + /** If specified, the client will call `/start parameter` on the bot once the bot has been added */ + parameter?: string + /** Admin rights to request */ + admin?: BotAdminRight[] +}>({ + internalBuild: ({ bot, parameter, admin }) => [ + 'resolve', + { domain: bot, startgroup: parameter || true, admin: normalizeBotAdmin(admin) }, + ], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const bot = query.get('domain') + const parameter = query.get('startgroup') + const admin = query.get('admin') + + if (!bot || parameter === null) return null + + return { + bot, + parameter: parameter === '' ? undefined : parameter, + admin: parseBotAdmin(admin), + } + }, + + externalBuild: ({ bot, parameter, admin }) => [ + bot, + { startgroup: parameter || true, admin: normalizeBotAdmin(admin) }, + ], + externalParse: (path, query) => { + if (path.includes('/') || path[0] === '+' || path.length <= 2) return null + + const bot = path + const parameter = query.get('startgroup') + const admin = query.get('admin') + + if (parameter === null) return null + + return { + bot, + parameter: parameter === '' ? undefined : parameter, + admin: parseBotAdmin(admin), + } + }, +}) + +/** + * Bot add to channel links + * + * Used to ask the user to add a bot to a channel, optionally with admin rights. + * Note that the user is still free to choose which rights to grant, and + * whether to grant them at all. + */ +export const botAddToChannel = deeplinkBuilder<{ + /** Bot username */ + bot: string + /** Admin rights to request */ + admin?: BotAdminRight[] +}>({ + internalBuild: ({ bot, admin }) => [ + 'resolve', + { domain: bot, startchannel: true, admin: normalizeBotAdmin(admin) }, + ], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const bot = query.get('domain') + const parameter = query.get('startchannel') + const admin = query.get('admin') + + if (!bot || parameter === null) return null + + return { + bot, + admin: parseBotAdmin(admin), + } + }, + + externalBuild: ({ bot, admin }) => [bot, { startchannel: true, admin: normalizeBotAdmin(admin) }], + externalParse: (path, query) => { + if (path.includes('/') || path[0] === '+' || path.length <= 2) return null + + const bot = path + const parameter = query.get('startchannel') + const admin = query.get('admin') + + if (parameter === null) return null + + return { + bot, + admin: parseBotAdmin(admin), + } + }, +}) + +/** + * Game links + * + * Used to share games. + */ +export const botGame = deeplinkBuilder<{ + /** Bot username */ + bot: string + /** Game short name */ + game: string +}>({ + internalBuild: ({ bot, game }) => ['resolve', { domain: bot, game }], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const bot = query.get('domain') + const game = query.get('game') + + if (!bot || !game) return null + + return { bot, game } + }, + externalBuild: ({ bot, game }) => [bot, { game }], + externalParse: (path, query) => { + if (path.includes('/') || path[0] === '+' || path.length <= 2) return null + + const bot = path + const game = query.get('game') + + if (!game) return null + + return { bot, game } + }, +}) + +/** + * Named bot web app links + * + * Used to share named bot web apps. + * + * These links are different from bot attachment menu deep links, + * because they don't require the user to install an attachment menu, + * and a single bot can offer multiple named web apps, distinguished by + * their `short_name`. + */ +export const botWebApp = deeplinkBuilder<{ + /** Bot username */ + bot: string + /** App short name */ + app: string + /** Parameter to be passed by the client to messages.requestAppWebView as `start_param` */ + parameter?: string +}>({ + internalBuild: ({ bot, app, parameter }) => ['resolve', { domain: bot, appname: app, startapp: parameter }], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const bot = query.get('domain') + const app = query.get('appname') + const parameter = query.get('startapp') + + if (!bot || !app) return null + + return { bot, app, parameter: parameter || undefined } + }, + + externalBuild: ({ bot, app, parameter }) => [`${bot}/${app}`, { startapp: parameter }], + externalParse: (path, query) => { + const [bot, app, rest] = path.split('/') + + if (!app || rest) return null + + const parameter = query.get('startapp') + + return { bot, app, parameter: parameter || undefined } + }, +}) diff --git a/packages/core/src/utils/links/bundle.ts b/packages/core/src/utils/links/bundle.ts new file mode 100644 index 00000000..29dfa60d --- /dev/null +++ b/packages/core/src/utils/links/bundle.ts @@ -0,0 +1,7 @@ +export * from './bots.js' +export * from './calls.js' +export * from './chat.js' +export * from './misc.js' +export * from './proxy.js' +export * from './stickers.js' +export * from './user.js' diff --git a/packages/core/src/utils/links/calls.ts b/packages/core/src/utils/links/calls.ts new file mode 100644 index 00000000..05bb6b3f --- /dev/null +++ b/packages/core/src/utils/links/calls.ts @@ -0,0 +1,69 @@ +import { deeplinkBuilder } from './common.js' + +/** + * Video chat/Livestream links + * + * Used to join video/voice chats in groups, and livestreams in channels. + * Such links are generated using phone.exportGroupCallInvite. + */ +export const videoChat = deeplinkBuilder<{ + username: string + /** + * Invite hash exported if the `can_self_unmute` flag is set when calling `phone.exportGroupCallInvite`: + * should be passed to `phone.joinGroupCall`, allows the user to speak in livestreams + * or muted group chats. + */ + inviteHash?: string + isLivestream?: boolean +}>({ + internalBuild: ({ username, inviteHash, isLivestream }) => [ + 'resolve', + { + domain: username, + [isLivestream ? 'livestream' : 'videochat']: inviteHash || true, + }, + ], + externalBuild: ({ username, inviteHash, isLivestream }) => [ + username, + { + [isLivestream ? 'livestream' : 'videochat']: inviteHash || true, + }, + ], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const domain = query.get('domain') + if (!domain) return null + + const livestream = query.get('livestream') + const videochat = query.get('videochat') + const voicechat = query.get('voicechat') + + if (livestream === null && videochat === null && voicechat === null) return null + + const inviteHash = livestream || videochat || voicechat + + return { + username: domain, + inviteHash: inviteHash || undefined, + isLivestream: livestream !== null, + } + }, + externalParse: (path, query) => { + if (path.length <= 1 || path.includes('/') || path[0] === '+') return null + + const livestream = query.get('livestream') + const videochat = query.get('videochat') + const voicechat = query.get('voicechat') + + if (livestream === null && videochat === null && voicechat === null) return null + + const inviteHash = livestream || videochat || voicechat + + return { + username: path, + inviteHash: inviteHash || undefined, + isLivestream: livestream !== null, + } + }, +}) diff --git a/packages/core/src/utils/links/chat.ts b/packages/core/src/utils/links/chat.ts new file mode 100644 index 00000000..8f9920c1 --- /dev/null +++ b/packages/core/src/utils/links/chat.ts @@ -0,0 +1,203 @@ +/* eslint-disable indent,func-call-spacing */ +import { deeplinkBuilder } from './common.js' + +/** + * Chat invite links + * + * Used to invite users to private groups and channels + */ +export const chatInvite = deeplinkBuilder<{ hash: string }>({ + internalBuild: ({ hash }) => ['join', { invite: hash }], + internalParse: (path, query) => { + if (path !== 'join') return null + + const invite = query.get('invite') + if (!invite) return null + + return { hash: invite } + }, + externalBuild: ({ hash }) => [`+${hash}`, null], + externalParse: (path) => { + const m = path.match(/^(?:\+|joinchat\/)([a-zA-Z0-9_-]+)$/) + if (!m) return null + + if (m[1].match(/^[0-9]+$/)) { + // phone number + return null + } + + return { hash: m[1] } + }, +}) + +/** + * Chat folder links + */ +export const chatFolder = deeplinkBuilder<{ slug: string }>({ + internalBuild: ({ slug }) => ['addlist', { slug }], + internalParse: (path, query) => { + if (path !== 'addlist') return null + + const slug = query.get('slug') + if (!slug) return null + + return { slug } + }, + externalBuild: ({ slug }) => [`addlist/${slug}`, null], + externalParse: (path) => { + const [prefix, slug] = path.split('/') + if (prefix !== 'addlist') return null + + return { slug } + }, +}) + +function parseMediaTimestamp(timestamp: string) { + let m + + if ((m = timestamp.match(/^(\d+)$/))) { + return Number(m[1]) + } + + if ((m = timestamp.match(/^(\d+):(\d{1,2})$/))) { + return Number(m[1]) * 60 + Number(m[2]) + } + + if ((m = timestamp.match(/^(?:(\d+)h)?(?:(\d{1,2})m)?(?:(\d{1,2})s)$/))) { + return (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0) + } + + return undefined +} + +/** + * Message links + * + * Used to link to specific messages in public or private groups and channels. + * + * Note: `channelId` is a non-marked channel ID + */ +export const message = deeplinkBuilder< + ({ username: string } | { channelId: number }) & { + /** Message ID */ + id: number + /** Thread ID */ + threadId?: number + + /** + * For comments, `id` will contain the message ID of the channel message that started + * the comment section and this field will contain the message ID of the comment in + * the discussion group. + */ + commentId?: number + + /** + * Timestamp at which to start playing the media file present + * in the body or in the webpage preview of the message + */ + mediaTimestamp?: number + + /** + * Whether this is a link to a specific message in the album or to the entire album + */ + single?: boolean + } +>({ + internalBuild: (params) => { + const common = { + post: params.id, + thread: params.threadId, + comment: params.commentId, + t: params.mediaTimestamp, + single: params.single ? '' : undefined, + } + + if ('username' in params) { + return ['resolve', { domain: params.username, ...common }] + } + + return ['privatepost', { channel: params.channelId, ...common }] + }, + internalParse: (path, query) => { + const common = { + id: Number(query.get('post')), + threadId: query.has('thread') ? Number(query.get('thread')) : undefined, + commentId: query.has('comment') ? Number(query.get('comment')) : undefined, + mediaTimestamp: query.has('t') ? parseMediaTimestamp(query.get('t')!) : undefined, + single: query.has('single'), + } + + if (path === 'resolve') { + const username = query.get('domain') + if (!username) return null + + return { username, ...common } + } + + if (path === 'privatepost') { + const channelId = Number(query.get('channel')) + if (!channelId) return null + + return { channelId, ...common } + } + + return null + }, + + externalBuild: (params) => { + const common = { + comment: params.commentId, + t: params.mediaTimestamp, + single: params.single ? '' : undefined, + } + + if ('username' in params) { + if (params.threadId) { + return [`${params.username}/${params.threadId}/${params.id}`, common] + } + + return [`${params.username}/${params.id}`, common] + } + + if (params.threadId) { + return [`c/${params.channelId}/${params.threadId}/${params.id}`, common] + } + + return [`c/${params.channelId}/${params.id}`, common] + }, + externalParse: (path, query) => { + const chunks = path.split('/') + + if (chunks.length < 2) return null + + const id = Number(chunks[chunks.length - 1]) + if (isNaN(id)) return null + + const common = { + id, + commentId: query.has('comment') ? Number(query.get('comment')) : undefined, + mediaTimestamp: query.has('t') ? parseMediaTimestamp(query.get('t')!) : undefined, + single: query.has('single'), + } + + if (chunks[0] === 'c') { + const channelId = Number(chunks[1]) + if (isNaN(channelId)) return null + + return { + channelId, + threadId: chunks[3] ? Number(chunks[2]) : undefined, + ...common, + } + } + + const username = chunks[0] + if (username[0] === '+') return null + + return { + username, + threadId: chunks[2] ? Number(chunks[1]) : undefined, + ...common, + } + }, +}) diff --git a/packages/core/src/utils/links/common.ts b/packages/core/src/utils/links/common.ts new file mode 100644 index 00000000..796d1dd0 --- /dev/null +++ b/packages/core/src/utils/links/common.ts @@ -0,0 +1,107 @@ +export interface CommonDeeplinkOptions { + /** + * Protocol to use for deeplink + * - `tg` - use `tg://` protocol links (default) + * - `http` - use `http://` protocol + * - `https` - use `https://` protocol + * - `none` - don't use any protocol + * + * @default 'https' + */ + protocol?: 'tg' | 'http' | 'https' | 'none' + + /** + * For protocols except `tg://`, domain to use + * + * @default 't.me' + */ + domain?: string +} + +type InputQuery = Record | null + +interface BuildDeeplinkOptions { + internalBuild?: (options: T) => [string, InputQuery] + internalParse?: (path: string, query: URLSearchParams) => T | null + + externalBuild?: (options: T) => [string, InputQuery] + externalParse?: (path: string, query: URLSearchParams) => T | null +} + +export type Deeplink = { + (options: T & CommonDeeplinkOptions): string + parse: (url: string) => T | null +} + +function writeQuery(query: InputQuery) { + if (!query) return '' + + let str = '' + + for (const [key, value] of Object.entries(query)) { + // eslint-disable-next-line eqeqeq + if (value == undefined) continue + + if (str) str += '&' + + if (value === true) { + str += `${key}` + continue + } + + str += `${key}=${encodeURIComponent(value)}` + } + + if (!str) return '' + + return `?${str}` +} + +export function deeplinkBuilder(params: BuildDeeplinkOptions): Deeplink { + const { internalBuild, internalParse, externalBuild, externalParse } = params + + const fn_ = (options: T & CommonDeeplinkOptions) => { + const { protocol = 'https', domain = 't.me', ...rest } = options + + if (protocol === 'tg') { + if (!internalBuild) throw new Error('tg:// deeplink is not supported') + + const [path, query] = internalBuild(rest as T) + + return `tg://${path}${writeQuery(query)}` + } + + if (!externalBuild) throw new Error('t.me deeplink is not supported') + + const [path, query] = externalBuild(rest as T) + + return `${protocol}://${domain}/${path}${writeQuery(query)}` + } + + const fn = fn_ as Deeplink + + fn.parse = (url: string) => { + const isInternal = url.startsWith('tg://') + + // URL with non-standard protocols has bad behavior across environments + if (isInternal) url = `https://fake/${url.slice(5)}` + + const parsed = new URL(url) + + if (isInternal) { + if (!internalParse) throw new Error('tg:// deeplink is not supported') + + const path = parsed.pathname.slice(1) + + return internalParse(path, parsed.searchParams) + } + + if (!externalParse) throw new Error('t.me deeplink is not supported') + + const path = parsed.pathname.slice(1) + + return externalParse(path, parsed.searchParams) + } + + return fn +} diff --git a/packages/core/src/utils/links/index.ts b/packages/core/src/utils/links/index.ts new file mode 100644 index 00000000..a78c5c5b --- /dev/null +++ b/packages/core/src/utils/links/index.ts @@ -0,0 +1,2 @@ +import * as links from './bundle.js' +export { links } diff --git a/packages/core/src/utils/links/misc.ts b/packages/core/src/utils/links/misc.ts new file mode 100644 index 00000000..bbaeca1a --- /dev/null +++ b/packages/core/src/utils/links/misc.ts @@ -0,0 +1,84 @@ +import { deeplinkBuilder } from './common.js' + +/** + * Share links + * + * Used to share a prepared message and URL into a chosen chat's text field. + */ +export const share = deeplinkBuilder<{ url: string; text?: string }>({ + internalBuild: ({ url, text }) => ['msg_url', { url, text }], + internalParse: (path, query) => { + if (path !== 'msg_url') return null + + const url = query.get('url') + if (!url) return null + + const text = query.get('text') + + return { url, text: text || undefined } + }, + externalBuild: ({ url, text }) => ['share', { url, text }], + externalParse: (path, query) => { + if (path !== 'share') return null + + const url = query.get('url') + if (!url) return null + + const text = query.get('text') + + return { url, text: text || undefined } + }, +}) + +/** + * Boost links + * + * Used by users to boost channels, granting them the ability to post stories. + */ +export const boost = deeplinkBuilder<{ username: string } | { channelId: number }>({ + internalBuild: (params) => { + if ('username' in params) { + return ['boost', { domain: params.username }] + } + + return ['boost', { channel: params.channelId }] + }, + internalParse: (path, query) => { + if (path !== 'boost') return null + + const username = query.get('domain') + + if (username) { + return { username } + } + + const channelId = Number(query.get('channel')) + + if (!isNaN(channelId)) { + return { channelId: Number(channelId) } + } + + return null + }, + externalBuild: (params) => { + if ('username' in params) { + return [params.username, { boost: true }] + } + + return [`c/${params.channelId}`, { boost: true }] + }, + externalParse: (path, query) => { + if (!query.has('boost')) return null + + if (path.startsWith('c/')) { + const channelId = Number(path.slice(2)) + if (isNaN(channelId)) return null + + return { channelId } + } + + if (path.includes('/')) return null + + return { username: path } + }, +}) diff --git a/packages/core/src/utils/links/proxy.ts b/packages/core/src/utils/links/proxy.ts new file mode 100644 index 00000000..8bef20dd --- /dev/null +++ b/packages/core/src/utils/links/proxy.ts @@ -0,0 +1,72 @@ +import { deeplinkBuilder } from './common.js' + +/** + * MTProxy links + */ +export const mtproxy = deeplinkBuilder<{ + server: string + port: number + secret: string +}>({ + internalBuild: (params) => ['proxy', params], + externalBuild: (params) => ['proxy', params], + internalParse: (path, query) => { + if (path !== 'proxy') return null + + const server = query.get('server') + const port = Number(query.get('port')) + const secret = query.get('secret') + + if (!server || isNaN(port) || !secret) return null + + return { server, port, secret } + }, + externalParse: (path, query) => { + if (path !== 'proxy') return null + + const server = query.get('server') + const port = Number(query.get('port')) + const secret = query.get('secret') + + if (!server || isNaN(port) || !secret) return null + + return { server, port, secret } + }, +}) + +/** + * Socks5 proxy links + */ +export const socks5 = deeplinkBuilder<{ + server: string + port: number + user?: string + pass?: string +}>({ + internalBuild: (params) => ['socks', params], + externalBuild: (params) => ['socks', params], + internalParse: (path, query) => { + if (path !== 'socks') return null + + const server = query.get('server') + const port = Number(query.get('port')) + const user = query.get('user') + const pass = query.get('pass') + + if (!server || isNaN(port)) return null + + return { server, port, user: user || undefined, pass: pass || undefined } + }, + externalParse: (path, query) => { + if (path !== 'socks') return null + + const server = query.get('server') + const port = Number(query.get('port')) + const user = query.get('user') + const pass = query.get('pass') + + if (!server || isNaN(port)) return null + + return { server, port, user: user || undefined, pass: pass || undefined } + }, +}) diff --git a/packages/core/src/utils/links/stickers.ts b/packages/core/src/utils/links/stickers.ts new file mode 100644 index 00000000..a29edc3a --- /dev/null +++ b/packages/core/src/utils/links/stickers.ts @@ -0,0 +1,28 @@ +import { deeplinkBuilder } from './common.js' + +/** + * Sticker set links + * + * Used to import stickersets or custom emoji stickersets + */ +export const stickerset = deeplinkBuilder<{ + slug: string + emoji?: boolean +}>({ + internalBuild: ({ slug, emoji }) => [emoji ? 'addemoji' : 'addstickers', { set: slug }], + internalParse: (path, query) => { + if (path !== 'addstickers' && path !== 'addemoji') return null + + const slug = query.get('set') + if (!slug) return null + + return { slug, emoji: path === 'addemoji' } + }, + externalBuild: ({ slug, emoji }) => [`${emoji ? 'addemoji' : 'addstickers'}/${slug}`, null], + externalParse: (path) => { + const [prefix, slug] = path.split('/') + if (prefix !== 'addstickers' && prefix !== 'addemoji') return null + + return { slug, emoji: prefix === 'addemoji' } + }, +}) diff --git a/packages/core/src/utils/links/user.ts b/packages/core/src/utils/links/user.ts new file mode 100644 index 00000000..39363fa6 --- /dev/null +++ b/packages/core/src/utils/links/user.ts @@ -0,0 +1,80 @@ +import { deeplinkBuilder } from './common.js' + +/** + * Public username links + * + * Used to link to public users, groups and channels + */ +export const publicUsername = deeplinkBuilder<{ username: string }>({ + internalBuild: ({ username }) => ['resolve', { domain: username }], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const domain = query.get('domain') + if (!domain) return null + + // might be some more precise deeplink + if ([...query.keys()].length > 1) return null + + return { username: domain } + }, + externalBuild: ({ username }) => [username, null], + externalParse: (path, query) => { + if (path.length <= 1 || path.includes('/') || path[0] === '+') return null + + if ([...query.keys()].length > 0) return null + + return { username: path } + }, +}) + +/** + * Temporary profile links + * + * Used to link to user profiles, generated using contacts.exportContactToken. + * These links can be generated even for profiles that don't have a username, + * and they have an expiration date, specified by the expires field of the exportedContactToken + * constructor returned by contacts.exportContactToken. + */ +export const temporaryProfile = deeplinkBuilder<{ token: string }>({ + internalBuild: ({ token }) => ['contact', { token }], + internalParse: (path, query) => { + if (path !== 'contact') return null + + const token = query.get('token') + if (!token) return null + + return { token } + }, + externalBuild: ({ token }) => [`contact/${token}`, null], + externalParse: (path) => { + const [prefix, token] = path.split('/') + if (prefix !== 'contact') return null + + return { token } + }, +}) + +/** + * Phone number links + * + * Used to link to public and private users by their phone number. + */ +export const phoneNumber = deeplinkBuilder<{ phone: string }>({ + internalBuild: ({ phone }) => ['resolve', { phone }], + internalParse: (path, query) => { + if (path !== 'resolve') return null + + const phone = query.get('phone') + if (!phone) return null + + return { phone } + }, + externalBuild: ({ phone }) => [`+${phone}`, null], + externalParse: (path) => { + const m = path.match(/^\+(\d+)$/) + if (!m) return null + + return { phone: m[1] } + }, +}) diff --git a/packages/core/tests/links/bots-links.spec.ts b/packages/core/tests/links/bots-links.spec.ts new file mode 100644 index 00000000..16873998 --- /dev/null +++ b/packages/core/tests/links/bots-links.spec.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { links } from '../../src/utils/links/index.js' + +describe('Deep links', function () { + describe('Bot start links', () => { + it('should generate t.me/username?start=parameter links', () => { + expect(links.botStart({ username: 'username', parameter: 'parameter' })).eq( + 'https://t.me/username?start=parameter', + ) + }) + + it('should generate tg://resolve?domain=username&start=parameter links', () => { + expect(links.botStart({ username: 'username', parameter: 'parameter', protocol: 'tg' })).eq( + 'tg://resolve?domain=username&start=parameter', + ) + }) + + it('should parse t.me/username?start=parameter links', () => { + expect(links.botStart.parse('https://t.me/username?start=parameter')).eql({ + username: 'username', + parameter: 'parameter', + }) + }) + + it('should parse tg://resolve?domain=username&start=parameter links', () => { + expect(links.botStart.parse('tg://resolve?domain=username&start=parameter')).eql({ + username: 'username', + parameter: 'parameter', + }) + }) + }) + + describe('Bot add to group links', () => { + it('should generate t.me links', () => { + expect(links.botAddToGroup({ bot: 'bot_username' })).eq('https://t.me/bot_username?startgroup') + expect(links.botAddToGroup({ bot: 'bot_username', parameter: 'parameter' })).eq( + 'https://t.me/bot_username?startgroup=parameter', + ) + expect(links.botAddToGroup({ bot: 'bot_username', admin: ['postStories'] })).eq( + 'https://t.me/bot_username?startgroup&admin=post_stories', + ) + }) + + it('should generate tg://resolve links', () => { + expect(links.botAddToGroup({ bot: 'bot_username', protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&startgroup', + ) + expect(links.botAddToGroup({ bot: 'bot_username', parameter: 'parameter', protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&startgroup=parameter', + ) + expect(links.botAddToGroup({ bot: 'bot_username', admin: ['postStories'], protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&startgroup&admin=post_stories', + ) + }) + + it('should parse t.me links', () => { + expect(links.botAddToGroup.parse('https://t.me/bot_username?startgroup')).eql({ + bot: 'bot_username', + parameter: undefined, + admin: undefined, + }) + expect(links.botAddToGroup.parse('https://t.me/bot_username?startgroup=parameter')).eql({ + bot: 'bot_username', + parameter: 'parameter', + admin: undefined, + }) + expect(links.botAddToGroup.parse('https://t.me/bot_username?startgroup&admin=post_stories')).eql({ + bot: 'bot_username', + parameter: undefined, + admin: ['postStories'], + }) + }) + + it('should parse tg://resolve links', () => { + expect(links.botAddToGroup.parse('tg://resolve?domain=bot_username&startgroup')).eql({ + bot: 'bot_username', + parameter: undefined, + admin: undefined, + }) + expect(links.botAddToGroup.parse('tg://resolve?domain=bot_username&startgroup=parameter')).eql({ + bot: 'bot_username', + parameter: 'parameter', + admin: undefined, + }) + expect(links.botAddToGroup.parse('tg://resolve?domain=bot_username&startgroup&admin=post_stories')).eql({ + bot: 'bot_username', + parameter: undefined, + admin: ['postStories'], + }) + }) + }) + + describe('Bot add to channel links', () => { + it('should generate t.me links', () => { + expect(links.botAddToChannel({ bot: 'bot_username' })).eq('https://t.me/bot_username?startchannel') + expect(links.botAddToChannel({ bot: 'bot_username', admin: ['postStories'] })).eq( + 'https://t.me/bot_username?startchannel&admin=post_stories', + ) + }) + + it('should generate tg://resolve links', () => { + expect(links.botAddToChannel({ bot: 'bot_username', protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&startchannel', + ) + expect(links.botAddToChannel({ bot: 'bot_username', admin: ['postStories'], protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&startchannel&admin=post_stories', + ) + }) + + it('should parse t.me links', () => { + expect(links.botAddToChannel.parse('https://t.me/bot_username?startchannel')).eql({ + bot: 'bot_username', + admin: undefined, + }) + expect(links.botAddToChannel.parse('https://t.me/bot_username?startchannel&admin=post_stories')).eql({ + bot: 'bot_username', + admin: ['postStories'], + }) + }) + + it('should parse tg://resolve links', () => { + expect(links.botAddToChannel.parse('tg://resolve?domain=bot_username&startchannel')).eql({ + bot: 'bot_username', + admin: undefined, + }) + expect(links.botAddToChannel.parse('tg://resolve?domain=bot_username&startchannel&admin=post_stories')).eql( + { bot: 'bot_username', admin: ['postStories'] }, + ) + }) + }) + + describe('Game links', () => { + it('should generate t.me links', () => { + expect(links.botGame({ bot: 'bot_username', game: 'short_name' })).eq( + 'https://t.me/bot_username?game=short_name', + ) + }) + + it('should generate tg://resolve links', () => { + expect(links.botGame({ bot: 'bot_username', game: 'short_name', protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&game=short_name', + ) + }) + + it('should parse t.me links', () => { + expect(links.botGame.parse('https://t.me/bot_username?game=short_name')).eql({ + bot: 'bot_username', + game: 'short_name', + }) + }) + + it('should parse tg://resolve links', () => { + expect(links.botGame.parse('tg://resolve?domain=bot_username&game=short_name')).eql({ + bot: 'bot_username', + game: 'short_name', + }) + }) + }) + + describe('Named web apps', () => { + it('should generate t.me links', () => { + expect(links.botWebApp({ bot: 'bot_username', app: 'short_name' })).eq( + 'https://t.me/bot_username/short_name', + ) + expect(links.botWebApp({ bot: 'bot_username', app: 'short_name', parameter: 'parameter' })).eq( + 'https://t.me/bot_username/short_name?startapp=parameter', + ) + }) + + it('should generate tg://resolve links', () => { + expect(links.botWebApp({ bot: 'bot_username', app: 'short_name', protocol: 'tg' })).eq( + 'tg://resolve?domain=bot_username&appname=short_name', + ) + expect( + links.botWebApp({ bot: 'bot_username', app: 'short_name', parameter: 'parameter', protocol: 'tg' }), + ).eq('tg://resolve?domain=bot_username&appname=short_name&startapp=parameter') + }) + + it('should parse t.me links', () => { + expect(links.botWebApp.parse('https://t.me/bot_username/short_name')).eql({ + bot: 'bot_username', + app: 'short_name', + parameter: undefined, + }) + expect(links.botWebApp.parse('https://t.me/bot_username/short_name?startapp=parameter')).eql({ + bot: 'bot_username', + app: 'short_name', + parameter: 'parameter', + }) + }) + + it('should parse tg://resolve links', () => { + expect(links.botWebApp.parse('tg://resolve?domain=bot_username&appname=short_name')).eql({ + bot: 'bot_username', + app: 'short_name', + parameter: undefined, + }) + expect(links.botWebApp.parse('tg://resolve?domain=bot_username&appname=short_name&startapp=parameter')).eql( + { bot: 'bot_username', app: 'short_name', parameter: 'parameter' }, + ) + }) + }) +}) diff --git a/packages/core/tests/links/chat-links.spec.ts b/packages/core/tests/links/chat-links.spec.ts new file mode 100644 index 00000000..91303ed5 --- /dev/null +++ b/packages/core/tests/links/chat-links.spec.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { links } from '../../src/utils/links/index.js' + +describe('Deep links', function () { + describe('Chat invite links', () => { + it('should generate t.me/+hash links', () => { + expect(links.chatInvite({ hash: 'hash' })).eq('https://t.me/+hash') + }) + + it('should generate tg://join?invite=hash links', () => { + expect(links.chatInvite({ hash: 'hash', protocol: 'tg' })).eq('tg://join?invite=hash') + }) + + it('should parse t.me/joinchat/hash links', () => { + expect(links.chatInvite.parse('https://t.me/joinchat/hash')).eql({ hash: 'hash' }) + }) + + it('should parse t.me/+hash links', () => { + expect(links.chatInvite.parse('https://t.me/+hash')).eql({ hash: 'hash' }) + }) + + it('should parse tg://join?invite=hash links', () => { + expect(links.chatInvite.parse('tg://join?invite=hash')).eql({ hash: 'hash' }) + }) + }) + + describe('Chat folder links', () => { + it('should generate t.me/addlist/slug links', () => { + expect(links.chatFolder({ slug: 'slug' })).eq('https://t.me/addlist/slug') + }) + + it('should generate tg://addlist?slug=slug links', () => { + expect(links.chatFolder({ slug: 'slug', protocol: 'tg' })).eq('tg://addlist?slug=slug') + }) + + it('should parse t.me/addlist/slug links', () => { + expect(links.chatFolder.parse('https://t.me/addlist/slug')).eql({ slug: 'slug' }) + }) + + it('should parse tg://addlist?slug=slug links', () => { + expect(links.chatFolder.parse('tg://addlist?slug=slug')).eql({ slug: 'slug' }) + }) + }) + + describe('Message links', () => { + const result = (it: object) => { + return { + threadId: undefined, + commentId: undefined, + mediaTimestamp: undefined, + single: false, + ...it, + } + } + + it('should generate t.me/username/id links', () => { + expect(links.message({ username: 'username', id: 123 })).eq('https://t.me/username/123') + expect(links.message({ username: 'username', threadId: 123, id: 456 })).eq('https://t.me/username/123/456') + }) + + it('should generate tg:// links', () => { + expect(links.message({ username: 'username', id: 123, protocol: 'tg' })).eq( + 'tg://resolve?domain=username&post=123', + ) + expect(links.message({ username: 'username', threadId: 123, id: 456, protocol: 'tg' })).eq( + 'tg://resolve?domain=username&post=456&thread=123', + ) + }) + + it('should generate t.me/c/channel/id links', () => { + expect(links.message({ channelId: 321, id: 123 })).eq('https://t.me/c/321/123') + expect(links.message({ channelId: 321, threadId: 123, id: 456 })).eq('https://t.me/c/321/123/456') + }) + + it('should generate tg://privatepost links', () => { + expect(links.message({ channelId: 321, id: 123, protocol: 'tg' })).eq( + 'tg://privatepost?channel=321&post=123', + ) + expect(links.message({ channelId: 321, threadId: 123, id: 456, protocol: 'tg' })).eq( + 'tg://privatepost?channel=321&post=456&thread=123', + ) + }) + + it('should parse t.me/username/id links', () => { + expect(links.message.parse('https://t.me/username/123')).eql(result({ username: 'username', id: 123 })) + }) + + it('should parse t.me/username/thread/id links', () => { + expect(links.message.parse('https://t.me/username/123/456')).eql( + result({ username: 'username', threadId: 123, id: 456 }), + ) + }) + + it('should parse tg://resolve links', () => { + expect(links.message.parse('tg://resolve?domain=username&post=123')).eql( + result({ username: 'username', id: 123 }), + ) + }) + + it('should parse t.me/c/channel/id links', () => { + expect(links.message.parse('https://t.me/c/666/123')).eql(result({ channelId: 666, id: 123 })) + }) + + it('should parse t.me/c/channel/thread/id links', () => { + expect(links.message.parse('https://t.me/c/666/123/456')).eql( + result({ channelId: 666, threadId: 123, id: 456 }), + ) + }) + + it('should parse tg://privatepost links', () => { + expect(links.message.parse('tg://privatepost?channel=666&post=123')).eql( + result({ channelId: 666, id: 123 }), + ) + }) + }) +}) diff --git a/packages/core/tests/links/misc-links.spec.ts b/packages/core/tests/links/misc-links.spec.ts new file mode 100644 index 00000000..e762021d --- /dev/null +++ b/packages/core/tests/links/misc-links.spec.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { links } from '../../src/utils/links/index.js' + +describe('Deep links', function () { + describe('Video chat links', () => { + it('should generate t.me/username?videochat links', () => { + expect(links.videoChat({ username: 'username' })).eq('https://t.me/username?videochat') + expect(links.videoChat({ username: 'username', inviteHash: 'invite_hash' })).eq( + 'https://t.me/username?videochat=invite_hash', + ) + }) + + it('should generate t.me/username?livestream links', () => { + expect(links.videoChat({ username: 'username', isLivestream: true })).eq('https://t.me/username?livestream') + expect(links.videoChat({ username: 'username', inviteHash: 'invite_hash', isLivestream: true })).eq( + 'https://t.me/username?livestream=invite_hash', + ) + }) + + it('should generate tg://resolve?domain=username&videochat links', () => { + expect(links.videoChat({ username: 'username', protocol: 'tg' })).eq( + 'tg://resolve?domain=username&videochat', + ) + expect(links.videoChat({ username: 'username', inviteHash: 'invite_hash', protocol: 'tg' })).eq( + 'tg://resolve?domain=username&videochat=invite_hash', + ) + }) + + it('should generate tg://resolve?domain=username&livestream links', () => { + expect(links.videoChat({ username: 'username', isLivestream: true, protocol: 'tg' })).eq( + 'tg://resolve?domain=username&livestream', + ) + expect( + links.videoChat({ + username: 'username', + inviteHash: 'invite_hash', + isLivestream: true, + protocol: 'tg', + }), + ).eq('tg://resolve?domain=username&livestream=invite_hash') + }) + + it('should parse t.me/username?videochat links', () => { + expect(links.videoChat.parse('https://t.me/username?videochat')).eql({ + username: 'username', + inviteHash: undefined, + isLivestream: false, + }) + expect(links.videoChat.parse('https://t.me/username?videochat=invite_hash')).eql({ + username: 'username', + inviteHash: 'invite_hash', + isLivestream: false, + }) + }) + + it('should parse t.me/username?livestream links', () => { + expect(links.videoChat.parse('https://t.me/username?livestream')).eql({ + username: 'username', + inviteHash: undefined, + isLivestream: true, + }) + expect(links.videoChat.parse('https://t.me/username?livestream=invite_hash')).eql({ + username: 'username', + inviteHash: 'invite_hash', + isLivestream: true, + }) + }) + + it('should parse tg://resolve?domain=username&videochat links', () => { + expect(links.videoChat.parse('tg://resolve?domain=username&videochat')).eql({ + username: 'username', + inviteHash: undefined, + isLivestream: false, + }) + expect(links.videoChat.parse('tg://resolve?domain=username&videochat=invite_hash')).eql({ + username: 'username', + inviteHash: 'invite_hash', + isLivestream: false, + }) + }) + + it('should parse tg://resolve?domain=username&livestream links', () => { + expect(links.videoChat.parse('tg://resolve?domain=username&livestream')).eql({ + username: 'username', + inviteHash: undefined, + isLivestream: true, + }) + expect(links.videoChat.parse('tg://resolve?domain=username&livestream=invite_hash')).eql({ + username: 'username', + inviteHash: 'invite_hash', + isLivestream: true, + }) + }) + + it('should parse tg://resolve?domain=username&voicechat links', () => { + expect(links.videoChat.parse('tg://resolve?domain=username&voicechat')).eql({ + username: 'username', + inviteHash: undefined, + isLivestream: false, + }) + expect(links.videoChat.parse('tg://resolve?domain=username&voicechat=invite_hash')).eql({ + username: 'username', + inviteHash: 'invite_hash', + isLivestream: false, + }) + }) + }) + + describe('Share links', () => { + it('should generate t.me/share?url=link links', () => { + expect(links.share({ url: 'link' })).eq('https://t.me/share?url=link') + expect(links.share({ url: 'link', text: 'text' })).eq('https://t.me/share?url=link&text=text') + }) + + it('should generate tg://msg_url?url=link links', () => { + expect(links.share({ url: 'link', protocol: 'tg' })).eq('tg://msg_url?url=link') + expect(links.share({ url: 'link', text: 'text', protocol: 'tg' })).eq('tg://msg_url?url=link&text=text') + }) + + it('should parse t.me/share?url=link links', () => { + expect(links.share.parse('https://t.me/share?url=link')).eql({ url: 'link', text: undefined }) + expect(links.share.parse('https://t.me/share?url=link&text=text')).eql({ url: 'link', text: 'text' }) + }) + + it('should parse tg://msg_url?url=link links', () => { + expect(links.share.parse('tg://msg_url?url=link')).eql({ url: 'link', text: undefined }) + expect(links.share.parse('tg://msg_url?url=link&text=text')).eql({ url: 'link', text: 'text' }) + }) + }) + + describe('Stickerset links', () => { + it('should generate t.me/addstickers/ links', () => { + expect(links.stickerset({ slug: 'slug' })).eq('https://t.me/addstickers/slug') + expect(links.stickerset({ slug: 'slug', emoji: true })).eq('https://t.me/addemoji/slug') + }) + + it('should generate tg://addstickers?set= links', () => { + expect(links.stickerset({ slug: 'slug', protocol: 'tg' })).eq('tg://addstickers?set=slug') + expect(links.stickerset({ slug: 'slug', emoji: true, protocol: 'tg' })).eq('tg://addemoji?set=slug') + }) + + it('should parse t.me/addstickers/ links', () => { + expect(links.stickerset.parse('https://t.me/addstickers/slug')).eql({ slug: 'slug', emoji: false }) + expect(links.stickerset.parse('https://t.me/addemoji/slug')).eql({ slug: 'slug', emoji: true }) + }) + + it('should parse tg://addstickers?set= links', () => { + expect(links.stickerset.parse('tg://addstickers?set=slug')).eql({ slug: 'slug', emoji: false }) + expect(links.stickerset.parse('tg://addemoji?set=slug')).eql({ slug: 'slug', emoji: true }) + }) + }) + + describe('Boost links', () => { + it('should generate t.me/username?boost links', () => { + expect(links.boost({ username: 'username' })).eq('https://t.me/username?boost') + }) + + it('should generate t.me/c/id?boost links', () => { + expect(links.boost({ channelId: 123 })).eq('https://t.me/c/123?boost') + }) + + it('should generate tg://boost?domain=username links', () => { + expect(links.boost({ username: 'username', protocol: 'tg' })).eq('tg://boost?domain=username') + }) + + it('should generate tg://boost?channel=id links', () => { + expect(links.boost({ channelId: 123, protocol: 'tg' })).eq('tg://boost?channel=123') + }) + + it('should parse t.me/username?boost links', () => { + expect(links.boost.parse('https://t.me/username?boost')).eql({ username: 'username' }) + }) + + it('should parse t.me/c/id?boost links', () => { + expect(links.boost.parse('https://t.me/c/123?boost')).eql({ channelId: 123 }) + }) + + it('should parse tg://boost?domain=username links', () => { + expect(links.boost.parse('tg://boost?domain=username')).eql({ username: 'username' }) + }) + + it('should parse tg://boost?channel=id links', () => { + expect(links.boost.parse('tg://boost?channel=123')).eql({ channelId: 123 }) + }) + }) +}) diff --git a/packages/core/tests/links/proxy-links.spec.ts b/packages/core/tests/links/proxy-links.spec.ts new file mode 100644 index 00000000..73fb2db2 --- /dev/null +++ b/packages/core/tests/links/proxy-links.spec.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { links } from '../../src/utils/links/index.js' + +describe('Deep links', function () { + describe('MTProxy links', () => { + it('should generate t.me/proxy links', () => { + expect( + links.mtproxy({ + server: 'server', + port: 123, + secret: 'secret', + }), + ).eq('https://t.me/proxy?server=server&port=123&secret=secret') + }) + + it('should generate tg://proxy links', () => { + expect( + links.mtproxy({ + server: 'server', + port: 123, + secret: 'secret', + protocol: 'tg', + }), + ).eq('tg://proxy?server=server&port=123&secret=secret') + }) + + it('should parse t.me/proxy links', () => { + expect(links.mtproxy.parse('https://t.me/proxy?server=server&port=123&secret=secret')).eql({ + server: 'server', + port: 123, + secret: 'secret', + }) + }) + + it('should parse tg://proxy links', () => { + expect(links.mtproxy.parse('tg://proxy?server=server&port=123&secret=secret')).eql({ + server: 'server', + port: 123, + secret: 'secret', + }) + }) + }) + + describe('Socks5 links', () => { + it('should generate t.me/socks links', () => { + expect( + links.socks5({ + server: 'server', + port: 123, + user: 'user', + pass: 'pass', + }), + ).eq('https://t.me/socks?server=server&port=123&user=user&pass=pass') + }) + + it('should generate tg://socks links', () => { + expect( + links.socks5({ + server: 'server', + port: 123, + user: 'user', + pass: 'pass', + protocol: 'tg', + }), + ).eq('tg://socks?server=server&port=123&user=user&pass=pass') + }) + + it('should parse t.me/socks links', () => { + expect(links.socks5.parse('https://t.me/socks?server=server&port=123&user=user&pass=pass')).eql({ + server: 'server', + port: 123, + user: 'user', + pass: 'pass', + }) + }) + + it('should parse tg://socks links', () => { + expect(links.socks5.parse('tg://socks?server=server&port=123&user=user&pass=pass')).eql({ + server: 'server', + port: 123, + user: 'user', + pass: 'pass', + }) + }) + }) +}) diff --git a/packages/core/tests/links/user-links.spec.ts b/packages/core/tests/links/user-links.spec.ts new file mode 100644 index 00000000..12f72cd1 --- /dev/null +++ b/packages/core/tests/links/user-links.spec.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { links } from '../../src/utils/links/index.js' + +describe('Deep links', function () { + describe('Public username links', () => { + it('should generate t.me/username links', () => { + expect(links.publicUsername({ username: 'username' })).eq('https://t.me/username') + }) + + it('should generate tg://resolve?domain=username links', () => { + expect(links.publicUsername({ username: 'username', protocol: 'tg' })).eq('tg://resolve?domain=username') + }) + + it('should parse t.me/username links', () => { + expect(links.publicUsername.parse('https://t.me/username')).eql({ username: 'username' }) + }) + + it('should not parse t.me/+... links', () => { + expect(links.publicUsername.parse('https://t.me/+79991231234')).eql(null) + expect(links.publicUsername.parse('https://t.me/+lAj1jA01-2daJJ')).eql(null) + }) + + it('should not parse t.me/username/123 links', () => { + expect(links.publicUsername.parse('https://t.me/username/123')).eql(null) + }) + + it('should not parse t.me/username?whatever links', () => { + expect(links.publicUsername.parse('https://t.me/username?whatever')).eql(null) + }) + + it('should parse tg://resolve?domain=username links', () => { + expect(links.publicUsername.parse('tg://resolve?domain=username')).eql({ username: 'username' }) + }) + + it('should not parse tg://resolve?domain&whatever links', () => { + expect(links.publicUsername.parse('tg://resolve?domain=username&whatever')).eql(null) + }) + }) + + describe('Temporary profile links', () => { + it('should generate t.me/contact links', () => { + expect(links.temporaryProfile({ token: 'abc' })).eq('https://t.me/contact/abc') + }) + + it('should generate tg://contact?token links', () => { + expect(links.temporaryProfile({ token: 'abc', protocol: 'tg' })).eq('tg://contact?token=abc') + }) + + it('should parse t.me/contact links', () => { + expect(links.temporaryProfile.parse('https://t.me/contact/abc')).eql({ token: 'abc' }) + }) + + it('should parse tg://contact?token links', () => { + expect(links.temporaryProfile.parse('tg://contact?token=abc')).eql({ token: 'abc' }) + }) + }) + + describe('Phone number links', () => { + it('should generate t.me/+phone links', () => { + expect(links.phoneNumber({ phone: '79991231234' })).eq('https://t.me/+79991231234') + }) + + it('should generate tg://resolve?phone links', () => { + expect(links.phoneNumber({ phone: '79991231234', protocol: 'tg' })).eq('tg://resolve?phone=79991231234') + }) + + it('should parse t.me/+phone links', () => { + expect(links.phoneNumber.parse('https://t.me/+79991231234')).eql({ phone: '79991231234' }) + }) + + it('should parse tg://resolve?phone links', () => { + expect(links.phoneNumber.parse('tg://resolve?phone=79991231234')).eql({ phone: '79991231234' }) + }) + }) +})