diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 06495571..6272d592 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -100,6 +100,17 @@ import { _normalizeInputFile } from './methods/files/normalize-input-file' import { _normalizeInputMedia } from './methods/files/normalize-input-media' import { uploadFile } from './methods/files/upload-file' import { uploadMedia } from './methods/files/upload-media' +import { createForumTopic } from './methods/forums/create-forum-topic' +import { deleteForumTopicHistory } from './methods/forums/delete-forum-topic-history' +import { editForumTopic } from './methods/forums/edit-forum-topic' +import { getForumTopics, GetForumTopicsOffset } from './methods/forums/get-forum-topics' +import { getForumTopicsById } from './methods/forums/get-forum-topics-by-id' +import { iterForumTopics } from './methods/forums/iter-forum-topics' +import { reorderPinnedForumTopics } from './methods/forums/reorder-pinned-forum-topics' +import { toggleForum } from './methods/forums/toggle-forum' +import { toggleForumTopicClosed } from './methods/forums/toggle-forum-topic-closed' +import { toggleForumTopicPinned } from './methods/forums/toggle-forum-topic-pinned' +import { toggleGeneralTopicHidden } from './methods/forums/toggle-general-topic-hidden' import { createInviteLink } from './methods/invite-links/create-invite-link' import { editInviteLink } from './methods/invite-links/edit-invite-link' import { exportInviteLink } from './methods/invite-links/export-invite-link' @@ -221,6 +232,7 @@ import { Dialog, FileDownloadParameters, FormattedString, + ForumTopic, GameHighScore, HistoryReadUpdate, IMessageEntityParser, @@ -1045,11 +1057,19 @@ export interface TelegramClient extends BaseTelegramClient { /** * Create a new broadcast channel * - * @param title Channel title - * @param description (default: `''`) Channel description * @returns Newly created channel */ - createChannel(title: string, description?: string): Promise + createChannel(params: { + /** + * Channel title + */ + title: string + + /** + * Channel description + */ + description?: string + }): Promise /** * Create a legacy group chat * @@ -1065,10 +1085,31 @@ export interface TelegramClient extends BaseTelegramClient { /** * Create a new supergroup * - * @param title Title of the supergroup - * @param description (default: `''`) Description of the supergroup + * @returns Newly created supergroup */ - createSupergroup(title: string, description?: string): Promise + createSupergroup(params: { + /** + * Supergroup title + */ + title: string + + /** + * Supergroup description + */ + description?: string + + /** + * Whether to create a forum + */ + forum?: boolean + + /** + * TTL period (in seconds) for the newly created channel + * + * @default 0 (i.e. messages don't expire) + */ + ttlPeriod?: number + }): Promise /** * Delete a channel or a supergroup @@ -1916,6 +1957,193 @@ export interface TelegramClient extends BaseTelegramClient { progressCallback?: (uploaded: number, total: number) => void }, ): Promise> + /** + * Create a topic in a forum + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @returns Service message for the created topic + */ + createForumTopic( + chatId: InputPeerLike, + params: { + /** + * Topic title + */ + title: string + + /** + * Icon of the topic. + * + * Can be a number (color in RGB, see {@link ForumTopic} static members for allowed values) + * or a custom emoji ID. + * + * Icon color can't be changed after the topic is created. + */ + icon?: number | tl.Long + + /** + * Send as a specific channel + */ + sendAs?: InputPeerLike + }, + ): Promise + /** + * Delete a forum topic and all its history + * + * @param chat Chat or user ID, username, phone number, `"me"` or `"self"` + * @param topicId ID of the topic (i.e. its top message ID) + */ + deleteForumTopicHistory(chat: InputPeerLike, topicId: number): Promise + /** + * Modify a topic in a forum + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @returns Service message about the modification + */ + editForumTopic( + chatId: InputPeerLike, + topicId: number, + params: { + /** + * New topic title + */ + title?: string + + /** + * New icon of the topic. + * + * Can be a custom emoji ID, or `null` to remove the icon + * and use static color instead + */ + icon?: tl.Long | null + }, + ): Promise + /** + * Get a single forum topic by its ID + * + * @param chatId Chat ID or username + */ + getForumTopicsById(chatId: InputPeerLike, ids: number): Promise + /** + * Get forum topics by their IDs + * + * @param chatId Chat ID or username + */ + getForumTopicsById(chatId: InputPeerLike, ids: number[]): Promise + /** + * Get forum topics + * + * @param chatId Chat ID or username + */ + getForumTopics( + chatId: InputPeerLike, + params?: { + /** + * Search query + */ + query?: string + + /** + * Offset for pagination + */ + offset?: GetForumTopicsOffset + + /** + * Maximum number of topics to return. + * + * @default 100 + */ + limit?: number + }, + ): Promise> + /** + * Iterate over forum topics. Wrapper over {@link getForumTopics}. + * + * @param chatId Chat ID or username + */ + iterForumTopics( + chatId: InputPeerLike, + params?: Parameters[1] & { + /** + * Maximum number of topics to return. + * + * @default `Infinity`, i.e. return all topics + */ + limit?: number + + /** + * Chunk size. Usually you shouldn't care about this. + */ + chunkSize?: number + }, + ): AsyncIterableIterator + /** + * Reorder pinned forum topics + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + */ + reorderPinnedForumTopics( + chatId: InputPeerLike, + params: { + /** + * Order of the pinned topics + */ + order: number[] + + /** + * Whether to un-pin topics not present in the order + */ + force?: boolean + }, + ): Promise + /** + * Toggle open/close status of a topic in a forum + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @param closed Whether the topic should be closed + * @returns Service message about the modification + */ + toggleForumTopicClosed(chatId: InputPeerLike, topicId: number, closed: boolean): Promise + /** + * Toggle whether a topic in a forum is pinned + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @param pinned Whether the topic should be pinned + */ + toggleForumTopicPinned(chatId: InputPeerLike, topicId: number, pinned: boolean): Promise + /** + * Set whether a supergroup is a forum. + * + * Only owner of the supergroup can change this setting. + * + * @param chatId Chat ID or username + * @param enabled (default: `false`) Whether the supergroup should be a forum + */ + toggleForum(chatId: InputPeerLike, enabled?: boolean): Promise + /** + * Toggle whether "General" topic in a forum is hidden or not + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param hidden Whether the topic should be hidden + * @returns Service message about the modification + */ + toggleGeneralTopicHidden(chatId: InputPeerLike, hidden: boolean): Promise /** * Create an additional invite link for the chat. * @@ -3061,6 +3289,8 @@ export interface TelegramClient extends BaseTelegramClient { /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message @@ -3116,6 +3346,8 @@ export interface TelegramClient extends BaseTelegramClient { params?: { /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message @@ -3226,6 +3458,8 @@ export interface TelegramClient extends BaseTelegramClient { /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message @@ -3353,6 +3587,8 @@ export interface TelegramClient extends BaseTelegramClient { params?: { /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message @@ -3509,7 +3745,15 @@ export interface TelegramClient extends BaseTelegramClient { * * @param chatId Chat or user ID */ - unpinAllMessages(chatId: InputPeerLike): Promise + unpinAllMessages( + chatId: InputPeerLike, + params?: { + /** + * For forums - unpin only messages from the given topic + */ + topicId?: number + }, + ): Promise /** * Unpin a message in a group, supergroup, channel or PM. * @@ -4195,6 +4439,17 @@ export class TelegramClient extends BaseTelegramClient { _normalizeInputMedia = _normalizeInputMedia uploadFile = uploadFile uploadMedia = uploadMedia + createForumTopic = createForumTopic + deleteForumTopicHistory = deleteForumTopicHistory + editForumTopic = editForumTopic + getForumTopicsById = getForumTopicsById + getForumTopics = getForumTopics + iterForumTopics = iterForumTopics + reorderPinnedForumTopics = reorderPinnedForumTopics + toggleForumTopicClosed = toggleForumTopicClosed + toggleForumTopicPinned = toggleForumTopicPinned + toggleForum = toggleForum + toggleGeneralTopicHidden = toggleGeneralTopicHidden createInviteLink = createInviteLink editInviteLink = editInviteLink exportInviteLink = exportInviteLink diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index f15fc22b..b8a5be24 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -31,6 +31,7 @@ import { Dialog, FileDownloadParameters, FormattedString, + ForumTopic, GameHighScore, HistoryReadUpdate, IMessageEntityParser, diff --git a/packages/client/src/methods/chats/create-channel.ts b/packages/client/src/methods/chats/create-channel.ts index d06b8349..03994ee3 100644 --- a/packages/client/src/methods/chats/create-channel.ts +++ b/packages/client/src/methods/chats/create-channel.ts @@ -5,12 +5,25 @@ import { assertIsUpdatesGroup } from '../../utils/updates-utils' /** * Create a new broadcast channel * - * @param title Channel title - * @param description Channel description * @returns Newly created channel * @internal */ -export async function createChannel(this: TelegramClient, title: string, description = ''): Promise { +export async function createChannel( + this: TelegramClient, + params: { + /** + * Channel title + */ + title: string + + /** + * Channel description + */ + description?: string + }, +): Promise { + const { title, description = '' } = params + const res = await this.call({ _: 'channels.createChannel', title, diff --git a/packages/client/src/methods/chats/create-supergroup.ts b/packages/client/src/methods/chats/create-supergroup.ts index fc61aaa7..4a1cb054 100644 --- a/packages/client/src/methods/chats/create-supergroup.ts +++ b/packages/client/src/methods/chats/create-supergroup.ts @@ -5,16 +5,44 @@ import { assertIsUpdatesGroup } from '../../utils/updates-utils' /** * Create a new supergroup * - * @param title Title of the supergroup - * @param description Description of the supergroup + * @returns Newly created supergroup * @internal */ -export async function createSupergroup(this: TelegramClient, title: string, description = ''): Promise { +export async function createSupergroup( + this: TelegramClient, + params: { + /** + * Supergroup title + */ + title: string + + /** + * Supergroup description + */ + description?: string + + /** + * Whether to create a forum + */ + forum?: boolean + + /** + * TTL period (in seconds) for the newly created channel + * + * @default 0 (i.e. messages don't expire) + */ + ttlPeriod?: number + }, +): Promise { + const { title, description = '', forum, ttlPeriod = 0 } = params + const res = await this.call({ _: 'channels.createChannel', title, about: description, megagroup: true, + forum, + ttlPeriod, }) assertIsUpdatesGroup('channels.createChannel', res) diff --git a/packages/client/src/methods/forums/create-forum-topic.ts b/packages/client/src/methods/forums/create-forum-topic.ts new file mode 100644 index 00000000..8240dde2 --- /dev/null +++ b/packages/client/src/methods/forums/create-forum-topic.ts @@ -0,0 +1,55 @@ +import { tl } from '@mtcute/core' +import { randomLong } from '@mtcute/core/utils' + +import { TelegramClient } from '../../client' +import { InputPeerLike, Message } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Create a topic in a forum + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @returns Service message for the created topic + * @internal + */ +export async function createForumTopic( + this: TelegramClient, + chatId: InputPeerLike, + params: { + /** + * Topic title + */ + title: string + + /** + * Icon of the topic. + * + * Can be a number (color in RGB, see {@link ForumTopic} static members for allowed values) + * or a custom emoji ID. + * + * Icon color can't be changed after the topic is created. + */ + icon?: number | tl.Long + + /** + * Send as a specific channel + */ + sendAs?: InputPeerLike + }, +): Promise { + const { title, icon, sendAs } = params + + const res = await this.call({ + _: 'channels.createForumTopic', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + title, + iconColor: typeof icon === 'number' ? icon : undefined, + iconEmojiId: typeof icon !== 'number' ? icon : undefined, + sendAs: sendAs ? await this.resolvePeer(sendAs) : undefined, + randomId: randomLong(), + }) + + return this._findMessageInUpdate(res) +} diff --git a/packages/client/src/methods/forums/delete-forum-topic-history.ts b/packages/client/src/methods/forums/delete-forum-topic-history.ts new file mode 100644 index 00000000..50c0bd3d --- /dev/null +++ b/packages/client/src/methods/forums/delete-forum-topic-history.ts @@ -0,0 +1,30 @@ +import { assertTypeIsNot } from '@mtcute/core/utils' + +import { TelegramClient } from '../../client' +import { InputPeerLike } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' +import { createDummyUpdate } from '../../utils/updates-utils' + +/** + * Delete a forum topic and all its history + * + * @param chat Chat or user ID, username, phone number, `"me"` or `"self"` + * @param topicId ID of the topic (i.e. its top message ID) + * @internal + */ +export async function deleteForumTopicHistory( + this: TelegramClient, + chat: InputPeerLike, + topicId: number, +): Promise { + const channel = normalizeToInputChannel(await this.resolvePeer(chat), chat) + assertTypeIsNot('deleteForumTopicHistory', channel, 'inputChannelEmpty') + + const res = await this.call({ + _: 'channels.deleteTopicHistory', + channel, + topMsgId: topicId, + }) + + this._handleUpdate(createDummyUpdate(res.pts, res.ptsCount, channel.channelId)) +} diff --git a/packages/client/src/methods/forums/edit-forum-topic.ts b/packages/client/src/methods/forums/edit-forum-topic.ts new file mode 100644 index 00000000..20273619 --- /dev/null +++ b/packages/client/src/methods/forums/edit-forum-topic.ts @@ -0,0 +1,49 @@ +import Long from 'long' + +import { tl } from '@mtcute/core' + +import { TelegramClient } from '../../client' +import { InputPeerLike, Message } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Modify a topic in a forum + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @returns Service message about the modification + * @internal + */ +export async function editForumTopic( + this: TelegramClient, + chatId: InputPeerLike, + topicId: number, + params: { + /** + * New topic title + */ + title?: string + + /** + * New icon of the topic. + * + * Can be a custom emoji ID, or `null` to remove the icon + * and use static color instead + */ + icon?: tl.Long | null + }, +): Promise { + const { title, icon } = params + + const res = await this.call({ + _: 'channels.editForumTopic', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + topicId, + title, + iconEmojiId: icon ? icon ?? Long.ZERO : undefined, + }) + + return this._findMessageInUpdate(res) +} diff --git a/packages/client/src/methods/forums/get-forum-topics-by-id.ts b/packages/client/src/methods/forums/get-forum-topics-by-id.ts new file mode 100644 index 00000000..880c7caa --- /dev/null +++ b/packages/client/src/methods/forums/get-forum-topics-by-id.ts @@ -0,0 +1,50 @@ +import { MaybeArray } from '@mtcute/core' + +import { TelegramClient } from '../../client' +import { ForumTopic, InputPeerLike } from '../../types' +import { normalizeToInputChannel } from '../../utils' + +/** + * Get a single forum topic by its ID + * + * @param chatId Chat ID or username + * @internal + */ +export async function getForumTopicsById(this: TelegramClient, chatId: InputPeerLike, ids: number): Promise + +/** + * Get forum topics by their IDs + * + * @param chatId Chat ID or username + * @internal + */ +export async function getForumTopicsById( + this: TelegramClient, + chatId: InputPeerLike, + ids: number[], +): Promise + +/** + * Get forum topics by their IDs + * + * @param chatId Chat ID or username + * @internal + */ +export async function getForumTopicsById( + this: TelegramClient, + chatId: InputPeerLike, + ids: MaybeArray, +): Promise> { + const single = !Array.isArray(ids) + if (single) ids = [ids as number] + + const res = await this.call({ + _: 'channels.getForumTopicsByID', + channel: normalizeToInputChannel(await this.resolvePeer(chatId)), + topics: ids as number[], + }) + + const topics = ForumTopic.parseTlForumTopics(this, res) + + return single ? topics[0] : topics +} diff --git a/packages/client/src/methods/forums/get-forum-topics.ts b/packages/client/src/methods/forums/get-forum-topics.ts new file mode 100644 index 00000000..36e0dbd0 --- /dev/null +++ b/packages/client/src/methods/forums/get-forum-topics.ts @@ -0,0 +1,77 @@ +import { TelegramClient } from '../../client' +import { ArrayPaginated, ForumTopic, InputPeerLike } from '../../types' +import { makeArrayPaginated } from '../../utils' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +// @exported +export interface GetForumTopicsOffset { + date: number + id: number + topic: number +} + +const defaultOffset: GetForumTopicsOffset = { + date: 0, + id: 0, + topic: 0, +} + +/** + * Get forum topics + * + * @param chatId Chat ID or username + * @internal + */ +export async function getForumTopics( + this: TelegramClient, + chatId: InputPeerLike, + params?: { + /** + * Search query + */ + query?: string + + /** + * Offset for pagination + */ + offset?: GetForumTopicsOffset + + /** + * Maximum number of topics to return. + * + * @default 100 + */ + limit?: number + }, +): Promise> { + if (!params) params = {} + + const { + query, + offset: { date: offsetDate, id: offsetId, topic: offsetTopic } = defaultOffset, + limit = 100, + } = params + + const res = await this.call({ + _: 'channels.getForumTopics', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + q: query, + offsetDate, + offsetId, + offsetTopic, + limit, + }) + + const topics = ForumTopic.parseTlForumTopics(this, res) + + const last = topics[topics.length - 1] + const next = last ? + { + date: res.orderByCreateDate ? last.raw.date : last.lastMessage.raw.date, + id: last.raw.topMessage, + topic: last.raw.id, + } : + undefined + + return makeArrayPaginated(topics, res.count, next) +} diff --git a/packages/client/src/methods/forums/iter-forum-topics.ts b/packages/client/src/methods/forums/iter-forum-topics.ts new file mode 100644 index 00000000..5273310d --- /dev/null +++ b/packages/client/src/methods/forums/iter-forum-topics.ts @@ -0,0 +1,53 @@ +import { TelegramClient } from '../../client' +import { ForumTopic, InputPeerLike } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Iterate over forum topics. Wrapper over {@link getForumTopics}. + * + * @param chatId Chat ID or username + * @internal + */ +export async function* iterForumTopics( + this: TelegramClient, + chatId: InputPeerLike, + params?: Parameters[1] & { + /** + * Maximum number of topics to return. + * + * @default `Infinity`, i.e. return all topics + */ + limit?: number + + /** + * Chunk size. Usually you shouldn't care about this. + */ + chunkSize?: number + }, +): AsyncIterableIterator { + if (!params) params = {} + + const { query, limit = Infinity, chunkSize = 100 } = params + + const peer = normalizeToInputChannel(await this.resolvePeer(chatId)) + + let { offset } = params + let current = 0 + + for (;;) { + const res = await this.getForumTopics(peer, { + query, + offset, + limit: Math.min(chunkSize, limit - current), + }) + + for (const topic of res) { + yield topic + + if (++current >= limit) return + } + + if (!res.next) return + offset = res.next + } +} diff --git a/packages/client/src/methods/forums/reorder-pinned-forum-topics.ts b/packages/client/src/methods/forums/reorder-pinned-forum-topics.ts new file mode 100644 index 00000000..fea07ccf --- /dev/null +++ b/packages/client/src/methods/forums/reorder-pinned-forum-topics.ts @@ -0,0 +1,36 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Reorder pinned forum topics + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @internal + */ +export async function reorderPinnedForumTopics( + this: TelegramClient, + chatId: InputPeerLike, + params: { + /** + * Order of the pinned topics + */ + order: number[] + + /** + * Whether to un-pin topics not present in the order + */ + force?: boolean + }, +): Promise { + const { order, force } = params + await this.call({ + _: 'channels.reorderPinnedForumTopics', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + order, + force, + }) +} diff --git a/packages/client/src/methods/forums/toggle-forum-topic-closed.ts b/packages/client/src/methods/forums/toggle-forum-topic-closed.ts new file mode 100644 index 00000000..75475549 --- /dev/null +++ b/packages/client/src/methods/forums/toggle-forum-topic-closed.ts @@ -0,0 +1,30 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike, Message } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Toggle open/close status of a topic in a forum + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @param closed Whether the topic should be closed + * @returns Service message about the modification + * @internal + */ +export async function toggleForumTopicClosed( + this: TelegramClient, + chatId: InputPeerLike, + topicId: number, + closed: boolean, +): Promise { + const res = await this.call({ + _: 'channels.editForumTopic', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + topicId, + closed, + }) + + return this._findMessageInUpdate(res) +} diff --git a/packages/client/src/methods/forums/toggle-forum-topic-pinned.ts b/packages/client/src/methods/forums/toggle-forum-topic-pinned.ts new file mode 100644 index 00000000..e0bef375 --- /dev/null +++ b/packages/client/src/methods/forums/toggle-forum-topic-pinned.ts @@ -0,0 +1,27 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Toggle whether a topic in a forum is pinned + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param topicId ID of the topic (i.e. its top message ID) + * @param pinned Whether the topic should be pinned + * @internal + */ +export async function toggleForumTopicPinned( + this: TelegramClient, + chatId: InputPeerLike, + topicId: number, + pinned: boolean, +): Promise { + await this.call({ + _: 'channels.updatePinnedForumTopic', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + topicId, + pinned, + }) +} diff --git a/packages/client/src/methods/forums/toggle-forum.ts b/packages/client/src/methods/forums/toggle-forum.ts new file mode 100644 index 00000000..323a05a4 --- /dev/null +++ b/packages/client/src/methods/forums/toggle-forum.ts @@ -0,0 +1,21 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Set whether a supergroup is a forum. + * + * Only owner of the supergroup can change this setting. + * + * @param chatId Chat ID or username + * @param enabled Whether the supergroup should be a forum + * @internal + */ +export async function toggleForum(this: TelegramClient, chatId: InputPeerLike, enabled = false): Promise { + const res = await this.call({ + _: 'channels.toggleForum', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + enabled, + }) + this._handleUpdate(res) +} diff --git a/packages/client/src/methods/forums/toggle-general-topic-hidden.ts b/packages/client/src/methods/forums/toggle-general-topic-hidden.ts new file mode 100644 index 00000000..2f1ff952 --- /dev/null +++ b/packages/client/src/methods/forums/toggle-general-topic-hidden.ts @@ -0,0 +1,28 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike, Message } from '../../types' +import { normalizeToInputChannel } from '../../utils/peer-utils' + +/** + * Toggle whether "General" topic in a forum is hidden or not + * + * Only admins with `manageTopics` permission can do this. + * + * @param chatId Chat ID or username + * @param hidden Whether the topic should be hidden + * @returns Service message about the modification + * @internal + */ +export async function toggleGeneralTopicHidden( + this: TelegramClient, + chatId: InputPeerLike, + hidden: boolean, +): Promise { + const res = await this.call({ + _: 'channels.editForumTopic', + channel: normalizeToInputChannel(await this.resolvePeer(chatId), chatId), + topicId: 1, + hidden, + }) + + return this._findMessageInUpdate(res) +} diff --git a/packages/client/src/methods/messages/send-copy.ts b/packages/client/src/methods/messages/send-copy.ts index 096a854f..3a1c8d1b 100644 --- a/packages/client/src/methods/messages/send-copy.ts +++ b/packages/client/src/methods/messages/send-copy.ts @@ -57,6 +57,8 @@ export async function sendCopy( /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message diff --git a/packages/client/src/methods/messages/send-media-group.ts b/packages/client/src/methods/messages/send-media-group.ts index 90fec4b8..1cdddf6d 100644 --- a/packages/client/src/methods/messages/send-media-group.ts +++ b/packages/client/src/methods/messages/send-media-group.ts @@ -25,6 +25,8 @@ export async function sendMediaGroup( params?: { /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message diff --git a/packages/client/src/methods/messages/send-media.ts b/packages/client/src/methods/messages/send-media.ts index cf1a08ac..28a13d1a 100644 --- a/packages/client/src/methods/messages/send-media.ts +++ b/packages/client/src/methods/messages/send-media.ts @@ -48,6 +48,8 @@ export async function sendMedia( /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message diff --git a/packages/client/src/methods/messages/send-text.ts b/packages/client/src/methods/messages/send-text.ts index eb3a05fb..895b166f 100644 --- a/packages/client/src/methods/messages/send-text.ts +++ b/packages/client/src/methods/messages/send-text.ts @@ -30,6 +30,8 @@ export async function sendText( params?: { /** * Message to reply to. Either a message object or message ID. + * + * For forums - can also be an ID of the topic (i.e. its top message ID) */ replyTo?: number | Message diff --git a/packages/client/src/methods/messages/unpin-all-messages.ts b/packages/client/src/methods/messages/unpin-all-messages.ts index e1a1a13e..3221473c 100644 --- a/packages/client/src/methods/messages/unpin-all-messages.ts +++ b/packages/client/src/methods/messages/unpin-all-messages.ts @@ -9,12 +9,24 @@ import { createDummyUpdate } from '../../utils/updates-utils' * @param chatId Chat or user ID * @internal */ -export async function unpinAllMessages(this: TelegramClient, chatId: InputPeerLike): Promise { +export async function unpinAllMessages( + this: TelegramClient, + chatId: InputPeerLike, + params?: { + /** + * For forums - unpin only messages from the given topic + */ + topicId?: number + }, +): Promise { + const { topicId } = params ?? {} + const peer = await this.resolvePeer(chatId) const res = await this.call({ _: 'messages.unpinAllMessages', peer, + topMsgId: topicId, }) if (isInputPeerChannel(peer)) { diff --git a/packages/client/src/types/messages/dialog.ts b/packages/client/src/types/messages/dialog.ts index 3946c5ea..ec13f29d 100644 --- a/packages/client/src/types/messages/dialog.ts +++ b/packages/client/src/types/messages/dialog.ts @@ -25,7 +25,7 @@ export class Dialog { readonly client: TelegramClient, readonly raw: tl.RawDialog, readonly _peers: PeersIndex, - readonly _messages: Record, + readonly _messages: Map, ) {} /** @@ -44,11 +44,11 @@ export class Dialog { const peers = PeersIndex.from(dialogs) - const messages: Record = {} + const messages = new Map() dialogs.messages.forEach((msg) => { if (!msg.peerId) return - messages[getMarkedPeerId(msg.peerId)] = msg + messages.set(getMarkedPeerId(msg.peerId), msg) }) const arr = dialogs.dialogs @@ -228,8 +228,8 @@ export class Dialog { if (!this._lastMessage) { const cid = this.chat.id - if (cid in this._messages) { - this._lastMessage = new Message(this.client, this._messages[cid], this._peers) + if (this._messages.has(cid)) { + this._lastMessage = new Message(this.client, this._messages.get(cid)!, this._peers) } else { throw new MtMessageNotFoundError(cid, 0) } @@ -267,12 +267,19 @@ export class Dialog { } /** - * Number of unread messages + * Number of unread mentions */ get unreadMentionsCount(): number { return this.raw.unreadMentionsCount } + /** + * Number of unread reactions + */ + get unreadReactionsCount(): number { + return this.raw.unreadReactionsCount + } + private _draftMessage?: DraftMessage | null /** * Draft message in this dialog @@ -280,7 +287,7 @@ export class Dialog { get draftMessage(): DraftMessage | null { if (this._draftMessage === undefined) { if (this.raw.draft?._ === 'draftMessage') { - this._draftMessage = new DraftMessage(this.client, this.raw.draft, this.chat.inputPeer) + this._draftMessage = new DraftMessage(this.client, this.raw.draft) } else { this._draftMessage = null } diff --git a/packages/client/src/types/messages/draft-message.ts b/packages/client/src/types/messages/draft-message.ts index 0f0127d7..3687fc4c 100644 --- a/packages/client/src/types/messages/draft-message.ts +++ b/packages/client/src/types/messages/draft-message.ts @@ -2,16 +2,13 @@ import { tl } from '@mtcute/core' import { TelegramClient } from '../../client' import { makeInspectable } from '../../utils' -import { InputMediaLike } from '../media' -import { InputPeerLike } from '../peers' -import { Message } from './message' import { MessageEntity } from './message-entity' /** * A draft message */ export class DraftMessage { - constructor(readonly client: TelegramClient, readonly raw: tl.RawDraftMessage, readonly _chatId: InputPeerLike) {} + constructor(readonly client: TelegramClient, readonly raw: tl.RawDraftMessage) {} /** * Text of the draft message @@ -59,45 +56,6 @@ export class DraftMessage { return this._entities } - - /** - * Send this draft as a message. - * Calling this method will clear current draft. - * - * @param params Additional sending parameters - * @link TelegramClient.sendText - */ - send(params?: Parameters[2]): Promise { - return this.client.sendText(this._chatId, this.raw.message, { - clearDraft: true, - disableWebPreview: this.raw.noWebpage, - entities: this.raw.entities, - replyTo: this.raw.replyToMsgId, - ...(params || {}), - }) - } - - /** - * Send this draft as a message with media. - * Calling this method will clear current draft. - * - * If passed media does not have an - * explicit caption, it will be set to {@link text}, - * and its entities to {@link entities} - * - * @param media Media to be sent - * @param params Additional sending parameters - * @link TelegramClient.sendMedia - */ - sendWithMedia(media: InputMediaLike, params?: Parameters[2]): Promise { - return this.client.sendMedia(this._chatId, media, { - clearDraft: true, - replyTo: this.raw.replyToMsgId, - caption: this.raw.message, - entities: this.raw.entities, - ...(params || {}), - }) - } } makeInspectable(DraftMessage) diff --git a/packages/client/src/types/messages/message-action.ts b/packages/client/src/types/messages/message-action.ts index f98afa83..d0f3ee44 100644 --- a/packages/client/src/types/messages/message-action.ts +++ b/packages/client/src/types/messages/message-action.ts @@ -249,6 +249,37 @@ export interface ActionSetTtl { readonly period: number } +/** Forum topic was created */ +export interface ActionTopicCreated { + readonly type: 'topic_created' + + /** Title of the topic */ + title: string + + /** Icon color of the topic */ + iconColor: number + + /** Icon emoji of the topic */ + iconCustomEmoji?: tl.Long +} + +/** Forum topic was modified */ +export interface ActionTopicEdited { + readonly type: 'topic_edited' + + /** New title of the topic */ + title?: string + + /** New icon emoji of the topic (may be empty) */ + iconCustomEmoji?: tl.Long + + /** Whether the topic was opened/closed */ + closed?: boolean + + /** Whether the topic was (un-)hidden - only for "General" topic (`id=1`) */ + hidden?: boolean +} + export type MessageAction = | ActionChatCreated | ActionChannelCreated @@ -275,6 +306,8 @@ export type MessageAction = | ActionGroupCallEnded | ActionGroupInvite | ActionSetTtl + | ActionTopicCreated + | ActionTopicEdited | null /** @internal */ @@ -426,6 +459,21 @@ export function _messageActionFromTl(this: Message, act: tl.TypeMessageAction): type: 'set_ttl', period: act.period, } + case 'messageActionTopicCreate': + return { + type: 'topic_created', + title: act.title, + iconColor: act.iconColor, + iconCustomEmoji: act.iconEmojiId, + } + case 'messageActionTopicEdit': + return { + type: 'topic_edited', + title: act.title, + iconCustomEmoji: act.iconEmojiId, + closed: act.closed, + hidden: act.hidden, + } default: return null } diff --git a/packages/client/src/types/peers/chat-event/actions.ts b/packages/client/src/types/peers/chat-event/actions.ts index 2944121c..58f8c4f0 100644 --- a/packages/client/src/types/peers/chat-event/actions.ts +++ b/packages/client/src/types/peers/chat-event/actions.ts @@ -1,6 +1,7 @@ import { tl } from '@mtcute/core' +import { assertTypeIs } from '@mtcute/core/utils' -import { PeersIndex, TelegramClient, toggleChannelIdMark } from '../../..' +import { ForumTopic, PeersIndex, TelegramClient, toggleChannelIdMark } from '../../..' import { Photo } from '../../media' import { Message } from '../../messages' import { ChatInviteLink } from '../chat-invite-link' @@ -297,6 +298,41 @@ export interface ChatActionTtlChanged { new: number } +/** Forum has been toggled */ +export interface ChatActionForumToggled { + type: 'forum_toggled' + + /** New status */ + enabled: boolean +} + +/** Forum topic has been created */ +export interface ChatActionTopicCreated { + type: 'topic_created' + + /** Topic that has been created */ + topic: ForumTopic +} + +/** Forum topic has been edited */ +export interface ChatActionTopicEdited { + type: 'topic_edited' + + /** Old topic info */ + old: ForumTopic + + /** New topic info */ + new: ForumTopic +} + +/** Forum topic has been edited */ +export interface ChatActionTopicDeleted { + type: 'topic_deleted' + + /** Old topic info */ + topic: ForumTopic +} + /** Chat event action (`null` if unsupported) */ export type ChatAction = | ChatActionUserJoined @@ -329,6 +365,10 @@ export type ChatAction = | ChatActionInviteLinkRevoked | ChatActionUserJoinedApproved | ChatActionTtlChanged + | ChatActionForumToggled + | ChatActionTopicCreated + | ChatActionTopicEdited + | ChatActionTopicDeleted | null /** @internal */ @@ -341,12 +381,6 @@ export function _actionFromTl( // channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; // todo - MTQ-57 // channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector - // todo - MTQ-77 - // channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; - // channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; - // channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic - // channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; - // channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic // todo - MTQ-72 // channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; // channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions @@ -520,6 +554,39 @@ export function _actionFromTl( link: new ChatInviteLink(client, e.invite, peers), approvedBy: new User(client, peers.user(e.approvedBy)), } + // channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; + // channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic + // channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; + case 'channelAdminLogEventActionToggleForum': + return { + type: 'forum_toggled', + enabled: e.newValue, + } + case 'channelAdminLogEventActionCreateTopic': + assertTypeIs('ChannelAdminLogEventActionCreateTopic#topic', e.topic, 'forumTopic') + + return { + type: 'topic_created', + topic: new ForumTopic(client, e.topic, peers), + } + case 'channelAdminLogEventActionEditTopic': + assertTypeIs('ChannelAdminLogEventActionCreateTopic#topic', e.prevTopic, 'forumTopic') + assertTypeIs('ChannelAdminLogEventActionCreateTopic#topic', e.newTopic, 'forumTopic') + + return { + type: 'topic_edited', + old: new ForumTopic(client, e.prevTopic, peers), + new: new ForumTopic(client, e.newTopic, peers), + } + case 'channelAdminLogEventActionDeleteTopic': + assertTypeIs('ChannelAdminLogEventActionCreateTopic#topic', e.topic, 'forumTopic') + + return { + type: 'topic_deleted', + topic: new ForumTopic(client, e.topic, peers), + } + // case 'channelAdminLogEventActionPinTopic' + // ^ looks like it is not used, and pinned topics are not at all presented in the event log default: return null } diff --git a/packages/client/src/types/peers/chat-event/filters.ts b/packages/client/src/types/peers/chat-event/filters.ts index 842ad2a2..5ae8989d 100644 --- a/packages/client/src/types/peers/chat-event/filters.ts +++ b/packages/client/src/types/peers/chat-event/filters.ts @@ -57,6 +57,7 @@ export function normalizeChatEventFilters(input: InputChatEventFilters): ChatEve case 'history_toggled': case 'signatures_toggled': case 'def_perms_changed': + case 'forum_toggled': serverFilter.settings = true break case 'msg_pinned': @@ -94,6 +95,11 @@ export function normalizeChatEventFilters(input: InputChatEventFilters): ChatEve case 'invite_revoked': serverFilter.invites = true break + case 'topic_created': + case 'topic_edited': + case 'topic_deleted': + serverFilter.forums = true + break default: assertNever(type) } diff --git a/packages/client/src/types/peers/forum-topic.ts b/packages/client/src/types/peers/forum-topic.ts new file mode 100644 index 00000000..c32ec809 --- /dev/null +++ b/packages/client/src/types/peers/forum-topic.ts @@ -0,0 +1,202 @@ +import { MtTypeAssertionError, tl } from '@mtcute/core' + +import { TelegramClient } from '../../client' +import { hasValueAtKey, makeInspectable } from '../../utils' +import { MtMessageNotFoundError } from '../errors' +import { DraftMessage, Message } from '../messages' +import { Chat } from './chat' +import { PeersIndex } from './peers-index' +import { User } from './user' + +export class ForumTopic { + static COLOR_BLUE = 0x6fb9f0 + static COLOR_YELLOW = 0xffd67e + static COLOR_PURPLE = 0xcb86db + static COLOR_GREEN = 0x8eee98 + static COLOR_PINK = 0xff93b2 + static COLOR_RED = 0xfb6f5f + + constructor( + readonly client: TelegramClient, + readonly raw: tl.RawForumTopic, + readonly _peers: PeersIndex, + readonly _messages?: Map, + ) {} + + static parseTlForumTopics(client: TelegramClient, topics: tl.messages.TypeForumTopics): ForumTopic[] { + const peers = PeersIndex.from(topics) + const messages = new Map() + + topics.messages.forEach((msg) => { + if (!msg.peerId) return + + messages.set(msg.id, msg) + }) + + return topics.topics + .filter(hasValueAtKey('_', 'forumTopic')) + .map((it) => new ForumTopic(client, it, peers, messages)) + } + + /** + * Whether the topic was created by the current user + */ + get isMy(): boolean { + return this.raw.my! + } + + /** + * Whether the topic is closed + */ + get isClosed(): boolean { + return this.raw.closed! + } + + /** + * Whether the topic is pinned + */ + get isPinned(): boolean { + return this.raw.pinned! + } + + /** + * Whether this constructor is a reduced version of the full topic information. + * + * If `true`, only {@link isMy}, {@link isClosed}, {@link id}, {@link date}, + * {@link title}, {@link iconColor}, {@link iconCustomEmoji} and {@link creator} + * parameters will contain valid information. + */ + get isShort(): boolean { + return this.raw.short! + } + + /** + * ID of the topic + */ + get id(): number { + return this.raw.id + } + + /** + * Date when the topic was created + */ + get date(): Date { + return new Date(this.raw.date * 1000) + } + + /** + * Title of the topic + */ + get title(): string { + return this.raw.title + } + + /** + * Color of the topic's icon, used as a fallback + * in case {@link iconEmoji} is not set. + * + * One of the static `COLOR_*` fields. + */ + get iconColor(): number | null { + return this.raw.iconColor ?? null + } + + /** + * Emoji used as the topic's icon. + */ + get iconCustomEmoji(): tl.Long | null { + return this.raw.iconEmojiId ?? null + } + + private _creator?: User | Chat + /** + * Creator of the topic + */ + get creator(): User | Chat { + if (this._creator) return this._creator + + switch (this.raw.fromId._) { + case 'peerUser': + return (this._creator = new User(this.client, this._peers.user(this.raw.fromId.userId))) + case 'peerChat': + return (this._creator = new Chat(this.client, this._peers.chat(this.raw.fromId.chatId))) + default: + throw new MtTypeAssertionError('ForumTopic#creator', 'peerUser | peerChat', this.raw.fromId._) + } + } + + private _lastMessage?: Message + /** + * The latest message sent in this topic + */ + get lastMessage(): Message { + if (!this._lastMessage) { + const id = this.raw.topMessage + + if (this._messages?.has(id)) { + this._lastMessage = new Message(this.client, this._messages.get(id)!, this._peers) + } else { + throw new MtMessageNotFoundError(0, id) + } + } + + return this._lastMessage + } + + /** + * ID of the last read outgoing message in this topic + */ + get lastReadIngoing(): number { + return this.raw.readInboxMaxId + } + + /** + * ID of the last read ingoing message in this topic + */ + get lastReadOutgoing(): number { + return this.raw.readOutboxMaxId + } + + /** + * ID of the last read message in this topic + */ + get lastRead(): number { + return Math.max(this.raw.readOutboxMaxId, this.raw.readInboxMaxId) + } + + /** + * Number of unread messages in the topic + */ + get unreadCount(): number { + return this.raw.unreadCount + } + + /** + * Number of unread mentions in the topic + */ + get unreadMentionsCount(): number { + return this.raw.unreadMentionsCount + } + + /** + * Number of unread reactions in the topic + */ + get unreadReactionsCount(): number { + return this.raw.unreadReactionsCount + } + + private _draftMessage?: DraftMessage + + /** + * Draft message in the topic + */ + get draftMessage(): DraftMessage | null { + if (this._draftMessage) return this._draftMessage + + if (!this.raw.draft || this.raw.draft._ === 'draftMessageEmpty') return null + + return (this._draftMessage = new DraftMessage(this.client, this.raw.draft)) + } +} + +makeInspectable(ForumTopic) diff --git a/packages/client/src/types/peers/index.ts b/packages/client/src/types/peers/index.ts index 49a5cb58..1bc49584 100644 --- a/packages/client/src/types/peers/index.ts +++ b/packages/client/src/types/peers/index.ts @@ -9,6 +9,7 @@ export * from './chat-member' export * from './chat-permissions' export * from './chat-photo' export * from './chat-preview' +export * from './forum-topic' export * from './peers-index' export * from './typing-status' export * from './user'