feat(core): helpers for deeplinks
This commit is contained in:
parent
92dddb75f1
commit
0054491665
16 changed files with 1643 additions and 0 deletions
|
@ -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'
|
||||
|
|
309
packages/core/src/utils/links/bots.ts
Normal file
309
packages/core/src/utils/links/bots.ts
Normal 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 }
|
||||
},
|
||||
})
|
7
packages/core/src/utils/links/bundle.ts
Normal file
7
packages/core/src/utils/links/bundle.ts
Normal 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'
|
69
packages/core/src/utils/links/calls.ts
Normal file
69
packages/core/src/utils/links/calls.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
203
packages/core/src/utils/links/chat.ts
Normal file
203
packages/core/src/utils/links/chat.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
107
packages/core/src/utils/links/common.ts
Normal file
107
packages/core/src/utils/links/common.ts
Normal 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
|
||||
}
|
2
packages/core/src/utils/links/index.ts
Normal file
2
packages/core/src/utils/links/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import * as links from './bundle.js'
|
||||
export { links }
|
84
packages/core/src/utils/links/misc.ts
Normal file
84
packages/core/src/utils/links/misc.ts
Normal 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 }
|
||||
},
|
||||
})
|
72
packages/core/src/utils/links/proxy.ts
Normal file
72
packages/core/src/utils/links/proxy.ts
Normal 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 }
|
||||
},
|
||||
})
|
28
packages/core/src/utils/links/stickers.ts
Normal file
28
packages/core/src/utils/links/stickers.ts
Normal 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' }
|
||||
},
|
||||
})
|
80
packages/core/src/utils/links/user.ts
Normal file
80
packages/core/src/utils/links/user.ts
Normal 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] }
|
||||
},
|
||||
})
|
206
packages/core/tests/links/bots-links.spec.ts
Normal file
206
packages/core/tests/links/bots-links.spec.ts
Normal 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' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
119
packages/core/tests/links/chat-links.spec.ts
Normal file
119
packages/core/tests/links/chat-links.spec.ts
Normal 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 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
189
packages/core/tests/links/misc-links.spec.ts
Normal file
189
packages/core/tests/links/misc-links.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
89
packages/core/tests/links/proxy-links.spec.ts
Normal file
89
packages/core/tests/links/proxy-links.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
78
packages/core/tests/links/user-links.spec.ts
Normal file
78
packages/core/tests/links/user-links.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue