diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 1740f2fb..9fdf7a36 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -33,9 +33,11 @@ import { MaybeDynamic, Message, MessageMedia, + MessageReactions, ParsedUpdate, PartialExcept, PartialOnly, + PeerReaction, PeersIndex, Photo, Poll, @@ -172,19 +174,23 @@ import { } from './methods/messages/get-discussion-message' import { getHistory } from './methods/messages/get-history' import { getMessageGroup } from './methods/messages/get-message-group' +import { getMessageReactions } from './methods/messages/get-message-reactions' import { getMessagesUnsafe } from './methods/messages/get-messages-unsafe' import { getMessages } from './methods/messages/get-messages' +import { getReactionUsers } from './methods/messages/get-reaction-users' import { getScheduledMessages } from './methods/messages/get-scheduled-messages' import { iterHistory } from './methods/messages/iter-history' import { _normalizeInline } from './methods/messages/normalize-inline' import { _parseEntities } from './methods/messages/parse-entities' import { pinMessage } from './methods/messages/pin-message' import { readHistory } from './methods/messages/read-history' +import { readReactions } from './methods/messages/read-reactions' import { searchGlobal } from './methods/messages/search-global' import { searchMessages } from './methods/messages/search-messages' import { sendCopy } from './methods/messages/send-copy' import { sendMediaGroup } from './methods/messages/send-media-group' import { sendMedia } from './methods/messages/send-media' +import { sendReaction } from './methods/messages/send-reaction' import { sendScheduled } from './methods/messages/send-scheduled' import { sendText } from './methods/messages/send-text' import { sendTyping } from './methods/messages/send-typing' @@ -2430,6 +2436,36 @@ export interface TelegramClient extends BaseTelegramClient { * @param message ID of one of the messages in the group */ getMessageGroup(chatId: InputPeerLike, message: number): Promise + /** + * Get reactions to a message. + * + * > Apps should short-poll reactions for visible messages + * > (that weren't sent by the user) once every 15-30 seconds, + * > but only if `message.reactions` is set + * + * @param chatId ID of the chat with the message + * @param messages Message ID + * @returns Reactions to the corresponding message, or `null` if there are none + */ + getMessageReactions( + chatId: InputPeerLike, + messages: number + ): Promise + /** + * Get reactions to messages. + * + * > Apps should short-poll reactions for visible messages + * > (that weren't sent by the user) once every 15-30 seconds, + * > but only if `message.reactions` is set + * + * @param chatId ID of the chat with messages + * @param messages Message IDs + * @returns Reactions to corresponding messages, or `null` if there are none + */ + getMessageReactions( + chatId: InputPeerLike, + messages: number[] + ): Promise<(MessageReactions | null)[]> /** * Get a single message from PM or legacy group by its ID. * For channels, use {@link getMessages}. @@ -2496,6 +2532,37 @@ export interface TelegramClient extends BaseTelegramClient { messageIds: number[], fromReply?: boolean ): Promise<(Message | null)[]> + /** + * Get users who have reacted to the message. + * + * @param chatId Chat ID + * @param messageId Message ID + * @param params + */ + getReactionUsers( + chatId: InputPeerLike, + messageId: number, + params?: { + /** + * Get only reactions with the specified emoji + */ + emoji?: string + + /** + * Limit the number of events returned. + * + * Defaults to `Infinity`, i.e. all events are returned + */ + limit?: number + + /** + * Chunk size, usually not needed. + * + * Defaults to `100` + */ + chunkSize?: number + } + ): AsyncIterableIterator /** * Get a single scheduled message in chat by its ID * @@ -2603,6 +2670,12 @@ export interface TelegramClient extends BaseTelegramClient { message?: number, clearMentions?: boolean ): Promise + /** + * Mark all reactions in chat as read. + * + * @param chatId Chat ID + */ + readReactions(chatId: InputPeerLike): Promise /** * Search for messages globally from all of your chats * @@ -2979,6 +3052,21 @@ export interface TelegramClient extends BaseTelegramClient { clearDraft?: boolean } ): Promise + /** + * Send or remove a reaction. + * + * @param chatId Chat ID with the message to react to + * @param message Message ID to react to + * @param emoji Reaction emoji (or `null` to remove) + * @param big (default: `false`) Whether to use a big reaction + * @returns Message to which the reaction was sent + */ + sendReaction( + chatId: InputPeerLike, + message: number, + emoji: string | null, + big?: boolean + ): Promise /** * Send s previously scheduled message. * @@ -3891,19 +3979,23 @@ export class TelegramClient extends BaseTelegramClient { getDiscussionMessage = getDiscussionMessage getHistory = getHistory getMessageGroup = getMessageGroup + getMessageReactions = getMessageReactions getMessagesUnsafe = getMessagesUnsafe getMessages = getMessages + getReactionUsers = getReactionUsers getScheduledMessages = getScheduledMessages iterHistory = iterHistory protected _normalizeInline = _normalizeInline protected _parseEntities = _parseEntities pinMessage = pinMessage readHistory = readHistory + readReactions = readReactions searchGlobal = searchGlobal searchMessages = searchMessages sendCopy = sendCopy sendMediaGroup = sendMediaGroup sendMedia = sendMedia + sendReaction = sendReaction sendScheduled = sendScheduled sendText = sendText sendTyping = sendTyping diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index 37e27bf1..adabc6e4 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -55,6 +55,8 @@ import { BotStoppedUpdate, BotChatJoinRequestUpdate, ChatJoinRequestUpdate, + PeerReaction, + MessageReactions, } from '../types' // @copy diff --git a/packages/client/src/methods/messages/get-message-reactions.ts b/packages/client/src/methods/messages/get-message-reactions.ts new file mode 100644 index 00000000..9e357a11 --- /dev/null +++ b/packages/client/src/methods/messages/get-message-reactions.ts @@ -0,0 +1,95 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike, PeersIndex, MessageReactions } from '../../types' +import { assertIsUpdatesGroup } from '../../utils/updates-utils' +import { getMarkedPeerId, MaybeArray } from '@mtcute/core' +import { assertTypeIs } from '../../utils/type-assertion' + +/** + * Get reactions to a message. + * + * > Apps should short-poll reactions for visible messages + * > (that weren't sent by the user) once every 15-30 seconds, + * > but only if `message.reactions` is set + * + * @param chatId ID of the chat with the message + * @param messages Message ID + * @returns Reactions to the corresponding message, or `null` if there are none + * @internal + */ +export async function getMessageReactions( + this: TelegramClient, + chatId: InputPeerLike, + messages: number +): Promise + +/** + * Get reactions to messages. + * + * > Apps should short-poll reactions for visible messages + * > (that weren't sent by the user) once every 15-30 seconds, + * > but only if `message.reactions` is set + * + * @param chatId ID of the chat with messages + * @param messages Message IDs + * @returns Reactions to corresponding messages, or `null` if there are none + * @internal + */ +export async function getMessageReactions( + this: TelegramClient, + chatId: InputPeerLike, + messages: number[] +): Promise<(MessageReactions | null)[]> + +/** + * @internal + */ +export async function getMessageReactions( + this: TelegramClient, + chatId: InputPeerLike, + messages: MaybeArray +): Promise> { + const single = !Array.isArray(messages) + if (!Array.isArray(messages)) { + messages = [messages] + } + + const res = await this.call({ + _: 'messages.getMessagesReactions', + peer: await this.resolvePeer(chatId), + id: messages, + }) + + assertIsUpdatesGroup('messages.getMessagesReactions', res) + + // normally the group contains updateMessageReactions + // for each message requested that has reactions + // + // these updates are not ordered in any way, so + // we don't need to pass them to updates engine + + const index: Record = {} + + const peers = PeersIndex.from(res) + + for (const update of res.updates) { + assertTypeIs( + 'messages.getMessagesReactions', + update, + 'updateMessageReactions' + ) + + index[update.msgId] = new MessageReactions( + this, + update.msgId, + getMarkedPeerId(update.peer), + update.reactions, + peers + ) + } + + if (single) { + return index[messages[0]] ?? null + } + + return messages.map((messageId) => index[messageId] ?? null) +} diff --git a/packages/client/src/methods/messages/get-reaction-users.ts b/packages/client/src/methods/messages/get-reaction-users.ts new file mode 100644 index 00000000..c6a22270 --- /dev/null +++ b/packages/client/src/methods/messages/get-reaction-users.ts @@ -0,0 +1,77 @@ +import { TelegramClient } from '../../client' +import { + InputPeerLike, + PeerReaction, + PeersIndex, +} from '../../types' +import { tl } from '@mtcute/tl' + +/** + * Get users who have reacted to the message. + * + * @param chatId Chat ID + * @param messageId Message ID + * @param params + * @internal + */ +export async function* getReactionUsers( + this: TelegramClient, + chatId: InputPeerLike, + messageId: number, + params?: { + /** + * Get only reactions with the specified emoji + */ + emoji?: string + + /** + * Limit the number of events returned. + * + * Defaults to `Infinity`, i.e. all events are returned + */ + limit?: number + + /** + * Chunk size, usually not needed. + * + * Defaults to `100` + */ + chunkSize?: number + } +): AsyncIterableIterator { + if (!params) params = {} + + const peer = await this.resolvePeer(chatId) + + let current = 0 + let offset: string | undefined = undefined + const total = params.limit || Infinity + const chunkSize = Math.min(params.chunkSize ?? 100, total) + + for (;;) { + const res: tl.RpcCallReturn['messages.getMessageReactionsList'] = + await this.call({ + _: 'messages.getMessageReactionsList', + peer, + id: messageId, + reaction: params.emoji, + limit: Math.min(chunkSize, total - current), + offset, + }) + + if (!res.reactions.length) break + + offset = res.nextOffset + + const peers = PeersIndex.from(res) + + for (const reaction of res.reactions) { + const parsed = new PeerReaction(this, reaction, peers) + + current += 1 + yield parsed + + if (current >= total) break + } + } +} diff --git a/packages/client/src/methods/messages/read-reactions.ts b/packages/client/src/methods/messages/read-reactions.ts new file mode 100644 index 00000000..6546c290 --- /dev/null +++ b/packages/client/src/methods/messages/read-reactions.ts @@ -0,0 +1,20 @@ +import { TelegramClient } from '../../client' +import { InputPeerLike } from '../../types' +import { createDummyUpdate } from '../../utils/updates-utils' + +/** + * Mark all reactions in chat as read. + * + * @param chatId Chat ID + * @internal + */ +export async function readReactions( + this: TelegramClient, + chatId: InputPeerLike, +): Promise { + const res = await this.call({ + _: 'messages.readReactions', + peer: await this.resolvePeer(chatId) + }) + this._handleUpdate(createDummyUpdate(res.pts, res.ptsCount)) +} diff --git a/packages/client/src/methods/messages/send-reaction.ts b/packages/client/src/methods/messages/send-reaction.ts new file mode 100644 index 00000000..6191a50f --- /dev/null +++ b/packages/client/src/methods/messages/send-reaction.ts @@ -0,0 +1,60 @@ +import { TelegramClient } from '../../client' +import { + InputPeerLike, + Message, + MtTypeAssertionError, + PeersIndex, +} from '../../types' +import { assertIsUpdatesGroup } from '../../utils/updates-utils' +import { tl } from '@mtcute/tl' + +/** + * Send or remove a reaction. + * + * @param chatId Chat ID with the message to react to + * @param message Message ID to react to + * @param emoji Reaction emoji (or `null` to remove) + * @param big Whether to use a big reaction + * @returns Message to which the reaction was sent + * @internal + */ +export async function sendReaction( + this: TelegramClient, + chatId: InputPeerLike, + message: number, + emoji: string | null, + big = false +): Promise { + const res = await this.call({ + _: 'messages.sendReaction', + peer: await this.resolvePeer(chatId), + msgId: message, + reaction: emoji ?? undefined, + big, + }) + + assertIsUpdatesGroup('messages.sendReaction', res) + + // normally the group contains 2 updates: + // updateEditChannelMessage + // updateMessageReactions + // idk why, they contain literally the same data + // so we can just return the message from the first one + + this._handleUpdate(res, true) + + const upd = res.updates.find( + (it) => it._ === 'updateEditChannelMessage' + ) as tl.RawUpdateEditChannelMessage | undefined + if (!upd) { + throw new MtTypeAssertionError( + 'messages.sendReaction (@ .updates[*])', + 'updateEditChannelMessage', + 'undefined' + ) + } + + const peers = PeersIndex.from(res) + + return new Message(this, upd.message, peers) +} diff --git a/packages/client/src/types/messages/index.ts b/packages/client/src/types/messages/index.ts index dc051bad..c5d515e9 100644 --- a/packages/client/src/types/messages/index.ts +++ b/packages/client/src/types/messages/index.ts @@ -5,3 +5,4 @@ export * from './message' export * from './search-filters' export * from './draft-message' export * from './dialog' +export * from './reactions' diff --git a/packages/client/src/types/messages/message.ts b/packages/client/src/types/messages/message.ts index df71aa8a..e7160fe9 100644 --- a/packages/client/src/types/messages/message.ts +++ b/packages/client/src/types/messages/message.ts @@ -1,11 +1,8 @@ import { User, Chat, InputPeerLike, PeersIndex } from '../peers' import { tl } from '@mtcute/tl' import { BotKeyboard, ReplyMarkup } from '../bots' -import { assertNever, getMarkedPeerId, toggleChannelIdMark } from "@mtcute/core"; -import { - MtArgumentError, - MtTypeAssertionError, -} from '../errors' +import { assertNever, getMarkedPeerId, toggleChannelIdMark } from '@mtcute/core' +import { MtArgumentError, MtTypeAssertionError } from '../errors' import { TelegramClient } from '../../client' import { MessageEntity } from './message-entity' import { makeInspectable } from '../utils' @@ -13,6 +10,7 @@ import { InputMediaLike, WebPage } from '../media' import { _messageActionFromTl, MessageAction } from './message-action' import { _messageMediaFromTl, MessageMedia } from './message-media' import { FormattedString } from '../parser' +import { MessageReactions } from './reactions' /** * A message or a service message @@ -314,7 +312,7 @@ export class Message { } if (r.comments) { - const o = (obj as unknown) as Message.MessageCommentsInfo + const o = obj as unknown as Message.MessageCommentsInfo o.discussion = getMarkedPeerId(r.channelId!, 'channel') o.repliers = r.recentRepliers?.map((it) => getMarkedPeerId(it)) ?? [] @@ -490,6 +488,34 @@ export class Message { return this._markup } + /** + * Whether this message can be forwarded + * + * `false` for service mesasges and private restricted chats/chanenls + */ + get canBeForwarded(): boolean { + return this.raw._ === 'message' && !this.raw.noforwards + } + + private _reactions?: MessageReactions | null + get reactions(): MessageReactions | null { + if (this._reactions === undefined) { + if (this.raw._ === 'messageService' || !this.raw.reactions) { + this._reactions = null + } else { + this._reactions = new MessageReactions( + this.client, + this.raw.id, + getMarkedPeerId(this.raw.peerId), + this.raw.reactions, + this._peers + ) + } + } + + return this._reactions + } + /** * Generated permalink to this message, only for groups and channels * @@ -892,6 +918,21 @@ export class Message { clearMentions ) } + + /** + * React to this message + * + * @param emoji Reaction emoji + * @param big Whether to use a big reaction + */ + async react(emoji: string | null, big?: boolean): Promise { + return this.client.sendReaction( + this.chat.inputPeer, + this.raw.id, + emoji, + big + ) + } } makeInspectable(Message, ['isScheduled'], ['link']) diff --git a/packages/client/src/types/messages/reactions.ts b/packages/client/src/types/messages/reactions.ts new file mode 100644 index 00000000..2656e08a --- /dev/null +++ b/packages/client/src/types/messages/reactions.ts @@ -0,0 +1,118 @@ +import { TelegramClient } from '../../client' +import { tl } from '@mtcute/tl' +import { makeInspectable } from '../utils' +import { getMarkedPeerId } from '@mtcute/core' +import { PeersIndex, User } from '../peers' +import { assertTypeIs } from '../../utils/type-assertion' + +export class PeerReaction { + constructor( + readonly client: TelegramClient, + readonly raw: tl.RawMessagePeerReaction, + readonly _peers: PeersIndex + ) {} + + /** + * Emoji representing the reaction + */ + get emoji(): string { + return this.raw.reaction! + } + + /** + * Whether this is a big reaction + */ + get big(): boolean { + return this.raw.big! + } + + /** + * Whether this reaction is unread by the current user + */ + get unread(): boolean { + return this.raw.unread! + } + + /** + * ID of the user who has reacted + */ + get userId(): number { + return getMarkedPeerId(this.raw.peerId) + } + + private _user?: User + + /** + * User who has reacted + */ + get user(): User { + if (!this._user) { + assertTypeIs('PeerReaction#user', this.raw.peerId, 'peerUser') + + this._user = new User( + this.client, + this._peers.user(this.raw.peerId.userId) + ) + } + + return this._user + } +} + +makeInspectable(PeerReaction) + +export class MessageReactions { + constructor( + readonly client: TelegramClient, + readonly messageId: number, + readonly chatId: number, + readonly raw: tl.RawMessageReactions, + readonly _peers: PeersIndex + ) {} + + /** + * Whether you can use {@link getUsers} + * (or {@link TelegramClient.getReactionUsers}) + * to get the users who reacted to this message + */ + get usersVisible(): boolean { + return this.raw.canSeeList! + } + + /** + * Reactions on the message, along with their counts + */ + get reactions(): tl.TypeReactionCount[] { + return this.raw.results + } + + private _recentReactions?: PeerReaction[] + + /** + * Recently reacted users. + * To get a full list of users, use {@link getUsers} + */ + get recentReactions(): PeerReaction[] { + if (!this.raw.recentReactions) { + return [] + } + + if (!this._recentReactions) { + this._recentReactions = this.raw.recentReactions.map( + (reaction) => + new PeerReaction(this.client, reaction, this._peers) + ) + } + + return this._recentReactions + } + + /** + * Get the users who reacted to this message + */ + getUsers(params?: Parameters[2]): AsyncIterableIterator { + return this.client.getReactionUsers(this.messageId, this.chatId, params) + } +} + +makeInspectable(MessageReactions)