diff --git a/packages/core/src/highlevel/client.ts b/packages/core/src/highlevel/client.ts index bd067425..a8f82a03 100644 --- a/packages/core/src/highlevel/client.ts +++ b/packages/core/src/highlevel/client.ts @@ -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 + /** + * Get a preview of a chatlist by its invite link + * + * **Available**: ✅ both users and bots + * + * @param link Invite link + */ + getChatlistPreview(link: string): Promise /** * Get list of folders. * **Available**: 👤 users only @@ -2329,6 +2340,22 @@ export interface TelegramClient extends ITelegramClient { */ filter?: Partial> }): AsyncIterableIterator + /** + * 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 + }, + ): Promise /** * 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) } diff --git a/packages/core/src/highlevel/methods.ts b/packages/core/src/highlevel/methods.ts index aa06c23e..8e9c52d3 100644 --- a/packages/core/src/highlevel/methods.ts +++ b/packages/core/src/highlevel/methods.ts @@ -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' diff --git a/packages/core/src/highlevel/methods/_imports.ts b/packages/core/src/highlevel/methods/_imports.ts index 76824dee..72c12c7e 100644 --- a/packages/core/src/highlevel/methods/_imports.ts +++ b/packages/core/src/highlevel/methods/_imports.ts @@ -36,6 +36,7 @@ import { ChatInviteLink, ChatInviteLinkMember, ChatJoinRequestUpdate, + ChatlistPreview, ChatMember, ChatMemberUpdate, ChatPreview, diff --git a/packages/core/src/highlevel/methods/dialogs/get-chatlist-preview.ts b/packages/core/src/highlevel/methods/dialogs/get-chatlist-preview.ts new file mode 100644 index 00000000..0af5d9f8 --- /dev/null +++ b/packages/core/src/highlevel/methods/dialogs/get-chatlist-preview.ts @@ -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 { + const res = await client.call({ + _: 'chatlists.checkChatlistInvite', + slug: link, + }) + + return new ChatlistPreview(res) +} diff --git a/packages/core/src/highlevel/methods/dialogs/iter-dialogs.ts b/packages/core/src/highlevel/methods/dialogs/iter-dialogs.ts index 5c8d38ca..12096356 100644 --- a/packages/core/src/highlevel/methods/dialogs/iter-dialogs.ts +++ b/packages/core/src/highlevel/methods/dialogs/iter-dialogs.ts @@ -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) => (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' } diff --git a/packages/core/src/highlevel/methods/dialogs/join-chatlist.ts b/packages/core/src/highlevel/methods/dialogs/join-chatlist.ts new file mode 100644 index 00000000..574dbbca --- /dev/null +++ b/packages/core/src/highlevel/methods/dialogs/join-chatlist.ts @@ -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 + }, +): Promise { + 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 +} diff --git a/packages/core/src/highlevel/types/peers/chat.ts b/packages/core/src/highlevel/types/peers/chat.ts index 82d4cc21..4248b766 100644 --- a/packages/core/src/highlevel/types/peers/chat.ts +++ b/packages/core/src/highlevel/types/peers/chat.ts @@ -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. * diff --git a/packages/core/src/highlevel/types/peers/chatlist-preview.ts b/packages/core/src/highlevel/types/peers/chatlist-preview.ts new file mode 100644 index 00000000..3fadfbf9 --- /dev/null +++ b/packages/core/src/highlevel/types/peers/chatlist-preview.ts @@ -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) diff --git a/packages/core/src/highlevel/types/peers/index.ts b/packages/core/src/highlevel/types/peers/index.ts index cbf59756..ea28f486 100644 --- a/packages/core/src/highlevel/types/peers/index.ts +++ b/packages/core/src/highlevel/types/peers/index.ts @@ -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' diff --git a/packages/core/src/utils/links/misc.test.ts b/packages/core/src/utils/links/misc.test.ts index 7788c3b8..75f459ac 100644 --- a/packages/core/src/utils/links/misc.test.ts +++ b/packages/core/src/utils/links/misc.test.ts @@ -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' }) + }) + }) }) diff --git a/packages/core/src/utils/links/misc.ts b/packages/core/src/utils/links/misc.ts index bbaeca1a..8904cfe1 100644 --- a/packages/core/src/utils/links/misc.ts +++ b/packages/core/src/utils/links/misc.ts @@ -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 } + }, +})