feat(core): helpers for deeplinks

This commit is contained in:
alina 🌸 2023-10-22 23:42:10 +03:00
parent 92dddb75f1
commit 0054491665
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
16 changed files with 1643 additions and 0 deletions

View file

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

View file

@ -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<keyof tl.RawChatAdminRights, '_'>
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 }
},
})

View file

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

View file

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

View file

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

View file

@ -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<string, string | number | true | undefined> | null
interface BuildDeeplinkOptions<T> {
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<T> = {
(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<T>(params: BuildDeeplinkOptions<T>): Deeplink<T> {
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<T>
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
}

View file

@ -0,0 +1,2 @@
import * as links from './bundle.js'
export { links }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<slug> 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=<slug> 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/<slug> 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=<slug> 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 })
})
})
})

View file

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

View file

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