diff --git a/packages/client/src/types/messages/index.ts b/packages/client/src/types/messages/index.ts index b78c1290..488d1086 100644 --- a/packages/client/src/types/messages/index.ts +++ b/packages/client/src/types/messages/index.ts @@ -8,4 +8,5 @@ export * from './message-forward.js' export * from './message-media.js' export * from './message-reactions.js' export * from './message-replies.js' +export * from './replied-message.js' export * from './search-filters.js' diff --git a/packages/client/src/types/messages/message-forward.ts b/packages/client/src/types/messages/message-forward.ts index 290906c2..868eeb9d 100644 --- a/packages/client/src/types/messages/message-forward.ts +++ b/packages/client/src/types/messages/message-forward.ts @@ -5,8 +5,7 @@ import { memoizeGetters } from '../../utils/memoize.js' import { Chat } from '../peers/chat.js' import { PeersIndex } from '../peers/peers-index.js' import { User } from '../peers/user.js' -import { MessageEntity } from './message-entity.js' -import { _messageMediaFromTl, MessageMedia } from './message-media.js' +import { _messageMediaFromTl } from './message-media.js' /** * Information about forwarded message origin @@ -76,74 +75,3 @@ export class MessageForwardInfo { memoizeGetters(MessageForwardInfo, ['sender', 'fromChat']) makeInspectable(MessageForwardInfo) - -/** - * Information about a message that this message is a reply to - */ -export class MessageReplyInfo { - constructor( - readonly raw: tl.RawMessageReplyHeader, - readonly _peers: PeersIndex, - ) {} - - /** Whether this message is a reply to another scheduled message */ - get isScheduled(): boolean { - return this.raw.replyToScheduled! - } - - /** Whether this message was sent to a forum topic */ - get isForumTopic(): boolean { - return this.raw.forumTopic! - } - - /** Whether this message is quoting another message */ - get isQuote(): boolean { - return this.raw.quote! - } - - /** - * If replied-to message is available, its ID - */ - get id(): number | null { - return this.raw.replyToMsgId ?? null - } - - /** ID of the replies thread where this message belongs to */ - get threadId(): number | null { - return this.raw.replyToTopId ?? null - } - - /** - * If replied-to message is available, chat where the message was sent. - * - * If `null`, the message was sent in the same chat. - */ - get chat(): Chat | null { - return this.raw.replyToPeerId ? Chat._parseFromPeer(this.raw.replyToPeerId, this._peers) : null - } - - /** If this message is a quote, text of the quote */ - get quoteText(): string { - return this.raw.quoteText ?? '' - } - - /** Message entities contained in the quote */ - get quoteEntities(): MessageEntity[] { - return this.raw.quoteEntities?.map((e) => new MessageEntity(e)) ?? [] - } - - /** - * Media contained in the replied-to message - * - * Only available in case the replied-to message is in a different chat - * (i.e. {@link chat} is not `null`) - */ - get media(): MessageMedia { - if (!this.raw.replyMedia) return null - - return _messageMediaFromTl(this._peers, this.raw.replyMedia) - } -} - -memoizeGetters(MessageReplyInfo, ['chat', 'quoteEntities', 'media']) -makeInspectable(MessageReplyInfo) diff --git a/packages/client/src/types/messages/message-replies.ts b/packages/client/src/types/messages/message-replies.ts index 4a9852c5..5e7644d4 100644 --- a/packages/client/src/types/messages/message-replies.ts +++ b/packages/client/src/types/messages/message-replies.ts @@ -53,25 +53,31 @@ export class MessageRepliesInfo { /** * ID of the discussion group for the post + * + * `null` if the post is not a channel post */ - get discussion(): number { - return getMarkedPeerId(this.raw.channelId!, 'channel') + get discussion(): number | null { + if (!this.raw.channelId) return null + + return getMarkedPeerId(this.raw.channelId, 'channel') } /** * Last few commenters to the post (usually 3) */ get repliers(): (User | Chat)[] { - return this.raw.recentRepliers!.map((it) => { - switch (it._) { - case 'peerUser': - return new User(this._peers.user(it.userId)) - case 'peerChannel': - return new Chat(this._peers.chat(it.channelId)) - default: - throw new Error('Unexpected peer type: ' + it._) - } - }) + return ( + this.raw.recentRepliers?.map((it) => { + switch (it._) { + case 'peerUser': + return new User(this._peers.user(it.userId)) + case 'peerChannel': + return new Chat(this._peers.chat(it.channelId)) + default: + throw new Error('Unexpected peer type: ' + it._) + } + }) ?? [] + ) } } diff --git a/packages/client/src/types/messages/message.ts b/packages/client/src/types/messages/message.ts index 0b3cab07..22a533e0 100644 --- a/packages/client/src/types/messages/message.ts +++ b/packages/client/src/types/messages/message.ts @@ -17,10 +17,11 @@ import { PeersIndex } from '../peers/peers-index.js' import { User } from '../peers/user.js' import { _messageActionFromTl, MessageAction } from './message-action.js' import { MessageEntity } from './message-entity.js' -import { MessageForwardInfo, MessageReplyInfo } from './message-forward.js' +import { MessageForwardInfo } from './message-forward.js' import { _messageMediaFromTl, MessageMedia } from './message-media.js' import { MessageReactions } from './message-reactions.js' import { MessageRepliesInfo } from './message-replies.js' +import { RepliedMessageInfo } from './replied-message.js' /** * A Telegram message. @@ -190,10 +191,10 @@ export class Message { * * Mutually exclusive with {@link replyToStory} */ - get replyToMessage(): MessageReplyInfo | null { + get replyToMessage(): RepliedMessageInfo | null { if (this.raw.replyTo?._ !== 'messageReplyHeader') return null - return new MessageReplyInfo(this.raw.replyTo, this._peers) + return new RepliedMessageInfo(this.raw.replyTo, this._peers) } /** diff --git a/packages/client/src/types/messages/replied-message.ts b/packages/client/src/types/messages/replied-message.ts new file mode 100644 index 00000000..6ed02758 --- /dev/null +++ b/packages/client/src/types/messages/replied-message.ts @@ -0,0 +1,204 @@ +import { MtTypeAssertionError, tl } from '@mtcute/core' + +import { makeInspectable } from '../../utils/inspectable.js' +import { memoizeGetters } from '../../utils/memoize.js' +import { Chat } from '../peers/chat.js' +import { PeersIndex } from '../peers/peers-index.js' +import { User } from '../peers/user.js' +import { MessageEntity } from './message-entity.js' +import { _messageMediaFromTl, MessageMedia } from './message-media.js' + +/** + * Origin of the replied-to message + * - `same_chat` — reply to a message in the same chat as this message + * - `other_chat` — reply to a message in a different chat + * - `private` — reply to a message in a private chat + */ +export type RepliedMessageOrigin = 'same_chat' | 'other_chat' | 'private' + +/** @hidden */ +export interface _RepliedMessageAssertionsByOrigin { + same_chat: { + id: number + chat: null + media: null + sender: null + } + other_chat: { + id: number + chat: Chat + sender: User | Chat | string + } + private: { + id: null + chat: null + sender: User | Chat | string + } +} + +/** + * Information about a message that this message is a reply to + */ +export class RepliedMessageInfo { + constructor( + readonly raw: tl.RawMessageReplyHeader, + readonly _peers: PeersIndex, + ) {} + + /** Whether this message is a reply to another scheduled message */ + get isScheduled(): boolean { + return this.raw.replyToScheduled! + } + + /** Whether this message was sent to a forum topic */ + get isForumTopic(): boolean { + return this.raw.forumTopic! + } + + /** Whether this message is quoting another message */ + get isQuote(): boolean { + return this.raw.quote! + } + + /** Origin of the replied-to message */ + get origin(): RepliedMessageOrigin { + if (!this.raw.replyToMsgId) return 'private' + if (!this.raw.replyFrom) return 'same_chat' + + return 'other_chat' + } + + /** + * Helper method to check {@link origin} that will also + * narrow the type of this object respectively + */ + originIs( + origin: T, + ): this is RepliedMessageInfo & _RepliedMessageAssertionsByOrigin[T] { + if (this.origin === origin) { + // do some type assertions just in case + switch (origin) { + case 'same_chat': + // no need, `null` is pretty safe, and `id` is checked in `origin` getter + return true + case 'other_chat': + // replyToMsgId != null, replyFrom != null. checking for replyToPeerId is enough + return this.raw.replyToPeerId !== undefined + case 'private': + // replyFrom.fromId should be available + return this.raw.replyFrom?.fromId !== undefined + } + } + + return true + } + + /** + * For non-`private` origin, ID of the replied-to message in the original chat. + */ + get id(): number | null { + return this.raw.replyToMsgId ?? null + } + + /** ID of the replies thread where this message belongs to */ + get threadId(): number | null { + return this.raw.replyToTopId ?? null + } + + /** + * If replied-to message is available, chat where the message was sent. + * + * If `null`, the message was sent in the same chat. + */ + get chat(): Chat | null { + if (!this.raw.replyToPeerId || !this.raw.replyFrom) { + // same chat or private. even if `replyToPeerId` is available, + // without `replyFrom` it would contain the sender, not the chat + return null + } + + return Chat._parseFromPeer(this.raw.replyToPeerId, this._peers) + } + + /** + * Sender of the replied-to message (either user or a channel) + * or their name (for users with private forwards). + * + * For replies to channel messages, this will be the channel itself. + * + * `null` if the sender is not available (for `same_chat` origin) + */ + get sender(): User | Chat | string | null { + const { replyFrom, replyToPeerId } = this.raw + if (!replyFrom && !replyToPeerId) return null + + if (replyFrom?.fromName) { + return replyFrom.fromName + } + + const peer = replyFrom?.fromId ?? replyToPeerId + + if (peer) { + switch (peer._) { + case 'peerChannel': + return new Chat(this._peers.chat(peer.channelId)) + case 'peerUser': + return new User(this._peers.user(peer.userId)) + default: + throw new MtTypeAssertionError('fromId ?? replyToPeerId', 'peerUser | peerChannel', peer._) + } + } + + throw new MtTypeAssertionError('replyFrom', 'to have fromId, replyToPeerId or fromName', 'neither') + } + + /** + * For non-`same_chat` origin, date the original message was sent. + */ + get date(): Date | null { + if (!this.raw.replyFrom?.date) return null + + return new Date(this.raw.replyFrom.date * 1000) + } + + /** + * If this message is a quote, text of the quote. + * + * For non-`same_chat` origin, this will be the full text of the + * replied-to message in case `.isQuote` is `false` + */ + get quoteText(): string { + return this.raw.quoteText ?? '' + } + + /** Message entities contained in the quote */ + get quoteEntities(): MessageEntity[] { + return this.raw.quoteEntities?.map((e) => new MessageEntity(e)) ?? [] + } + + /** + * Offset of the start of the {@link quoteText} in the replied-to message. + * + * `null` if not available, in which case it should be assumed that the quote + * starts at `.indexOf(quoteText)` of the replied-to message text. + */ + get quoteOffset(): number | null { + if (!this.raw.quoteOffset) return null + + return this.raw.quoteOffset + } + + /** + * Media contained in the replied-to message + * + * Only available for non-`same_chat` origin + */ + get media(): MessageMedia { + if (!this.raw.replyMedia) return null + + return _messageMediaFromTl(this._peers, this.raw.replyMedia) + } +} + +memoizeGetters(RepliedMessageInfo, ['chat', 'sender', 'quoteEntities', 'media']) +makeInspectable(RepliedMessageInfo) diff --git a/packages/dispatcher/src/filters/message.ts b/packages/dispatcher/src/filters/message.ts index 0540790b..562900e2 100644 --- a/packages/dispatcher/src/filters/message.ts +++ b/packages/dispatcher/src/filters/message.ts @@ -1,14 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // ^^ will be looked into in MTQ-29 import { + _RepliedMessageAssertionsByOrigin, Chat, MaybeArray, Message, MessageAction, MessageMediaType, - MessageReplyInfo, RawDocument, RawLocation, + RepliedMessageInfo, + RepliedMessageOrigin, Sticker, StickerSourceType, StickerType, @@ -36,7 +38,22 @@ export const outgoing: UpdateFilter = (msg) => ms /** * Filter messages that are replies to some other message */ -export const reply: UpdateFilter = (msg) => msg.replyToMessage !== null +export const reply: UpdateFilter = (msg) => msg.replyToMessage !== null + +/** + * Filter messages that are replies with the given origin type + */ +export const replyOrigin = + ( + origin: T, + ): UpdateFilter< + Message, + { + replyToMessage: Modify + } + > => + (msg) => + msg.replyToMessage?.originIs(origin) ?? false // originIs does additional checks /** * Filter messages containing some media @@ -74,7 +91,7 @@ export const liveLocation = mediaOf('live_location') /** Filter messages containing a game */ export const game = mediaOf('game') /** Filter messages containing a web page */ -export const webpage = mediaOf('web_page') +export const webpage = mediaOf('webpage') /** Filter messages containing a venue */ export const venue = mediaOf('venue') /** Filter messages containing a poll */ @@ -212,7 +229,8 @@ export const sender = msg.sender.type === type /** - * Filter that matches messages that are replies to some other message. + * Filter that matches messages that are replies to some other message that can be fetched + * (i.e. not `private` origin, and has not been deleted) * * Optionally, you can pass a filter that will be applied to the replied message. */