import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiAttachment, ApiBaseThreadInfo, ApiChat, ApiCommentsInfo, ApiContact, ApiDice, ApiDraft, ApiFactCheck, ApiInputMessageReplyInfo, ApiInputReplyInfo, ApiInputSuggestedPostInfo, ApiMediaTodo, ApiMessage, ApiMessageEntity, ApiMessageForwardInfo, ApiMessagePoll, ApiMessageReportResult, ApiMessageThreadInfo, ApiNewMediaTodo, ApiNewPoll, ApiPeer, ApiPhoto, ApiPollResult, ApiPreparedInlineMessage, ApiQuickReply, ApiReplyInfo, ApiSearchPostsFlood, ApiSponsoredMessage, ApiSticker, ApiStory, ApiStorySkipped, ApiSuggestedPost, ApiThreadInfo, ApiVideo, MediaContent, } from '../../types'; import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../types'; import { DELETED_COMMENTS_CHANNEL_ID, LOCAL_MESSAGES_LIMIT, SERVICE_NOTIFICATIONS_USER_ID, SPONSORED_MESSAGE_CACHE_MS, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { addTimestampEntities } from '../../../util/dates/timestamp'; import { omitUndefined } from '../../../util/iteratees'; import { toJSNumber } from '../../../util/numbers'; import { getServerTime } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; import { buildApiCurrencyAmount, } from '../apiBuilders/payments'; import { buildPeer, getEntityTypeById } from '../gramjsBuilders'; import { addDocumentToLocalDb, addPhotoToLocalDb, addWebDocumentToLocalDb, type MediaRepairContext, } from '../helpers/localDb'; import { resolveMessageApiChatId, serializeBytes } from '../helpers/misc'; import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildApiInlineQueryPeerType, buildReplyButtons, } from './bots'; import { buildApiFormattedText, buildApiPhoto, } from './common'; import { type OmitVirtualFields } from './helpers'; import { buildApiMessageAction } from './messageActions'; import { buildMessageContent, buildMessageMediaContent, buildMessageTextContent } from './messageContent'; import { buildApiRestrictionReasons } from './misc'; import { buildApiPeerColor, buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildMessageReactions } from './reactions'; const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp'; const INPUT_WAVEFORM_LENGTH = 63; const MIN_SCHEDULED_PERIOD = 10; let localMessageCounter = 0; function getNextLocalMessageId(lastMessageId = 0) { return lastMessageId + (++localMessageCounter / LOCAL_MESSAGES_LIMIT); } export function incrementLocalMessageCounter() { localMessageCounter++; } let currentUserId!: string; export function setMessageBuilderCurrentUserId(_currentUserId: string) { currentUserId = _currentUserId; } export function buildApiSponsoredMessage( mtpMessage: GramJs.SponsoredMessage, chatId: string, ): ApiSponsoredMessage | undefined { const { message, entities, randomId, recommended, sponsorInfo, additionalInfo, buttonText, canReport, title, url, color, } = mtpMessage; let photo: ApiPhoto | undefined; if (mtpMessage.photo instanceof GramJs.Photo) { addPhotoToLocalDb(mtpMessage.photo); photo = buildApiPhoto(mtpMessage.photo); } let media: MediaContent | undefined; if (mtpMessage.media) { media = buildMessageMediaContent(mtpMessage.media); } return { chatId, randomId: serializeBytes(randomId), expiresAt: Math.round(Date.now() / 1000) + SPONSORED_MESSAGE_CACHE_MS, isRecommended: recommended, sponsorInfo, additionalInfo, buttonText, canReport, title, url, peerColor: color && buildApiPeerColor(color), photo, content: { ...media, text: buildMessageTextContent(message, entities), }, }; } export function buildApiMessage(mtpMessage: GramJs.TypeMessage): ApiMessage | undefined { const chatId = resolveMessageApiChatId(mtpMessage); if ( !chatId || !(mtpMessage instanceof GramJs.Message || mtpMessage instanceof GramJs.MessageService)) { return undefined; } return buildApiMessageWithChatId(chatId, mtpMessage); } export function buildApiMessageFromShort(mtpMessage: GramJs.UpdateShortMessage): ApiMessage { const chatId = buildApiPeerId(mtpMessage.userId, 'user'); return buildApiMessageWithChatId(chatId, { ...mtpMessage, peerId: buildPeer(mtpMessage.out ? buildApiPeerId(mtpMessage.userId, 'user') : currentUserId), }); } export function buildApiMessageFromShortChat(mtpMessage: GramJs.UpdateShortChatMessage): ApiMessage { const chatId = buildApiPeerId(mtpMessage.chatId, 'chat'); return buildApiMessageWithChatId(chatId, { ...mtpMessage, fromId: buildPeer(buildApiPeerId(mtpMessage.fromId, 'user')), peerId: buildPeer(buildApiPeerId(mtpMessage.chatId, 'chat')), }); } export function buildApiMessageFromNotification( notification: GramJs.UpdateServiceNotification, currentDate: number, ): ApiMessage { const localId = getNextLocalMessageId(currentDate); const content = buildMessageContent(notification); return { id: localId, chatId: SERVICE_NOTIFICATIONS_USER_ID, date: notification.inboxDate || currentDate, content, isInvertedMedia: notification.invertMedia, isOutgoing: false, }; } type TypeMessageWithContent = OmitVirtualFields & OmitVirtualFields; export type UniversalMessage = ( Pick & Partial ); export function buildApiMessageWithChatId( chatId: string, mtpMessage: UniversalMessage, ): ApiMessage { const isPrivateChat = getEntityTypeById(chatId) === 'user'; // Server can return `fromId` for our own messages in private chats, but not for incoming ones // This can break grouping logic, as we do not fill `fromId` for `UpdateShortMessage` case const fromId = mtpMessage.fromId && (!isPrivateChat || mtpMessage.guestchatViaFrom) ? getApiChatIdFromMtpPeer(mtpMessage.fromId) : undefined; const isChatWithSelf = !fromId && chatId === currentUserId; const forwardInfo = mtpMessage.fwdFrom && buildApiMessageForwardInfo(mtpMessage.fwdFrom, isChatWithSelf); const isSavedOutgoing = Boolean(!forwardInfo || forwardInfo.fromId === currentUserId || forwardInfo.isSavedOutgoing); const isOutgoing = !isChatWithSelf ? Boolean(mtpMessage.out && !mtpMessage.post) : isSavedOutgoing; const content = buildMessageContent(mtpMessage); const action = mtpMessage.action && buildApiMessageAction(mtpMessage.action); if (action) { content.action = action; } const isScheduled = mtpMessage.date > getServerTime() + MIN_SCHEDULED_PERIOD; const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice && Boolean(mtpMessage.media.extendedMedia); const isEdited = Boolean(mtpMessage.editDate) && !mtpMessage.editHide; const { inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective, } = buildReplyButtons( mtpMessage.replyMarkup, mtpMessage.media instanceof GramJs.MessageMediaInvoice ? mtpMessage.media.receiptMsgId : undefined, ) || {}; const { mediaUnread: isMediaUnread, postAuthor } = mtpMessage; const groupedId = mtpMessage.groupedId !== undefined ? String(mtpMessage.groupedId) : undefined; const isInAlbum = Boolean(groupedId) && !(content.document || content.audio || content.sticker); const shouldHideKeyboardButtons = mtpMessage.replyMarkup instanceof GramJs.ReplyKeyboardHide; const isHideKeyboardSelective = mtpMessage.replyMarkup instanceof GramJs.ReplyKeyboardHide && mtpMessage.replyMarkup.selective; const isProtected = mtpMessage.noforwards || isInvoiceMedia; const isForwardingAllowed = !mtpMessage.noforwards; const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId); if (content.text && emojiOnlyCount) content.text.emojiOnlyCount = emojiOnlyCount; const hasComments = mtpMessage.replies?.comments; const senderBoosts = mtpMessage.fromBoostsApplied; const factCheck = mtpMessage.factcheck && buildApiFactCheck(mtpMessage.factcheck); const isVideoProcessingPending = mtpMessage.videoProcessingPending; const areReactionsPossible = mtpMessage.reactionsArePossible; const isInvertedMedia = mtpMessage.invertMedia; const savedPeerId = mtpMessage.savedPeerId && getApiChatIdFromMtpPeer(mtpMessage.savedPeerId); const restrictionReasons = buildApiRestrictionReasons(mtpMessage.restrictionReason); return { id: mtpMessage.id, chatId, isOutgoing, content, date: mtpMessage.date, senderId: fromId, viewsCount: mtpMessage.views, forwardsCount: mtpMessage.forwards, isScheduled, scheduleRepeatPeriod: mtpMessage.scheduleRepeatPeriod, isFromScheduled: mtpMessage.fromScheduled, isSilent: mtpMessage.silent, isPinned: mtpMessage.pinned, reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), ...(mtpMessage.replyTo && { replyInfo: buildApiReplyInfo(mtpMessage.replyTo, mtpMessage) }), ...(mtpMessage.suggestedPost && { suggestedPostInfo: buildApiSuggestedPost(mtpMessage.suggestedPost) }), forwardInfo, isEdited, editDate: mtpMessage.editDate, isMediaUnread, hasUnreadMention: mtpMessage.mentioned && isMediaUnread, areReactionsPossible, isMentioned: mtpMessage.mentioned, ...(groupedId && { groupedId, isInAlbum, }), inlineButtons, ...(keyboardButtons && { keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective, }), ...(shouldHideKeyboardButtons && { shouldHideKeyboardButtons, isHideKeyboardSelective }), ...(mtpMessage.viaBotId && { viaBotId: buildApiPeerId(mtpMessage.viaBotId, 'user') }), postAuthorTitle: postAuthor, isProtected, isForwardingAllowed, hasComments, savedPeerId, senderBoosts, viaBusinessBotId: mtpMessage.viaBusinessBotId?.toString(), factCheck, effectId: mtpMessage.effect?.toString(), isInvertedMedia, isVideoProcessingPending, reportDeliveryUntilDate: mtpMessage.reportDeliveryUntilDate, paidMessageStars: toJSNumber(mtpMessage.paidMessageStars), restrictionReasons, summaryLanguageCode: mtpMessage.summaryFromLanguage, fromRank: mtpMessage.fromRank, guestChatViaId: mtpMessage.guestchatViaFrom && getApiChatIdFromMtpPeer(mtpMessage.guestchatViaFrom), }; } export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | undefined { if (draft instanceof GramJs.DraftMessageEmpty) { return undefined; } const { message, entities, replyTo, date, effect, suggestedPost, } = draft; const replyInfo = replyTo instanceof GramJs.InputReplyToMessage ? { type: 'message', replyToMsgId: replyTo.replyToMsgId, replyToTopId: replyTo.topMsgId, replyToPeerId: replyTo.replyToPeerId && getApiChatIdFromMtpPeer(replyTo.replyToPeerId), monoforumPeerId: replyTo.monoforumPeerId && getApiChatIdFromMtpPeer(replyTo.monoforumPeerId), quoteText: replyTo.quoteText ? buildMessageTextContent(replyTo.quoteText, replyTo.quoteEntities) : undefined, quoteOffset: replyTo.quoteOffset, } satisfies ApiInputMessageReplyInfo : undefined; const suggestedPostInfo = suggestedPost instanceof GramJs.SuggestedPost ? { isAccepted: suggestedPost.accepted, isRejected: suggestedPost.rejected, price: suggestedPost.price ? buildApiCurrencyAmount(suggestedPost.price) : undefined, scheduleDate: suggestedPost.scheduleDate, } satisfies ApiInputSuggestedPostInfo : undefined; return { text: message ? buildMessageTextContent(message, entities) : undefined, replyInfo, suggestedPostInfo, date, effectId: effect?.toString(), }; } function buildApiSuggestedPost(suggestedPost: GramJs.SuggestedPost): ApiSuggestedPost { return { isAccepted: suggestedPost.accepted, isRejected: suggestedPost.rejected, price: suggestedPost.price ? buildApiCurrencyAmount(suggestedPost.price) : undefined, scheduleDate: suggestedPost.scheduleDate, }; } function buildApiMessageForwardInfo(fwdFrom: GramJs.MessageFwdHeader, isChatWithSelf = false): ApiMessageForwardInfo { const savedFromPeerId = fwdFrom.savedFromPeer && getApiChatIdFromMtpPeer(fwdFrom.savedFromPeer); const fromId = fwdFrom.fromId && getApiChatIdFromMtpPeer(fwdFrom.fromId); return { date: fwdFrom.date, savedDate: fwdFrom.savedDate, isImported: fwdFrom.imported, isChannelPost: Boolean(fwdFrom.channelPost), channelPostId: fwdFrom.channelPost, isLinkedChannelPost: Boolean(fwdFrom.channelPost && savedFromPeerId === fromId && fwdFrom.savedFromMsgId === fwdFrom.channelPost && !isChatWithSelf), savedFromPeerId, isSavedOutgoing: fwdFrom.savedOut, fromId, fromChatId: fromId || savedFromPeerId, fromMessageId: fwdFrom.savedFromMsgId || fwdFrom.channelPost, hiddenUserName: fwdFrom.fromName, postAuthorTitle: fwdFrom.postAuthor, }; } function buildApiReplyInfo( replyHeader: GramJs.TypeMessageReplyHeader, context?: MediaRepairContext, ): ApiReplyInfo | undefined { if (replyHeader instanceof GramJs.MessageReplyStoryHeader) { return { type: 'story', peerId: getApiChatIdFromMtpPeer(replyHeader.peer), storyId: replyHeader.storyId, }; } if (replyHeader instanceof GramJs.MessageReplyHeader) { const { replyFrom, replyToMsgId, replyToTopId, replyMedia, replyToPeerId, forumTopic, quote, quoteText, quoteEntities, quoteOffset, } = replyHeader; return { type: 'message', replyToMsgId, replyToTopId, isForumTopic: forumTopic, replyFrom: replyFrom && buildApiMessageForwardInfo(replyFrom), replyToPeerId: replyToPeerId && getApiChatIdFromMtpPeer(replyToPeerId), replyMedia: replyMedia && buildMessageMediaContent(replyMedia, context), isQuote: quote, quoteText: quoteText ? buildMessageTextContent(quoteText, quoteEntities) : undefined, quoteOffset, }; } return undefined; } export function buildApiFactCheck(factCheck: GramJs.FactCheck): ApiFactCheck { return { shouldFetch: factCheck.needCheck, hash: factCheck.hash.toString(), text: factCheck.text && buildApiFormattedText(factCheck.text), countryCode: factCheck.country, }; } function buildNewLocalPoll(poll: ApiNewPoll): ApiMessagePoll { const resultByOption = poll.correctAnswers?.length ? poll.summary.answers.reduce((acc, answer, index) => { const isCorrect = poll.correctAnswers?.includes(index); acc[answer.option] = { option: answer.option, votersCount: 0, isCorrect: isCorrect ? true : undefined, }; return acc; }, {} as Record) : undefined; return { mediaType: 'poll', summary: poll.summary, results: { resultByOption, solution: poll.solution, solutionEntities: poll.solutionEntities, solutionMedia: poll.solutionMedia ? buildUploadingMedia(poll.solutionMedia) : undefined, }, attachedMedia: poll.attachedMedia ? buildUploadingMedia(poll.attachedMedia) : undefined, }; } function buildNewLocalTodo(todo: ApiNewMediaTodo): ApiMediaTodo { return { mediaType: 'todo', todo: todo.todo, }; } export function buildLocalMessage({ chat, lastMessageId, text, entities, replyInfo, suggestedPostInfo, attachment, sticker, gif, poll, todo, contact, groupedId, scheduledAt, scheduleRepeatPeriod, sendAs, story, isInvertedMedia, effectId, isPending, messagePriceInStars, dice, }: { chat: ApiChat; lastMessageId?: number; text?: string; entities?: ApiMessageEntity[]; replyInfo?: ApiInputReplyInfo; suggestedPostInfo?: ApiInputSuggestedPostInfo; attachment?: ApiAttachment; sticker?: ApiSticker; gif?: ApiVideo; poll?: ApiNewPoll; todo?: ApiNewMediaTodo; contact?: ApiContact; groupedId?: string; scheduledAt?: number; scheduleRepeatPeriod?: number; sendAs?: ApiPeer; story?: ApiStory | ApiStorySkipped; isInvertedMedia?: true; effectId?: string; isPending?: true; messagePriceInStars?: number; dice?: string; }) { const localId = getNextLocalMessageId(lastMessageId); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; const resultReplyInfo = replyInfo && buildReplyInfo(replyInfo, chat.isForum); const localPoll = poll && buildNewLocalPoll(poll); const localTodo = todo && buildNewLocalTodo(todo); const localDice = dice ? { mediaType: 'dice', value: -1, emoticon: dice, } satisfies ApiDice : undefined; const formattedText = text && !dice ? addTimestampEntities( { text, entities, emojiOnlyCount: undefined }, ) : undefined; const message = { id: localId, chatId: chat.id, content: omitUndefined({ text: formattedText, ...media, sticker, video: gif || media?.video, contact, storyData: story && { mediaType: 'storyData', ...story }, pollId: localPoll?.summary.id, todo: localTodo, dice: localDice, }), date: scheduledAt || getServerTime(), isOutgoing: !isChannel, senderId: chat.type !== 'chatTypePrivate' ? (sendAs?.id || currentUserId) : undefined, replyInfo: resultReplyInfo, suggestedPostInfo, ...(groupedId && { groupedId, ...(media && (media.photo || media.video) && { isInAlbum: true }), }), ...(scheduledAt && { isScheduled: true }), scheduleRepeatPeriod, isForwardingAllowed: true, isInvertedMedia, effectId, ...(isPending && { sendingState: 'messageSendingStatePending' }), ...(messagePriceInStars && { paidMessageStars: messagePriceInStars }), } satisfies ApiMessage; const emojiOnlyCount = getEmojiOnlyCountForMessage(message.content, message.groupedId); if (emojiOnlyCount && message.content.text) message.content.text.emojiOnlyCount = emojiOnlyCount; return { message, poll: localPoll, }; } export function buildLocalForwardedMessage({ toChat, toThreadId, message, scheduledAt, scheduleRepeatPeriod, noAuthors, noCaptions, isCurrentUserPremium, lastMessageId, sendAs, effectId, }: { toChat: ApiChat; toThreadId?: number; message: ApiMessage; scheduledAt?: number; scheduleRepeatPeriod?: number; noAuthors?: boolean; noCaptions?: boolean; isCurrentUserPremium?: boolean; lastMessageId?: number; sendAs?: ApiPeer; effectId?: string; }): ApiMessage { const localId = getNextLocalMessageId(lastMessageId); const { content, chatId: fromChatId, id: fromMessageId, senderId, groupedId, isInAlbum, isInvertedMedia, } = message; const isAudio = content.audio; const asIncomingInChatWithSelf = ( toChat.id === currentUserId && (fromChatId !== toChat.id || message.forwardInfo) && !isAudio ); const shouldHideText = Object.keys(content).length > 1 && content.text && noCaptions; const shouldDropCustomEmoji = !isCurrentUserPremium; const strippedText = content.text?.entities && shouldDropCustomEmoji ? { text: content.text.text, entities: content.text.entities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji), } : content.text; const textWithTimestamps = strippedText && addTimestampEntities(strippedText); const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId); if (emojiOnlyCount && textWithTimestamps) textWithTimestamps.emojiOnlyCount = emojiOnlyCount; const updatedContent = { ...content, text: !shouldHideText ? textWithTimestamps : undefined, }; // TODO Prepare reply info between forwarded messages locally, to prevent height jumps const isToMainThread = toThreadId === MAIN_THREAD_ID; const replyInfo: ApiReplyInfo | undefined = toThreadId && !isToMainThread ? { type: 'message', replyToMsgId: toThreadId, replyToTopId: toThreadId, isForumTopic: toChat.isForum || undefined, } : undefined; return { id: localId, chatId: toChat.id, content: updatedContent, date: scheduledAt || getServerTime(), scheduleRepeatPeriod, isOutgoing: !asIncomingInChatWithSelf && toChat.type !== 'chatTypeChannel', senderId: toChat.type !== 'chatTypePrivate' ? (sendAs?.id || currentUserId) : undefined, sendingState: 'messageSendingStatePending', groupedId, isInAlbum, isForwardingAllowed: true, replyInfo, isInvertedMedia, effectId, ...(toThreadId && toChat?.isForum && { isTopicReply: true }), // Forward info doesn't get added when user forwards own messages and when forwarding audio ...(message.chatId !== currentUserId && !isAudio && !noAuthors && { forwardInfo: { date: message.forwardInfo?.date || message.date, savedDate: message.date, isChannelPost: false, fromChatId, fromMessageId, fromId: senderId, savedFromPeerId: message.chatId, }, }), ...(message.chatId === currentUserId && !noAuthors && { forwardInfo: message.forwardInfo }), ...(scheduledAt && { isScheduled: true }), }; } function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiReplyInfo { if (inputInfo.type === 'story') { return { type: 'story', peerId: inputInfo.peerId, storyId: inputInfo.storyId, }; } return { type: 'message', replyToMsgId: inputInfo.replyToMsgId, replyToTopId: inputInfo.replyToTopId, replyToPeerId: inputInfo.replyToPeerId, quoteText: inputInfo.quoteText, quoteOffset: inputInfo.quoteOffset, isForumTopic: isForum && inputInfo.replyToTopId ? true : undefined, ...(Boolean(inputInfo.quoteText) && { isQuote: true }), }; } export function buildUploadingMedia( attachment: ApiAttachment, ): MediaContent { if (attachment.gif) { return { video: attachment.gif }; } const { filename: fileName, blobUrl, previewBlobUrl, mimeType, size, audio, shouldSendAsFile, shouldSendAsSpoiler, ttlSeconds, } = attachment; if (!shouldSendAsFile) { if (attachment.quick) { // TODO Handle GIF as video, but support playback in