diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 2f7487f7..2d369848 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -26,10 +26,12 @@ import { getChatMember } from './methods/chats/get-chat-member' import { getChatMembers } from './methods/chats/get-chat-members' import { getChatPreview } from './methods/chats/get-chat-preview' import { getChat } from './methods/chats/get-chat' +import { getDialogs } from './methods/chats/get-dialogs' import { getFullChat } from './methods/chats/get-full-chat' import { iterChatMembers } from './methods/chats/iter-chat-members' import { joinChat } from './methods/chats/join-chat' import { leaveChat } from './methods/chats/leave-chat' +import { saveDraft } from './methods/chats/save-draft' import { setChatDefaultPermissions } from './methods/chats/set-chat-default-permissions' import { setChatDescription } from './methods/chats/set-chat-description' import { setChatPhoto } from './methods/chats/set-chat-photo' @@ -83,6 +85,7 @@ import { Chat, ChatMember, ChatPreview, + Dialog, FileDownloadParameters, InputChatPermissions, InputFileLike, @@ -560,6 +563,45 @@ export class TelegramClient extends BaseTelegramClient { getChat(chatId: InputPeerLike): Promise { return getChat.apply(this, arguments) } + /** + * Get a chunk of dialogs + * + * You can get up to 100 dialogs at once + * + * @param params Fetch parameters + */ + getDialogs(params?: { + /** + * Offset date used as an anchor for pagination. + * + * Use {@link Dialog.date} for this value. + */ + offsetDate?: Date | number + + /** + * Limits the number of dialogs to be received. + * + * Defaults to 100. + */ + limit?: number + + /** + * How to handle pinned dialogs? + * Whether to `include` them, `exclude`, + * or `only` return pinned dialogs. + * + * Defaults to `include` + */ + pinned?: 'include' | 'exclude' | 'only' + + /** + * Whether to get dialogs from the + * archived dialogs list. + */ + archived?: boolean + }): Promise { + return getDialogs.apply(this, arguments) + } /** * Get full information about a chat. * @@ -614,6 +656,18 @@ export class TelegramClient extends BaseTelegramClient { leaveChat(chatId: InputPeerLike, clear?: boolean): Promise { return leaveChat.apply(this, arguments) } + /** + * Save or delete a draft message associated with some chat + * + * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` + * @param draft Draft message, or `null` to delete. + */ + saveDraft( + chatId: InputPeerLike, + draft: null | Omit + ): Promise { + return saveDraft.apply(this, arguments) + } /** * Change default chat permissions for all members. * @@ -1293,6 +1347,13 @@ export class TelegramClient extends BaseTelegramClient { * @param total Total file size */ progressCallback?: (uploaded: number, total: number) => void + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean } ): Promise { return sendMedia.apply(this, arguments) @@ -1366,6 +1427,13 @@ export class TelegramClient extends BaseTelegramClient { * @param total Total file size */ progressCallback?: (uploaded: number, total: number) => void + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean } ): Promise { return sendPhoto.apply(this, arguments) @@ -1423,6 +1491,13 @@ export class TelegramClient extends BaseTelegramClient { * to hide a reply keyboard or to force a reply. */ replyMarkup?: ReplyMarkup + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean } ): Promise { return sendText.apply(this, arguments) diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index 84019d75..ebaf2ade 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -12,6 +12,7 @@ import { Chat, ChatPreview, ChatMember, + Dialog, InputChatPermissions, TermsOfService, SentCode, diff --git a/packages/client/src/methods/chats/get-dialogs.ts b/packages/client/src/methods/chats/get-dialogs.ts new file mode 100644 index 00000000..c8b8a15b --- /dev/null +++ b/packages/client/src/methods/chats/get-dialogs.ts @@ -0,0 +1,94 @@ +import { TelegramClient } from '../../client' +import { Dialog } from '../../types' +import { normalizeDate } from '../../utils/misc-utils' +import { createUsersChatsIndex } from '../../utils/peer-utils' +import { MtCuteTypeAssertionError } from '../../types' +import { tl } from '@mtcute/tl' +import { getMarkedPeerId } from '@mtcute/core' + +/** + * Get a chunk of dialogs + * + * You can get up to 100 dialogs at once + * + * @param params Fetch parameters + * @internal + */ +export async function getDialogs( + this: TelegramClient, + params?: { + /** + * Offset date used as an anchor for pagination. + * + * Use {@link Dialog.date} for this value. + */ + offsetDate?: Date | number + + /** + * Limits the number of dialogs to be received. + * + * Defaults to 100. + */ + limit?: number + + /** + * How to handle pinned dialogs? + * Whether to `include` them, `exclude`, + * or `only` return pinned dialogs. + * + * Defaults to `include` + */ + pinned?: 'include' | 'exclude' | 'only' + + /** + * Whether to get dialogs from the + * archived dialogs list. + */ + archived?: boolean + } +): Promise { + if (!params) params = {} + + let res + if (params.pinned === 'only') { + res = await this.call({ + _: 'messages.getPinnedDialogs', + folderId: params.archived ? 1 : 0, + }) + } else { + res = await this.call({ + _: 'messages.getDialogs', + excludePinned: params.pinned === 'exclude', + folderId: params.archived ? 1 : 0, + offsetDate: normalizeDate(params.offsetDate) ?? 0, + + // offseting by id and peer is useless because when some peer sends + // a message, their dialog goes to the top and we get a cycle + offsetId: 0, + offsetPeer: { _: 'inputPeerEmpty' }, + + limit: params.limit ?? 100, + hash: 0, + }) + } + + if (res._ === 'messages.dialogsNotModified') + throw new MtCuteTypeAssertionError( + 'getDialogs', + '!messages.dialogsNotModified', + 'messages.dialogsNotModified' + ) + + const { users, chats } = createUsersChatsIndex(res) + + const messages: Record = {} + res.messages.forEach((msg) => { + if (!msg.peerId) return + + messages[getMarkedPeerId(msg.peerId)] = msg + }) + + return res.dialogs + .filter(it => it._ === 'dialog') + .map(it => new Dialog(this, it as tl.RawDialog, users, chats, messages)) +} diff --git a/packages/client/src/methods/chats/save-draft.ts b/packages/client/src/methods/chats/save-draft.ts new file mode 100644 index 00000000..30008a72 --- /dev/null +++ b/packages/client/src/methods/chats/save-draft.ts @@ -0,0 +1,33 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike } from '../../types' +import { tl } from '@mtcute/tl' +import { normalizeToInputPeer } from '../../utils/peer-utils' + +/** + * Save or delete a draft message associated with some chat + * + * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` + * @param draft Draft message, or `null` to delete. + * @internal + */ +export async function saveDraft( + this: TelegramClient, + chatId: InputPeerLike, + draft: null | Omit +): Promise { + const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) + + if (draft) { + await this.call({ + _: 'messages.saveDraft', + peer, + ...draft + }) + } else { + await this.call({ + _: 'messages.saveDraft', + peer, + message: '' + }) + } +} diff --git a/packages/client/src/methods/messages/send-media.ts b/packages/client/src/methods/messages/send-media.ts index 877c953f..959618b0 100644 --- a/packages/client/src/methods/messages/send-media.ts +++ b/packages/client/src/methods/messages/send-media.ts @@ -65,6 +65,13 @@ export async function sendMedia( * @param total Total file size */ progressCallback?: (uploaded: number, total: number) => void + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean } ): Promise { if (!params) params = {} @@ -143,9 +150,10 @@ export async function sendMedia( w: media.width || 0, h: media.height || 0, supportsStreaming: media.supportsStreaming, - roundMessage: media.isRound + roundMessage: media.isRound, }) - if (media.isAnimated) attributes.push({ _: 'documentAttributeAnimated' }) + if (media.isAnimated) + attributes.push({ _: 'documentAttributeAnimated' }) } if (media.type === 'audio' || media.type === 'voice') { @@ -155,7 +163,7 @@ export async function sendMedia( duration: media.duration || 0, title: media.type === 'audio' ? media.title : undefined, performer: media.type === 'audio' ? media.performer : undefined, - waveform: media.type === 'voice' ? media.waveform : undefined + waveform: media.type === 'voice' ? media.waveform : undefined, }) } @@ -166,7 +174,7 @@ export async function sendMedia( file: inputFile, thumb, mimeType: mime, - attributes + attributes, } } @@ -179,7 +187,6 @@ export async function sendMedia( const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) - const res = await this.call({ _: 'messages.sendMedia', peer, @@ -195,6 +202,7 @@ export async function sendMedia( replyMarkup, message, entities, + clearDraft: params.clearDraft, }) return this._findMessageInUpdate(res) diff --git a/packages/client/src/methods/messages/send-photo.ts b/packages/client/src/methods/messages/send-photo.ts index 2050a68d..fb5011d1 100644 --- a/packages/client/src/methods/messages/send-photo.ts +++ b/packages/client/src/methods/messages/send-photo.ts @@ -82,6 +82,13 @@ export async function sendPhoto( * @param total Total file size */ progressCallback?: (uploaded: number, total: number) => void + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean } ): Promise { if (!params) params = {} @@ -141,6 +148,7 @@ export async function sendPhoto( replyMarkup, message, entities, + clearDraft: params.clearDraft, }) return this._findMessageInUpdate(res) diff --git a/packages/client/src/methods/messages/send-text.ts b/packages/client/src/methods/messages/send-text.ts index e36e6674..d2ea840e 100644 --- a/packages/client/src/methods/messages/send-text.ts +++ b/packages/client/src/methods/messages/send-text.ts @@ -2,12 +2,7 @@ import { TelegramClient } from '../../client' import { tl } from '@mtcute/tl' import { inputPeerToPeer, normalizeToInputPeer } from '../../utils/peer-utils' import { normalizeDate, randomUlong } from '../../utils/misc-utils' -import { - InputPeerLike, - Message, - BotKeyboard, - ReplyMarkup, -} from '../../types' +import { InputPeerLike, Message, BotKeyboard, ReplyMarkup } from '../../types' /** * Send a text message @@ -64,6 +59,13 @@ export async function sendText( * to hide a reply keyboard or to force a reply. */ replyMarkup?: ReplyMarkup + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean } ): Promise { if (!params) params = {} @@ -92,6 +94,7 @@ export async function sendText( replyMarkup, message, entities, + clearDraft: params.clearDraft, }) if (res._ === 'updateShortSentMessage') { diff --git a/packages/client/src/types/messages/dialog.ts b/packages/client/src/types/messages/dialog.ts new file mode 100644 index 00000000..976c0304 --- /dev/null +++ b/packages/client/src/types/messages/dialog.ts @@ -0,0 +1,133 @@ +import { TelegramClient } from '../../client' +import { tl } from '@mtcute/tl' +import { Chat } from '../peers' +import { Message } from './message' +import { DraftMessage } from './draft-message' +import { makeInspectable } from '../utils' + +/** + * A dialog. + * + * Think of it as something that is listed + * in Telegram's main window. + */ +export class Dialog { + readonly client: TelegramClient + readonly raw: tl.RawDialog + + /** Map of users in this object. Mainly for internal use */ + readonly _users: Record + + /** Map of chats in this object. Mainly for internal use */ + readonly _chats: Record + + /** Map of messages in this object. Mainly for internal use */ + readonly _messages: Record + + constructor( + client: TelegramClient, + raw: tl.RawDialog, + users: Record, + chats: Record, + messages: Record + ) { + this.client = client + this.raw = raw + this._users = users + this._chats = chats + this._messages = messages + } + + /** + * Whether this dialog is pinned + */ + get isPinned(): boolean { + return !!this.raw.pinned + } + + /** + * Whether this chat was manually marked as unread + */ + get isManuallyUnread(): boolean { + return !!this.raw.unreadMark + } + + /** + * Whether this chat should be considered unread + * (i.e. has more than 1 unread message, or has + * a "manually unread" mark) + */ + get isUnread(): boolean { + return this.raw.unreadMark || this.raw.unreadCount > 1 + } + + private _chat?: Chat + /** + * Chat that this dialog represents + */ + get chat(): Chat { + if (!this._chat) { + const peer = this.raw.peer + + let chat + if (peer._ === 'peerChannel' || peer._ === 'peerChat') { + chat = this._chats[peer._ === 'peerChannel' ? peer.channelId : peer.chatId] + } else { + chat = this._users[peer.userId] + } + + this._chat = new Chat(this.client, chat) + } + + return this._chat + } + + private _lastMessage?: Message | null + /** + * The latest message sent in this chat + */ + get lastMessage(): Message | null { + if (this._lastMessage === undefined) { + const cid = this.chat.id + if (cid in this._messages) { + this._lastMessage = new Message(this.client, this._messages[cid], this._users, this._chats) + } else { + this._lastMessage = null + } + } + + return this._lastMessage + } + + /** + * Number of unread messages + */ + get unreadCount(): number { + return this.raw.unreadCount + } + + /** + * Number of unread messages + */ + get unreadMentionsCount(): number { + return this.raw.unreadMentionsCount + } + + private _draftMessage?: DraftMessage | null + /** + * Draft message in this 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) + } else { + this._draftMessage = null + } + } + + return this._draftMessage + } +} + +makeInspectable(Dialog) diff --git a/packages/client/src/types/messages/draft-message.ts b/packages/client/src/types/messages/draft-message.ts new file mode 100644 index 00000000..961553b6 --- /dev/null +++ b/packages/client/src/types/messages/draft-message.ts @@ -0,0 +1,120 @@ +/** + * A draft message + */ +import { tl } from '@mtcute/tl' +import { TelegramClient } from '../../client' +import { MessageEntity } from './message-entity' +import { Message } from './message' +import { InputPeerLike } from '../peers' +import { makeInspectable } from '../utils' +import { InputMediaLike } from '../media' + +export class DraftMessage { + readonly client: TelegramClient + readonly raw: tl.RawDraftMessage + + private _chatId: InputPeerLike + + constructor( + client: TelegramClient, + raw: tl.RawDraftMessage, + chatId: InputPeerLike + ) { + this.client = client + this.raw = raw + this._chatId = chatId + } + + /** + * Text of the draft message + */ + get text(): string { + return this.raw.message + } + + /** + * The message this message will reply to + */ + get replyToMessageId(): number | null { + return this.raw.replyToMsgId ?? null + } + + /** + * Date of the last time this draft was updated + */ + get date(): Date { + return new Date(this.raw.date * 1000) + } + + /** + * Whether no webpage preview will be generated + */ + get disableWebPreview(): boolean { + return !!this.raw.noWebpage + } + + private _entities?: MessageEntity[] + /** + * Message text entities (may be empty) + */ + get entities(): MessageEntity[] { + if (!this._entities) { + this._entities = [] + if (this.raw.entities?.length) { + for (const ent of this.raw.entities) { + const parsed = MessageEntity._parse(ent) + if (parsed) this._entities.push(parsed) + } + } + } + + 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 { + if (!media.caption) { + media.caption = this.raw.message + media.entities = this.raw.entities + } + + return this.client.sendMedia(this._chatId, media, { + clearDraft: true, + replyTo: this.raw.replyToMsgId, + ...(params || {}), + }) + } +} + +makeInspectable(DraftMessage) diff --git a/packages/client/src/types/messages/index.ts b/packages/client/src/types/messages/index.ts index 1d30582c..4644fbb2 100644 --- a/packages/client/src/types/messages/index.ts +++ b/packages/client/src/types/messages/index.ts @@ -1,3 +1,5 @@ export * from './message-entity' export * from './message' export * from './search-filters' +export * from './draft-message' +export * from './dialog' diff --git a/packages/client/src/types/peers/chat.ts b/packages/client/src/types/peers/chat.ts index 294740ef..77ed6336 100644 --- a/packages/client/src/types/peers/chat.ts +++ b/packages/client/src/types/peers/chat.ts @@ -163,6 +163,11 @@ export class Chat { return this.peer._ === 'user' && this.peer.support! } + /** Whether this chat is chat with yourself (i.e. Saved Messages) */ + get isSelf(): boolean { + return this.peer._ === 'user' && this.peer.self! + } + /** * Title, for supergroups, channels and groups */