feat(core): initial chatlist support

This commit is contained in:
alina 🌸 2024-06-02 18:26:48 +03:00
parent ce11d535cf
commit 0b57a7be51
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
11 changed files with 277 additions and 5 deletions

View file

@ -91,9 +91,11 @@ import { deleteFolder } from './methods/dialogs/delete-folder.js'
import { editFolder } from './methods/dialogs/edit-folder.js'
import { findDialogs } from './methods/dialogs/find-dialogs.js'
import { findFolder } from './methods/dialogs/find-folder.js'
import { getChatlistPreview } from './methods/dialogs/get-chatlist-preview.js'
import { getFolders } from './methods/dialogs/get-folders.js'
import { getPeerDialogs } from './methods/dialogs/get-peer-dialogs.js'
import { iterDialogs } from './methods/dialogs/iter-dialogs.js'
import { joinChatlist } from './methods/dialogs/join-chatlist.js'
import { setFoldersOrder } from './methods/dialogs/set-folders-order.js'
import { downloadAsBuffer } from './methods/files/download-buffer.js'
import { downloadAsIterable } from './methods/files/download-iterable.js'
@ -270,6 +272,7 @@ import {
ChatInviteLink,
ChatInviteLinkMember,
ChatJoinRequestUpdate,
ChatlistPreview,
ChatMember,
ChatMemberUpdate,
ChatPreview,
@ -2205,6 +2208,14 @@ export interface TelegramClient extends ITelegramClient {
/** Folder ID */
id?: number
}): Promise<tl.RawDialogFilter | null>
/**
* Get a preview of a chatlist by its invite link
*
* **Available**: both users and bots
*
* @param link Invite link
*/
getChatlistPreview(link: string): Promise<ChatlistPreview>
/**
* Get list of folders.
* **Available**: 👤 users only
@ -2329,6 +2340,22 @@ export interface TelegramClient extends ITelegramClient {
*/
filter?: Partial<Omit<tl.RawDialogFilter, '_' | 'id' | 'title'>>
}): AsyncIterableIterator<Dialog>
/**
* Join a chatlist by its link
*
* **Available**: both users and bots
*
* @param link Invite link to the chatlist
* @param params Additional parameters
* @returns Folder representing the chatlist
*/
joinChatlist(
link: string,
params?: {
/** Chats to join from the chatlist (all by default) */
peers?: MaybeArray<InputPeerLike>
},
): Promise<tl.RawDialogFilterChatlist>
/**
* Reorder folders
*
@ -5796,6 +5823,9 @@ TelegramClient.prototype.findDialogs = function (...args) {
TelegramClient.prototype.findFolder = function (...args) {
return findFolder(this._client, ...args)
}
TelegramClient.prototype.getChatlistPreview = function (...args) {
return getChatlistPreview(this._client, ...args)
}
TelegramClient.prototype.getFolders = function (...args) {
return getFolders(this._client, ...args)
}
@ -5805,6 +5835,9 @@ TelegramClient.prototype.getPeerDialogs = function (...args) {
TelegramClient.prototype.iterDialogs = function (...args) {
return iterDialogs(this._client, ...args)
}
TelegramClient.prototype.joinChatlist = function (...args) {
return joinChatlist(this._client, ...args)
}
TelegramClient.prototype.setFoldersOrder = function (...args) {
return setFoldersOrder(this._client, ...args)
}

View file

@ -83,9 +83,11 @@ export { deleteFolder } from './methods/dialogs/delete-folder.js'
export { editFolder } from './methods/dialogs/edit-folder.js'
export { findDialogs } from './methods/dialogs/find-dialogs.js'
export { findFolder } from './methods/dialogs/find-folder.js'
export { getChatlistPreview } from './methods/dialogs/get-chatlist-preview.js'
export { getFolders } from './methods/dialogs/get-folders.js'
export { getPeerDialogs } from './methods/dialogs/get-peer-dialogs.js'
export { iterDialogs } from './methods/dialogs/iter-dialogs.js'
export { joinChatlist } from './methods/dialogs/join-chatlist.js'
export { setFoldersOrder } from './methods/dialogs/set-folders-order.js'
export { downloadAsBuffer } from './methods/files/download-buffer.js'
export { downloadAsIterable } from './methods/files/download-iterable.js'

View file

@ -36,6 +36,7 @@ import {
ChatInviteLink,
ChatInviteLinkMember,
ChatJoinRequestUpdate,
ChatlistPreview,
ChatMember,
ChatMemberUpdate,
ChatPreview,

View file

@ -0,0 +1,16 @@
import { ITelegramClient } from '../../client.types.js'
import { ChatlistPreview } from '../../types/index.js'
/**
* Get a preview of a chatlist by its invite link
*
* @param link Invite link
*/
export async function getChatlistPreview(client: ITelegramClient, link: string): Promise<ChatlistPreview> {
const res = await client.call({
_: 'chatlists.checkChatlistInvite',
slug: link,
})
return new ChatlistPreview(res)
}

View file

@ -2,7 +2,7 @@ import Long from 'long'
import { tl } from '@mtcute/tl'
import { MtUnsupportedError } from '../../../types/errors.js'
import { MtArgumentError } from '../../../types/errors.js'
import { ITelegramClient } from '../../client.types.js'
import { Dialog, InputDialogFolder } from '../../types/index.js'
import { normalizeDate } from '../../utils/misc-utils.js'
@ -156,12 +156,76 @@ export async function* iterDialogs(
localFilters_ = undefined
}
if (localFilters_?._ === 'dialogFilterChatlist') {
throw new MtUnsupportedError('Shared chat folders are not supported yet')
}
const localFilters = localFilters_
if (localFilters?._ === 'dialogFilterChatlist') {
if (offsetId !== 0 || offsetDate !== 0 || offsetPeer._ !== 'inputPeerEmpty') {
throw new MtArgumentError('Cannot use offset parameters with chatlist filters')
}
// we only need to fetch pinnedPeers and includePeers
// instead of fetching the entire dialog list, we can shortcut
// and just fetch the peer dialogs
let remaining = Math.min(limit, localFilters.includePeers.length + localFilters.pinnedPeers.length)
if (pinned === 'include' || pinned === 'only') {
// yield pinned dialogs
const peers: tl.TypeInputDialogPeer[] = []
for (const peer of localFilters.pinnedPeers) {
if (remaining <= 0) break
remaining--
peers.push({
_: 'inputDialogPeer',
peer,
})
}
const res = await client.call({
_: 'messages.getPeerDialogs',
peers,
})
res.dialogs.forEach((dialog: tl.Mutable<tl.TypeDialog>) => (dialog.pinned = true))
yield* Dialog.parseTlDialogs(res)
}
if (pinned === 'only' || remaining <= 0) {
return
}
// yield non-pinned dialogs
let offset = 0
while (remaining > 0) {
const peers: tl.TypeInputDialogPeer[] = []
for (let i = 0; i < chunkSize; i++) {
if (remaining <= 0) break
remaining--
peers.push({
_: 'inputDialogPeer',
peer: localFilters.includePeers[offset + i],
})
}
offset += chunkSize
const res = await client.call({
_: 'messages.getPeerDialogs',
peers,
})
yield* Dialog.parseTlDialogs(res)
}
return
}
if (localFilters) {
archived = localFilters.excludeArchived ? 'exclude' : 'keep'
}

View file

@ -0,0 +1,55 @@
import { tl } from '@mtcute/tl'
import { MtTypeAssertionError } from '../../../types/errors.js'
import { MaybeArray } from '../../../types/utils.js'
import { assertTypeIs, isPresent } from '../../../utils/type-assertions.js'
import { ITelegramClient } from '../../client.types.js'
import { InputPeerLike } from '../../types/index.js'
import { assertIsUpdatesGroup } from '../../updates/utils.js'
import { resolvePeerMany } from '../users/resolve-peer-many.js'
import { getChatlistPreview } from './get-chatlist-preview.js'
/**
* Join a chatlist by its link
*
* @param link Invite link to the chatlist
* @param params Additional parameters
* @returns Folder representing the chatlist
*/
export async function joinChatlist(
client: ITelegramClient,
link: string,
params?: {
/** Chats to join from the chatlist (all by default) */
peers?: MaybeArray<InputPeerLike>
},
): Promise<tl.RawDialogFilterChatlist> {
let peers: tl.TypeInputPeer[]
if (params?.peers) {
const inputs = Array.isArray(params.peers) ? params.peers : [params.peers]
const all = await resolvePeerMany(client, inputs)
peers = all.filter(isPresent)
} else {
const preview = await getChatlistPreview(client, link)
peers = preview.chats.filter((it) => !it.isUnavailable).map((it) => it.inputPeer)
}
const res = await client.call({
_: 'chatlists.joinChatlistInvite',
slug: link,
peers,
})
assertIsUpdatesGroup('joinChatlist', res)
const filter = res.updates.find((it) => it._ === 'updateDialogFilter') as tl.RawUpdateDialogFilter
if (!filter?.filter) {
throw new MtTypeAssertionError('joinChatlist', 'updateDialogFilter', 'nothing')
}
assertTypeIs('joinChatlist', filter.filter, 'dialogFilterChatlist')
return filter.filter
}

View file

@ -237,6 +237,11 @@ export class Chat {
return this.peer._ === 'channel' && this.peer.forum!
}
/** Whether the chat is not available (e.g. because the user was banned from there) */
get isUnavailable(): boolean {
return this.peer._ === 'chatForbidden' || this.peer._ === 'channelForbidden'
}
/**
* Whether the current user is a member of the chat.
*

View file

@ -0,0 +1,52 @@
import { tl } from '@mtcute/tl'
import { makeInspectable } from '../../utils/inspectable.js'
import { memoizeGetters } from '../../utils/memoize.js'
import { Chat } from './chat.js'
import { PeersIndex } from './peers-index.js'
/**
* Information about a chatlist
*/
export class ChatlistPreview {
readonly peers: PeersIndex
constructor(readonly raw: tl.chatlists.TypeChatlistInvite) {
this.peers = PeersIndex.from(raw)
}
/** Whether the current user has already joined this chatlist */
get isJoined(): boolean {
return this.raw._ === 'chatlists.chatlistInviteAlready'
}
/** If we joined the chatlist, ID of the folder representing it */
get folderId(): number | null {
return this.raw._ === 'chatlists.chatlistInviteAlready' ? this.raw.filterId : null
}
/** Title of the chatlist (only available for non-joined chatlists) */
get title(): string {
return this.raw._ === 'chatlists.chatlistInvite' ? this.raw.title : ''
}
/** Emoji representing an icon of the chatlist (may only be available for non-joined chatlists) */
get emoji(): string | null {
return this.raw._ === 'chatlists.chatlistInvite' ? this.raw.emoticon ?? null : null
}
/** List of all chats contained in the chatlist */
get chats(): Chat[] {
let peers
if (this.raw._ === 'chatlists.chatlistInvite') {
peers = this.raw.peers
} else {
peers = [...this.raw.alreadyPeers, ...this.raw.missingPeers]
}
return peers.map((peer) => Chat._parseFromPeer(peer, this.peers))
}
}
memoizeGetters(ChatlistPreview, ['chats'])
makeInspectable(ChatlistPreview)

View file

@ -7,6 +7,7 @@ export * from './chat-member.js'
export * from './chat-permissions.js'
export * from './chat-photo.js'
export * from './chat-preview.js'
export * from './chatlist-preview.js'
export * from './forum-topic.js'
export * from './full-chat.js'
export * from './peer.js'

View file

@ -185,4 +185,22 @@ describe('Deep links', function () {
expect(links.boost.parse('tg://boost?channel=123')).eql({ channelId: 123 })
})
})
describe('Shared folder links', () => {
it('should generate tg://addlist?slug=XXX links', () => {
expect(links.folder({ slug: 'XXX', protocol: 'tg' })).eq('tg://addlist?slug=XXX')
})
it('should generate https://t.me/addlist/XXX links', () => {
expect(links.folder({ slug: 'XXX' })).eq('https://t.me/addlist/XXX')
})
it('should parse tg://addlist?slug=XXX links', () => {
expect(links.folder.parse('tg://addlist?slug=XXX')).eql({ slug: 'XXX' })
})
it('should parse https://t.me/addlist/XXX links', () => {
expect(links.folder.parse('https://t.me/addlist/XXX')).eql({ slug: 'XXX' })
})
})
})

View file

@ -82,3 +82,28 @@ export const boost = deeplinkBuilder<{ username: string } | { channelId: number
return { username: path }
},
})
/**
* Link to a shared folder (chat list)
*/
export const folder = deeplinkBuilder<{ slug: string }>({
// tg://addlist?slug=XXX
internalBuild: ({ slug }) => ['addlist', { slug }],
internalParse: (path, query) => {
if (path !== 'addlist') return null
const slug = query.get('slug')
if (!slug) return null
return { slug }
},
// https://t.me/addlist/XXX
externalBuild: ({ slug }) => [`addlist/${slug}`, null],
externalParse: (path) => {
const [prefix, slug] = path.split('/')
if (prefix !== 'addlist') return null
return { slug }
},
})