import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiMessage, ApiMessageForwardInfo, ApiPhoto, ApiSticker, ApiVideo, ApiVoice, ApiAudio, ApiDocument, ApiAction, ApiContact, ApiAttachment, ApiPoll, ApiNewPoll, ApiWebPage, ApiMessageEntity, ApiFormattedText, ApiReplyKeyboard, ApiKeyboardButton, ApiChat, ApiThreadInfo, ApiInvoice, ApiGroupCall, ApiReactions, ApiReactionCount, ApiUserReaction, ApiAvailableReaction, ApiSponsoredMessage, ApiUser, ApiLocation, ApiGame, PhoneCallAction, ApiWebDocument, } from '../../types'; import { DELETED_COMMENTS_CHANNEL_ID, SERVICE_NOTIFICATIONS_USER_ID, SPONSORED_MESSAGE_CACHE_MS, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_MOV_TYPE, VIDEO_WEBM_TYPE, } from '../../../config'; import { pick } from '../../../util/iteratees'; import { buildStickerFromDocument } from './symbols'; import { buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromStripped } from './common'; import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers'; import { buildApiCallDiscardReason } from './calls'; const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp'; const INPUT_WAVEFORM_LENGTH = 63; let localMessageCounter = 0; const getNextLocalMessageId = () => parseFloat(`${Date.now()}.${localMessageCounter++}`); let currentUserId!: string; export function setMessageBuilderCurrentUserId(_currentUserId: string) { currentUserId = _currentUserId; } export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): ApiSponsoredMessage | undefined { const { fromId, message, entities, startParam, channelPost, chatInvite, chatInviteHash, randomId, recommended, } = mtpMessage; const chatId = fromId ? getApiChatIdFromMtpPeer(fromId) : undefined; const chatInviteTitle = chatInvite ? (chatInvite instanceof GramJs.ChatInvite ? chatInvite.title : !(chatInvite.chat instanceof GramJs.ChatEmpty) ? chatInvite.chat.title : undefined) : undefined; return { randomId: serializeBytes(randomId), isBot: fromId ? isPeerUser(fromId) : false, text: buildMessageTextContent(message, entities), expiresAt: Math.round(Date.now() / 1000) + SPONSORED_MESSAGE_CACHE_MS, isRecommended: Boolean(recommended), ...(chatId && { chatId }), ...(chatInviteHash && { chatInviteHash }), ...(chatInvite && { chatInviteTitle }), ...(startParam && { startParam }), ...(channelPost && { channelPostId: channelPost }), }; } 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, fromId: buildPeer(mtpMessage.out ? currentUserId : buildApiPeerId(mtpMessage.userId, 'user')), }); } export function buildApiMessageFromShortChat(mtpMessage: GramJs.UpdateShortChatMessage): ApiMessage { const chatId = buildApiPeerId(mtpMessage.chatId, 'chat'); return buildApiMessageWithChatId(chatId, { ...mtpMessage, fromId: buildPeer(buildApiPeerId(mtpMessage.fromId, 'user')), }); } export function buildApiMessageFromNotification( notification: GramJs.UpdateServiceNotification, currentDate: number, ): ApiMessage { const localId = getNextLocalMessageId(); const content = buildMessageContent(notification); return { id: localId, chatId: SERVICE_NOTIFICATIONS_USER_ID, date: notification.inboxDate || currentDate, content, isOutgoing: false, }; } type UniversalMessage = ( Pick & Pick, ( 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | 'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' | 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' )> ); export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalMessage): ApiMessage { const fromId = mtpMessage.fromId ? getApiChatIdFromMtpPeer(mtpMessage.fromId) : undefined; const peerId = mtpMessage.peerId ? getApiChatIdFromMtpPeer(mtpMessage.peerId) : undefined; const isChatWithSelf = !fromId && chatId === currentUserId; const isOutgoing = (mtpMessage.out && !mtpMessage.post) || (isChatWithSelf && !mtpMessage.fwdFrom); const content = buildMessageContent(mtpMessage); const action = mtpMessage.action && buildAction(mtpMessage.action, fromId, peerId, Boolean(mtpMessage.post), isOutgoing); if (action) { content.action = action; } const { replyToMsgId, replyToTopId, replyToPeerId } = mtpMessage.replyTo || {}; const isEdited = mtpMessage.editDate && !mtpMessage.editHide; const { inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, } = buildReplyButtons(mtpMessage) || {}; const forwardInfo = mtpMessage.fwdFrom && buildApiMessageForwardInfo(mtpMessage.fwdFrom, isChatWithSelf); const { replies, mediaUnread: isMediaUnread, postAuthor } = mtpMessage; const groupedId = mtpMessage.groupedId && String(mtpMessage.groupedId); const isInAlbum = Boolean(groupedId) && !(content.document || content.audio || content.sticker); const shouldHideKeyboardButtons = mtpMessage.replyMarkup instanceof GramJs.ReplyKeyboardHide; return { id: mtpMessage.id, chatId, isOutgoing, content, date: mtpMessage.date, senderId: fromId || (mtpMessage.out && mtpMessage.post && currentUserId) || chatId, views: mtpMessage.views, forwards: mtpMessage.forwards, isFromScheduled: mtpMessage.fromScheduled, reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), ...(replyToMsgId && { replyToMessageId: replyToMsgId }), ...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }), ...(replyToTopId && { replyToTopMessageId: replyToTopId }), ...(forwardInfo && { forwardInfo }), ...(isEdited && { isEdited }), ...(mtpMessage.editDate && { editDate: mtpMessage.editDate }), ...(isMediaUnread && { isMediaUnread }), ...(mtpMessage.mentioned && isMediaUnread && { hasUnreadMention: true }), ...(mtpMessage.mentioned && { isMentioned: true }), ...(groupedId && { groupedId, isInAlbum, }), inlineButtons, ...(keyboardButtons && { keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse }), ...(shouldHideKeyboardButtons && { shouldHideKeyboardButtons }), ...(mtpMessage.viaBotId && { viaBotId: buildApiPeerId(mtpMessage.viaBotId, 'user') }), ...(replies?.comments && { threadInfo: buildThreadInfo(replies, mtpMessage.id, chatId) }), ...(postAuthor && { adminTitle: postAuthor }), ...(mtpMessage.noforwards && { isProtected: true }), }; } export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions { const { recentReactions, results, canSeeList, } = reactions; return { canSeeList, results: results.map(buildReactionCount), recentReactions: recentReactions?.map(buildMessagePeerReaction), }; } function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount { const { chosen, count, reaction } = reactionCount; return { isChosen: chosen, count, reaction, }; } export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiUserReaction { const { peerId, reaction, big, unread, } = userReaction; return { userId: getApiChatIdFromMtpPeer(peerId), reaction, isUnread: unread, isBig: big, }; } export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction { const { selectAnimation, staticIcon, reaction, title, inactive, aroundAnimation, centerIcon, effectAnimation, activateAnimation, premium, } = availableReaction; return { selectAnimation: buildApiDocument(selectAnimation), activateAnimation: buildApiDocument(activateAnimation), effectAnimation: buildApiDocument(effectAnimation), staticIcon: buildApiDocument(staticIcon), aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined, centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined, reaction, title, isInactive: inactive, isPremium: premium, }; } export function buildMessageContent( mtpMessage: UniversalMessage | GramJs.UpdateServiceNotification, ) { let content: ApiMessage['content'] = {}; if (mtpMessage.media) { content = { ...buildMessageMediaContent(mtpMessage.media), }; } if (mtpMessage.message && !content.sticker && !content.poll && !content.contact && !(content.video?.isRound)) { content = { ...content, text: buildMessageTextContent(mtpMessage.message, mtpMessage.entities), }; } return content; } export function buildMessageTextContent( message: string, entities?: GramJs.TypeMessageEntity[], ): ApiFormattedText { return { text: message, ...(entities && { entities: entities.map(buildApiMessageEntity) }), }; } export function buildMessageDraft(draft: GramJs.TypeDraftMessage) { if (draft instanceof GramJs.DraftMessageEmpty) { return undefined; } const { message, entities, replyToMsgId, date, } = draft; return { formattedText: message ? buildMessageTextContent(message, entities) : undefined, replyingToId: replyToMsgId, date, }; } export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMessage['content'] | undefined { if ('ttlSeconds' in media && media.ttlSeconds) { return undefined; } const sticker = buildSticker(media); if (sticker) return { sticker }; const photo = buildPhoto(media); if (photo) return { photo }; const video = buildVideo(media); if (video) return { video }; const audio = buildAudio(media); if (audio) return { audio }; const voice = buildVoice(media); if (voice) return { voice }; const document = buildDocumentFromMedia(media); if (document) return { document }; const contact = buildContact(media); if (contact) return { contact }; const poll = buildPollFromMedia(media); if (poll) return { poll }; const webPage = buildWebPage(media); if (webPage) return { webPage }; const invoice = buildInvoiceFromMedia(media); if (invoice) return { invoice }; const location = buildLocationFromMedia(media); if (location) return { location }; const game = buildGameFromMedia(media); if (game) return { game }; return undefined; } 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, isChannelPost: Boolean(fwdFrom.channelPost), channelPostId: fwdFrom.channelPost, isLinkedChannelPost: Boolean(fwdFrom.channelPost && savedFromPeerId && !isChatWithSelf), fromChatId: savedFromPeerId || fromId, fromMessageId: fwdFrom.savedFromMsgId || fwdFrom.channelPost, senderUserId: fromId, hiddenUserName: fwdFrom.fromName, adminTitle: fwdFrom.postAuthor, }; } function buildSticker(media: GramJs.TypeMessageMedia): ApiSticker | undefined { if ( !(media instanceof GramJs.MessageMediaDocument) || !media.document || !(media.document instanceof GramJs.Document) ) { return undefined; } return buildStickerFromDocument(media.document, media.nopremium); } function buildPhoto(media: GramJs.TypeMessageMedia): ApiPhoto | undefined { if (!(media instanceof GramJs.MessageMediaPhoto) || !media.photo || !(media.photo instanceof GramJs.Photo)) { return undefined; } return buildApiPhoto(media.photo); } export function buildVideoFromDocument(document: GramJs.Document): ApiVideo | undefined { if (document instanceof GramJs.DocumentEmpty) { return undefined; } const { id, mimeType, thumbs, size, attributes, } = document; // eslint-disable-next-line no-restricted-globals if (mimeType === VIDEO_WEBM_TYPE && !(self as any).isWebmSupported) { return undefined; } // eslint-disable-next-line no-restricted-globals if (mimeType === VIDEO_MOV_TYPE && !(self as any).isMovSupported) { return undefined; } const videoAttr = attributes .find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo); if (!videoAttr) { return undefined; } const gifAttr = attributes .find((a: any): a is GramJs.DocumentAttributeAnimated => a instanceof GramJs.DocumentAttributeAnimated); const { duration, w: width, h: height, supportsStreaming = false, roundMessage: isRound = false, } = videoAttr; return { id: String(id), mimeType, duration, fileName: getFilenameFromDocument(document, 'video'), width, height, supportsStreaming, isRound, isGif: Boolean(gifAttr), thumbnail: buildApiThumbnailFromStripped(thumbs), size: size.toJSNumber(), }; } function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined { if ( !(media instanceof GramJs.MessageMediaDocument) || !(media.document instanceof GramJs.Document) || !media.document.mimeType.startsWith('video') ) { return undefined; } return buildVideoFromDocument(media.document); } function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined { if ( !(media instanceof GramJs.MessageMediaDocument) || !media.document || !(media.document instanceof GramJs.Document) ) { return undefined; } const audioAttribute = media.document.attributes .find((attr: any): attr is GramJs.DocumentAttributeAudio => ( attr instanceof GramJs.DocumentAttributeAudio )); if (!audioAttribute || audioAttribute.voice) { return undefined; } const thumbnailSizes = media.document.thumbs && media.document.thumbs .filter((thumb): thumb is GramJs.PhotoSize => thumb instanceof GramJs.PhotoSize) .map((thumb) => buildApiPhotoSize(thumb)); return { id: String(media.document.id), fileName: getFilenameFromDocument(media.document, 'audio'), thumbnailSizes, size: media.document.size.toJSNumber(), ...pick(media.document, ['mimeType']), ...pick(audioAttribute, ['duration', 'performer', 'title']), }; } function buildVoice(media: GramJs.TypeMessageMedia): ApiVoice | undefined { if ( !(media instanceof GramJs.MessageMediaDocument) || !media.document || !(media.document instanceof GramJs.Document) ) { return undefined; } const audioAttribute = media.document.attributes .find((attr: any): attr is GramJs.DocumentAttributeAudio => ( attr instanceof GramJs.DocumentAttributeAudio )); if (!audioAttribute || !audioAttribute.voice) { return undefined; } const { duration, waveform } = audioAttribute; return { id: String(media.document.id), duration, waveform: waveform ? Array.from(waveform) : undefined, }; } function buildDocumentFromMedia(media: GramJs.TypeMessageMedia) { if (!(media instanceof GramJs.MessageMediaDocument) || !media.document) { return undefined; } return buildApiDocument(media.document); } export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | undefined { if (!(document instanceof GramJs.Document)) { return undefined; } const { id, size, mimeType, date, thumbs, attributes, } = document; const thumbnail = thumbs && buildApiThumbnailFromStripped(thumbs); let mediaType: ApiDocument['mediaType'] | undefined; let mediaSize: ApiDocument['mediaSize'] | undefined; const photoSize = thumbs && thumbs.find((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize); if (photoSize) { mediaSize = { width: photoSize.w, height: photoSize.h, }; if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) { mediaType = 'photo'; const imageAttribute = attributes .find((a: any): a is GramJs.DocumentAttributeImageSize => a instanceof GramJs.DocumentAttributeImageSize); if (imageAttribute) { const { w: width, h: height } = imageAttribute; mediaSize = { width, height, }; } } else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) { mediaType = 'video'; const videoAttribute = attributes .find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo); if (videoAttribute) { const { w: width, h: height } = videoAttribute; mediaSize = { width, height, }; } } } return { id: String(id), size: size.toJSNumber(), mimeType, timestamp: date, fileName: getFilenameFromDocument(document), thumbnail, mediaType, mediaSize, }; } function buildContact(media: GramJs.TypeMessageMedia): ApiContact | undefined { if (!(media instanceof GramJs.MessageMediaContact)) { return undefined; } const { firstName, lastName, phoneNumber, userId, } = media; return { firstName, lastName, phoneNumber, userId: buildApiPeerId(userId, 'user'), }; } function buildPollFromMedia(media: GramJs.TypeMessageMedia): ApiPoll | undefined { if (!(media instanceof GramJs.MessageMediaPoll)) { return undefined; } return buildPoll(media.poll, media.results); } function buildInvoiceFromMedia(media: GramJs.TypeMessageMedia): ApiInvoice | undefined { if (!(media instanceof GramJs.MessageMediaInvoice)) { return undefined; } return buildInvoice(media); } function buildLocationFromMedia(media: GramJs.TypeMessageMedia): ApiLocation | undefined { if (media instanceof GramJs.MessageMediaGeo) { return buildGeo(media); } if (media instanceof GramJs.MessageMediaVenue) { return buildVenue(media); } if (media instanceof GramJs.MessageMediaGeoLive) { return buildGeoLive(media); } return undefined; } function buildGeo(media: GramJs.MessageMediaGeo): ApiLocation | undefined { const point = buildGeoPoint(media.geo); return point && { type: 'geo', geo: point }; } function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined { const { geo, title, provider, address, venueId, venueType, } = media; const point = buildGeoPoint(geo); return point && { type: 'venue', geo: point, title, provider, address, venueId, venueType, }; } function buildGeoLive(media: GramJs.MessageMediaGeoLive): ApiLocation | undefined { const { geo, period, heading } = media; const point = buildGeoPoint(geo); return point && { type: 'geoLive', geo: point, period, heading, }; } function buildGeoPoint(geo: GramJs.TypeGeoPoint): ApiLocation['geo'] | undefined { if (geo instanceof GramJs.GeoPointEmpty) return undefined; const { long, lat, accuracyRadius, accessHash, } = geo; return { long, lat, accessHash: accessHash.toString(), accuracyRadius, }; } function buildGameFromMedia(media: GramJs.TypeMessageMedia): ApiGame | undefined { if (!(media instanceof GramJs.MessageMediaGame)) { return undefined; } return buildGame(media); } function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined { const { id, accessHash, shortName, title, description, photo: apiPhoto, document: apiDocument, } = media.game; const photo = apiPhoto instanceof GramJs.Photo ? buildApiPhoto(apiPhoto) : undefined; const document = apiDocument instanceof GramJs.Document ? buildApiDocument(apiDocument) : undefined; return { id: id.toString(), accessHash: accessHash.toString(), shortName, title, description, photo, document, }; } export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll { const { id, answers: rawAnswers } = poll; const answers = rawAnswers.map((answer) => ({ text: answer.text, option: serializeBytes(answer.option), })); return { id: String(id), summary: { isPublic: poll.publicVoters, ...pick(poll, [ 'closed', 'multipleChoice', 'quiz', 'question', 'closePeriod', 'closeDate', ]), answers, }, results: buildPollResults(pollResults), }; } export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice { const { description: text, title, photo, test, totalAmount, currency, receiptMsgId, } = media; return { text, title, photo: buildApiWebDocument(photo), receiptMsgId, amount: Number(totalAmount), currency, isTest: test, }; } export function buildPollResults(pollResults: GramJs.PollResults): ApiPoll['results'] { const { results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min, } = pollResults; const results = rawResults && rawResults.map(({ option, chosen, correct, voters, }) => ({ isChosen: chosen, isCorrect: correct, option: serializeBytes(option), votersCount: voters, })); return { isMin: min, totalVoters, recentVoterIds: recentVoters?.map((id) => buildApiPeerId(id, 'user')), results, solution, ...(entities && { solutionEntities: entities.map(buildApiMessageEntity) }), }; } export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undefined { if ( !(media instanceof GramJs.MessageMediaWebPage) || !(media.webpage instanceof GramJs.WebPage) ) { return undefined; } const { id, photo, document } = media.webpage; let video; if (document instanceof GramJs.Document && document.mimeType.startsWith('video/')) { video = buildVideoFromDocument(document); } return { id: Number(id), ...pick(media.webpage, [ 'url', 'displayUrl', 'type', 'siteName', 'title', 'description', 'duration', ]), photo: photo instanceof GramJs.Photo ? buildApiPhoto(photo) : undefined, document: !video && document ? buildApiDocument(document) : undefined, video, }; } function buildAction( action: GramJs.TypeMessageAction, senderId: string | undefined, targetPeerId: string | undefined, isChannelPost: boolean, isOutgoing: boolean, ): ApiAction | undefined { if (action instanceof GramJs.MessageActionEmpty) { return undefined; } let phoneCall: PhoneCallAction | undefined; let call: Partial | undefined; let amount: number | undefined; let currency: string | undefined; let text: string; const translationValues = []; let type: ApiAction['type'] = 'other'; let photo: ApiPhoto | undefined; let score: number | undefined; const targetUserIds = 'users' in action ? action.users && action.users.map((id) => buildApiPeerId(id, 'user')) : ('userId' in action && [buildApiPeerId(action.userId, 'user')]) || []; let targetChatId: string | undefined; if (action instanceof GramJs.MessageActionChatCreate) { text = 'Notification.CreatedChatWithTitle'; translationValues.push('%action_origin%', action.title); type = 'chatCreate'; } else if (action instanceof GramJs.MessageActionChatEditTitle) { if (isChannelPost) { text = 'Channel.MessageTitleUpdated'; translationValues.push(action.title); } else { text = 'Notification.ChangedGroupName'; translationValues.push('%action_origin%', action.title); } } else if (action instanceof GramJs.MessageActionChatEditPhoto) { if (isChannelPost) { text = 'Channel.MessagePhotoUpdated'; } else { text = 'Notification.ChangedGroupPhoto'; translationValues.push('%action_origin%'); } } else if (action instanceof GramJs.MessageActionChatDeletePhoto) { if (isChannelPost) { text = 'Channel.MessagePhotoRemoved'; } else { text = 'Group.MessagePhotoRemoved'; } } else if (action instanceof GramJs.MessageActionChatAddUser) { if (!senderId || targetUserIds.includes(senderId)) { text = 'Notification.JoinedChat'; translationValues.push('%target_user%'); } else { text = 'Notification.Invited'; translationValues.push('%action_origin%', '%target_user%'); } } else if (action instanceof GramJs.MessageActionChatDeleteUser) { if (!senderId || targetUserIds.includes(senderId)) { text = 'Notification.LeftChat'; translationValues.push('%target_user%'); } else { text = 'Notification.Kicked'; translationValues.push('%action_origin%', '%target_user%'); } } else if (action instanceof GramJs.MessageActionChatJoinedByLink) { text = 'Notification.JoinedGroupByLink'; translationValues.push('%action_origin%'); } else if (action instanceof GramJs.MessageActionChannelCreate) { text = 'Notification.CreatedChannel'; } else if (action instanceof GramJs.MessageActionChatMigrateTo) { targetChatId = getApiChatIdFromMtpPeer(action); text = 'Migrated to %target_chat%'; translationValues.push('%target_chat%'); } else if (action instanceof GramJs.MessageActionChannelMigrateFrom) { targetChatId = getApiChatIdFromMtpPeer(action); text = 'Migrated from %target_chat%'; translationValues.push('%target_chat%'); } else if (action instanceof GramJs.MessageActionPinMessage) { text = 'Chat.Service.Group.UpdatedPinnedMessage1'; translationValues.push('%action_origin%', '%message%'); } else if (action instanceof GramJs.MessageActionHistoryClear) { text = 'HistoryCleared'; type = 'historyClear'; } else if (action instanceof GramJs.MessageActionPhoneCall) { const withDuration = Boolean(action.duration); text = [ withDuration ? 'ChatList.Service' : 'Chat', action.video ? 'VideoCall' : 'Call', isOutgoing ? (withDuration ? 'outgoing' : 'Outgoing') : (withDuration ? 'incoming' : 'Incoming'), ].join('.'); if (withDuration) { const mins = Math.max(Math.round(action.duration! / 60), 1); translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } phoneCall = { isOutgoing, isVideo: action.video, duration: action.duration, reason: buildApiCallDiscardReason(action.reason), }; } else if (action instanceof GramJs.MessageActionInviteToGroupCall) { text = 'Notification.VoiceChatInvitation'; call = { id: action.call.id.toString(), accessHash: action.call.accessHash.toString(), }; translationValues.push('%action_origin%', '%target_user%'); } else if (action instanceof GramJs.MessageActionContactSignUp) { text = 'Notification.Joined'; translationValues.push('%action_origin%'); type = 'contactSignUp'; } else if (action instanceof GramJs.MessageActionPaymentSent) { amount = Number(action.totalAmount); currency = action.currency; text = 'PaymentSuccessfullyPaid'; if (targetPeerId) { targetUserIds.push(targetPeerId); } translationValues.push('%payment_amount%', '%target_user%', '%product%'); } else if (action instanceof GramJs.MessageActionGroupCall) { if (action.duration) { const mins = Math.max(Math.round(action.duration / 60), 1); text = 'Notification.VoiceChatEnded'; translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } else { text = 'Notification.VoiceChatStartedChannel'; call = { id: action.call.id.toString(), accessHash: action.call.accessHash.toString(), }; } } else if (action instanceof GramJs.MessageActionBotAllowed) { text = 'Chat.Service.BotPermissionAllowed'; translationValues.push(action.domain); } else if (action instanceof GramJs.MessageActionCustomAction) { text = action.message; } else if (action instanceof GramJs.MessageActionChatJoinedByRequest) { text = 'ChatService.UserJoinedGroupByRequest'; translationValues.push('%action_origin%'); } else if (action instanceof GramJs.MessageActionGameScore) { text = senderId === currentUserId ? 'ActionYouScoredInGame' : 'ActionUserScoredInGame'; translationValues.push('%score%'); score = action.score; } else if (action instanceof GramJs.MessageActionWebViewDataSent) { text = 'Notification.WebAppSentData'; translationValues.push(action.text); } else { text = 'ChatList.UnsupportedMessage'; } if ('photo' in action && action.photo instanceof GramJs.Photo) { addPhotoToLocalDb(action.photo); photo = buildApiPhoto(action.photo); } return { text, type, targetUserIds, targetChatId, photo, // TODO Only used internally now, will be used for the UI in future amount, currency, translationValues, call, phoneCall, score, }; } function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefined { const { replyMarkup, media } = message; // TODO Move to the proper button inside preview if (!replyMarkup) { if (media instanceof GramJs.MessageMediaWebPage && media.webpage instanceof GramJs.WebPage) { if (media.webpage.type === 'telegram_message') { return { inlineButtons: [[{ type: 'url', text: 'Show Message', url: media.webpage.url, }]], }; } } return undefined; } // TODO if (!(replyMarkup instanceof GramJs.ReplyKeyboardMarkup || replyMarkup instanceof GramJs.ReplyInlineMarkup)) { return undefined; } const markup = replyMarkup.rows.map(({ buttons }) => { return buttons.map((button): ApiKeyboardButton => { const { text } = button; if (button instanceof GramJs.KeyboardButton) { return { type: 'command', text, }; } if (button instanceof GramJs.KeyboardButtonUrl) { if (button.url.includes('?startgroup=')) { return { type: 'unsupported', text, }; } return { type: 'url', text, url: button.url, }; } if (button instanceof GramJs.KeyboardButtonCallback) { if (button.requiresPassword) { return { type: 'unsupported', text, }; } return { type: 'callback', text, data: serializeBytes(button.data), }; } if (button instanceof GramJs.KeyboardButtonRequestPoll) { return { type: 'requestPoll', text, isQuiz: button.quiz, }; } if (button instanceof GramJs.KeyboardButtonRequestPhone) { return { type: 'requestPhone', text, }; } if (button instanceof GramJs.KeyboardButtonBuy) { if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) { return { type: 'receipt', text: 'PaymentReceipt', receiptMessageId: media.receiptMsgId, }; } return { type: 'buy', text, }; } if (button instanceof GramJs.KeyboardButtonGame) { return { type: 'game', text, }; } if (button instanceof GramJs.KeyboardButtonSwitchInline) { return { type: 'switchBotInline', text, query: button.query, isSamePeer: button.samePeer, }; } if (button instanceof GramJs.KeyboardButtonUserProfile) { return { type: 'userProfile', text, userId: button.userId.toString(), }; } if (button instanceof GramJs.KeyboardButtonSimpleWebView) { return { type: 'simpleWebView', text, url: button.url, }; } if (button instanceof GramJs.KeyboardButtonWebView) { return { type: 'webView', text, url: button.url, }; } if (button instanceof GramJs.KeyboardButtonUrlAuth) { return { type: 'urlAuth', text, url: button.url, buttonId: button.buttonId, }; } return { type: 'unsupported', text, }; }); }); return { [replyMarkup instanceof GramJs.ReplyKeyboardMarkup ? 'keyboardButtons' : 'inlineButtons']: markup, ...(replyMarkup instanceof GramJs.ReplyKeyboardMarkup && { keyboardPlaceholder: replyMarkup.placeholder, isKeyboardSingleUse: replyMarkup.singleUse, }), }; } function getFilenameFromDocument(document: GramJs.Document, defaultBase = 'file') { const { mimeType, attributes } = document; const filenameAttribute = attributes .find((a: any): a is GramJs.DocumentAttributeFilename => a instanceof GramJs.DocumentAttributeFilename); if (filenameAttribute) { return filenameAttribute.fileName; } const extension = mimeType.split('/')[1]; return `${defaultBase}${String(document.id)}.${extension}`; } export function buildLocalMessage( chat: ApiChat, text?: string, entities?: ApiMessageEntity[], replyingTo?: number, attachment?: ApiAttachment, sticker?: ApiSticker, gif?: ApiVideo, poll?: ApiNewPoll, contact?: ApiContact, groupedId?: string, scheduledAt?: number, sendAs?: ApiChat | ApiUser, serverTimeOffset = 0, ): ApiMessage { const localId = getNextLocalMessageId(); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; return { id: localId, chatId: chat.id, content: { ...(text && { text: { text, entities, }, }), ...media, ...(sticker && { sticker }), ...(gif && { video: gif }), ...(poll && buildNewPoll(poll, localId)), ...(contact && { contact }), }, date: scheduledAt || Math.round(Date.now() / 1000) + serverTimeOffset, isOutgoing: !isChannel, senderId: sendAs?.id || currentUserId, ...(replyingTo && { replyToMessageId: replyingTo }), ...(groupedId && { groupedId, ...(media && (media.photo || media.video) && { isInAlbum: true }), }), ...(scheduledAt && { isScheduled: true }), }; } export function buildLocalForwardedMessage( toChat: ApiChat, message: ApiMessage, serverTimeOffset: number, scheduledAt?: number, ): ApiMessage { const localId = getNextLocalMessageId(); const { content, chatId: fromChatId, id: fromMessageId, senderId, groupedId, isInAlbum, } = message; const isAudio = content.audio; const asIncomingInChatWithSelf = ( toChat.id === currentUserId && (fromChatId !== toChat.id || message.forwardInfo) && !isAudio ); return { id: localId, chatId: toChat.id, content, date: scheduledAt || Math.round(Date.now() / 1000) + serverTimeOffset, isOutgoing: !asIncomingInChatWithSelf && toChat.type !== 'chatTypeChannel', senderId: currentUserId, sendingState: 'messageSendingStatePending', groupedId, isInAlbum, // Forward info doesn't get added when users forwards his own messages, also when forwarding audio ...(senderId !== currentUserId && !isAudio && { forwardInfo: { date: message.date, isChannelPost: false, fromChatId, fromMessageId, senderUserId: senderId, }, }), ...(scheduledAt && { isScheduled: true }), }; } function buildUploadingMedia( attachment: ApiAttachment, ): ApiMessage['content'] { const { filename: fileName, blobUrl, previewBlobUrl, mimeType, size, } = attachment; if (attachment.quick) { if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) { const { width, height } = attachment.quick; return { photo: { id: LOCAL_MEDIA_UPLOADING_TEMP_ID, sizes: [], thumbnail: { width, height, dataUri: '' }, // Used only for dimensions blobUrl, }, }; } if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) { const { width, height, duration } = attachment.quick; return { video: { id: LOCAL_MEDIA_UPLOADING_TEMP_ID, mimeType, duration: duration || 0, fileName, width, height, blobUrl, ...(previewBlobUrl && { thumbnail: { width, height, dataUri: previewBlobUrl } }), size, }, }; } } if (attachment.voice) { const { duration, waveform } = attachment.voice; const { data: inputWaveform } = interpolateArray(waveform, INPUT_WAVEFORM_LENGTH); return { voice: { id: LOCAL_MEDIA_UPLOADING_TEMP_ID, duration, waveform: inputWaveform, }, }; } if (SUPPORTED_AUDIO_CONTENT_TYPES.has(mimeType)) { const { duration, performer, title } = attachment.audio || {}; return { audio: { id: LOCAL_MEDIA_UPLOADING_TEMP_ID, mimeType, fileName, size, duration: duration || 0, title, performer, }, }; } return { document: { mimeType, fileName, size, ...(previewBlobUrl && { previewBlobUrl }), }, }; } export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDocument | undefined { if (!document) return undefined; const { url, size, mimeType, } = document; const accessHash = document instanceof GramJs.WebDocument ? document.accessHash.toString() : undefined; const sizeAttr = document.attributes.find((attr): attr is GramJs.DocumentAttributeImageSize => ( attr instanceof GramJs.DocumentAttributeImageSize )); const dimensions = sizeAttr && { width: sizeAttr.w, height: sizeAttr.h }; return { url, accessHash, size, mimeType, dimensions, }; } function buildNewPoll(poll: ApiNewPoll, localId: number) { return { poll: { id: String(localId), summary: pick(poll.summary, ['question', 'answers']), results: {}, }, }; } export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMessageEntity { const { className: type, offset, length } = entity; return { type, offset, length, ...(entity instanceof GramJs.MessageEntityMentionName && { userId: buildApiPeerId(entity.userId, 'user') }), ...('url' in entity && { url: entity.url }), ...('language' in entity && { language: entity.language }), }; } function buildThreadInfo( messageReplies: GramJs.TypeMessageReplies, messageId: number, chatId: string, ): ApiThreadInfo | undefined { const { channelId, replies, maxId, readMaxId, recentRepliers, } = messageReplies; if (!channelId) { return undefined; } const apiChannelId = buildApiPeerId(channelId, 'channel'); if (apiChannelId === DELETED_COMMENTS_CHANNEL_ID) { return undefined; } const isPostThread = chatId !== apiChannelId; return { threadId: messageId, ...(isPostThread ? { chatId: apiChannelId, originChannelId: chatId, } : { chatId, }), messagesCount: replies, lastMessageId: maxId, lastReadInboxMessageId: readMaxId, ...(recentRepliers && { recentReplierIds: recentRepliers.map(getApiChatIdFromMtpPeer) }), }; }