From 337418a34c1c49f3ba8ba3a1f4d85ce1d0ef8e54 Mon Sep 17 00:00:00 2001 From: Alina Sireneva Date: Wed, 11 Oct 2023 08:24:11 +0300 Subject: [PATCH] feat: contexts --- packages/client/scripts/generate-client.js | 2 +- packages/client/scripts/generate-updates.js | 3 +- packages/client/scripts/update-types.txt | 18 +- packages/client/src/client.ts | 382 +++++++----------- packages/client/src/methods/_imports.ts | 1 + packages/client/src/methods/_init.ts | 5 + packages/client/src/methods/auth/run.ts | 1 + .../invite-links/hide-all-join-requests.ts | 6 +- .../methods/invite-links/hide-join-request.ts | 6 +- .../src/methods/messages/get-reply-to.ts | 28 ++ .../src/methods/messages/send-answer.ts | 55 +++ .../src/methods/messages/send-comment.ts | 95 +++++ .../src/methods/messages/send-common.ts | 119 ++++++ .../src/methods/messages/send-copy-group.ts | 70 ++++ .../client/src/methods/messages/send-copy.ts | 46 +-- .../src/methods/messages/send-media-group.ts | 98 +---- .../client/src/methods/messages/send-media.ts | 116 +----- .../client/src/methods/messages/send-reply.ts | 43 ++ .../client/src/methods/messages/send-text.ts | 110 +---- .../src/methods/stickers/get-custom-emojis.ts | 33 +- .../src/types/messages/input-message-id.ts | 3 + packages/client/src/types/messages/message.ts | 10 +- .../types/updates/bot-chat-join-request.ts | 7 - .../src/types/updates/chat-join-request.ts | 17 - .../src/types/updates/chosen-inline-result.ts | 8 - .../client/src/types/updates/poll-update.ts | 13 +- packages/core/src/utils/long-utils.ts | 10 + packages/dispatcher/scripts/generate.js | 65 +-- packages/dispatcher/src/context/base.ts | 8 + .../dispatcher/src/context/callback-query.ts | 64 +++ .../src/context/chat-join-request.ts | 39 ++ .../src/context/chosen-inline-result.ts | 38 ++ packages/dispatcher/src/context/index.ts | 7 + .../dispatcher/src/context/inline-query.ts | 24 ++ packages/dispatcher/src/context/message.ts | 170 ++++++++ packages/dispatcher/src/context/parse.ts | 37 ++ .../src/context/pre-checkout-query.ts | 29 ++ packages/dispatcher/src/dispatcher.ts | 208 +++++----- packages/dispatcher/src/filters/bots.ts | 29 +- packages/dispatcher/src/filters/bundle.ts | 1 + packages/dispatcher/src/filters/chat.ts | 114 +++--- packages/dispatcher/src/filters/group.ts | 88 ++++ packages/dispatcher/src/filters/logic.ts | 78 ---- packages/dispatcher/src/filters/message.ts | 7 + packages/dispatcher/src/filters/state.ts | 6 +- packages/dispatcher/src/filters/text.ts | 48 +-- packages/dispatcher/src/filters/types.ts | 5 +- packages/dispatcher/src/filters/user.ts | 191 ++++----- packages/dispatcher/src/handler.ts | 59 +-- packages/dispatcher/src/index.ts | 1 + packages/dispatcher/src/wizard.ts | 7 +- 51 files changed, 1550 insertions(+), 1078 deletions(-) create mode 100644 packages/client/src/methods/messages/get-reply-to.ts create mode 100644 packages/client/src/methods/messages/send-answer.ts create mode 100644 packages/client/src/methods/messages/send-comment.ts create mode 100644 packages/client/src/methods/messages/send-common.ts create mode 100644 packages/client/src/methods/messages/send-copy-group.ts create mode 100644 packages/client/src/methods/messages/send-reply.ts create mode 100644 packages/dispatcher/src/context/base.ts create mode 100644 packages/dispatcher/src/context/callback-query.ts create mode 100644 packages/dispatcher/src/context/chat-join-request.ts create mode 100644 packages/dispatcher/src/context/chosen-inline-result.ts create mode 100644 packages/dispatcher/src/context/index.ts create mode 100644 packages/dispatcher/src/context/inline-query.ts create mode 100644 packages/dispatcher/src/context/message.ts create mode 100644 packages/dispatcher/src/context/parse.ts create mode 100644 packages/dispatcher/src/context/pre-checkout-query.ts create mode 100644 packages/dispatcher/src/filters/group.ts diff --git a/packages/client/scripts/generate-client.js b/packages/client/scripts/generate-client.js index dac19e91..bd24ac12 100644 --- a/packages/client/scripts/generate-client.js +++ b/packages/client/scripts/generate-client.js @@ -343,7 +343,7 @@ async function addSingleMethod(state, fileName) { state.imports[module] = new Set() } - state.imports[module].add(name) + if (!isManual) state.imports[module].add(name) } } } else if (stmt.kind === ts.SyntaxKind.InterfaceDeclaration) { diff --git a/packages/client/scripts/generate-updates.js b/packages/client/scripts/generate-updates.js index 85b3af8b..c02734cc 100644 --- a/packages/client/scripts/generate-updates.js +++ b/packages/client/scripts/generate-updates.js @@ -26,7 +26,7 @@ function parseUpdateTypes() { const ret = [] for (const line of lines) { - const m = line.match(/^([a-z_]+)(?:: ([a-zA-Z]+))? = ([a-zA-Z]+(?:\[\])?)( \+ State)?$/) + const m = line.match(/^([a-z_]+)(?:: ([a-zA-Z]+))? = ([a-zA-Z]+(?:\[\])?)( \+ State)?(?: in ([a-zA-Z]+))?$/) if (!m) throw new Error(`invalid syntax: ${line}`) ret.push({ typeName: m[1], @@ -34,6 +34,7 @@ function parseUpdateTypes() { updateType: m[3], funcName: m[2] ? m[2][0].toLowerCase() + m[2].substr(1) : snakeToCamel(m[1]), state: Boolean(m[4]), + context: m[5] ?? `UpdateContext<${m[3]}>`, }) } diff --git a/packages/client/scripts/update-types.txt b/packages/client/scripts/update-types.txt index 53a6d7d9..b3346593 100644 --- a/packages/client/scripts/update-types.txt +++ b/packages/client/scripts/update-types.txt @@ -1,20 +1,20 @@ -# format: type_name[: handler_type_name] = update_type[ + State] -new_message = Message + State -edit_message = Message + State -message_group = Message[] + State +# format: type_name[: handler_type_name] = update_type[ + State][in ContextClassName] +new_message = Message + State in MessageContext +edit_message = Message + State in MessageContext +message_group = Message[] + State in MessageContext delete_message = DeleteMessageUpdate chat_member: ChatMemberUpdate = ChatMemberUpdate -inline_query = InlineQuery -chosen_inline_result = ChosenInlineResult -callback_query = CallbackQuery + State +inline_query = InlineQuery in InlineQueryContext +chosen_inline_result = ChosenInlineResult in ChosenInlineResultContext +callback_query = CallbackQuery + State in CallbackQueryContext poll: PollUpdate = PollUpdate poll_vote = PollVoteUpdate user_status: UserStatusUpdate = UserStatusUpdate user_typing = UserTypingUpdate history_read = HistoryReadUpdate bot_stopped = BotStoppedUpdate -bot_chat_join_request = BotChatJoinRequestUpdate +bot_chat_join_request = BotChatJoinRequestUpdate in ChatJoinRequestUpdateContext chat_join_request = ChatJoinRequestUpdate -pre_checkout_query = PreCheckoutQuery +pre_checkout_query = PreCheckoutQuery in PreCheckoutQueryContext story: StoryUpdate = StoryUpdate delete_story = DeleteStoryUpdate \ No newline at end of file diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 0ca9d89b..47a3dc4c 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -20,7 +20,7 @@ import { getPasswordHint } from './methods/auth/get-password-hint' import { logOut } from './methods/auth/log-out' import { recoverPassword } from './methods/auth/recover-password' import { resendCode } from './methods/auth/resend-code' -import { run } from './methods/auth/run' +import {} from './methods/auth/run' import { sendCode } from './methods/auth/send-code' import { sendRecoveryCode } from './methods/auth/send-recovery-code' import { signIn } from './methods/auth/sign-in' @@ -141,6 +141,7 @@ import { getMessageReactions, getMessageReactionsById } from './methods/messages import { getMessages } from './methods/messages/get-messages' import { getMessagesUnsafe } from './methods/messages/get-messages-unsafe' import { getReactionUsers, GetReactionUsersOffset } from './methods/messages/get-reaction-users' +import { getReplyTo } from './methods/messages/get-reply-to' import { getScheduledMessages } from './methods/messages/get-scheduled-messages' import { iterHistory } from './methods/messages/iter-history' import { iterReactionUsers } from './methods/messages/iter-reaction-users' @@ -152,10 +153,15 @@ import { readHistory } from './methods/messages/read-history' import { readReactions } from './methods/messages/read-reactions' import { searchGlobal, SearchGlobalOffset } from './methods/messages/search-global' import { searchMessages, SearchMessagesOffset } from './methods/messages/search-messages' +import { answerMedia, answerMediaGroup, answerText } from './methods/messages/send-answer' +import { commentMedia, commentMediaGroup, commentText } from './methods/messages/send-comment' +import { CommonSendParams } from './methods/messages/send-common' import { sendCopy, SendCopyParams } from './methods/messages/send-copy' +import { sendCopyGroup, SendCopyGroupParams } from './methods/messages/send-copy-group' import { sendMedia } from './methods/messages/send-media' import { sendMediaGroup } from './methods/messages/send-media-group' import { sendReaction } from './methods/messages/send-reaction' +import { replyMedia, replyMediaGroup, replyText } from './methods/messages/send-reply' import { sendScheduled } from './methods/messages/send-scheduled' import { sendText } from './methods/messages/send-text' import { sendTyping } from './methods/messages/send-typing' @@ -180,7 +186,7 @@ import { removeCloudPassword } from './methods/password/remove-cloud-password' import { addStickerToSet } from './methods/stickers/add-sticker-to-set' import { createStickerSet } from './methods/stickers/create-sticker-set' import { deleteStickerFromSet } from './methods/stickers/delete-sticker-from-set' -import { getCustomEmojis } from './methods/stickers/get-custom-emojis' +import { getCustomEmojis, getCustomEmojisFromMessages } from './methods/stickers/get-custom-emojis' import { getInstalledStickers } from './methods/stickers/get-installed-stickers' import { getStickerSet } from './methods/stickers/get-sticker-set' import { moveStickerInSet } from './methods/stickers/move-sticker-in-set' @@ -287,6 +293,7 @@ import { MessageEntity, MessageMedia, MessageReactions, + ParametersSkip2, ParsedUpdate, PeerReaction, PeersIndex, @@ -547,6 +554,7 @@ export interface TelegramClient extends BaseTelegramClient { * * @param params Parameters to be passed to {@link start} * @param then Function to be called after {@link start} returns + * @manual */ run(params: Parameters[1], then?: (user: User) => void | Promise): void /** @@ -2713,7 +2721,7 @@ export interface TelegramClient extends BaseTelegramClient { */ getPrimaryInviteLink(chatId: InputPeerLike): Promise /** - * Approve or deny multiple join requests to a chat. + * Approve or decline multiple join requests to a chat. * **Available**: 👤 users only * */ @@ -2721,14 +2729,14 @@ export interface TelegramClient extends BaseTelegramClient { /** Chat/channel ID */ chatId: InputPeerLike - /** Whether to approve or deny the join requests */ - action: 'approve' | 'deny' + /** Whether to approve or decline the join requests */ + action: 'approve' | 'decline' /** Invite link to target */ link?: string | ChatInviteLink }): Promise /** - * Approve or deny join request to a chat. + * Approve or decline join request to a chat. * **Available**: ✅ both users and bots * */ @@ -2737,8 +2745,8 @@ export interface TelegramClient extends BaseTelegramClient { chatId: InputPeerLike /** User ID */ user: InputPeerLike - /** Whether to approve or deny the join request */ - action: 'approve' | 'deny' + /** Whether to approve or decline the join request */ + action: 'approve' | 'decline' }): Promise /** * Iterate over users who have joined @@ -3215,6 +3223,16 @@ export interface TelegramClient extends BaseTelegramClient { offset?: GetReactionUsersOffset }, ): Promise> + /** + * For messages containing a reply, fetch the message that is being replied. + * + * Note that even if a message has {@link replyToMessageId}, + * the message itself may have been deleted, in which case + * this method will also return `null`. + * **Available**: ✅ both users and bots + * + */ + getReplyTo(message: Message): Promise /** * Get scheduled messages in chat by their IDs * @@ -3549,6 +3567,76 @@ export interface TelegramClient extends BaseTelegramClient { */ fromUser?: InputPeerLike }): Promise> + /** Send a text to the same chat (and topic, if applicable) as a given message */ + answerText(message: Message, ...params: ParametersSkip2): ReturnType + /** Send a media to the same chat (and topic, if applicable) as a given message */ + answerMedia(message: Message, ...params: ParametersSkip2): ReturnType + /** Send a media group to the same chat (and topic, if applicable) as a given message */ + answerMediaGroup( + message: Message, + ...params: ParametersSkip2 + ): ReturnType + /** + * Send a text comment to a given message. + * + * If this is a normal message (not a channel post), + * a simple reply will be sent. + * + * **Available**: ✅ both users and bots + * + * @throws MtArgumentError + * If this is a channel post which does not have comments section. + * To check if a post has comments, use {@link Message#replies}.hasComments + */ + commentText(message: Message, ...params: ParametersSkip2): ReturnType + /** + * Send a text comment to a given message. + * + * If this is a normal message (not a channel post), + * a simple reply will be sent. + * + * **Available**: ✅ both users and bots + * + * @throws MtArgumentError + * If this is a channel post which does not have comments section. + * To check if a post has comments, use {@link Message#replies}.hasComments + */ + commentMedia(message: Message, ...params: ParametersSkip2): ReturnType + /** + * Send a text comment to a given message. + * + * If this is a normal message (not a channel post), + * a simple reply will be sent. + * + * **Available**: ✅ both users and bots + * + * @throws MtArgumentError + * If this is a channel post which does not have comments section. + * To check if a post has comments, use {@link Message#replies}.hasComments + */ + commentMediaGroup( + message: Message, + ...params: ParametersSkip2 + ): ReturnType + /** + * Copy a message group (i.e. send the same message group, but do not forward it). + * + * Note that all the provided messages must be in the same message group + * **Available**: ✅ both users and bots + * + */ + sendCopyGroup( + params: SendCopyGroupParams & + ( + | { + /** Source chat ID */ + fromChatId: InputPeerLike + /** Message IDs to forward */ + messages: number[] + } + | { messages: Message[] } + ), + ): Promise /** * Copy a message (i.e. send the same message, but do not forward it). * @@ -3556,10 +3644,8 @@ export interface TelegramClient extends BaseTelegramClient { * it will be copied simply as a text message, * and if the message contains an invoice, * it can't be copied. - * * **Available**: ✅ both users and bots * - * @param params */ sendCopy( params: SendCopyParams & @@ -3589,56 +3675,7 @@ export interface TelegramClient extends BaseTelegramClient { sendMediaGroup( chatId: InputPeerLike, medias: (InputMediaLike | string)[], - 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 - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - - /** - * Message to comment to. Either a message object or message ID. - * - * This overwrites `replyTo` if it was passed - */ - commentTo?: number | Message - - /** - * Parse mode to use to parse entities before sending - * the message. Defaults to current default parse mode (if any). - * - * Passing `null` will explicitly disable formatting. - */ - parseMode?: string | null - - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - + params?: CommonSendParams & { /** * Function that will be called after some part has been uploaded. * Only used when a file that requires uploading is passed, @@ -3649,26 +3686,6 @@ export interface TelegramClient extends BaseTelegramClient { * @param total Total file size */ progressCallback?: (index: number, uploaded: number, total: number) => void - - /** - * Whether to clear draft after sending this message. - * - * Defaults to `false` - */ - clearDraft?: boolean - - /** - * Whether to disallow further forwards of this message. - * - * Only for bots, works even if the target chat does not - * have content protection. - */ - forbidForwards?: boolean - - /** - * Peer to use when sending the message. - */ - sendAs?: InputPeerLike }, ): Promise /** @@ -3687,7 +3704,13 @@ export interface TelegramClient extends BaseTelegramClient { sendMedia( chatId: InputPeerLike, media: InputMediaLike | string, - params?: { + params?: CommonSendParams & { + /** + * For bots: inline or reply markup or an instruction + * to hide a reply keyboard or to force a reply. + */ + replyMarkup?: ReplyMarkup + /** * Override caption for `media`. * @@ -3704,61 +3727,6 @@ export interface TelegramClient extends BaseTelegramClient { */ entities?: tl.TypeMessageEntity[] - /** - * 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 - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - - /** - * Message to comment to. Either a message object or message ID. - * - * This overwrites `replyTo` if it was passed - */ - commentTo?: number | Message - - /** - * Parse mode to use to parse entities before sending - * the message. Defaults to current default parse mode (if any). - * - * Passing `null` will explicitly disable formatting. - */ - parseMode?: string | null - - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - - /** - * For bots: inline or reply markup or an instruction - * to hide a reply keyboard or to force a reply. - */ - replyMarkup?: ReplyMarkup - /** * Function that will be called after some part has been uploaded. * Only used when a file that requires uploading is passed, @@ -3768,32 +3736,6 @@ export interface 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 - - /** - * Whether to disallow further forwards of this message. - * - * Only for bots, works even if the target chat does not - * have content protection. - */ - forbidForwards?: boolean - - /** - * Peer to use when sending the message. - */ - sendAs?: InputPeerLike - - /** - * Whether to dispatch the returned message - * to the client's update handler. - */ - shouldDispatch?: true }, ): Promise /** @@ -3817,6 +3759,15 @@ export interface TelegramClient extends BaseTelegramClient { shouldDispatch?: true }, ): Promise + /** Send a text in reply to a given message */ + replyText(message: Message, ...params: ParametersSkip2): ReturnType + /** Send a media in reply to a given message */ + replyMedia(message: Message, ...params: ParametersSkip2): ReturnType + /** Send a media group in reply to a given message */ + replyMediaGroup( + message: Message, + ...params: ParametersSkip2 + ): ReturnType /** * Send previously scheduled message(s) * @@ -3842,41 +3793,12 @@ export interface TelegramClient extends BaseTelegramClient { sendText( chatId: InputPeerLike, text: string | FormattedString, - params?: { + params?: CommonSendParams & { /** - * 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) + * For bots: inline or reply markup or an instruction + * to hide a reply keyboard or to force a reply. */ - replyTo?: number | Message - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - - /** - * Message to comment to. Either a message object or message ID. - * - * This overwrites `replyTo` if it was passed - */ - commentTo?: number | Message - - /** - * Parse mode to use to parse entities before sending - * the message. Defaults to current default parse mode (if any). - * - * Passing `null` will explicitly disable formatting. - */ - parseMode?: string | null + replyMarkup?: ReplyMarkup /** * List of formatting entities to use instead of parsing via a @@ -3890,52 +3812,6 @@ export interface TelegramClient extends BaseTelegramClient { * Whether to disable links preview in this message */ disableWebPreview?: boolean - - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - - /** - * For bots: inline or reply markup or an instruction - * 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 - - /** - * Whether to disallow further forwards of this message. - * - * Only for bots, works even if the target chat does not - * have content protection. - */ - forbidForwards?: boolean - - /** - * Peer to use when sending the message. - */ - sendAs?: InputPeerLike - - /** - * Whether to dispatch the returned message - * to the client's update handler. - */ - shouldDispatch?: true }, ): Promise /** @@ -4279,6 +4155,12 @@ export interface TelegramClient extends BaseTelegramClient { * @param ids IDs of the stickers (as defined in {@link MessageEntity.emojiId}) */ getCustomEmojis(ids: tl.Long[]): Promise + /** + * Given one or more messages, extract all unique custom emojis from it and fetch them + * **Available**: ✅ both users and bots + * + */ + getCustomEmojisFromMessages(messages: MaybeArray): Promise /** * Get a list of all installed sticker packs * @@ -5259,6 +5141,11 @@ export class TelegramClient extends BaseTelegramClient { } else { this.start = start.bind(null, this) } + this.run = (params, then) => { + this.start(params) + .then(then) + .catch((err) => this._emitError(err)) + } } getAuthState = getAuthState.bind(null, this) _onAuthorization = _onAuthorization.bind(null, this) @@ -5267,7 +5154,6 @@ export class TelegramClient extends BaseTelegramClient { logOut = logOut.bind(null, this) recoverPassword = recoverPassword.bind(null, this) resendCode = resendCode.bind(null, this) - run = run.bind(null, this) sendCode = sendCode.bind(null, this) sendRecoveryCode = sendRecoveryCode.bind(null, this) signInBot = signInBot.bind(null, this) @@ -5396,6 +5282,7 @@ export class TelegramClient extends BaseTelegramClient { getMessagesUnsafe = getMessagesUnsafe.bind(null, this) getMessages = getMessages.bind(null, this) getReactionUsers = getReactionUsers.bind(null, this) + getReplyTo = getReplyTo.bind(null, this) getScheduledMessages = getScheduledMessages.bind(null, this) iterHistory = iterHistory.bind(null, this) iterReactionUsers = iterReactionUsers.bind(null, this) @@ -5407,10 +5294,20 @@ export class TelegramClient extends BaseTelegramClient { readReactions = readReactions.bind(null, this) searchGlobal = searchGlobal.bind(null, this) searchMessages = searchMessages.bind(null, this) + answerText = answerText.bind(null, this) + answerMedia = answerMedia.bind(null, this) + answerMediaGroup = answerMediaGroup.bind(null, this) + commentText = commentText.bind(null, this) + commentMedia = commentMedia.bind(null, this) + commentMediaGroup = commentMediaGroup.bind(null, this) + sendCopyGroup = sendCopyGroup.bind(null, this) sendCopy = sendCopy.bind(null, this) sendMediaGroup = sendMediaGroup.bind(null, this) sendMedia = sendMedia.bind(null, this) sendReaction = sendReaction.bind(null, this) + replyText = replyText.bind(null, this) + replyMedia = replyMedia.bind(null, this) + replyMediaGroup = replyMediaGroup.bind(null, this) sendScheduled = sendScheduled.bind(null, this) sendText = sendText.bind(null, this) sendTyping = sendTyping.bind(null, this) @@ -5436,6 +5333,7 @@ export class TelegramClient extends BaseTelegramClient { createStickerSet = createStickerSet.bind(null, this) deleteStickerFromSet = deleteStickerFromSet.bind(null, this) getCustomEmojis = getCustomEmojis.bind(null, this) + getCustomEmojisFromMessages = getCustomEmojisFromMessages.bind(null, this) getInstalledStickers = getInstalledStickers.bind(null, this) getStickerSet = getStickerSet.bind(null, this) moveStickerInSet = moveStickerInSet.bind(null, this) diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index 5efdcdde..7008a832 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -53,6 +53,7 @@ import { MessageEntity, MessageMedia, MessageReactions, + ParametersSkip2, ParsedUpdate, PeerReaction, PeersIndex, diff --git a/packages/client/src/methods/_init.ts b/packages/client/src/methods/_init.ts index 5e58bc60..054e92cd 100644 --- a/packages/client/src/methods/_init.ts +++ b/packages/client/src/methods/_init.ts @@ -45,4 +45,9 @@ function _initializeClient(this: TelegramClient, opts: TelegramClientOptions) { } else { this.start = start.bind(null, this) } + this.run = (params, then) => { + this.start(params) + .then(then) + .catch((err) => this._emitError(err)) + } } diff --git a/packages/client/src/methods/auth/run.ts b/packages/client/src/methods/auth/run.ts index 3d78cc8f..a385616e 100644 --- a/packages/client/src/methods/auth/run.ts +++ b/packages/client/src/methods/auth/run.ts @@ -13,6 +13,7 @@ import { start } from './start' * * @param params Parameters to be passed to {@link start} * @param then Function to be called after {@link start} returns + * @manual */ export function run( client: BaseTelegramClient, diff --git a/packages/client/src/methods/invite-links/hide-all-join-requests.ts b/packages/client/src/methods/invite-links/hide-all-join-requests.ts index ccc43386..7273fea6 100644 --- a/packages/client/src/methods/invite-links/hide-all-join-requests.ts +++ b/packages/client/src/methods/invite-links/hide-all-join-requests.ts @@ -4,7 +4,7 @@ import type { ChatInviteLink, InputPeerLike } from '../../types' import { resolvePeer } from '../users/resolve-peer' /** - * Approve or deny multiple join requests to a chat. + * Approve or decline multiple join requests to a chat. */ export async function hideAllJoinRequests( client: BaseTelegramClient, @@ -12,8 +12,8 @@ export async function hideAllJoinRequests( /** Chat/channel ID */ chatId: InputPeerLike - /** Whether to approve or deny the join requests */ - action: 'approve' | 'deny' + /** Whether to approve or decline the join requests */ + action: 'approve' | 'decline' /** Invite link to target */ link?: string | ChatInviteLink diff --git a/packages/client/src/methods/invite-links/hide-join-request.ts b/packages/client/src/methods/invite-links/hide-join-request.ts index b063d7e8..f30f885c 100644 --- a/packages/client/src/methods/invite-links/hide-join-request.ts +++ b/packages/client/src/methods/invite-links/hide-join-request.ts @@ -5,7 +5,7 @@ import { normalizeToInputUser } from '../../utils/peer-utils' import { resolvePeer } from '../users/resolve-peer' /** - * Approve or deny join request to a chat. + * Approve or decline join request to a chat. */ export async function hideJoinRequest( client: BaseTelegramClient, @@ -14,8 +14,8 @@ export async function hideJoinRequest( chatId: InputPeerLike /** User ID */ user: InputPeerLike - /** Whether to approve or deny the join request */ - action: 'approve' | 'deny' + /** Whether to approve or decline the join request */ + action: 'approve' | 'decline' }, ): Promise { const { chatId, user, action } = params diff --git a/packages/client/src/methods/messages/get-reply-to.ts b/packages/client/src/methods/messages/get-reply-to.ts new file mode 100644 index 00000000..81bd4175 --- /dev/null +++ b/packages/client/src/methods/messages/get-reply-to.ts @@ -0,0 +1,28 @@ +import { BaseTelegramClient } from '@mtcute/core' + +import { Message } from '../../types/messages' +import { getMessages } from './get-messages' +import { getMessagesUnsafe } from './get-messages-unsafe' + +/** + * For messages containing a reply, fetch the message that is being replied. + * + * Note that even if a message has {@link replyToMessageId}, + * the message itself may have been deleted, in which case + * this method will also return `null`. + */ +export async function getReplyTo(client: BaseTelegramClient, message: Message): Promise { + if (!message.replyToMessageId) { + return null + } + + if (message.raw.peerId._ === 'peerChannel') { + const [msg] = await getMessages(client, message.chat.inputPeer, message.id, true) + + return msg + } + + const [msg] = await getMessagesUnsafe(client, message.id, true) + + return msg +} diff --git a/packages/client/src/methods/messages/send-answer.ts b/packages/client/src/methods/messages/send-answer.ts new file mode 100644 index 00000000..93961a9e --- /dev/null +++ b/packages/client/src/methods/messages/send-answer.ts @@ -0,0 +1,55 @@ +import { BaseTelegramClient } from '@mtcute/core' + +import { Message } from '../../types/messages/message' +import { ParametersSkip2 } from '../../types/utils' +import { sendMedia } from './send-media' +import { sendMediaGroup } from './send-media-group' +import { sendText } from './send-text' + +/** Send a text to the same chat (and topic, if applicable) as a given message */ +export function answerText( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + if (!message.isTopicMessage || !message.replyToThreadId) { + return sendText(client, message.chat.inputPeer, ...params) + } + + const [text, params_ = {}] = params + params_.replyTo = message.replyToThreadId + + return sendText(client, message.chat.inputPeer, text, params_) +} + +/** Send a media to the same chat (and topic, if applicable) as a given message */ +export function answerMedia( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + if (!message.isTopicMessage || !message.replyToThreadId) { + return sendMedia(client, message.chat.inputPeer, ...params) + } + + const [media, params_ = {}] = params + params_.replyTo = message.replyToThreadId + + return sendMedia(client, message.chat.inputPeer, media, params_) +} + +/** Send a media group to the same chat (and topic, if applicable) as a given message */ +export function answerMediaGroup( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + if (!message.isTopicMessage || !message.replyToThreadId) { + return sendMediaGroup(client, message.chat.inputPeer, ...params) + } + + const [media, params_ = {}] = params + params_.replyTo = message.replyToThreadId + + return sendMediaGroup(client, message.chat.inputPeer, media, params_) +} diff --git a/packages/client/src/methods/messages/send-comment.ts b/packages/client/src/methods/messages/send-comment.ts new file mode 100644 index 00000000..3ef6431a --- /dev/null +++ b/packages/client/src/methods/messages/send-comment.ts @@ -0,0 +1,95 @@ +import { BaseTelegramClient, MtArgumentError } from '@mtcute/core' + +import { Message } from '../../types/messages/message' +import { ParametersSkip2 } from '../../types/utils' +import { sendMedia } from './send-media' +import { sendMediaGroup } from './send-media-group' +import { replyMedia, replyMediaGroup, replyText } from './send-reply' +import { sendText } from './send-text' + +/** + * Send a text comment to a given message. + * + * If this is a normal message (not a channel post), + * a simple reply will be sent. + * + * @throws MtArgumentError + * If this is a channel post which does not have comments section. + * To check if a post has comments, use {@link Message#replies}.hasComments + */ +export function commentText( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + if (message.chat.chatType !== 'channel') { + return replyText(client, message, ...params) + } + + if (!message.replies || !message.replies.hasComments) { + throw new MtArgumentError('This message does not have comments section') + } + + const [text, params_ = {}] = params + params_.commentTo = message.id + + return sendText(client, message.chat.inputPeer, text, params_) +} + +/** + * Send a text comment to a given message. + * + * If this is a normal message (not a channel post), + * a simple reply will be sent. + * + * @throws MtArgumentError + * If this is a channel post which does not have comments section. + * To check if a post has comments, use {@link Message#replies}.hasComments + */ +export function commentMedia( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + if (message.chat.chatType !== 'channel') { + return replyMedia(client, message, ...params) + } + + if (!message.replies || !message.replies.hasComments) { + throw new MtArgumentError('This message does not have comments section') + } + + const [media, params_ = {}] = params + params_.commentTo = message.id + + return sendMedia(client, message.chat.inputPeer, media, params_) +} + +/** + * Send a text comment to a given message. + * + * If this is a normal message (not a channel post), + * a simple reply will be sent. + * + * @throws MtArgumentError + * If this is a channel post which does not have comments section. + * To check if a post has comments, use {@link Message#replies}.hasComments + */ +export function commentMediaGroup( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + if (message.chat.chatType !== 'channel') { + return replyMediaGroup(client, message, ...params) + } + + if (!message.replies || !message.replies.hasComments) { + throw new MtArgumentError('This message does not have comments section') + } + + const [media, params_ = {}] = params + params_.commentTo = message.id + + return sendMediaGroup(client, message.chat.inputPeer, media, params_) +} diff --git a/packages/client/src/methods/messages/send-common.ts b/packages/client/src/methods/messages/send-common.ts new file mode 100644 index 00000000..8b9f4cbc --- /dev/null +++ b/packages/client/src/methods/messages/send-common.ts @@ -0,0 +1,119 @@ +import { BaseTelegramClient, getMarkedPeerId, MtArgumentError } from '@mtcute/core' + +import { MtMessageNotFoundError } from '../../types/errors' +import { Message } from '../../types/messages/message' +import { InputPeerLike } from '../../types/peers' +import { normalizeMessageId } from '../../utils' +import { resolvePeer } from '../users/resolve-peer' +import { _getDiscussionMessage } from './get-discussion-message' +import { getMessages } from './get-messages' + +// @exported +export interface CommonSendParams { + /** + * 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 + + /** + * Whether to throw an error if {@link replyTo} + * message does not exist. + * + * If that message was not found, `NotFoundError` is thrown, + * with `text` set to `MESSAGE_NOT_FOUND`. + * + * Incurs an additional request, so only use when really needed. + * + * Defaults to `false` + */ + mustReply?: boolean + + /** + * Message to comment to. Either a message object or message ID. + * + * This overwrites `replyTo` if it was passed + */ + commentTo?: number | Message + + /** + * Parse mode to use to parse entities before sending + * the message. Defaults to current default parse mode (if any). + * + * Passing `null` will explicitly disable formatting. + */ + parseMode?: string | null + + /** + * Whether to send this message silently. + */ + silent?: boolean + + /** + * If set, the message will be scheduled to this date. + * When passing a number, a UNIX time in ms is expected. + * + * You can also pass `0x7FFFFFFE`, this will send the message + * once the peer is online + */ + schedule?: Date | number + + /** + * Whether to clear draft after sending this message. + * + * Defaults to `false` + */ + clearDraft?: boolean + + /** + * Whether to disallow further forwards of this message. + * + * Only for bots, works even if the target chat does not + * have content protection. + */ + forbidForwards?: boolean + + /** + * Peer to use when sending the message. + */ + sendAs?: InputPeerLike + + /** + * Whether to dispatch the returned message + * to the client's update handler. + */ + shouldDispatch?: true +} + +/** + * @internal + * @noemit + */ +export async function _processCommonSendParameters( + client: BaseTelegramClient, + chatId: InputPeerLike, + params: CommonSendParams, +) { + let peer = await resolvePeer(client, chatId) + + let replyTo = normalizeMessageId(params.replyTo) + + if (params.commentTo) { + [peer, replyTo] = await _getDiscussionMessage(client, peer, normalizeMessageId(params.commentTo)!) + } + + if (params.mustReply) { + if (!replyTo) { + throw new MtArgumentError('mustReply used, but replyTo was not passed') + } + + const msg = await getMessages(client, peer, replyTo) + + if (!msg) { + throw new MtMessageNotFoundError(getMarkedPeerId(peer), replyTo, 'to reply to') + } + } + + return { peer, replyTo } +} diff --git a/packages/client/src/methods/messages/send-copy-group.ts b/packages/client/src/methods/messages/send-copy-group.ts new file mode 100644 index 00000000..d1301b7b --- /dev/null +++ b/packages/client/src/methods/messages/send-copy-group.ts @@ -0,0 +1,70 @@ +import { BaseTelegramClient, MtArgumentError, tl } from '@mtcute/core' +import { isPresent } from '@mtcute/core/utils' + +import { Message } from '../../types/messages/message' +import { InputPeerLike } from '../../types/peers' +import { resolvePeer } from '../users/resolve-peer' +import { getMessages } from './get-messages' +import { CommonSendParams } from './send-common' +import { sendMediaGroup } from './send-media-group' + +// @exported +export interface SendCopyGroupParams extends CommonSendParams { + /** Destination chat ID */ + toChatId: InputPeerLike +} + +/** + * Copy a message group (i.e. send the same message group, but do not forward it). + * + * Note that all the provided messages must be in the same message group + */ +export async function sendCopyGroup( + client: BaseTelegramClient, + params: SendCopyGroupParams & + ( + | { + /** Source chat ID */ + fromChatId: InputPeerLike + /** Message IDs to forward */ + messages: number[] + } + | { messages: Message[] } + ), +): Promise { + const { toChatId, ...rest } = params + + let msgs + + if ('fromChatId' in params) { + const fromPeer = await resolvePeer(client, params.fromChatId) + + msgs = await getMessages(client, fromPeer, params.messages).then((r) => r.filter(isPresent)) + } else { + msgs = params.messages + } + + const messageGroupId = msgs[0].groupedId! + + for (let i = 1; i < msgs.length; i++) { + if (!msgs[i].groupedId?.eq(messageGroupId) || !msgs[i].media) { + throw new MtArgumentError('All messages must be in the same message group') + } + } + + return sendMediaGroup( + client, + toChatId, + msgs.map((msg) => { + const raw = msg.raw as tl.RawMessage + + return { + type: 'auto', + file: msg.media!.inputMedia, + caption: raw.message, + entities: raw.entities, + } + }), + rest, + ) +} diff --git a/packages/client/src/methods/messages/send-copy.ts b/packages/client/src/methods/messages/send-copy.ts index d9cd8d75..499f74fe 100644 --- a/packages/client/src/methods/messages/send-copy.ts +++ b/packages/client/src/methods/messages/send-copy.ts @@ -3,28 +3,15 @@ import { BaseTelegramClient, getMarkedPeerId, MtArgumentError, tl } from '@mtcut import { FormattedString, InputPeerLike, Message, MtMessageNotFoundError, ReplyMarkup } from '../../types' import { resolvePeer } from '../users/resolve-peer' import { getMessages } from './get-messages' +import { CommonSendParams } from './send-common' import { sendMedia } from './send-media' import { sendText } from './send-text' // @exported -export interface SendCopyParams { +export interface SendCopyParams extends CommonSendParams { /** Target chat ID */ toChatId: InputPeerLike - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - /** * New message caption (only used for media) */ @@ -38,26 +25,6 @@ export interface SendCopyParams { */ parseMode?: string | null - /** - * 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 - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - /** * List of formatting entities to use instead of parsing via a * parse mode. @@ -71,13 +38,6 @@ export interface SendCopyParams { * 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 } /** @@ -87,8 +47,6 @@ export interface SendCopyParams { * it will be copied simply as a text message, * and if the message contains an invoice, * it can't be copied. - * - * @param params */ export async function sendCopy( client: BaseTelegramClient, diff --git a/packages/client/src/methods/messages/send-media-group.ts b/packages/client/src/methods/messages/send-media-group.ts index 5777ccf4..02b34dc0 100644 --- a/packages/client/src/methods/messages/send-media-group.ts +++ b/packages/client/src/methods/messages/send-media-group.ts @@ -1,17 +1,16 @@ -import { BaseTelegramClient, getMarkedPeerId, MtArgumentError, tl } from '@mtcute/core' +import { BaseTelegramClient, tl } from '@mtcute/core' import { randomLong } from '@mtcute/core/utils' -import { MtMessageNotFoundError } from '../../types/errors' import { InputMediaLike } from '../../types/media/input-media' import { Message } from '../../types/messages/message' import { InputPeerLike, PeersIndex } from '../../types/peers' -import { normalizeDate, normalizeMessageId } from '../../utils/misc-utils' +import { normalizeDate } from '../../utils/misc-utils' import { assertIsUpdatesGroup } from '../../utils/updates-utils' import { _normalizeInputMedia } from '../files/normalize-input-media' import { resolvePeer } from '../users/resolve-peer' import { _getDiscussionMessage } from './get-discussion-message' -import { getMessages } from './get-messages' import { _parseEntities } from './parse-entities' +import { _processCommonSendParameters, CommonSendParams } from './send-common' /** * Send a group of media. @@ -28,56 +27,7 @@ export async function sendMediaGroup( client: BaseTelegramClient, chatId: InputPeerLike, medias: (InputMediaLike | string)[], - 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 - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - - /** - * Message to comment to. Either a message object or message ID. - * - * This overwrites `replyTo` if it was passed - */ - commentTo?: number | Message - - /** - * Parse mode to use to parse entities before sending - * the message. Defaults to current default parse mode (if any). - * - * Passing `null` will explicitly disable formatting. - */ - parseMode?: string | null - - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - + params?: CommonSendParams & { /** * Function that will be called after some part has been uploaded. * Only used when a file that requires uploading is passed, @@ -88,49 +38,11 @@ export async function sendMediaGroup( * @param total Total file size */ progressCallback?: (index: number, uploaded: number, total: number) => void - - /** - * Whether to clear draft after sending this message. - * - * Defaults to `false` - */ - clearDraft?: boolean - - /** - * Whether to disallow further forwards of this message. - * - * Only for bots, works even if the target chat does not - * have content protection. - */ - forbidForwards?: boolean - - /** - * Peer to use when sending the message. - */ - sendAs?: InputPeerLike }, ): Promise { if (!params) params = {} - let peer = await resolvePeer(client, chatId) - - let replyTo = normalizeMessageId(params.replyTo) - - if (params.commentTo) { - [peer, replyTo] = await _getDiscussionMessage(client, peer, normalizeMessageId(params.commentTo)!) - } - - if (params.mustReply) { - if (!replyTo) { - throw new MtArgumentError('mustReply used, but replyTo was not passed') - } - - const msg = await getMessages(client, peer, replyTo) - - if (!msg) { - throw new MtMessageNotFoundError(getMarkedPeerId(peer), replyTo, 'to reply to') - } - } + const { peer, replyTo } = await _processCommonSendParameters(client, chatId, params) const multiMedia: tl.RawInputSingleMedia[] = [] diff --git a/packages/client/src/methods/messages/send-media.ts b/packages/client/src/methods/messages/send-media.ts index 8a733d9f..67badb35 100644 --- a/packages/client/src/methods/messages/send-media.ts +++ b/packages/client/src/methods/messages/send-media.ts @@ -1,19 +1,18 @@ -import { BaseTelegramClient, getMarkedPeerId, MtArgumentError, tl } from '@mtcute/core' +import { BaseTelegramClient, tl } from '@mtcute/core' import { randomLong } from '@mtcute/core/utils' import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards' -import { MtMessageNotFoundError } from '../../types/errors' import { InputMediaLike } from '../../types/media/input-media' import { Message } from '../../types/messages/message' import { FormattedString } from '../../types/parser' import { InputPeerLike } from '../../types/peers' -import { normalizeDate, normalizeMessageId } from '../../utils/misc-utils' +import { normalizeDate } from '../../utils/misc-utils' import { _normalizeInputMedia } from '../files/normalize-input-media' import { resolvePeer } from '../users/resolve-peer' import { _findMessageInUpdate } from './find-in-update' import { _getDiscussionMessage } from './get-discussion-message' -import { getMessages } from './get-messages' import { _parseEntities } from './parse-entities' +import { _processCommonSendParameters, CommonSendParams } from './send-common' /** * Send a single media (a photo or a document-based media) @@ -30,7 +29,13 @@ export async function sendMedia( client: BaseTelegramClient, chatId: InputPeerLike, media: InputMediaLike | string, - params?: { + params?: CommonSendParams & { + /** + * For bots: inline or reply markup or an instruction + * to hide a reply keyboard or to force a reply. + */ + replyMarkup?: ReplyMarkup + /** * Override caption for `media`. * @@ -47,61 +52,6 @@ export async function sendMedia( */ entities?: tl.TypeMessageEntity[] - /** - * 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 - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - - /** - * Message to comment to. Either a message object or message ID. - * - * This overwrites `replyTo` if it was passed - */ - commentTo?: number | Message - - /** - * Parse mode to use to parse entities before sending - * the message. Defaults to current default parse mode (if any). - * - * Passing `null` will explicitly disable formatting. - */ - parseMode?: string | null - - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - - /** - * For bots: inline or reply markup or an instruction - * to hide a reply keyboard or to force a reply. - */ - replyMarkup?: ReplyMarkup - /** * Function that will be called after some part has been uploaded. * Only used when a file that requires uploading is passed, @@ -111,32 +61,6 @@ 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 - - /** - * Whether to disallow further forwards of this message. - * - * Only for bots, works even if the target chat does not - * have content protection. - */ - forbidForwards?: boolean - - /** - * Peer to use when sending the message. - */ - sendAs?: InputPeerLike - - /** - * Whether to dispatch the returned message - * to the client's update handler. - */ - shouldDispatch?: true }, ): Promise { if (!params) params = {} @@ -160,26 +84,8 @@ export async function sendMedia( params.entities || (media as Extract).entities, ) - let peer = await resolvePeer(client, chatId) const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) - - let replyTo = normalizeMessageId(params.replyTo) - - if (params.commentTo) { - [peer, replyTo] = await _getDiscussionMessage(client, peer, normalizeMessageId(params.commentTo)!) - } - - if (params.mustReply) { - if (!replyTo) { - throw new MtArgumentError('mustReply used, but replyTo was not passed') - } - - const msg = await getMessages(client, peer, replyTo) - - if (!msg) { - throw new MtMessageNotFoundError(getMarkedPeerId(peer), replyTo, 'to reply to') - } - } + const { peer, replyTo } = await _processCommonSendParameters(client, chatId, params) const res = await client.call({ _: 'messages.sendMedia', diff --git a/packages/client/src/methods/messages/send-reply.ts b/packages/client/src/methods/messages/send-reply.ts new file mode 100644 index 00000000..38bd7e89 --- /dev/null +++ b/packages/client/src/methods/messages/send-reply.ts @@ -0,0 +1,43 @@ +import { BaseTelegramClient } from '@mtcute/core' + +import { Message } from '../../types/messages/message' +import { ParametersSkip2 } from '../../types/utils' +import { sendMedia } from './send-media' +import { sendMediaGroup } from './send-media-group' +import { sendText } from './send-text' + +/** Send a text in reply to a given message */ +export function replyText( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + const [text, params_ = {}] = params + params_.replyTo = message.id + + return sendText(client, message.chat.inputPeer, text, params_) +} + +/** Send a media in reply to a given message */ +export function replyMedia( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + const [media, params_ = {}] = params + params_.replyTo = message.id + + return sendMedia(client, message.chat.inputPeer, media, params_) +} + +/** Send a media group in reply to a given message */ +export function replyMediaGroup( + client: BaseTelegramClient, + message: Message, + ...params: ParametersSkip2 +): ReturnType { + const [media, params_ = {}] = params + params_.replyTo = message.id + + return sendMediaGroup(client, message.chat.inputPeer, media, params_) +} diff --git a/packages/client/src/methods/messages/send-text.ts b/packages/client/src/methods/messages/send-text.ts index ece3d394..ffd6af81 100644 --- a/packages/client/src/methods/messages/send-text.ts +++ b/packages/client/src/methods/messages/send-text.ts @@ -1,20 +1,19 @@ -import { BaseTelegramClient, getMarkedPeerId, MtArgumentError, MtTypeAssertionError, tl } from '@mtcute/core' +import { BaseTelegramClient, getMarkedPeerId, MtTypeAssertionError, tl } from '@mtcute/core' import { randomLong } from '@mtcute/core/utils' import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards' -import { MtMessageNotFoundError } from '../../types/errors' import { Message } from '../../types/messages/message' import { FormattedString } from '../../types/parser' import { InputPeerLike, PeersIndex } from '../../types/peers' -import { normalizeDate, normalizeMessageId } from '../../utils/misc-utils' +import { normalizeDate } from '../../utils/misc-utils' import { inputPeerToPeer } from '../../utils/peer-utils' import { createDummyUpdate } from '../../utils/updates-utils' import { getAuthState } from '../auth/_state' import { resolvePeer } from '../users/resolve-peer' import { _findMessageInUpdate } from './find-in-update' import { _getDiscussionMessage } from './get-discussion-message' -import { getMessages } from './get-messages' import { _parseEntities } from './parse-entities' +import { _processCommonSendParameters, CommonSendParams } from './send-common' /** * Send a text message @@ -27,41 +26,12 @@ export async function sendText( client: BaseTelegramClient, chatId: InputPeerLike, text: string | FormattedString, - params?: { + params?: CommonSendParams & { /** - * 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) + * For bots: inline or reply markup or an instruction + * to hide a reply keyboard or to force a reply. */ - replyTo?: number | Message - - /** - * Whether to throw an error if {@link replyTo} - * message does not exist. - * - * If that message was not found, `NotFoundError` is thrown, - * with `text` set to `MESSAGE_NOT_FOUND`. - * - * Incurs an additional request, so only use when really needed. - * - * Defaults to `false` - */ - mustReply?: boolean - - /** - * Message to comment to. Either a message object or message ID. - * - * This overwrites `replyTo` if it was passed - */ - commentTo?: number | Message - - /** - * Parse mode to use to parse entities before sending - * the message. Defaults to current default parse mode (if any). - * - * Passing `null` will explicitly disable formatting. - */ - parseMode?: string | null + replyMarkup?: ReplyMarkup /** * List of formatting entities to use instead of parsing via a @@ -75,78 +45,14 @@ export async function sendText( * Whether to disable links preview in this message */ disableWebPreview?: boolean - - /** - * Whether to send this message silently. - */ - silent?: boolean - - /** - * If set, the message will be scheduled to this date. - * When passing a number, a UNIX time in ms is expected. - * - * You can also pass `0x7FFFFFFE`, this will send the message - * once the peer is online - */ - schedule?: Date | number - - /** - * For bots: inline or reply markup or an instruction - * 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 - - /** - * Whether to disallow further forwards of this message. - * - * Only for bots, works even if the target chat does not - * have content protection. - */ - forbidForwards?: boolean - - /** - * Peer to use when sending the message. - */ - sendAs?: InputPeerLike - - /** - * Whether to dispatch the returned message - * to the client's update handler. - */ - shouldDispatch?: true }, ): Promise { if (!params) params = {} const [message, entities] = await _parseEntities(client, text, params.parseMode, params.entities) - let peer = await resolvePeer(client, chatId) const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) - - let replyTo = normalizeMessageId(params.replyTo) - - if (params.commentTo) { - [peer, replyTo] = await _getDiscussionMessage(client, peer, normalizeMessageId(params.commentTo)!) - } - - if (params.mustReply) { - if (!replyTo) { - throw new MtArgumentError('mustReply used, but replyTo was not passed') - } - - const msg = await getMessages(client, peer, replyTo) - - if (!msg) { - throw new MtMessageNotFoundError(getMarkedPeerId(peer), replyTo, 'to reply to') - } - } + const { peer, replyTo } = await _processCommonSendParameters(client, chatId, params) const res = await client.call({ _: 'messages.sendMessage', diff --git a/packages/client/src/methods/stickers/get-custom-emojis.ts b/packages/client/src/methods/stickers/get-custom-emojis.ts index 8d7fa3e9..8254668d 100644 --- a/packages/client/src/methods/stickers/get-custom-emojis.ts +++ b/packages/client/src/methods/stickers/get-custom-emojis.ts @@ -1,7 +1,7 @@ -import { BaseTelegramClient, MtTypeAssertionError, tl } from '@mtcute/core' -import { assertTypeIs } from '@mtcute/core/utils' +import { BaseTelegramClient, MaybeArray, MtTypeAssertionError, tl } from '@mtcute/core' +import { assertTypeIs, LongSet } from '@mtcute/core/utils' -import { Sticker } from '../../types' +import { Message, Sticker } from '../../types' import { parseDocument } from '../../types/media/document-utils' /** @@ -27,3 +27,30 @@ export async function getCustomEmojis(client: BaseTelegramClient, ids: tl.Long[] return doc }) } + +/** + * Given one or more messages, extract all unique custom emojis from it and fetch them + */ +export async function getCustomEmojisFromMessages( + client: BaseTelegramClient, + messages: MaybeArray, +): Promise { + const set = new LongSet() + + if (!Array.isArray(messages)) messages = [messages] + + for (const { raw } of messages) { + if (raw._ === 'messageService' || !raw.entities) continue + + for (const entity of raw.entities) { + if (entity._ === 'messageEntityCustomEmoji') { + set.add(entity.documentId) + } + } + } + + const arr = set.toArray() + if (!arr.length) return [] + + return getCustomEmojis(client, arr) +} diff --git a/packages/client/src/types/messages/input-message-id.ts b/packages/client/src/types/messages/input-message-id.ts index 1843bbca..f7a67378 100644 --- a/packages/client/src/types/messages/input-message-id.ts +++ b/packages/client/src/types/messages/input-message-id.ts @@ -8,6 +8,9 @@ import type { Message } from './message' */ export type InputMessageId = { chatId: InputPeerLike; message: number } | { message: Message } +/** Remove {@link InputMessageId} type from the given type */ +export type OmitInputMessageId = Omit + /** @internal */ export function normalizeInputMessageId(id: InputMessageId) { if ('chatId' in id) return id diff --git a/packages/client/src/types/messages/message.ts b/packages/client/src/types/messages/message.ts index 57de3388..adf6a6c0 100644 --- a/packages/client/src/types/messages/message.ts +++ b/packages/client/src/types/messages/message.ts @@ -47,9 +47,10 @@ export interface MessageForwardInfo { /** Information about replies to a message */ export interface MessageRepliesInfo { /** - * Whether this is a comments thread under a channel post + * Whether this message is a channel post that has a comments thread + * in the linked discussion group */ - isComments: false + hasComments: false /** * Total number of replies @@ -75,7 +76,8 @@ export interface MessageRepliesInfo { /** Information about comments to a channel post */ export interface MessageCommentsInfo extends Omit { /** - * Whether this is a comments thread under a channel post + * Whether this message is a channel post that has a comments thread + * in the linked discussion group */ isComments: true @@ -303,7 +305,7 @@ export class Message { if (!this._replies) { const r = this.raw.replies const obj: MessageRepliesInfo = { - isComments: r.comments as false, + hasComments: r.comments as false, count: r.replies, hasUnread: r.readMaxId !== undefined && r.readMaxId !== r.maxId, lastMessageId: r.maxId, diff --git a/packages/client/src/types/updates/bot-chat-join-request.ts b/packages/client/src/types/updates/bot-chat-join-request.ts index 83c4acda..921c2322 100644 --- a/packages/client/src/types/updates/bot-chat-join-request.ts +++ b/packages/client/src/types/updates/bot-chat-join-request.ts @@ -68,13 +68,6 @@ export class BotChatJoinRequestUpdate { get invite(): ChatInviteLink { return (this._invite ??= new ChatInviteLink(this.raw.invite)) } - - /** - * Approve or deny the request. - */ - // hide(action: Parameters[1]['action']): Promise { - // return this.client.hideJoinRequest(this.chat.inputPeer, { action, user: this.user.inputPeer }) - // } } makeInspectable(BotChatJoinRequestUpdate) diff --git a/packages/client/src/types/updates/chat-join-request.ts b/packages/client/src/types/updates/chat-join-request.ts index 90e90eba..62226dc8 100644 --- a/packages/client/src/types/updates/chat-join-request.ts +++ b/packages/client/src/types/updates/chat-join-request.ts @@ -47,23 +47,6 @@ export class ChatJoinRequestUpdate { get totalPending(): number { return this.raw.requestsPending } - - /** - * Approve or deny the last requested user - */ - // hideLast(action: Parameters[1]['action']): Promise { - // return this.client.hideJoinRequest(this.chatId, { user: this.raw.recentRequesters[0], action }) - // } - - /** - * Approve or deny all recent requests - * (the ones available in {@link recentRequesters}) - */ - // async hideAllRecent(action: Parameters[1]['action']): Promise { - // for (const id of this.raw.recentRequesters) { - // await this.client.hideJoinRequest(this.chatId, { user: id, action }) - // } - // } } makeInspectable(ChatJoinRequestUpdate) diff --git a/packages/client/src/types/updates/chosen-inline-result.ts b/packages/client/src/types/updates/chosen-inline-result.ts index 5abd2236..c769193a 100644 --- a/packages/client/src/types/updates/chosen-inline-result.ts +++ b/packages/client/src/types/updates/chosen-inline-result.ts @@ -76,14 +76,6 @@ export class ChosenInlineResult { return encodeInlineMessageId(this.raw.msgId) } - - // async editMessage(params: Parameters[1]): Promise { - // if (!this.raw.msgId) { - // throw new MtArgumentError('No message ID, make sure you have included reply markup!') - // } - - // return this.client.editInlineMessage(this.raw.msgId, params) - // } } makeInspectable(ChosenInlineResult) diff --git a/packages/client/src/types/updates/poll-update.ts b/packages/client/src/types/updates/poll-update.ts index 8586edea..fb403812 100644 --- a/packages/client/src/types/updates/poll-update.ts +++ b/packages/client/src/types/updates/poll-update.ts @@ -24,12 +24,18 @@ export class PollUpdate { return this.raw.pollId } + /** + * Whether this is a shortened version of update, not containing the poll itself. + */ + get isShort(): boolean { + return this.raw.poll === undefined + } + private _poll?: Poll /** * The poll. * - * Note that sometimes the update does not have the poll - * (Telegram limitation), and mtcute creates a stub poll + * When {@link isShort} is set, mtcute creates a stub poll * with empty question, answers and flags * (like `quiz`, `public`, etc.) * @@ -39,8 +45,7 @@ export class PollUpdate { * * Bot API and TDLib do basically the same internally, * and thus are able to always provide them, - * but mtcute tries to keep it simple in terms of local - * storage and only stores the necessary information. + * but mtcute currently does not have a way to do that. */ get poll(): Poll { if (!this._poll) { diff --git a/packages/core/src/utils/long-utils.ts b/packages/core/src/utils/long-utils.ts index e61fc911..3dc3b109 100644 --- a/packages/core/src/utils/long-utils.ts +++ b/packages/core/src/utils/long-utils.ts @@ -159,4 +159,14 @@ export class LongSet { clear() { this._set.clear() } + + toArray() { + const arr: Long[] = [] + + for (const v of this._set) { + arr.push(longFromFastString(v)) + } + + return arr + } } diff --git a/packages/dispatcher/scripts/generate.js b/packages/dispatcher/scripts/generate.js index a805c3f0..4d06039e 100644 --- a/packages/dispatcher/scripts/generate.js +++ b/packages/dispatcher/scripts/generate.js @@ -8,19 +8,21 @@ function generateHandler() { types.forEach((type) => { lines.push( - `export type ${type.handlerTypeName}Handler = ParsedUpdateHandler<` + - `'${type.typeName}', T${type.state ? ', S' : ''}>`, + `export type ${type.handlerTypeName}Handler = ParsedUpdateHandler<` + + `'${type.typeName}', T${type.state ? ', S' : ''}>`, ) names.push(`${type.handlerTypeName}Handler`) }) - replaceSections('handler.ts', { - codegen: - lines.join('\n') + - '\n\nexport type UpdateHandler = \n' + - names.map((i) => ` | ${i}\n`).join(''), - }, __dirname) + replaceSections( + 'handler.ts', + { + codegen: + lines.join('\n') + '\n\nexport type UpdateHandler = \n' + names.map((i) => ` | ${i}\n`).join(''), + }, + __dirname, + ) } function generateDispatcher() { @@ -37,9 +39,13 @@ function generateDispatcher() { * @param handler ${toSentence(type, 'full')} * @param group Handler group index */ - on${type.handlerTypeName}(handler: ${type.handlerTypeName}Handler${type.state ? `<${type.updateType}, State extends never ? never : UpdateState>` : ''}['callback'], group?: number): void + on${type.handlerTypeName}(handler: ${type.handlerTypeName}Handler${ + type.state ? `<${type.context}, State extends never ? never : UpdateState>` : '' +}['callback'], group?: number): void -${type.state ? ` +${ + type.state ? + ` /** * Register ${toSentence(type)} with a filter * @@ -48,11 +54,15 @@ ${type.state ? ` * @param group Handler group index */ on${type.handlerTypeName}( - filter: UpdateFilter<${type.updateType}, Mod, State>, - handler: ${type.handlerTypeName}Handler, State extends never ? never : UpdateState>['callback'], + filter: UpdateFilter<${type.context}, Mod, State>, + handler: ${type.handlerTypeName}Handler, State extends never ? never : UpdateState>['callback'], group?: number ): void - ` : ''} + ` : + '' +} /** * Register ${toSentence(type)} with a filter @@ -62,8 +72,10 @@ ${type.state ? ` * @param group Handler group index */ on${type.handlerTypeName}( - filter: UpdateFilter<${type.updateType}, Mod>, - handler: ${type.handlerTypeName}Handler${type.state ? ', State extends never ? never : UpdateState' : ''}>['callback'], + filter: UpdateFilter<${type.context}, Mod>, + handler: ${type.handlerTypeName}Handler${ + type.state ? ', State extends never ? never : UpdateState' : '' +}>['callback'], group?: number ): void @@ -74,13 +86,20 @@ ${type.state ? ` `) }) - replaceSections('dispatcher.ts', { - codegen: lines.join('\n'), - 'codegen-imports': - 'import {\n' + - imports.sort().map((i) => ` ${i},\n`).join('') + - "} from './handler'", - }, __dirname) + replaceSections( + 'dispatcher.ts', + { + codegen: lines.join('\n'), + 'codegen-imports': + 'import {\n' + + imports + .sort() + .map((i) => ` ${i},\n`) + .join('') + + "} from './handler'", + }, + __dirname, + ) } async function main() { diff --git a/packages/dispatcher/src/context/base.ts b/packages/dispatcher/src/context/base.ts new file mode 100644 index 00000000..d5f9d6c0 --- /dev/null +++ b/packages/dispatcher/src/context/base.ts @@ -0,0 +1,8 @@ +import { ParsedUpdate, TelegramClient } from '@mtcute/client' + +export type UpdateContext = T & { + client: TelegramClient + _name: Extract['name'] +} + +export type UpdateContextDistributed = T extends never ? never : UpdateContext diff --git a/packages/dispatcher/src/context/callback-query.ts b/packages/dispatcher/src/context/callback-query.ts new file mode 100644 index 00000000..0d6ad787 --- /dev/null +++ b/packages/dispatcher/src/context/callback-query.ts @@ -0,0 +1,64 @@ +import { CallbackQuery, getMarkedPeerId, MtArgumentError, MtMessageNotFoundError, TelegramClient } from '@mtcute/client' + +import { UpdateContext } from './base' + +/** + * Context of a callback query update. + * + * This is a subclass of {@link CallbackQuery}, so all its fields are also available. + */ +export class CallbackQueryContext extends CallbackQuery implements UpdateContext { + readonly _name = 'callback_query' + + constructor( + readonly client: TelegramClient, + query: CallbackQuery, + ) { + super(query.raw, query._peers) + } + + /** Answer to this callback query */ + answer(params: Parameters[1]) { + return this.client.answerCallbackQuery(this.id, params) + } + + /** + * * Message that contained the callback button that was clicked. + * + * Note that the message may have been deleted, in which case + * `MessageNotFoundError` is thrown. + * + * Can only be used if `isInline = false` + */ + async getMessage() { + if (this.raw._ !== 'updateBotCallbackQuery') { + throw new MtArgumentError('Cannot get message for inline callback query') + } + + const [msg] = await this.client.getMessages(this.raw.peer, this.raw.msgId) + + if (!msg) { + throw new MtMessageNotFoundError(getMarkedPeerId(this.raw.peer), this.raw.msgId, 'Message not found') + } + + return msg + } + + /** + * Edit the message that contained the callback button that was clicked. + */ + async editMessage(params: Omit[0], 'messageId'>) { + if (this.raw._ === 'updateInlineBotCallbackQuery') { + return this.client.editInlineMessage({ + messageId: this.raw.msgId, + ...params, + }) + } + + return this.client.editMessage({ + chatId: this.raw.peer, + message: this.raw.msgId, + ...params, + }) + } +} diff --git a/packages/dispatcher/src/context/chat-join-request.ts b/packages/dispatcher/src/context/chat-join-request.ts new file mode 100644 index 00000000..ebe8c186 --- /dev/null +++ b/packages/dispatcher/src/context/chat-join-request.ts @@ -0,0 +1,39 @@ +import { BotChatJoinRequestUpdate, TelegramClient } from '@mtcute/client' + +import { UpdateContext } from './base' + +/** + * Context of a chat join request update (for bots). + * + * This is a subclass of {@link BotChatJoinRequestUpdate}, so all its fields are also available. + */ +export class ChatJoinRequestUpdateContext + extends BotChatJoinRequestUpdate + implements UpdateContext { + readonly _name = 'bot_chat_join_request' + + constructor( + readonly client: TelegramClient, + update: BotChatJoinRequestUpdate, + ) { + super(update.raw, update._peers) + } + + /** Approve the request */ + approve(): Promise { + return this.client.hideJoinRequest({ + action: 'approve', + user: this.user.inputPeer, + chatId: this.chat.inputPeer, + }) + } + + /** Decline the request */ + decline(): Promise { + return this.client.hideJoinRequest({ + action: 'decline', + user: this.user.inputPeer, + chatId: this.chat.inputPeer, + }) + } +} diff --git a/packages/dispatcher/src/context/chosen-inline-result.ts b/packages/dispatcher/src/context/chosen-inline-result.ts new file mode 100644 index 00000000..39df89b2 --- /dev/null +++ b/packages/dispatcher/src/context/chosen-inline-result.ts @@ -0,0 +1,38 @@ +import { ChosenInlineResult, MtArgumentError, TelegramClient } from '@mtcute/client' + +import { UpdateContext } from './base' + +/** + * Context of a chosen inline result update. + * + * This is a subclass of {@link ChosenInlineResult}, so all its fields are also available. + * + * > **Note**: To receive these updates, you must enable + * > Inline feedback in [@BotFather](//t.me/botfather) + */ +export class ChosenInlineResultContext extends ChosenInlineResult implements UpdateContext { + readonly _name = 'chosen_inline_result' + + constructor( + readonly client: TelegramClient, + result: ChosenInlineResult, + ) { + super(result.raw, result._peers) + } + + /** + * Edit the message that was sent when this inline result that was chosen. + * + * > **Note**: This method can only be used if the message contained a reply markup + */ + async editMessage(params: Parameters[0]): Promise { + if (!this.raw.msgId) { + throw new MtArgumentError('No message ID, make sure you have included reply markup!') + } + + return this.client.editInlineMessage({ + ...params, + messageId: this.raw.msgId, + }) + } +} diff --git a/packages/dispatcher/src/context/index.ts b/packages/dispatcher/src/context/index.ts new file mode 100644 index 00000000..64eada26 --- /dev/null +++ b/packages/dispatcher/src/context/index.ts @@ -0,0 +1,7 @@ +export * from './base' +export * from './callback-query' +export * from './chat-join-request' +export * from './chosen-inline-result' +export * from './inline-query' +export * from './message' +export * from './pre-checkout-query' diff --git a/packages/dispatcher/src/context/inline-query.ts b/packages/dispatcher/src/context/inline-query.ts new file mode 100644 index 00000000..5417259c --- /dev/null +++ b/packages/dispatcher/src/context/inline-query.ts @@ -0,0 +1,24 @@ +import { InlineQuery, ParametersSkip1, TelegramClient } from '@mtcute/client' + +import { UpdateContext } from './base' + +/** + * Context of an inline query update. + * + * This is a subclass of {@link InlineQuery}, so all its fields are also available. + */ +export class InlineQueryContext extends InlineQuery implements UpdateContext { + readonly _name = 'inline_query' + + constructor( + readonly client: TelegramClient, + query: InlineQuery, + ) { + super(query.raw, query._peers) + } + + /** Answer to this inline query */ + answer(...params: ParametersSkip1) { + return this.client.answerInlineQuery(this.id, ...params) + } +} diff --git a/packages/dispatcher/src/context/message.ts b/packages/dispatcher/src/context/message.ts new file mode 100644 index 00000000..eb45048e --- /dev/null +++ b/packages/dispatcher/src/context/message.ts @@ -0,0 +1,170 @@ +import { Message, OmitInputMessageId, ParametersSkip1, TelegramClient } from '@mtcute/client' +import { DeleteMessagesParams } from '@mtcute/client/src/methods/messages/delete-messages' +import { ForwardMessageOptions } from '@mtcute/client/src/methods/messages/forward-messages' +import { SendCopyParams } from '@mtcute/client/src/methods/messages/send-copy' +import { SendCopyGroupParams } from '@mtcute/client/src/methods/messages/send-copy-group' + +import { UpdateContext } from './base' + +/** + * Context of a message-related update. + * + * This is a subclass of {@link Message}, so all fields + * of the message are available. + * + * For message groups, own fields are related to the last message + * in the group. To access all messages, use {@link MessageContext#messages}. + */ +export class MessageContext extends Message implements UpdateContext { + // this is primarily for proper types in filters, so don't bother much with actual value + readonly _name = 'new_message' + + /** + * List of messages in the message group. + * + * For other updates, this is a list with a single element (`this`). + */ + readonly messages: MessageContext[] + + /** Whether this update is about a message group */ + readonly isMessageGroup: boolean + + constructor( + readonly client: TelegramClient, + message: Message | Message[], + ) { + const msg = Array.isArray(message) ? message[message.length - 1] : message + super(msg.raw, msg._peers, msg.isScheduled) + + this.messages = Array.isArray(message) ? message.map((it) => new MessageContext(client, it)) : [this] + this.isMessageGroup = Array.isArray(message) + } + + /** Get a message that this message is a reply to */ + getReplyTo() { + return this.client.getReplyTo(this) + } + + /** If this is a channel post, get its automatic forward in the discussion group */ + getDiscussionMessage() { + return this.client.getDiscussionMessage({ chatId: this.chat.inputPeer, message: this.id }) + } + + /** Get all custom emojis contained in this message (message group), if any */ + getCustomEmojis() { + return this.client.getCustomEmojisFromMessages(this.messages) + } + + /** Send a text message to the same chat (and topic, if applicable) as a given message */ + answerText(...params: ParametersSkip1) { + return this.client.answerText(this, ...params) + } + + /** Send a media to the same chat (and topic, if applicable) as a given message */ + answerMedia(...params: ParametersSkip1) { + return this.client.answerMedia(this, ...params) + } + + /** Send a media group to the same chat (and topic, if applicable) as a given message */ + answerMediaGroup(...params: ParametersSkip1) { + return this.client.answerMediaGroup(this, ...params) + } + + /** Send a text message in reply to this message */ + replyText(...params: ParametersSkip1) { + return this.client.replyText(this, ...params) + } + + /** Send a media in reply to this message */ + replyMedia(...params: ParametersSkip1) { + return this.client.replyMedia(this, ...params) + } + + /** Send a media group in reply to this message */ + replyMediaGroup(...params: ParametersSkip1) { + return this.client.replyMediaGroup(this, ...params) + } + + /** Send a text as a comment to this message */ + commentText(...params: ParametersSkip1) { + return this.client.commentText(this, ...params) + } + + /** Send a media as a comment to this message */ + commentMedia(...params: ParametersSkip1) { + return this.client.commentMedia(this, ...params) + } + + /** Send a media group as a comment to this message */ + commentMediaGroup(...params: ParametersSkip1) { + return this.client.commentMediaGroup(this, ...params) + } + + /** Delete this message (message group) */ + delete(params?: DeleteMessagesParams) { + return this.client.deleteMessagesById( + this.chat.inputPeer, + this.messages.map((it) => it.id), + params, + ) + } + + /** Pin this message */ + pin(params?: OmitInputMessageId[0]>) { + return this.client.pinMessage({ + chatId: this.chat.inputPeer, + message: this.id, + ...params, + }) + } + + /** Unpin this message */ + unpin() { + return this.client.unpinMessage({ + chatId: this.chat.inputPeer, + message: this.id, + }) + } + + /** Edit this message */ + edit(params: OmitInputMessageId[0]>) { + return this.client.editMessage({ + chatId: this.chat.inputPeer, + message: this.id, + ...params, + }) + } + + /** Forward this message (message group) */ + forwardTo(params: ForwardMessageOptions) { + return this.client.forwardMessagesById({ + fromChatId: this.chat.inputPeer, + messages: this.messages.map((it) => it.id), + ...params, + }) + } + + /** Send a copy of this message (message group) */ + copy(params: SendCopyParams & SendCopyGroupParams) { + if (this.isMessageGroup) { + return this.client.sendCopyGroup({ + messages: this.messages, + ...params, + }) + } + + return this.client.sendCopy({ + message: this, + ...params, + }) + } + + /** React to this message */ + react(params: OmitInputMessageId[0]>) { + return this.client.sendReaction({ + chatId: this.chat.inputPeer, + message: this.id, + ...params, + }) + } +} diff --git a/packages/dispatcher/src/context/parse.ts b/packages/dispatcher/src/context/parse.ts new file mode 100644 index 00000000..dc91243f --- /dev/null +++ b/packages/dispatcher/src/context/parse.ts @@ -0,0 +1,37 @@ +import { ParsedUpdate, TelegramClient } from '@mtcute/client' + +import { UpdateContext } from './base' +import { CallbackQueryContext } from './callback-query' +import { ChatJoinRequestUpdateContext } from './chat-join-request' +import { ChosenInlineResultContext } from './chosen-inline-result' +import { InlineQueryContext } from './inline-query' +import { MessageContext } from './message' +import { PreCheckoutQueryContext } from './pre-checkout-query' + +/** @internal */ +export function _parsedUpdateToContext(client: TelegramClient, update: ParsedUpdate) { + switch (update.name) { + case 'new_message': + case 'edit_message': + case 'message_group': + return new MessageContext(client, update.data) + case 'inline_query': + return new InlineQueryContext(client, update.data) + case 'chosen_inline_result': + return new ChosenInlineResultContext(client, update.data) + case 'callback_query': + return new CallbackQueryContext(client, update.data) + case 'bot_chat_join_request': + return new ChatJoinRequestUpdateContext(client, update.data) + case 'pre_checkout_query': + return new PreCheckoutQueryContext(client, update.data) + } + + const _update = update.data as UpdateContext + _update.client = client + _update._name = update.name + + return _update +} + +export type UpdateContextType = ReturnType diff --git a/packages/dispatcher/src/context/pre-checkout-query.ts b/packages/dispatcher/src/context/pre-checkout-query.ts new file mode 100644 index 00000000..eb605e53 --- /dev/null +++ b/packages/dispatcher/src/context/pre-checkout-query.ts @@ -0,0 +1,29 @@ +import { PreCheckoutQuery, TelegramClient } from '@mtcute/client' + +import { UpdateContext } from './base' + +/** + * Context of a pre-checkout query update + * + * This is a subclass of {@link PreCheckoutQuery}, so all its fields are also available. + */ +export class PreCheckoutQueryContext extends PreCheckoutQuery implements UpdateContext { + readonly _name = 'pre_checkout_query' + + constructor( + readonly client: TelegramClient, + query: PreCheckoutQuery, + ) { + super(query.raw, query._peers) + } + + /** Approve the query */ + approve(): Promise { + return this.client.answerPreCheckoutQuery(this.raw.queryId) + } + + /** Decline the query, optionally with an error */ + decline(error = ''): Promise { + return this.client.answerPreCheckoutQuery(this.raw.queryId, { error }) + } +} diff --git a/packages/dispatcher/src/dispatcher.ts b/packages/dispatcher/src/dispatcher.ts index eb605fc5..716f0994 100644 --- a/packages/dispatcher/src/dispatcher.ts +++ b/packages/dispatcher/src/dispatcher.ts @@ -1,32 +1,38 @@ -/* eslint-disable */ +/* eslint-disable @typescript-eslint/unified-signatures,@typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-call,max-depth,dot-notation */ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types */ // ^^ will be looked into in MTQ-29 import { - BotChatJoinRequestUpdate, BotStoppedUpdate, - CallbackQuery, ChatJoinRequestUpdate, ChatMemberUpdate, - ChosenInlineResult, DeleteMessageUpdate, + DeleteStoryUpdate, HistoryReadUpdate, - InlineQuery, MaybeAsync, - Message, ParsedUpdate, PeersIndex, PollUpdate, PollVoteUpdate, - PreCheckoutQuery, StoryUpdate, - DeleteStoryUpdate, TelegramClient, + tl, UserStatusUpdate, UserTypingUpdate, - tl, } from '@mtcute/client' +import { MtArgumentError } from '@mtcute/core' +import { + CallbackQueryContext, + ChatJoinRequestUpdateContext, + ChosenInlineResultContext, + InlineQueryContext, + MessageContext, + PreCheckoutQueryContext, +} from './context' +import { UpdateContext } from './context/base' +import { _parsedUpdateToContext, UpdateContextType } from './context/parse' import { filters, UpdateFilter } from './filters' // begin-codegen-imports import { @@ -55,7 +61,6 @@ import { // end-codegen-imports import { PropagationAction } from './propagation' import { defaultStateKeyDelegate, IStateStorage, StateKeyDelegate, UpdateState } from './state' -import { MtArgumentError } from '@mtcute/core' /** * Updates dispatcher @@ -193,7 +198,7 @@ export class Dispatcher { // order does not matter in the dispatcher, // so we can handle each update in its own task - this.dispatchRawUpdateNow(update, peers).catch((err) => this._client!['_emitError'](err)) + this.dispatchRawUpdateNow(update, peers).catch((err) => this._client!._emitError(err)) } /** @@ -263,7 +268,7 @@ export class Dispatcher { // order does not matter in the dispatcher, // so we can handle each update in its own task - this.dispatchUpdateNow(update).catch((err) => this._client!['_emitError'](err)) + this.dispatchUpdateNow(update).catch((err) => this._client!._emitError(err)) } /** @@ -287,6 +292,7 @@ export class Dispatcher { parsedState?: UpdateState | null, parsedScene?: string | null, forceScene?: true, + parsedContext?: UpdateContextType, ): Promise { if (!this._client) return false @@ -301,9 +307,8 @@ export class Dispatcher { ) { // no need to fetch scene if there are no registered scenes - const key = await this._stateKeyDelegate!( - update.name === 'message_group' ? update.data[0] : update.data, - ) + if (!parsedContext) parsedContext = _parsedUpdateToContext(this._client, update) + const key = await this._stateKeyDelegate!(parsedContext as any) if (key) { parsedScene = await this._storage.getCurrentScene(key) @@ -334,16 +339,20 @@ export class Dispatcher { if (parsedState === undefined) { if ( this._storage && - (update.name === 'new_message' || update.name === 'edit_message' || update.name === 'callback_query') + (update.name === 'new_message' || + update.name === 'edit_message' || + update.name === 'callback_query' || + update.name === 'message_group') ) { - const key = await this._stateKeyDelegate!(update.data) + if (!parsedContext) parsedContext = _parsedUpdateToContext(this._client, update) + const key = await this._stateKeyDelegate!(parsedContext as any) if (key) { let customKey if ( !this._customStateKeyDelegate || - (customKey = await this._customStateKeyDelegate(update.data)) + (customKey = await this._customStateKeyDelegate(parsedContext as any)) ) { parsedState = new UpdateState( this._storage, @@ -386,8 +395,9 @@ export class Dispatcher { for (const h of handlers) { let result: void | PropagationAction - if (!h.check || (await h.check(update.data as any, parsedState as never))) { - result = await h.callback(update.data as any, parsedState as never) + if (!parsedContext) parsedContext = _parsedUpdateToContext(this._client, update) + if (!h.check || (await h.check(parsedContext as any, parsedState as never))) { + result = await h.callback(parsedContext as any, parsedState as never) handled = true } else continue @@ -436,7 +446,7 @@ export class Dispatcher { } } - this._postUpdateHandler?.(handled, update, parsedState as any) + await this._postUpdateHandler?.(handled, update, parsedState as any) return handled } @@ -601,9 +611,9 @@ export class Dispatcher { if (child._client) { throw new MtArgumentError( 'Provided dispatcher is ' + - (child._parent - ? 'already a child. Use parent.removeChild() before calling addChild()' - : 'already bound to a client. Use unbind() before calling addChild()'), + (child._parent ? + 'already a child. Use parent.removeChild() before calling addChild()' : + 'already bound to a client. Use unbind() before calling addChild()'), ) } @@ -953,21 +963,8 @@ export class Dispatcher { * @param group Handler group index */ onNewMessage( - handler: NewMessageHandler>['callback'], - group?: number, - ): void - - /** - * Register a new message handler with a filter - * - * @param filter Update filter - * @param handler New message handler - * @param group Handler group index - */ - onNewMessage( - filter: UpdateFilter, handler: NewMessageHandler< - filters.Modify, + MessageContext, State extends never ? never : UpdateState >['callback'], group?: number, @@ -981,9 +978,25 @@ export class Dispatcher { * @param group Handler group index */ onNewMessage( - filter: UpdateFilter, + filter: UpdateFilter, handler: NewMessageHandler< - filters.Modify, + filters.Modify, + State extends never ? never : UpdateState + >['callback'], + group?: number, + ): void + + /** + * Register a new message handler with a filter + * + * @param filter Update filter + * @param handler New message handler + * @param group Handler group index + */ + onNewMessage( + filter: UpdateFilter, + handler: NewMessageHandler< + filters.Modify, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1001,21 +1014,8 @@ export class Dispatcher { * @param group Handler group index */ onEditMessage( - handler: EditMessageHandler>['callback'], - group?: number, - ): void - - /** - * Register an edit message handler with a filter - * - * @param filter Update filter - * @param handler Edit message handler - * @param group Handler group index - */ - onEditMessage( - filter: UpdateFilter, handler: EditMessageHandler< - filters.Modify, + MessageContext, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1029,9 +1029,25 @@ export class Dispatcher { * @param group Handler group index */ onEditMessage( - filter: UpdateFilter, + filter: UpdateFilter, handler: EditMessageHandler< - filters.Modify, + filters.Modify, + State extends never ? never : UpdateState + >['callback'], + group?: number, + ): void + + /** + * Register an edit message handler with a filter + * + * @param filter Update filter + * @param handler Edit message handler + * @param group Handler group index + */ + onEditMessage( + filter: UpdateFilter, + handler: EditMessageHandler< + filters.Modify, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1050,7 +1066,7 @@ export class Dispatcher { */ onMessageGroup( handler: MessageGroupHandler< - Message[], + MessageContext, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1064,9 +1080,9 @@ export class Dispatcher { * @param group Handler group index */ onMessageGroup( - filter: UpdateFilter, + filter: UpdateFilter, handler: MessageGroupHandler< - filters.Modify, + filters.Modify, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1080,9 +1096,9 @@ export class Dispatcher { * @param group Handler group index */ onMessageGroup( - filter: UpdateFilter, + filter: UpdateFilter, handler: MessageGroupHandler< - filters.Modify, + filters.Modify, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1109,8 +1125,8 @@ export class Dispatcher { * @param group Handler group index */ onDeleteMessage( - filter: UpdateFilter, - handler: DeleteMessageHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: DeleteMessageHandler, Mod>>['callback'], group?: number, ): void @@ -1135,8 +1151,8 @@ export class Dispatcher { * @param group Handler group index */ onChatMemberUpdate( - filter: UpdateFilter, - handler: ChatMemberUpdateHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: ChatMemberUpdateHandler, Mod>>['callback'], group?: number, ): void @@ -1161,8 +1177,8 @@ export class Dispatcher { * @param group Handler group index */ onInlineQuery( - filter: UpdateFilter, - handler: InlineQueryHandler>['callback'], + filter: UpdateFilter, + handler: InlineQueryHandler>['callback'], group?: number, ): void @@ -1187,8 +1203,8 @@ export class Dispatcher { * @param group Handler group index */ onChosenInlineResult( - filter: UpdateFilter, - handler: ChosenInlineResultHandler>['callback'], + filter: UpdateFilter, + handler: ChosenInlineResultHandler>['callback'], group?: number, ): void @@ -1205,7 +1221,7 @@ export class Dispatcher { */ onCallbackQuery( handler: CallbackQueryHandler< - CallbackQuery, + CallbackQueryContext, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1219,9 +1235,9 @@ export class Dispatcher { * @param group Handler group index */ onCallbackQuery( - filter: UpdateFilter, + filter: UpdateFilter, handler: CallbackQueryHandler< - filters.Modify, + filters.Modify, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1235,9 +1251,9 @@ export class Dispatcher { * @param group Handler group index */ onCallbackQuery( - filter: UpdateFilter, + filter: UpdateFilter, handler: CallbackQueryHandler< - filters.Modify, + filters.Modify, State extends never ? never : UpdateState >['callback'], group?: number, @@ -1264,8 +1280,8 @@ export class Dispatcher { * @param group Handler group index */ onPollUpdate( - filter: UpdateFilter, - handler: PollUpdateHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: PollUpdateHandler, Mod>>['callback'], group?: number, ): void @@ -1290,8 +1306,8 @@ export class Dispatcher { * @param group Handler group index */ onPollVote( - filter: UpdateFilter, - handler: PollVoteHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: PollVoteHandler, Mod>>['callback'], group?: number, ): void @@ -1316,8 +1332,8 @@ export class Dispatcher { * @param group Handler group index */ onUserStatusUpdate( - filter: UpdateFilter, - handler: UserStatusUpdateHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: UserStatusUpdateHandler, Mod>>['callback'], group?: number, ): void @@ -1342,8 +1358,8 @@ export class Dispatcher { * @param group Handler group index */ onUserTyping( - filter: UpdateFilter, - handler: UserTypingHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: UserTypingHandler, Mod>>['callback'], group?: number, ): void @@ -1368,8 +1384,8 @@ export class Dispatcher { * @param group Handler group index */ onHistoryRead( - filter: UpdateFilter, - handler: HistoryReadHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: HistoryReadHandler, Mod>>['callback'], group?: number, ): void @@ -1394,8 +1410,8 @@ export class Dispatcher { * @param group Handler group index */ onBotStopped( - filter: UpdateFilter, - handler: BotStoppedHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: BotStoppedHandler, Mod>>['callback'], group?: number, ): void @@ -1420,8 +1436,8 @@ export class Dispatcher { * @param group Handler group index */ onBotChatJoinRequest( - filter: UpdateFilter, - handler: BotChatJoinRequestHandler>['callback'], + filter: UpdateFilter, + handler: BotChatJoinRequestHandler>['callback'], group?: number, ): void @@ -1446,8 +1462,8 @@ export class Dispatcher { * @param group Handler group index */ onChatJoinRequest( - filter: UpdateFilter, - handler: ChatJoinRequestHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: ChatJoinRequestHandler, Mod>>['callback'], group?: number, ): void @@ -1472,8 +1488,8 @@ export class Dispatcher { * @param group Handler group index */ onPreCheckoutQuery( - filter: UpdateFilter, - handler: PreCheckoutQueryHandler>['callback'], + filter: UpdateFilter, + handler: PreCheckoutQueryHandler>['callback'], group?: number, ): void @@ -1498,8 +1514,8 @@ export class Dispatcher { * @param group Handler group index */ onStoryUpdate( - filter: UpdateFilter, - handler: StoryUpdateHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: StoryUpdateHandler, Mod>>['callback'], group?: number, ): void @@ -1524,8 +1540,8 @@ export class Dispatcher { * @param group Handler group index */ onDeleteStory( - filter: UpdateFilter, - handler: DeleteStoryHandler>['callback'], + filter: UpdateFilter, Mod>, + handler: DeleteStoryHandler, Mod>>['callback'], group?: number, ): void diff --git a/packages/dispatcher/src/filters/bots.ts b/packages/dispatcher/src/filters/bots.ts index bb1d35cc..87091036 100644 --- a/packages/dispatcher/src/filters/bots.ts +++ b/packages/dispatcher/src/filters/bots.ts @@ -1,6 +1,7 @@ import { Message } from '@mtcute/client' import { MaybeArray, MaybeAsync } from '@mtcute/core' +import { MessageContext } from '../context' import { chat } from './chat' import { and } from './logic' import { UpdateFilter } from './types' @@ -25,7 +26,7 @@ export const command = ( commands: MaybeArray, prefixes: MaybeArray | null = '/', caseSensitive = false, -): UpdateFilter => { +): UpdateFilter => { if (!Array.isArray(commands)) commands = [commands] commands = commands.map((i) => (typeof i === 'string' ? i.toLowerCase() : i)) @@ -44,7 +45,9 @@ export const command = ( const _prefixes = prefixes - const check = (msg: Message): MaybeAsync => { + const check = (msg: MessageContext): MaybeAsync => { + if (msg.isMessageGroup) return check(msg.messages[0]) + for (const pref of _prefixes) { if (!msg.text.startsWith(pref)) continue @@ -54,17 +57,15 @@ export const command = ( const m = withoutPrefix.match(regex) if (!m) continue - // const lastGroup = m[m.length - 1] + const lastGroup = m[m.length - 1] - // eslint-disable-next-line dot-notation - // todo - // if (lastGroup && msg.client['_isBot']) { - // // check bot username - // // eslint-disable-next-line dot-notation - // if (lastGroup !== msg.client['_selfUsername']) { - // return false - // } - // } + if (lastGroup) { + const state = msg.client.getAuthState() + + if (state.isBot && lastGroup !== state.selfUsername) { + return false + } + } const match = m.slice(1, -1) @@ -74,7 +75,7 @@ export const command = ( return '' }) - ;(msg as Message & { command: string[] }).command = match + ;(msg as MessageContext & { command: string[] }).command = match return true } @@ -98,7 +99,7 @@ export const start = and(chat('private'), command('start')) * If the parameter is a regex, groups are added to `msg.command`, * meaning that the first group is available in `msg.command[2]`. */ -export const deeplink = (params: MaybeArray): UpdateFilter => { +export const deeplink = (params: MaybeArray): UpdateFilter => { if (!Array.isArray(params)) { return and(start, (_msg: Message) => { const msg = _msg as Message & { command: string[] } diff --git a/packages/dispatcher/src/filters/bundle.ts b/packages/dispatcher/src/filters/bundle.ts index a66b8413..f6d0bd12 100644 --- a/packages/dispatcher/src/filters/bundle.ts +++ b/packages/dispatcher/src/filters/bundle.ts @@ -1,5 +1,6 @@ export * from './bots' export * from './chat' +export * from './group' export * from './logic' export * from './message' export * from './state' diff --git a/packages/dispatcher/src/filters/chat.ts b/packages/dispatcher/src/filters/chat.ts index eb10c0c7..61d32dca 100644 --- a/packages/dispatcher/src/filters/chat.ts +++ b/packages/dispatcher/src/filters/chat.ts @@ -1,6 +1,17 @@ -import { Chat, ChatType, Message, PollVoteUpdate, User } from '@mtcute/client' +import { + BotChatJoinRequestUpdate, + Chat, + ChatMemberUpdate, + ChatType, + HistoryReadUpdate, + Message, + PollVoteUpdate, + User, + UserTypingUpdate, +} from '@mtcute/client' import { MaybeArray } from '@mtcute/core' +import { UpdateContextDistributed } from '../context' import { Modify, UpdateFilter } from './types' /** @@ -19,59 +30,68 @@ export const chat = (msg) => msg.chat.chatType === type +// prettier-ignore /** - * Filter updates by chat ID(s) or username(s) + * Filter updates by marked chat ID(s) or username(s) + * + * Note that only some updates support filtering by username. + * + * For messages, this filter checks for chat where the message + * was sent to, NOT the chat sender. */ -export const chatId = (id: MaybeArray): UpdateFilter => { - if (Array.isArray(id)) { - const index: Record = {} - let matchSelf = false - id.forEach((id) => { - if (id === 'me' || id === 'self') { - matchSelf = true - } else { - index[id] = true - } - }) +export const chatId: { + (id: MaybeArray): UpdateFilter> + (id: MaybeArray): UpdateFilter> +} = (id) => { + const indexId = new Set() + const indexUsername = new Set() + let matchSelf = false - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - const peer = upd.peer - - return peer.type === 'chat' && peer.id in index - } - - const chat = (upd as Exclude).chat - - return (matchSelf && chat.isSelf) || chat.id in index || chat.username! in index + if (!Array.isArray(id)) id = [id] + id.forEach((id) => { + if (id === 'me' || id === 'self') { + matchSelf = true + } else if (typeof id === 'number') { + indexId.add(id) + } else { + indexUsername.add(id) } - } - - if (id === 'me' || id === 'self') { - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - return upd.peer.type === 'chat' && upd.peer.isSelf - } - - return (upd as Exclude).chat.isSelf - } - } - - if (typeof id === 'string') { - return (upd) => { - if (upd.constructor === PollVoteUpdate) { - return upd.peer.type === 'chat' && upd.peer.username === id - } - - return (upd as Exclude).chat.username === id - } - } + }) return (upd) => { - if (upd.constructor === PollVoteUpdate) { - return upd.peer.type === 'chat' && upd.peer.id === id + switch (upd._name) { + case 'poll_vote': { + const peer = upd.peer + + return peer.type === 'chat' && ( + indexId.has(peer.id) || + Boolean(peer.usernames?.some((u) => indexUsername.has(u.username))) + ) + } + case 'history_read': + case 'user_typing': { + const id = upd.chatId + + return (matchSelf && id === upd.client.getAuthState().userId) || indexId.has(id) + } } - return (upd as Exclude).chat.id === id + const chat = upd.chat + + return (matchSelf && chat.isSelf) || + indexId.has(chat.id) || + Boolean(chat.usernames?.some((u) => indexUsername.has(u.username))) } } diff --git a/packages/dispatcher/src/filters/group.ts b/packages/dispatcher/src/filters/group.ts new file mode 100644 index 00000000..154ced31 --- /dev/null +++ b/packages/dispatcher/src/filters/group.ts @@ -0,0 +1,88 @@ +import { Message } from '@mtcute/client' +import { MaybeAsync } from '@mtcute/core' + +import { MessageContext } from '../context' +import { Modify, UpdateFilter } from './types' + +/** + * For message groups, apply a filter to every message in the group. + * Filter will match if **all** messages match. + * + * > **Note**: This also applies type modification to every message in the group + * + * @param filter + * @returns + */ +export function every( + filter: UpdateFilter, +): UpdateFilter< + MessageContext, + Mod & { + messages: Modify[] + }, + State +> { + return (ctx, state) => { + let i = 0 + const upds = ctx.messages + const max = upds.length + + const next = (): MaybeAsync => { + if (i === max) return true + + const res = filter(upds[i++], state) + + if (typeof res === 'boolean') { + if (!res) return false + + return next() + } + + return res.then((r: boolean) => { + if (!r) return false + + return next() + }) + } + + return next() + } +} + +/** + * For message groups, apply a filter to every message in the group. + * Filter will match if **any** message matches. + * + * > **Note**: This *does not* apply type modification to any message + * + * @param filter + * @returns + */ +// eslint-disable-next-line +export function some(filter: UpdateFilter): UpdateFilter { + return (ctx, state) => { + let i = 0 + const upds = ctx.messages + const max = upds.length + + const next = (): MaybeAsync => { + if (i === max) return false + + const res = filter(upds[i++], state) + + if (typeof res === 'boolean') { + if (res) return true + + return next() + } + + return res.then((r: boolean) => { + if (r) return true + + return next() + }) + } + + return next() + } +} diff --git a/packages/dispatcher/src/filters/logic.ts b/packages/dispatcher/src/filters/logic.ts index 00a8736a..81f6701c 100644 --- a/packages/dispatcher/src/filters/logic.ts +++ b/packages/dispatcher/src/filters/logic.ts @@ -256,8 +256,6 @@ export function or< * @param fns Filters to combine */ export function or(...fns: UpdateFilter[]): UpdateFilter { - if (fns.length === 2) return or(fns[0], fns[1]) - return (upd, state) => { let i = 0 const max = fns.length @@ -283,79 +281,3 @@ export function or(...fns: UpdateFilter[]): UpdateFilter **Note**: This also applies type modification to every element of the array. - * - * @param filter - * @returns - */ -export function every(filter: UpdateFilter): UpdateFilter { - return (upds, state) => { - let i = 0 - const max = upds.length - - const next = (): MaybeAsync => { - if (i === max) return true - - const res = filter(upds[i++], state) - - if (typeof res === 'boolean') { - if (!res) return false - - return next() - } - - return res.then((r: boolean) => { - if (!r) return false - - return next() - }) - } - - return next() - } -} - -/** - * For updates that contain an array of updates (e.g. `message_group`), - * apply a filter to every element of the array. - * - * Filter will match if **all** elements match. - * - * > **Note**: This *does not* apply type modification to any element of the array - * - * @param filter - * @returns - */ -export function some(filter: UpdateFilter): UpdateFilter { - return (upds, state) => { - let i = 0 - const max = upds.length - - const next = (): MaybeAsync => { - if (i === max) return false - - const res = filter(upds[i++], state) - - if (typeof res === 'boolean') { - if (res) return true - - return next() - } - - return res.then((r: boolean) => { - if (r) return true - - return next() - }) - } - - return next() - } -} diff --git a/packages/dispatcher/src/filters/message.ts b/packages/dispatcher/src/filters/message.ts index 27a86c16..64adaf22 100644 --- a/packages/dispatcher/src/filters/message.ts +++ b/packages/dispatcher/src/filters/message.ts @@ -201,3 +201,10 @@ export const action = ['type']>( return (msg) => msg.action?.type === type } + +export const sender = + ( + type: T, + ): UpdateFilter }> => + (msg) => + msg.sender.type === type diff --git a/packages/dispatcher/src/filters/state.ts b/packages/dispatcher/src/filters/state.ts index 32aefd44..fd18a5ff 100644 --- a/packages/dispatcher/src/filters/state.ts +++ b/packages/dispatcher/src/filters/state.ts @@ -1,4 +1,4 @@ -import { CallbackQuery, Message } from '@mtcute/client' +/* eslint-disable @typescript-eslint/no-explicit-any */ import { MaybeAsync } from '@mtcute/core' import { UpdateFilter } from './types' @@ -6,7 +6,7 @@ import { UpdateFilter } from './types' /** * Create a filter for the cases when the state is empty */ -export const stateEmpty: UpdateFilter = async (upd, state) => { +export const stateEmpty: UpdateFilter = async (upd, state) => { if (!state) return false return !(await state.get()) @@ -23,7 +23,7 @@ export const stateEmpty: UpdateFilter = async (upd, state) => { export const state = ( predicate: (state: T) => MaybeAsync, // eslint-disable-next-line @typescript-eslint/ban-types -): UpdateFilter => { +): UpdateFilter => { return async (upd, state) => { if (!state) return false const data = await state.get() diff --git a/packages/dispatcher/src/filters/text.ts b/packages/dispatcher/src/filters/text.ts index b883dfcc..8d003319 100644 --- a/packages/dispatcher/src/filters/text.ts +++ b/packages/dispatcher/src/filters/text.ts @@ -1,18 +1,20 @@ -// ^^ will be looked into in MTQ-29 - import { CallbackQuery, ChosenInlineResult, InlineQuery, Message } from '@mtcute/client' +import { UpdateContextDistributed } from '../context' import { UpdateFilter } from './types' -function extractText(obj: Message | InlineQuery | ChosenInlineResult | CallbackQuery): string | null { - if (obj.constructor === Message) { - return obj.text - } else if (obj.constructor === InlineQuery) { - return obj.query - } else if (obj.constructor === ChosenInlineResult) { - return obj.id - } else if (obj.constructor === CallbackQuery) { - if (obj.raw.data) return obj.dataStr +type UpdatesWithText = UpdateContextDistributed + +function extractText(obj: UpdatesWithText): string | null { + switch (obj._name) { + case 'new_message': + return obj.text + case 'inline_query': + return obj.query + case 'chosen_inline_result': + return obj.id + case 'callback_query': + if (obj.raw.data) return obj.dataStr } return null @@ -31,9 +33,7 @@ function extractText(obj: Message | InlineQuery | ChosenInlineResult | CallbackQ * @param regex Regex to be matched */ export const regex = - ( - regex: RegExp, - ): UpdateFilter => + (regex: RegExp): UpdateFilter => (obj) => { const txt = extractText(obj) if (!txt) return false @@ -59,10 +59,7 @@ export const regex = * @param str String to be matched * @param ignoreCase Whether string case should be ignored */ -export const equals = ( - str: string, - ignoreCase = false, -): UpdateFilter => { +export const equals = (str: string, ignoreCase = false): UpdateFilter => { if (ignoreCase) { str = str.toLowerCase() @@ -82,10 +79,7 @@ export const equals = ( * @param str Substring to be matched * @param ignoreCase Whether string case should be ignored */ -export const contains = ( - str: string, - ignoreCase = false, -): UpdateFilter => { +export const contains = (str: string, ignoreCase = false): UpdateFilter => { if (ignoreCase) { str = str.toLowerCase() @@ -113,10 +107,7 @@ export const contains = ( * @param str Substring to be matched * @param ignoreCase Whether string case should be ignored */ -export const startsWith = ( - str: string, - ignoreCase = false, -): UpdateFilter => { +export const startsWith = (str: string, ignoreCase = false): UpdateFilter => { if (ignoreCase) { str = str.toLowerCase() @@ -144,10 +135,7 @@ export const startsWith = ( * @param str Substring to be matched * @param ignoreCase Whether string case should be ignored */ -export const endsWith = ( - str: string, - ignoreCase = false, -): UpdateFilter => { +export const endsWith = (str: string, ignoreCase = false): UpdateFilter => { if (ignoreCase) { str = str.toLowerCase() diff --git a/packages/dispatcher/src/filters/types.ts b/packages/dispatcher/src/filters/types.ts index b47966c5..ec2ce541 100644 --- a/packages/dispatcher/src/filters/types.ts +++ b/packages/dispatcher/src/filters/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ // ^^ will be looked into in MTQ-29 @@ -73,13 +74,13 @@ import { UpdateState } from '../state' * > like `and`, `or`, etc. Those are meant to be inferred by the compiler! */ // we need the second parameter because it carries meta information -// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types +// eslint-disable-next-line @typescript-eslint/no-unused-vars export type UpdateFilter = ( update: Base, state?: UpdateState, ) => MaybeAsync -export type Modify = Base extends (infer T)[] ? Modify[] : Omit & Mod +export type Modify = Omit & Mod export type Invert = { [P in keyof Mod & keyof Base]: Exclude } diff --git a/packages/dispatcher/src/filters/user.ts b/packages/dispatcher/src/filters/user.ts index 4213fc21..0aee850c 100644 --- a/packages/dispatcher/src/filters/user.ts +++ b/packages/dispatcher/src/filters/user.ts @@ -3,15 +3,19 @@ import { CallbackQuery, ChatMemberUpdate, ChosenInlineResult, + DeleteStoryUpdate, + HistoryReadUpdate, InlineQuery, Message, PollVoteUpdate, + StoryUpdate, User, UserStatusUpdate, UserTypingUpdate, } from '@mtcute/client' import { MaybeArray } from '@mtcute/core' +import { UpdateContextDistributed } from '../context' import { UpdateFilter } from './types' /** @@ -25,130 +29,95 @@ export const me: UpdateFilter = (msg) => */ export const bot: UpdateFilter = (msg) => msg.sender.constructor === User && msg.sender.isBot +// prettier-ignore /** * Filter updates by user ID(s) or username(s) * - * Usernames are not supported for UserStatusUpdate - * and UserTypingUpdate. - * - * - * For chat member updates, uses `user.id` + * Note that only some updates support filtering by username. */ -export const userId = ( - id: MaybeArray, -): UpdateFilter< - | Message - | UserStatusUpdate - | UserTypingUpdate - | InlineQuery - | ChatMemberUpdate - | ChosenInlineResult - | CallbackQuery - | PollVoteUpdate - | BotChatJoinRequestUpdate -> => { - if (Array.isArray(id)) { - const index: Record = {} - let matchSelf = false - id.forEach((id) => { - if (id === 'me' || id === 'self') { - matchSelf = true - } else { - index[id] = true - } - }) +export const userId: { + (id: MaybeArray): UpdateFilter> + (id: MaybeArray): UpdateFilter> +} = (id) => { + const indexId = new Set() + const indexUsername = new Set() + let matchSelf = false - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - const sender = (upd as Message).sender - - return (matchSelf && sender.isSelf) || sender.id in index || sender.username! in index - } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { - // const id = (upd as UserStatusUpdate | UserTypingUpdate).userId - - return false - // todo - // eslint-disable-next-line dot-notation - // (matchSelf && id === upd.client['_userId']) || id in index - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return (matchSelf && peer.isSelf) || peer.id in index || peer.username! in index - } - - const user = (upd as Exclude) - .user - - return (matchSelf && user.isSelf) || user.id in index || user.username! in index + if (!Array.isArray(id)) id = [id] + id.forEach((id) => { + if (id === 'me' || id === 'self') { + matchSelf = true + } else if (typeof id === 'string') { + indexUsername.add(id) + } else { + indexId.add(id) } - } - - if (id === 'me' || id === 'self') { - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - return (upd as Message).sender.isSelf - } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { - return false - // todo - // (upd as UserStatusUpdate | UserTypingUpdate).userId === - // eslint-disable-next-line dot-notation - // upd.client['_userId'] - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return peer.isSelf - } - - return (upd as Exclude).user - .isSelf - } - } - - if (typeof id === 'string') { - return (upd) => { - const ctor = upd.constructor - - if (ctor === Message) { - return (upd as Message).sender.username === id - } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { - // username is not available - return false - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false - - return peer.username === id - } - - return ( - (upd as Exclude).user - .username === id - ) - } - } + }) return (upd) => { - const ctor = upd.constructor + switch (upd._name) { + case 'new_message': + case 'edit_message': { + const sender = upd.sender - if (ctor === Message) { - return (upd as Message).sender.id === id - } else if (ctor === UserStatusUpdate || ctor === UserTypingUpdate) { - return (upd as UserStatusUpdate | UserTypingUpdate).userId === id - } else if (ctor === PollVoteUpdate) { - const peer = (upd as PollVoteUpdate).peer - if (peer.type !== 'user') return false + return (matchSelf && sender.isSelf) || + indexId.has(sender.id) || + indexUsername.has(sender.username!) + } + case 'user_status': + case 'user_typing': { + const id = upd.userId - return peer.id === id + return (matchSelf && id === upd.client.getAuthState().userId) || + indexId.has(id) + } + case 'poll_vote': + case 'story': + case 'delete_story': { + const peer = upd.peer + if (peer.type !== 'user') return false + + return (matchSelf && peer.isSelf) || + indexId.has(peer.id) || + Boolean(peer.usernames?.some((u) => indexUsername.has(u.username))) + } + case 'history_read': { + const id = upd.chatId + + return (matchSelf && id === upd.client.getAuthState().userId) || + indexId.has(id) + } } + const user = upd.user + return ( - (upd as Exclude).user.id === id + (matchSelf && user.isSelf) || + indexId.has(user.id) || + Boolean(user.usernames?.some((u) => indexUsername.has(u.username))) ) } } diff --git a/packages/dispatcher/src/handler.ts b/packages/dispatcher/src/handler.ts index 92dfef20..657f637f 100644 --- a/packages/dispatcher/src/handler.ts +++ b/packages/dispatcher/src/handler.ts @@ -1,20 +1,14 @@ import { - BotChatJoinRequestUpdate, BotStoppedUpdate, - CallbackQuery, ChatJoinRequestUpdate, ChatMemberUpdate, - ChosenInlineResult, DeleteMessageUpdate, DeleteStoryUpdate, HistoryReadUpdate, - InlineQuery, MaybeAsync, - Message, PeersIndex, PollUpdate, PollVoteUpdate, - PreCheckoutQuery, StoryUpdate, TelegramClient, tl, @@ -22,6 +16,15 @@ import { UserTypingUpdate, } from '@mtcute/client' +import { + CallbackQueryContext, + ChatJoinRequestUpdateContext, + ChosenInlineResultContext, + InlineQueryContext, + MessageContext, + PreCheckoutQueryContext, +} from './context' +import { UpdateContext } from './context/base' import { PropagationAction } from './propagation' export interface BaseUpdateHandler { @@ -48,25 +51,31 @@ export type RawUpdateHandler = BaseUpdateHandler< > // begin-codegen -export type NewMessageHandler = ParsedUpdateHandler<'new_message', T, S> -export type EditMessageHandler = ParsedUpdateHandler<'edit_message', T, S> -export type MessageGroupHandler = ParsedUpdateHandler<'message_group', T, S> -export type DeleteMessageHandler = ParsedUpdateHandler<'delete_message', T> -export type ChatMemberUpdateHandler = ParsedUpdateHandler<'chat_member', T> -export type InlineQueryHandler = ParsedUpdateHandler<'inline_query', T> -export type ChosenInlineResultHandler = ParsedUpdateHandler<'chosen_inline_result', T> -export type CallbackQueryHandler = ParsedUpdateHandler<'callback_query', T, S> -export type PollUpdateHandler = ParsedUpdateHandler<'poll', T> -export type PollVoteHandler = ParsedUpdateHandler<'poll_vote', T> -export type UserStatusUpdateHandler = ParsedUpdateHandler<'user_status', T> -export type UserTypingHandler = ParsedUpdateHandler<'user_typing', T> -export type HistoryReadHandler = ParsedUpdateHandler<'history_read', T> -export type BotStoppedHandler = ParsedUpdateHandler<'bot_stopped', T> -export type BotChatJoinRequestHandler = ParsedUpdateHandler<'bot_chat_join_request', T> -export type ChatJoinRequestHandler = ParsedUpdateHandler<'chat_join_request', T> -export type PreCheckoutQueryHandler = ParsedUpdateHandler<'pre_checkout_query', T> -export type StoryUpdateHandler = ParsedUpdateHandler<'story', T> -export type DeleteStoryHandler = ParsedUpdateHandler<'delete_story', T> +export type NewMessageHandler = ParsedUpdateHandler<'new_message', T, S> +export type EditMessageHandler = ParsedUpdateHandler<'edit_message', T, S> +export type MessageGroupHandler = ParsedUpdateHandler<'message_group', T, S> +export type DeleteMessageHandler> = ParsedUpdateHandler<'delete_message', T> +export type ChatMemberUpdateHandler> = ParsedUpdateHandler<'chat_member', T> +export type InlineQueryHandler = ParsedUpdateHandler<'inline_query', T> +export type ChosenInlineResultHandler = ParsedUpdateHandler<'chosen_inline_result', T> +export type CallbackQueryHandler = ParsedUpdateHandler<'callback_query', T, S> +export type PollUpdateHandler> = ParsedUpdateHandler<'poll', T> +export type PollVoteHandler> = ParsedUpdateHandler<'poll_vote', T> +export type UserStatusUpdateHandler> = ParsedUpdateHandler<'user_status', T> +export type UserTypingHandler> = ParsedUpdateHandler<'user_typing', T> +export type HistoryReadHandler> = ParsedUpdateHandler<'history_read', T> +export type BotStoppedHandler> = ParsedUpdateHandler<'bot_stopped', T> +export type BotChatJoinRequestHandler = ParsedUpdateHandler< + 'bot_chat_join_request', + T +> +export type ChatJoinRequestHandler> = ParsedUpdateHandler< + 'chat_join_request', + T +> +export type PreCheckoutQueryHandler = ParsedUpdateHandler<'pre_checkout_query', T> +export type StoryUpdateHandler> = ParsedUpdateHandler<'story', T> +export type DeleteStoryHandler> = ParsedUpdateHandler<'delete_story', T> export type UpdateHandler = | RawUpdateHandler diff --git a/packages/dispatcher/src/index.ts b/packages/dispatcher/src/index.ts index d8e12aec..d23ce406 100644 --- a/packages/dispatcher/src/index.ts +++ b/packages/dispatcher/src/index.ts @@ -1,4 +1,5 @@ export * from './callback-data-builder' +export * from './context' export * from './dispatcher' export * from './filters' export * from './handler' diff --git a/packages/dispatcher/src/wizard.ts b/packages/dispatcher/src/wizard.ts index eba27ac7..ff1ba2d4 100644 --- a/packages/dispatcher/src/wizard.ts +++ b/packages/dispatcher/src/wizard.ts @@ -1,5 +1,6 @@ -import { MaybeAsync, Message } from '@mtcute/client' +import { MaybeAsync } from '@mtcute/client' +import { MessageContext } from './context' import { Dispatcher } from './dispatcher' import { filters } from './filters' import { UpdateState } from './state' @@ -54,13 +55,13 @@ export class WizardScene extends Dispa * Add a step to the wizard */ addStep( - handler: (msg: Message, state: UpdateState) => MaybeAsync, + handler: (msg: MessageContext, state: UpdateState) => MaybeAsync, ): void { const step = this._steps++ const filter = filters.state((it) => it.$step === step) - this.onNewMessage(step === 0 ? filters.or(filters.stateEmpty, filter) : filter, async (msg: Message, state) => { + this.onNewMessage(step === 0 ? filters.or(filters.stateEmpty, filter) : filter, async (msg, state) => { const result = await handler(msg, state) if (typeof result === 'number') {