feat!: support min updates

breaking: changed `ITelegramStorage` interface, changed tl schema a bit
This commit is contained in:
alina 🌸 2023-11-27 05:23:52 +03:00
parent 48411323af
commit b25f9dddfa
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
26 changed files with 804 additions and 183 deletions

View file

@ -1,11 +1,17 @@
/* eslint-disable max-depth,max-params */ /* eslint-disable max-depth,max-params */
import { assertNever, BaseTelegramClient, MtArgumentError, tl } from '@mtcute/core' import { assertNever, BaseTelegramClient, MaybeAsync, MtArgumentError, tl } from '@mtcute/core'
import { getBarePeerId, getMarkedPeerId, markedPeerIdToBare, toggleChannelIdMark } from '@mtcute/core/utils.js' import { getBarePeerId, getMarkedPeerId, markedPeerIdToBare, toggleChannelIdMark } from '@mtcute/core/utils.js'
import { PeersIndex } from '../../types/index.js' import { PeersIndex } from '../../types/index.js'
import { normalizeToInputChannel } from '../../utils/peer-utils.js' import {
isInputPeerChannel,
isInputPeerUser,
normalizeToInputChannel,
normalizeToInputUser,
} from '../../utils/peer-utils.js'
import { RpsMeter } from '../../utils/rps-meter.js' import { RpsMeter } from '../../utils/rps-meter.js'
import { getAuthState } from '../auth/_state.js' import { getAuthState } from '../auth/_state.js'
import { _getChannelsBatched, _getUsersBatched } from '../chats/batched-queries.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { createUpdatesState, PendingUpdate, toPendingUpdate, UpdatesManagerParams, UpdatesState } from './types.js' import { createUpdatesState, PendingUpdate, toPendingUpdate, UpdatesManagerParams, UpdatesState } from './types.js'
import { extractChannelIdFromUpdate, messageToUpdate } from './utils.js' import { extractChannelIdFromUpdate, messageToUpdate } from './utils.js'
@ -410,89 +416,58 @@ function addToNoDispatchIndex(state: UpdatesState, updates?: tl.TypeUpdates): vo
} }
} }
async function replaceMinPeers(client: BaseTelegramClient, peers: PeersIndex): Promise<boolean> { async function fetchMissingPeers(
for (const [key, user_] of peers.users) {
const user = user_ as Exclude<tl.TypeUser, tl.RawUserEmpty>
if (user.min) {
const cached = await client.storage.getFullPeerById(user.id)
if (!cached) return false
peers.users.set(key, cached as tl.TypeUser)
}
}
for (const [key, chat_] of peers.chats) {
const chat = chat_ as Extract<tl.TypeChat, { min?: boolean }>
if (chat.min) {
let id: number
switch (chat._) {
case 'channel':
id = toggleChannelIdMark(chat.id)
break
default:
id = -chat.id
}
const cached = await client.storage.getFullPeerById(id)
if (!cached) return false
peers.chats.set(key, cached as tl.TypeChat)
}
}
peers.hasMin = false
return true
}
async function fetchPeersForShort(
client: BaseTelegramClient, client: BaseTelegramClient,
upd: tl.TypeUpdate | tl.RawMessage | tl.RawMessageService, upd: tl.TypeUpdate,
): Promise<PeersIndex | null> { peers: PeersIndex,
const peers = new PeersIndex() allowMissing = false,
): Promise<Set<number>> {
const missing = new Set<number>()
const fetchPeer = async (peer?: tl.TypePeer | number) => { async function fetchPeer(peer?: tl.TypePeer | number) {
if (!peer) return true if (!peer) return true
const bare = typeof peer === 'number' ? markedPeerIdToBare(peer) : getBarePeerId(peer) const bare = typeof peer === 'number' ? markedPeerIdToBare(peer) : getBarePeerId(peer)
const marked = typeof peer === 'number' ? peer : getMarkedPeerId(peer) const marked = typeof peer === 'number' ? peer : getMarkedPeerId(peer)
const index = marked > 0 ? peers.chats : peers.users
if (index.has(bare)) return true
if (missing.has(marked)) return false
const cached = await client.storage.getFullPeerById(marked) const cached = await client.storage.getFullPeerById(marked)
if (!cached) return false
if (marked > 0) { if (!cached) {
peers.users.set(bare, cached as tl.TypeUser) missing.add(marked)
} else {
peers.chats.set(bare, cached as tl.TypeChat) return allowMissing
} }
// whatever, ts is not smart enough to understand
(index as Map<number, tl.TypeUser | tl.TypeChat>).set(bare, cached)
return true return true
} }
switch (upd._) { switch (upd._) {
// not really sure if these can be inside updateShort, but whatever
case 'message':
case 'messageService':
case 'updateNewMessage': case 'updateNewMessage':
case 'updateNewChannelMessage': case 'updateNewChannelMessage':
case 'updateEditMessage': case 'updateEditMessage':
case 'updateEditChannelMessage': { case 'updateEditChannelMessage': {
const msg = upd._ === 'message' || upd._ === 'messageService' ? upd : upd.message const msg = upd.message
if (msg._ === 'messageEmpty') return null if (msg._ === 'messageEmpty') return missing
// ref: https://github.com/tdlib/td/blob/master/td/telegram/UpdatesManager.cpp // ref: https://github.com/tdlib/td/blob/master/td/telegram/UpdatesManager.cpp
// (search by UpdatesManager::is_acceptable_update) // (search by UpdatesManager::is_acceptable_update)
if (!(await fetchPeer(msg.peerId))) return null if (!(await fetchPeer(msg.peerId))) return missing
if (!(await fetchPeer(msg.fromId))) return null if (!(await fetchPeer(msg.fromId))) return missing
if (msg.replyTo) { if (msg.replyTo) {
if (msg.replyTo._ === 'messageReplyHeader' && !(await fetchPeer(msg.replyTo.replyToPeerId))) { if (msg.replyTo._ === 'messageReplyHeader' && !(await fetchPeer(msg.replyTo.replyToPeerId))) {
return null return missing
} }
if (msg.replyTo._ === 'messageReplyStoryHeader' && !(await fetchPeer(msg.replyTo.userId))) { if (msg.replyTo._ === 'messageReplyStoryHeader' && !(await fetchPeer(msg.replyTo.userId))) {
return null return missing
} }
} }
@ -501,14 +476,14 @@ async function fetchPeersForShort(
msg.fwdFrom && msg.fwdFrom &&
(!(await fetchPeer(msg.fwdFrom.fromId)) || !(await fetchPeer(msg.fwdFrom.savedFromPeer))) (!(await fetchPeer(msg.fwdFrom.fromId)) || !(await fetchPeer(msg.fwdFrom.savedFromPeer)))
) { ) {
return null return missing
} }
if (!(await fetchPeer(msg.viaBotId))) return null if (!(await fetchPeer(msg.viaBotId))) return missing
if (msg.entities) { if (msg.entities) {
for (const ent of msg.entities) { for (const ent of msg.entities) {
if (ent._ === 'messageEntityMentionName') { if (ent._ === 'messageEntityMentionName') {
if (!(await fetchPeer(ent.userId))) return null if (!(await fetchPeer(ent.userId))) return missing
} }
} }
} }
@ -517,7 +492,7 @@ async function fetchPeersForShort(
switch (msg.media._) { switch (msg.media._) {
case 'messageMediaContact': case 'messageMediaContact':
if (msg.media.userId && !(await fetchPeer(msg.media.userId))) { if (msg.media.userId && !(await fetchPeer(msg.media.userId))) {
return null return missing
} }
} }
} }
@ -527,28 +502,28 @@ async function fetchPeersForShort(
case 'messageActionChatAddUser': case 'messageActionChatAddUser':
case 'messageActionInviteToGroupCall': case 'messageActionInviteToGroupCall':
for (const user of msg.action.users) { for (const user of msg.action.users) {
if (!(await fetchPeer(user))) return null if (!(await fetchPeer(user))) return missing
} }
break break
case 'messageActionChatJoinedByLink': case 'messageActionChatJoinedByLink':
if (!(await fetchPeer(msg.action.inviterId))) { if (!(await fetchPeer(msg.action.inviterId))) {
return null return missing
} }
break break
case 'messageActionChatDeleteUser': case 'messageActionChatDeleteUser':
if (!(await fetchPeer(msg.action.userId))) return null if (!(await fetchPeer(msg.action.userId))) return missing
break break
case 'messageActionChatMigrateTo': case 'messageActionChatMigrateTo':
if (!(await fetchPeer(toggleChannelIdMark(msg.action.channelId)))) { if (!(await fetchPeer(toggleChannelIdMark(msg.action.channelId)))) {
return null return missing
} }
break break
case 'messageActionChannelMigrateFrom': case 'messageActionChannelMigrateFrom':
if (!(await fetchPeer(-msg.action.chatId))) return null if (!(await fetchPeer(-msg.action.chatId))) return missing
break break
case 'messageActionGeoProximityReached': case 'messageActionGeoProximityReached':
if (!(await fetchPeer(msg.action.fromId))) return null if (!(await fetchPeer(msg.action.fromId))) return missing
if (!(await fetchPeer(msg.action.toId))) return null if (!(await fetchPeer(msg.action.toId))) return missing
break break
} }
} }
@ -558,13 +533,97 @@ async function fetchPeersForShort(
if ('entities' in upd.draft && upd.draft.entities) { if ('entities' in upd.draft && upd.draft.entities) {
for (const ent of upd.draft.entities) { for (const ent of upd.draft.entities) {
if (ent._ === 'messageEntityMentionName') { if (ent._ === 'messageEntityMentionName') {
if (!(await fetchPeer(ent.userId))) return null if (!(await fetchPeer(ent.userId))) return missing
} }
} }
} }
} }
return peers return missing
}
async function storeMessageReferences(client: BaseTelegramClient, msg: tl.TypeMessage): Promise<void> {
if (msg._ === 'messageEmpty') return
const peerId = msg.peerId
if (peerId._ !== 'peerChannel') return
const channelId = toggleChannelIdMark(peerId.channelId)
const promises: MaybeAsync<void>[] = []
function store(peer?: tl.TypePeer | number | number[]): void {
if (!peer) return
if (Array.isArray(peer)) {
peer.forEach(store)
return
}
const marked = typeof peer === 'number' ? peer : getMarkedPeerId(peer)
promises.push(client.storage.saveReferenceMessage(marked, channelId, msg.id))
}
// reference: https://github.com/tdlib/td/blob/master/td/telegram/MessagesManager.cpp
// (search by get_message_user_ids, get_message_channel_ids)
store(msg.fromId)
if (msg._ === 'message') {
store(msg.viaBotId)
store(msg.fwdFrom?.fromId)
if (msg.media) {
switch (msg.media._) {
case 'messageMediaWebPage':
if (msg.media.webpage._ === 'webPage' && msg.media.webpage.attributes) {
for (const attr of msg.media.webpage.attributes) {
if (attr._ === 'webPageAttributeStory') {
store(attr.peer)
}
}
}
break
case 'messageMediaContact':
store(msg.media.userId)
break
case 'messageMediaStory':
store(msg.media.peer)
break
case 'messageMediaGiveaway':
store(msg.media.channels.map(toggleChannelIdMark))
break
}
}
} else {
switch (msg.action._) {
case 'messageActionChatCreate':
case 'messageActionChatAddUser':
case 'messageActionInviteToGroupCall':
store(msg.action.users)
break
case 'messageActionChatDeleteUser':
store(msg.action.userId)
break
}
}
if (msg.replyTo) {
switch (msg.replyTo._) {
case 'messageReplyHeader':
store(msg.replyTo.replyToPeerId)
store(msg.replyTo.replyFrom?.fromId)
break
case 'messageReplyStoryHeader':
store(msg.replyTo.userId)
break
}
// in fact, we can also use peers contained in the replied-to message,
// but we don't fetch it automatically, so we can't know which peers are there
}
await Promise.all(promises)
} }
function isMessageEmpty(upd: tl.TypeUpdate): boolean { function isMessageEmpty(upd: tl.TypeUpdate): boolean {
@ -615,8 +674,7 @@ async function fetchChannelDifference(
state: UpdatesState, state: UpdatesState,
channelId: number, channelId: number,
fallbackPts?: number, fallbackPts?: number,
force = false, ): Promise<boolean> {
): Promise<void> {
let _pts: number | null | undefined = state.cpts.get(channelId) let _pts: number | null | undefined = state.cpts.get(channelId)
if (!_pts && state.catchUpChannels) { if (!_pts && state.catchUpChannels) {
@ -627,17 +685,15 @@ async function fetchChannelDifference(
if (!_pts) { if (!_pts) {
state.log.debug('fetchChannelDifference failed for channel %d: base pts not available', channelId) state.log.debug('fetchChannelDifference failed for channel %d: base pts not available', channelId)
return return false
} }
let channel const channel = normalizeToInputChannel(await resolvePeer(client, toggleChannelIdMark(channelId)))
try { if (channel._ === 'inputChannel' && channel.accessHash.isZero()) {
channel = normalizeToInputChannel(await resolvePeer(client, toggleChannelIdMark(channelId))) state.log.debug('fetchChannelDifference failed for channel %d: input peer not found', channelId)
} catch (e) {
state.log.warn('fetchChannelDifference failed for channel %d: input peer not found', channelId)
return return false
} }
// to make TS happy // to make TS happy
@ -652,7 +708,7 @@ async function fetchChannelDifference(
for (;;) { for (;;) {
const diff = await client.call({ const diff = await client.call({
_: 'updates.getChannelDifference', _: 'updates.getChannelDifference',
force, force: true, // Set to true to skip some possibly unneeded updates and reduce server-side load
channel, channel,
pts, pts,
limit, limit,
@ -688,7 +744,7 @@ async function fetchChannelDifference(
if (message._ === 'messageEmpty') return if (message._ === 'messageEmpty') return
state.pendingUnorderedUpdates.pushBack(toPendingUpdate(messageToUpdate(message), peers)) state.pendingUnorderedUpdates.pushBack(toPendingUpdate(messageToUpdate(message), peers, true))
}) })
break break
} }
@ -707,11 +763,11 @@ async function fetchChannelDifference(
if (message._ === 'messageEmpty') return if (message._ === 'messageEmpty') return
state.pendingUnorderedUpdates.pushBack(toPendingUpdate(messageToUpdate(message), peers)) state.pendingUnorderedUpdates.pushBack(toPendingUpdate(messageToUpdate(message), peers, true))
}) })
diff.otherUpdates.forEach((upd) => { diff.otherUpdates.forEach((upd) => {
const parsed = toPendingUpdate(upd, peers) const parsed = toPendingUpdate(upd, peers, true)
state.log.debug( state.log.debug(
'processing %s from diff for channel %d, pts_before: %d, pts: %d', 'processing %s from diff for channel %d, pts_before: %d, pts: %d',
@ -733,6 +789,8 @@ async function fetchChannelDifference(
state.cpts.set(channelId, pts) state.cpts.set(channelId, pts)
state.cptsMod.set(channelId, pts) state.cptsMod.set(channelId, pts)
return true
} }
function fetchChannelDifferenceLater( function fetchChannelDifferenceLater(
@ -741,17 +799,21 @@ function fetchChannelDifferenceLater(
requestedDiff: Map<number, Promise<void>>, requestedDiff: Map<number, Promise<void>>,
channelId: number, channelId: number,
fallbackPts?: number, fallbackPts?: number,
force = false,
): void { ): void {
if (!requestedDiff.has(channelId)) { if (!requestedDiff.has(channelId)) {
requestedDiff.set( requestedDiff.set(
channelId, channelId,
fetchChannelDifference(client, state, channelId, fallbackPts, force) fetchChannelDifference(client, state, channelId, fallbackPts)
.catch((err) => { .catch((err) => {
state.log.warn('error fetching difference for %d: %s', channelId, err) state.log.warn('error fetching difference for %d: %s', channelId, err)
}) })
.then(() => { .then((ok) => {
requestedDiff.delete(channelId) requestedDiff.delete(channelId)
if (!ok) {
state.log.debug('channel difference for %d failed, falling back to common diff', channelId)
fetchDifferenceLater(client, state, requestedDiff)
}
}), }),
) )
} }
@ -802,7 +864,7 @@ async function fetchDifference(
if (message._ === 'messageEmpty') return if (message._ === 'messageEmpty') return
// pts does not need to be checked for them // pts does not need to be checked for them
state.pendingUnorderedUpdates.pushBack(toPendingUpdate(messageToUpdate(message), peers)) state.pendingUnorderedUpdates.pushBack(toPendingUpdate(messageToUpdate(message), peers, true))
}) })
diff.otherUpdates.forEach((upd) => { diff.otherUpdates.forEach((upd) => {
@ -820,7 +882,7 @@ async function fetchDifference(
if (isMessageEmpty(upd)) return if (isMessageEmpty(upd)) return
const parsed = toPendingUpdate(upd, peers) const parsed = toPendingUpdate(upd, peers, true)
if (parsed.channelId && parsed.ptsBefore) { if (parsed.channelId && parsed.ptsBefore) {
// we need to check pts for these updates, put into pts queue // we need to check pts for these updates, put into pts queue
@ -870,6 +932,11 @@ function fetchDifferenceLater(
} }
state.log.warn('error fetching common difference: %s', err) state.log.warn('error fetching common difference: %s', err)
if (tl.RpcError.is(err, 'PERSISTENT_TIMESTAMP_INVALID')) {
// this function never throws
return fetchUpdatesState(client, state)
}
}) })
.then(() => { .then(() => {
requestedDiff.delete(0) requestedDiff.delete(0)
@ -888,48 +955,44 @@ async function onUpdate(
): Promise<void> { ): Promise<void> {
const upd = pending.update const upd = pending.update
// check for min peers, try to replace them let missing: Set<number> | undefined = undefined
// it is important to do this before updating pts // it is important to do this before updating pts
if (pending.peers && pending.peers.hasMin) { if (pending.peers.hasMin || pending.peers.empty) {
if (!(await replaceMinPeers(client, pending.peers))) { // even if we have min peers in difference, we can't do anything about them.
// we still want to collect them, so we can fetch them in the background.
// we won't wait for them, since that would block the updates loop
missing = await fetchMissingPeers(client, upd, pending.peers, pending.fromDifference)
if (!pending.fromDifference && missing.size) {
state.log.debug( state.log.debug(
'fetching difference because some peers were min and not cached for %s (pts = %d, cid = %d)', 'fetching difference because some peers were min (%J) and not cached for %s (pts = %d, cid = %d)',
missing,
upd._, upd._,
pending.pts, pending.pts,
pending.channelId, pending.channelId,
) )
if (pending.channelId) { if (pending.channelId && !(upd._ === 'updateNewChannelMessage' && upd.message._ === 'messageService')) {
fetchChannelDifferenceLater(client, state, requestedDiff, pending.channelId) // don't replace service messages, because they can be about bot's kicking
fetchChannelDifferenceLater(client, state, requestedDiff, pending.channelId, pending.ptsBefore)
} else { } else {
fetchDifferenceLater(client, state, requestedDiff) fetchDifferenceLater(client, state, requestedDiff)
} }
return return
} }
}
if (!pending.peers) { if (missing.size) {
// this is a short update, we need to fetch the peers
const peers = await fetchPeersForShort(client, upd)
if (!peers) {
state.log.debug( state.log.debug(
'fetching difference because some peers were not available for short %s (pts = %d, cid = %d)', 'peers still missing after fetching difference: %J for %s (pts = %d, cid = %d)',
missing,
upd._, upd._,
pending.pts, pending.pts,
pending.channelId, pending.channelId,
) )
if (pending.channelId) {
fetchChannelDifferenceLater(client, state, requestedDiff, pending.channelId)
} else {
fetchDifferenceLater(client, state, requestedDiff)
}
return
} }
pending.peers = peers
} }
// apply new pts/qts, if applicable // apply new pts/qts, if applicable
@ -988,7 +1051,7 @@ async function onUpdate(
// updates that are also used internally // updates that are also used internally
switch (upd._) { switch (upd._) {
case 'dummyUpdate': case 'mtcute.dummyUpdate':
// we just needed to apply new pts values // we just needed to apply new pts values
return return
case 'updateDcOptions': { case 'updateDcOptions': {
@ -1000,18 +1063,69 @@ async function onUpdate(
dcOptions: upd.dcOptions, dcOptions: upd.dcOptions,
}) })
} else { } else {
await client.network.config.update(true) client.network.config.update(true).catch((err) => client._emitError(err))
} }
break break
} }
case 'updateConfig': case 'updateConfig':
await client.network.config.update(true) client.network.config.update(true).catch((err) => client._emitError(err))
break break
case 'updateUserName': case 'updateUserName':
if (upd.userId === state.auth.userId) { if (upd.userId === state.auth.userId) {
state.auth.selfUsername = upd.usernames.find((it) => it.active)?.username ?? null state.auth.selfUsername = upd.usernames.find((it) => it.active)?.username ?? null
} }
break break
case 'updateDeleteChannelMessages':
if (!state.auth.isBot) {
await client.storage.deleteReferenceMessages(toggleChannelIdMark(upd.channelId), upd.messages)
}
break
case 'updateNewMessage':
case 'updateEditMessage':
case 'updateNewChannelMessage':
case 'updateEditChannelMessage':
if (!state.auth.isBot) {
await storeMessageReferences(client, upd.message)
}
break
}
if (missing?.size) {
if (state.auth.isBot) {
state.log.warn(
'missing peers (%J) after getDifference for %s (pts = %d, cid = %d)',
missing,
upd._,
pending.pts,
pending.channelId,
)
} else {
// force save storage so the min peers are stored
await client.storage.save?.()
for (const id of missing) {
Promise.resolve(client.storage.getPeerById(id))
.then((peer): unknown => {
if (!peer) {
state.log.warn('cannot fetch full peer %d - getPeerById returned null', id)
return
}
// the peer will be automatically cached by the `.call()`, we don't have to do anything
if (isInputPeerChannel(peer)) {
return _getChannelsBatched(client, normalizeToInputChannel(peer))
} else if (isInputPeerUser(peer)) {
return _getUsersBatched(client, normalizeToInputUser(peer))
}
state.log.warn('cannot fetch full peer %d - unknown peer type %s', id, peer._)
})
.catch((err) => {
state.log.warn('error fetching full peer %d: %s', id, err)
})
}
}
} }
// dispatch the update // dispatch the update
@ -1035,10 +1149,6 @@ async function onUpdate(
state.handler(upd, pending.peers) state.handler(upd, pending.peers)
} }
// todo: updateChannelTooLong with catchUpChannels disabled should not trigger getDifference (?)
// todo: when min peer or similar use pts_before as base pts for channels
// todo: fetchDiff when Session loss on the server: the client receives a new session created notification
async function updatesLoop(client: BaseTelegramClient, state: UpdatesState): Promise<void> { async function updatesLoop(client: BaseTelegramClient, state: UpdatesState): Promise<void> {
const { log } = state const { log } = state
@ -1125,14 +1235,35 @@ async function updatesLoop(client: BaseTelegramClient, state: UpdatesState): Pro
const peers = PeersIndex.from(upd) const peers = PeersIndex.from(upd)
for (const update of upd.updates) { for (const update of upd.updates) {
if (update._ === 'updateChannelTooLong') { switch (update._) {
log.debug( case 'updateChannelTooLong':
'received updateChannelTooLong for channel %d (pts = %d) in container, fetching diff', log.debug(
update.channelId, 'received updateChannelTooLong for channel %d (pts = %d) in container, fetching diff',
update.pts, update.channelId,
) update.pts,
fetchChannelDifferenceLater(client, state, requestedDiff, update.channelId, update.pts) )
continue fetchChannelDifferenceLater(
client,
state,
requestedDiff,
update.channelId,
update.pts,
)
continue
case 'updatePtsChanged':
// see https://github.com/tdlib/td/blob/07c1d53a6d3cb1fad58d2822e55eef6d57363581/td/telegram/UpdatesManager.cpp#L4051
if (client.network.getPoolSize('main') > 1) {
// highload bot
state.log.debug(
'updatePtsChanged received, resetting pts to 1 and fetching difference',
)
state.pts = 1
fetchDifferenceLater(client, state, requestedDiff)
} else {
state.log.debug('updatePtsChanged received, fetching updates state')
await fetchUpdatesState(client, state)
}
continue
} }
const parsed = toPendingUpdate(update, peers) const parsed = toPendingUpdate(update, peers)
@ -1156,7 +1287,7 @@ async function updatesLoop(client: BaseTelegramClient, state: UpdatesState): Pro
case 'updateShort': { case 'updateShort': {
log.debug('received short %s', upd._) log.debug('received short %s', upd._)
const parsed = toPendingUpdate(upd.update) const parsed = toPendingUpdate(upd.update, new PeersIndex())
if (parsed.ptsBefore !== undefined) { if (parsed.ptsBefore !== undefined) {
state.pendingPtsUpdates.add(parsed) state.pendingPtsUpdates.add(parsed)
@ -1206,6 +1337,8 @@ async function updatesLoop(client: BaseTelegramClient, state: UpdatesState): Pro
update, update,
ptsBefore: upd.pts - upd.ptsCount, ptsBefore: upd.pts - upd.ptsCount,
pts: upd.pts, pts: upd.pts,
peers: new PeersIndex(),
fromDifference: false,
}) })
break break
@ -1248,6 +1381,8 @@ async function updatesLoop(client: BaseTelegramClient, state: UpdatesState): Pro
update, update,
ptsBefore: upd.pts - upd.ptsCount, ptsBefore: upd.pts - upd.ptsCount,
pts: upd.pts, pts: upd.pts,
peers: new PeersIndex(),
fromDifference: false,
}) })
break break

View file

@ -76,7 +76,8 @@ export interface PendingUpdate {
qts?: number qts?: number
qtsBefore?: number qtsBefore?: number
timeout?: number timeout?: number
peers?: PeersIndex peers: PeersIndex
fromDifference: boolean
} }
/** @internal */ /** @internal */
@ -181,7 +182,7 @@ export function createUpdatesState(
} }
} }
export function toPendingUpdate(upd: tl.TypeUpdate, peers?: PeersIndex): PendingUpdate { export function toPendingUpdate(upd: tl.TypeUpdate, peers: PeersIndex, fromDifference = false): PendingUpdate {
const channelId = extractChannelIdFromUpdate(upd) || 0 const channelId = extractChannelIdFromUpdate(upd) || 0
const pts = 'pts' in upd ? upd.pts : undefined const pts = 'pts' in upd ? upd.pts : undefined
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
@ -196,5 +197,6 @@ export function toPendingUpdate(upd: tl.TypeUpdate, peers?: PeersIndex): Pending
qts, qts,
qtsBefore: qts ? qts - 1 : undefined, qtsBefore: qts ? qts - 1 : undefined,
peers, peers,
fromDifference,
} }
} }

View file

@ -46,6 +46,49 @@ describe('resolvePeer', () => {
}) })
}) })
it('should extract input peer from dummy min peers', async () => {
const client = StubTelegramClient.offline()
await client.registerPeers(
createStub('channel', {
id: 456,
accessHash: Long.fromBits(111, 222),
}),
)
await client.storage.saveReferenceMessage(123, -1000000000456, 789)
await client.storage.saveReferenceMessage(-1000000000123, -1000000000456, 789)
const resolved = await resolvePeer(client, {
_: 'mtcute.dummyInputPeerMinUser',
userId: 123,
})
const resolved2 = await resolvePeer(client, {
_: 'mtcute.dummyInputPeerMinChannel',
channelId: 123,
})
expect(resolved).toEqual({
_: 'inputPeerUserFromMessage',
userId: 123,
peer: {
_: 'inputPeerChannel',
channelId: 456,
accessHash: Long.fromBits(111, 222),
},
msgId: 789,
})
expect(resolved2).toEqual({
_: 'inputPeerChannelFromMessage',
channelId: 123,
peer: {
_: 'inputPeerChannel',
channelId: 456,
accessHash: Long.fromBits(111, 222),
},
msgId: 789,
})
})
it('should return inputPeerSelf for me/self', async () => { it('should return inputPeerSelf for me/self', async () => {
expect(await resolvePeer(StubTelegramClient.offline(), 'me')).toEqual({ _: 'inputPeerSelf' }) expect(await resolvePeer(StubTelegramClient.offline(), 'me')).toEqual({ _: 'inputPeerSelf' })
expect(await resolvePeer(StubTelegramClient.offline(), 'self')).toEqual({ _: 'inputPeerSelf' }) expect(await resolvePeer(StubTelegramClient.offline(), 'self')).toEqual({ _: 'inputPeerSelf' })
@ -72,6 +115,31 @@ describe('resolvePeer', () => {
}) })
}) })
it('should try checking for message references in storage', async () => {
const client = StubTelegramClient.offline()
await client.registerPeers(
createStub('channel', {
id: 456,
accessHash: Long.fromBits(111, 222),
}),
)
await client.storage.saveReferenceMessage(123, -1000000000456, 789)
const resolved = await resolvePeer(client, 123)
expect(resolved).toEqual({
_: 'inputPeerUserFromMessage',
userId: 123,
peer: {
_: 'inputPeerChannel',
channelId: 456,
accessHash: Long.fromBits(111, 222),
},
msgId: 789,
})
})
it('should return user with zero hash if not in storage', async () => { it('should return user with zero hash if not in storage', async () => {
const client = new StubTelegramClient() const client = new StubTelegramClient()
@ -105,6 +173,31 @@ describe('resolvePeer', () => {
}) })
}) })
it('should try checking for message references in storage', async () => {
const client = StubTelegramClient.offline()
await client.registerPeers(
createStub('channel', {
id: 456,
accessHash: Long.fromBits(111, 222),
}),
)
await client.storage.saveReferenceMessage(-1000000000123, -1000000000456, 789)
const resolved = await resolvePeer(client, -1000000000123)
expect(resolved).toEqual({
_: 'inputPeerChannelFromMessage',
channelId: 123,
peer: {
_: 'inputPeerChannel',
channelId: 456,
accessHash: Long.fromBits(111, 222),
},
msgId: 789,
})
})
it('should return channel with zero hash if not in storage', async () => { it('should return channel with zero hash if not in storage', async () => {
const client = new StubTelegramClient() const client = new StubTelegramClient()
@ -119,7 +212,7 @@ describe('resolvePeer', () => {
}) })
describe('chats', () => { describe('chats', () => {
it('should always return zero hash', async () => { it('should correctly resolve', async () => {
const client = StubTelegramClient.offline() const client = StubTelegramClient.offline()
const resolved = await resolvePeer(client, -123) const resolved = await resolvePeer(client, -123)

View file

@ -31,9 +31,22 @@ export async function resolvePeer(
peerId = getMarkedPeerId(peerId) peerId = getMarkedPeerId(peerId)
} else if ('inputPeer' in peerId) { } else if ('inputPeer' in peerId) {
// User | Chat // User | Chat
return peerId.inputPeer peerId = peerId.inputPeer
} else { } else {
return normalizeToInputPeer(peerId) peerId = normalizeToInputPeer(peerId)
}
}
if (typeof peerId === 'object') {
switch (peerId._) {
case 'mtcute.dummyInputPeerMinUser':
peerId = peerId.userId
break
case 'mtcute.dummyInputPeerMinChannel':
peerId = toggleChannelIdMark(peerId.channelId)
break
default:
return peerId
} }
} }

View file

@ -57,11 +57,53 @@ export class Chat {
} }
/** /**
* Chat's input peer * Whether this chat's information is incomplete.
*
* This usually only happens in large chats, where
* the server sometimes sends only a part of the chat's
* information. Basic info like name and profile photo
* are always available, but other fields may be omitted
* despite being available.
*
* It was observed that these fields may be missing:
* - `isMember`
* - and probably more
*
* This currently only ever happens for non-bot users, so if you are building
* a normal bot, you can safely ignore this field.
*
* To fetch the "complete" user information, use one of these methods:
* - {@link TelegramClient.getChat}
* - {@link TelegramClient.getFullChat}.
*
* Learn more: [Incomplete peers](https://mtcute.dev/guide/topics/peers.html#incomplete-peers)
*/
get isMin(): boolean {
// avoid additional runtime checks
return Boolean((this.peer as { min?: boolean }).min)
}
/**
* Chat's input peer for advanced use-cases.
*
* > **Note**: for {@link min} chats, this method will return
* > `mtcute.dummyInputPeerMin*`, which are actually not a valid input peer,
* > These are used to indicate that the user is incomplete, and a message
* > reference is needed to resolve the peer.
* >
* > Such objects are handled by {@link TelegramClient.resolvePeer} method,
* so prefer using it whenever you need an input peer.
*/ */
get inputPeer(): tl.TypeInputPeer { get inputPeer(): tl.TypeInputPeer {
switch (this.peer._) { switch (this.peer._) {
case 'user': case 'user':
if (this.peer.min) {
return {
_: 'mtcute.dummyInputPeerMinUser',
userId: this.peer.id,
}
}
if (!this.peer.accessHash) { if (!this.peer.accessHash) {
throw new MtArgumentError("Peer's access hash is not available!") throw new MtArgumentError("Peer's access hash is not available!")
} }
@ -79,6 +121,13 @@ export class Chat {
} }
case 'channel': case 'channel':
case 'channelForbidden': case 'channelForbidden':
if ((this.peer as tl.RawChannel).min) {
return {
_: 'mtcute.dummyInputPeerMinChannel',
channelId: this.peer.id,
}
}
if (!this.peer.accessHash) { if (!this.peer.accessHash) {
throw new MtArgumentError("Peer's access hash is not available!") throw new MtArgumentError("Peer's access hash is not available!")
} }
@ -188,6 +237,23 @@ export class Chat {
return this.peer._ === 'channel' && this.peer.forum! return this.peer._ === 'channel' && this.peer.forum!
} }
/**
* Whether the current user is a member of the chat.
*
* For users, this is always `true`.
*/
get isMember(): boolean {
switch (this.peer._) {
case 'user':
return true
case 'channel':
case 'chat':
return !this.peer.left
default:
return false
}
}
/** Whether you have hidden (arhived) this chat's stories */ /** Whether you have hidden (arhived) this chat's stories */
get storiesHidden(): boolean { get storiesHidden(): boolean {
return 'storiesHidden' in this.peer ? this.peer.storiesHidden! : false return 'storiesHidden' in this.peer ? this.peer.storiesHidden! : false

View file

@ -36,6 +36,10 @@ export class PeersIndex {
return index return index
} }
get empty(): boolean {
return this.users.size === 0 && this.chats.size === 0
}
user(id: number): tl.TypeUser { user(id: number): tl.TypeUser {
const r = this.users.get(id) const r = this.users.get(id)

View file

@ -33,6 +33,21 @@ describe('User', () => {
expect(() => user.inputPeer).toThrow() expect(() => user.inputPeer).toThrow()
}) })
it('should return correct input peer for min', () => {
const user = new User(
createStub('user', {
id: 123,
accessHash: Long.fromBits(456, 789),
min: true,
}),
)
expect(user.inputPeer).toEqual({
_: 'mtcute.dummyInputPeerMinUser',
userId: 123,
})
})
}) })
describe('status', () => { describe('status', () => {

View file

@ -45,6 +45,36 @@ export class User {
return this.raw.id return this.raw.id
} }
/**
* Whether this user's information is incomplete.
*
* This usually only happens in large chats, where
* the server sometimes sends only a part of the user's
* information. Basic info like name and profile photo
* are always available, but other fields may be omitted
* despite being available.
*
* It was observed that these fields may be missing:
* - `username, usernames`
* - `status, lastOnline, nextOffline`
* - `storiesMaxId`
* - `photo` - in some cases when user has some some privacy settings
* - and probably more
*
* This currently only ever happens for non-bot users, so if you are building
* a normal bot, you can safely ignore this field.
*
* To fetch the "complete" user information, use one of these methods:
* - {@link TelegramClient.getUsers}
* - {@link TelegramClient.getChat}
* - {@link TelegramClient.getFullChat}.
*
* Learn more: [Incomplete peers](https://mtcute.dev/guide/topics/peers.html#incomplete-peers)
*/
get isMin(): boolean {
return this.raw.min!
}
/** Whether this user is you yourself */ /** Whether this user is you yourself */
get isSelf(): boolean { get isSelf(): boolean {
return this.raw.self! return this.raw.self!
@ -260,8 +290,23 @@ export class User {
/** /**
* Get this user's input peer for advanced use-cases. * Get this user's input peer for advanced use-cases.
*
* > **Note**: for {@link min} users, this method will return
* > `mtcute.dummyInputPeerMinUser`, which is actually not a valid input peer.
* > These are used to indicate that the user is incomplete, and a message
* > reference is needed to resolve the peer.
* >
* > Such objects are handled by {@link TelegramClient.resolvePeer} method,
* > so prefer using it whenever you need an input peer.
*/ */
get inputPeer(): tl.TypeInputPeer { get inputPeer(): tl.TypeInputPeer {
if (this.raw.min) {
return {
_: 'mtcute.dummyInputPeerMinUser',
userId: this.raw.id,
}
}
if (!this.raw.accessHash) { if (!this.raw.accessHash) {
throw new MtArgumentError("user's access hash is not available!") throw new MtArgumentError("user's access hash is not available!")
} }

View file

@ -19,7 +19,7 @@ export function createDummyUpdate(pts: number, ptsCount: number, channelId = 0):
users: [], users: [],
updates: [ updates: [
{ {
_: 'dummyUpdate', _: 'mtcute.dummyUpdate',
channelId, channelId,
pts, pts,
ptsCount, ptsCount,

View file

@ -494,21 +494,15 @@ export class BaseTelegramClient extends EventEmitter {
/** /**
* Adds all peers from a given object to entity cache in storage. * Adds all peers from a given object to entity cache in storage.
*
* @returns `true` if there were any `min` peers
*/ */
async _cachePeersFrom(obj: object): Promise<boolean> { async _cachePeersFrom(obj: object): Promise<void> {
const parsedPeers: ITelegramStorage.PeerInfo[] = [] const parsedPeers: ITelegramStorage.PeerInfo[] = []
let hadMin = false
let count = 0 let count = 0
for (const peer of getAllPeersFrom(obj as tl.TlObject)) { for (const peer of getAllPeersFrom(obj as tl.TlObject)) {
if ((peer as any).min) { if ((peer as any).min) {
// absolutely incredible min peer handling, courtesy of levlam. // no point in caching min peers as we can't use them
// see this thread: https://t.me/tdlibchat/15084
hadMin = true
this.log.debug('received min peer: %j', peer)
continue continue
} }
@ -560,8 +554,6 @@ export class BaseTelegramClient extends EventEmitter {
await this.storage.updatePeers(parsedPeers) await this.storage.updatePeers(parsedPeers)
this.log.debug('cached %d peers', count) this.log.debug('cached %d peers', count)
} }
return hadMin
} }
/** /**

View file

@ -533,7 +533,7 @@ export class NetworkManager {
throw new MtArgumentError('DC manager already exists') throw new MtArgumentError('DC manager already exists')
} }
const dc = new DcConnectionManager(this, defaultDcs.main.id, defaultDcs) const dc = new DcConnectionManager(this, defaultDcs.main.id, defaultDcs, true)
this._dcConnections.set(defaultDcs.main.id, dc) this._dcConnections.set(defaultDcs.main.id, dc)
await this._switchPrimaryDc(dc) await this._switchPrimaryDc(dc)
} }
@ -619,7 +619,7 @@ export class NetworkManager {
const options = await this._findDcOptions(newDc) const options = await this._findDcOptions(newDc)
if (!this._dcConnections.has(newDc)) { if (!this._dcConnections.has(newDc)) {
this._dcConnections.set(newDc, new DcConnectionManager(this, newDc, options)) this._dcConnections.set(newDc, new DcConnectionManager(this, newDc, options, true))
} }
await this._storage.setDefaultDcs(options) await this._storage.setDefaultDcs(options)

View file

@ -312,7 +312,6 @@ export class SessionConnection extends PersistentConnection {
this._isPfsBindingPending = true this._isPfsBindingPending = true
} }
process.exit(0)
doAuthorization(this, this._crypto, TEMP_AUTH_KEY_EXPIRY) doAuthorization(this, this._crypto, TEMP_AUTH_KEY_EXPIRY)
.then(async ([tempAuthKey, tempServerSalt]) => { .then(async ([tempAuthKey, tempServerSalt]) => {
if (!this._usePfs) { if (!this._usePfs) {

View file

@ -135,19 +135,42 @@ export interface ITelegramStorage {
* are called, so you can safely batch these updates * are called, so you can safely batch these updates
*/ */
updatePeers(peers: ITelegramStorage.PeerInfo[]): MaybeAsync<void> updatePeers(peers: ITelegramStorage.PeerInfo[]): MaybeAsync<void>
/** /**
* Find a peer in local database by its marked ID * Find a peer in local database by its marked ID
*
* If no peer was found, the storage should try searching its
* reference messages database. If a reference message is found,
* a `inputPeer*FromMessage` constructor should be returned
*/ */
getPeerById(peerId: number): MaybeAsync<tl.TypeInputPeer | null> getPeerById(peerId: number): MaybeAsync<tl.TypeInputPeer | null>
/** /**
* Find a peer in local database by its username * Find a peer in local database by its username
*/ */
getPeerByUsername(username: string): MaybeAsync<tl.TypeInputPeer | null> getPeerByUsername(username: string): MaybeAsync<tl.TypeInputPeer | null>
/** /**
* Find a peer in local database by its phone number * Find a peer in local database by its phone number
*/ */
getPeerByPhone(phone: string): MaybeAsync<tl.TypeInputPeer | null> getPeerByPhone(phone: string): MaybeAsync<tl.TypeInputPeer | null>
/**
* For `*FromMessage` constructors: store a reference to a `peerId` -
* it was seen in message `messageId` in chat `chatId`.
*
* `peerId` and `chatId` are marked peer IDs.
*
* Learn more: https://core.telegram.org/api/min
*/
saveReferenceMessage(peerId: number, chatId: number, messageId: number): MaybeAsync<void>
/**
* For `*FromMessage` constructors: messages `messageIds` in chat `chatId` were deleted,
* so remove any stored peer references to them.
*/
deleteReferenceMessages(chatId: number, messageIds: number[]): MaybeAsync<void>
/** /**
* Get updates state (if available), represented as a tuple * Get updates state (if available), represented as a tuple
* containing: `pts, qts, date, seq` * containing: `pts, qts, date, seq`

View file

@ -31,6 +31,7 @@ export class JsonMemoryStorage extends MemoryStorage {
case 'pts': case 'pts':
case 'fsm': case 'fsm':
case 'rl': case 'rl':
case 'refs':
return new Map(Object.entries(value as Record<string, string>)) return new Map(Object.entries(value as Record<string, string>))
case 'entities': case 'entities':
return new Map() return new Map()

View file

@ -14,7 +14,7 @@ describe('MemoryStorage', () => {
constructor() { constructor() {
super() super()
this._setStateFrom({ this._setStateFrom({
$version: 1, $version: 2,
defaultDcs: null, defaultDcs: null,
authKeys: new Map(), authKeys: new Map(),
authKeysTemp: new Map(), authKeysTemp: new Map(),
@ -26,6 +26,7 @@ describe('MemoryStorage', () => {
pts: new Map(), pts: new Map(),
fsm: new Map(), fsm: new Map(),
rl: new Map(), rl: new Map(),
refs: new Map(),
self: null, self: null,
}) })
} }

View file

@ -3,7 +3,7 @@ import { tl } from '@mtcute/tl'
import { LruMap, toggleChannelIdMark } from '../utils/index.js' import { LruMap, toggleChannelIdMark } from '../utils/index.js'
import { ITelegramStorage } from './abstract.js' import { ITelegramStorage } from './abstract.js'
const CURRENT_VERSION = 1 const CURRENT_VERSION = 2
type PeerInfoWithUpdated = ITelegramStorage.PeerInfo & { updated: number } type PeerInfoWithUpdated = ITelegramStorage.PeerInfo & { updated: number }
@ -23,6 +23,9 @@ export interface MemorySessionState {
// username -> peer id // username -> peer id
usernameIndex: Map<string, number> usernameIndex: Map<string, number>
// reference messages. peer id -> `${chat id}:${msg id}][]
refs: Map<number, Set<string>>
// common pts, date, seq, qts // common pts, date, seq, qts
gpts: [number, number, number, number] | null gpts: [number, number, number, number] | null
// channel pts // channel pts
@ -110,12 +113,15 @@ export class MemoryStorage implements ITelegramStorage {
entities: new Map(), entities: new Map(),
phoneIndex: new Map(), phoneIndex: new Map(),
usernameIndex: new Map(), usernameIndex: new Map(),
refs: new Map(),
gpts: null, gpts: null,
pts: new Map(), pts: new Map(),
fsm: new Map(), fsm: new Map(),
rl: new Map(), rl: new Map(),
self: null, self: null,
} }
this._cachedInputPeers?.clear()
this._cachedFull?.clear()
} }
/** /**
@ -125,7 +131,14 @@ export class MemoryStorage implements ITelegramStorage {
* you plan on using it somewhere else, be sure to copy it beforehand. * you plan on using it somewhere else, be sure to copy it beforehand.
*/ */
protected _setStateFrom(obj: MemorySessionState): void { protected _setStateFrom(obj: MemorySessionState): void {
if (obj.$version !== CURRENT_VERSION) return let ver = obj.$version as number
if (ver === 1) {
// v2: introduced message references
obj.refs = new Map()
obj.$version = ver = 2
}
if (ver !== CURRENT_VERSION) return
// populate indexes if needed // populate indexes if needed
let populate = false let populate = false
@ -252,6 +265,11 @@ export class MemoryStorage implements ITelegramStorage {
if (peer.phone) this._state.phoneIndex.set(peer.phone, peer.id) if (peer.phone) this._state.phoneIndex.set(peer.phone, peer.id)
this._state.entities.set(peer.id, peer) this._state.entities.set(peer.id, peer)
// no point in storing references anymore, since we have the full peer
if (this._state.refs.has(peer.id)) {
this._state.refs.delete(peer.id)
}
} }
} }
@ -279,11 +297,42 @@ export class MemoryStorage implements ITelegramStorage {
} }
} }
private _findPeerByRef(peerId: number): tl.TypeInputPeer | null {
const refs = this._state.refs.get(peerId)
if (!refs || refs.size === 0) return null
const [ref] = refs.values()
const [chatId, msgId] = ref.split(':').map(Number)
const chatPeer = this._getInputPeer(this._state.entities.get(chatId))
if (!chatPeer) return null
if (peerId > 0) {
// user
return {
_: 'inputPeerUserFromMessage',
msgId,
userId: peerId,
peer: chatPeer,
}
}
// channel
return {
_: 'inputPeerChannelFromMessage',
msgId,
channelId: toggleChannelIdMark(peerId),
peer: chatPeer,
}
}
getPeerById(peerId: number): tl.TypeInputPeer | null { getPeerById(peerId: number): tl.TypeInputPeer | null {
if (this._cachedInputPeers.has(peerId)) { if (this._cachedInputPeers.has(peerId)) {
return this._cachedInputPeers.get(peerId)! return this._cachedInputPeers.get(peerId)!
} }
const peer = this._getInputPeer(this._state.entities.get(peerId))
let peer = this._getInputPeer(this._state.entities.get(peerId))
if (!peer) peer = this._findPeerByRef(peerId)
if (peer) this._cachedInputPeers.set(peerId, peer) if (peer) this._cachedInputPeers.set(peerId, peer)
return peer return peer
@ -307,6 +356,23 @@ export class MemoryStorage implements ITelegramStorage {
return this._getInputPeer(peer) return this._getInputPeer(peer)
} }
saveReferenceMessage(peerId: number, chatId: number, messageId: number): void {
if (!this._state.refs.has(peerId)) {
this._state.refs.set(peerId, new Set())
}
this._state.refs.get(peerId)!.add(`${chatId}:${messageId}`)
}
deleteReferenceMessages(chatId: number, messageIds: number[]): void {
// not the most efficient way, but it's fine
for (const refs of this._state.refs.values()) {
for (const msg of messageIds) {
refs.delete(`${chatId}:${msg}`)
}
}
}
getSelf(): ITelegramStorage.SelfInfo | null { getSelf(): ITelegramStorage.SelfInfo | null {
return this._state.self return this._state.self
} }

View file

@ -70,6 +70,7 @@ export function getMarkedPeerId(
} }
switch (peer._) { switch (peer._) {
case 'mtcute.dummyInputPeerMinUser':
case 'peerUser': case 'peerUser':
case 'inputPeerUser': case 'inputPeerUser':
case 'inputPeerUserFromMessage': case 'inputPeerUserFromMessage':
@ -79,6 +80,7 @@ export function getMarkedPeerId(
case 'peerChat': case 'peerChat':
case 'inputPeerChat': case 'inputPeerChat':
return -peer.chatId return -peer.chatId
case 'mtcute.dummyInputPeerMinChannel':
case 'peerChannel': case 'peerChannel':
case 'inputPeerChannel': case 'inputPeerChannel':
case 'inputPeerChannelFromMessage': case 'inputPeerChannelFromMessage':
@ -87,7 +89,7 @@ export function getMarkedPeerId(
return ZERO_CHANNEL_ID - peer.channelId return ZERO_CHANNEL_ID - peer.channelId
} }
throw new MtArgumentError('Invalid peer') throw new MtArgumentError(`Invalid peer: ${peer._}`)
} }
/** /**

View file

@ -1,4 +1,12 @@
import { Message, OmitInputMessageId, ParametersSkip1, TelegramClient } from '@mtcute/client' import {
Chat,
Message,
MtPeerNotFoundError,
OmitInputMessageId,
ParametersSkip1,
TelegramClient,
User,
} from '@mtcute/client'
import { DeleteMessagesParams } from '@mtcute/client/src/methods/messages/delete-messages.js' import { DeleteMessagesParams } from '@mtcute/client/src/methods/messages/delete-messages.js'
import { ForwardMessageOptions } from '@mtcute/client/src/methods/messages/forward-messages.js' import { ForwardMessageOptions } from '@mtcute/client/src/methods/messages/forward-messages.js'
import { SendCopyParams } from '@mtcute/client/src/methods/messages/send-copy.js' import { SendCopyParams } from '@mtcute/client/src/methods/messages/send-copy.js'
@ -40,6 +48,29 @@ export class MessageContext extends Message implements UpdateContext<Message> {
this.isMessageGroup = Array.isArray(message) this.isMessageGroup = Array.isArray(message)
} }
/**
* Get complete information about {@link sender}
*
* Learn more: [Incomplete peers](https://mtcute.dev/guide/topics/peers.html#incomplete-peers)
*/
async getSender(): Promise<User | Chat> {
if (!this.sender.isMin) return this.sender
let res
if (this.sender.type === 'user') {
[res] = await this.client.getUsers(this.sender)
} else {
res = await this.client.getChat(this.sender)
}
if (!res) throw new MtPeerNotFoundError('Failed to fetch sender')
Object.defineProperty(this, 'sender', { value: res })
return res
}
/** Get a message that this message is a reply to */ /** Get a message that this message is a reply to */
getReplyTo() { getReplyTo() {
return this.client.getReplyTo(this) return this.client.getReplyTo(this)

View file

@ -232,3 +232,23 @@ export const replyTo =
return filter(reply, state) return filter(reply, state)
} }
/**
* Middleware-like filter that will fetch the sender of the message
* and make it available to further filters, as well as the handler itself.
*/
export const withCompleteSender =
<Mod, State extends object>(
filter?: UpdateFilter<MessageContext, Mod, State>,
): UpdateFilter<MessageContext, Mod, State> =>
async (msg, state) => {
try {
await msg.getSender()
} catch (e) {
return false
}
if (!filter) return true
return filter(msg, state)
}

View file

@ -44,7 +44,7 @@ function getInputPeer(row: SqliteEntity | ITelegramStorage.PeerInfo): tl.TypeInp
throw new Error(`Invalid peer type: ${row.type}`) throw new Error(`Invalid peer type: ${row.type}`)
} }
const CURRENT_VERSION = 4 const CURRENT_VERSION = 5
// language=SQLite format=false // language=SQLite format=false
const TEMP_AUTH_TABLE = ` const TEMP_AUTH_TABLE = `
@ -57,6 +57,16 @@ const TEMP_AUTH_TABLE = `
); );
` `
// language=SQLite format=false
const MESSAGE_REFS_TABLE = `
create table message_refs (
peer_id integer primary key,
chat_id integer not null,
msg_id integer not null
);
create index idx_message_refs on message_refs (chat_id, msg_id);
`
// language=SQLite format=false // language=SQLite format=false
const SCHEMA = ` const SCHEMA = `
create table kv ( create table kv (
@ -93,6 +103,8 @@ const SCHEMA = `
); );
create index idx_entities_username on entities (username); create index idx_entities_username on entities (username);
create index idx_entities_phone on entities (phone); create index idx_entities_phone on entities (phone);
${MESSAGE_REFS_TABLE}
` `
// language=SQLite format=false // language=SQLite format=false
@ -100,7 +112,8 @@ const RESET = `
delete from kv where key <> 'ver'; delete from kv where key <> 'ver';
delete from state; delete from state;
delete from pts; delete from pts;
delete from entities delete from entities;
delete from message_refs;
` `
const RESET_AUTH_KEYS = ` const RESET_AUTH_KEYS = `
delete from auth_keys; delete from auth_keys;
@ -129,6 +142,12 @@ interface FsmItem<T = unknown> {
expires?: number expires?: number
} }
interface MessageRef {
peer_id: number
chat_id: number
msg_id: number
}
const STATEMENTS = { const STATEMENTS = {
getKv: 'select value from kv where key = ?', getKv: 'select value from kv where key = ?',
setKv: 'insert or replace into kv (key, value) values (?, ?)', setKv: 'insert or replace into kv (key, value) values (?, ?)',
@ -157,6 +176,11 @@ const STATEMENTS = {
getEntByPhone: 'select * from entities where phone = ? limit 1', getEntByPhone: 'select * from entities where phone = ? limit 1',
getEntByUser: 'select * from entities where username = ? limit 1', getEntByUser: 'select * from entities where username = ? limit 1',
storeMessageRef: 'insert or replace into message_refs (peer_id, chat_id, msg_id) values (?, ?, ?)',
getMessageRef: 'select chat_id, msg_id from message_refs where peer_id = ?',
delMessageRefs: 'delete from message_refs where chat_id = ? and msg_id = ?',
delAllMessageRefs: 'delete from message_refs where peer_id = ?',
delStaleState: 'delete from state where expires < ?', delStaleState: 'delete from state where expires < ?',
} as const } as const
@ -312,8 +336,6 @@ export class SqliteStorage implements ITelegramStorage /*, IStateStorage*/ {
) )
this._vacuumInterval = params?.vacuumInterval ?? 300_000 this._vacuumInterval = params?.vacuumInterval ?? 300_000
// todo: add support for workers (idk if really needed, but still)
} }
setup(log: Logger, readerMap: TlReaderMap, writerMap: TlWriterMap): void { setup(log: Logger, readerMap: TlReaderMap, writerMap: TlWriterMap): void {
@ -393,6 +415,12 @@ export class SqliteStorage implements ITelegramStorage /*, IStateStorage*/ {
from = 4 from = 4
} }
if (from === 4) {
// message references support added
this._db.exec(MESSAGE_REFS_TABLE)
from = 5
}
if (from !== CURRENT_VERSION) { if (from !== CURRENT_VERSION) {
// an assertion just in case i messed up // an assertion just in case i messed up
throw new Error('Migration incomplete') throw new Error('Migration incomplete')
@ -532,11 +560,19 @@ export class SqliteStorage implements ITelegramStorage /*, IStateStorage*/ {
this._statements.delAllAuthTemp.run(dcId) this._statements.delAllAuthTemp.run(dcId)
} }
private _cachedSelf?: ITelegramStorage.SelfInfo | null
getSelf(): ITelegramStorage.SelfInfo | null { getSelf(): ITelegramStorage.SelfInfo | null {
return this._getFromKv('self') if (this._cachedSelf !== undefined) return this._cachedSelf
const self = this._getFromKv<ITelegramStorage.SelfInfo | null>('self')
this._cachedSelf = self
return self
} }
setSelf(self: ITelegramStorage.SelfInfo | null): void { setSelf(self: ITelegramStorage.SelfInfo | null): void {
this._cachedSelf = self
return this._setToKv('self', self, true) return this._setToKv('self', self, true)
} }
@ -624,11 +660,43 @@ export class SqliteStorage implements ITelegramStorage /*, IStateStorage*/ {
peer: getInputPeer(peer), peer: getInputPeer(peer),
full: peer.full, full: peer.full,
}) })
// we have the full peer, we no longer need the references
// we can skip this in the other branch, since in that case it would've already been deleted
if (!this._cachedSelf?.isBot) {
this._pending.push([this._statements.delAllMessageRefs, [peer.id]])
}
} }
}) })
} }
getPeerById(peerId: number): tl.TypeInputPeer | null { private _findPeerByReference(peerId: number): tl.TypeInputPeer | null {
const row = this._statements.getMessageRef.get(peerId) as MessageRef | null
if (!row) return null
const chat = this.getPeerById(row.chat_id, false)
if (!chat) return null
if (peerId > 0) {
// user
return {
_: 'inputPeerUserFromMessage',
peer: chat,
userId: peerId,
msgId: row.msg_id,
}
}
// channel
return {
_: 'inputPeerChannelFromMessage',
peer: chat,
channelId: toggleChannelIdMark(peerId),
msgId: row.msg_id,
}
}
getPeerById(peerId: number, allowRefs = true): tl.TypeInputPeer | null {
const cached = this._cache?.get(peerId) const cached = this._cache?.get(peerId)
if (cached) return cached.peer if (cached) return cached.peer
@ -644,6 +712,10 @@ export class SqliteStorage implements ITelegramStorage /*, IStateStorage*/ {
return peer return peer
} }
if (allowRefs) {
return this._findPeerByReference(peerId)
}
return null return null
} }
@ -699,6 +771,16 @@ export class SqliteStorage implements ITelegramStorage /*, IStateStorage*/ {
return null return null
} }
saveReferenceMessage(peerId: number, chatId: number, messageId: number): void {
this._pending.push([this._statements.storeMessageRef, [peerId, chatId, messageId]])
}
deleteReferenceMessages(chatId: number, messageIds: number[]): void {
for (const id of messageIds) {
this._pending.push([this._statements.delMessageRefs, [chatId, id]])
}
}
// IStateStorage implementation // IStateStorage implementation
getState(key: string, parse = true): unknown { getState(key: string, parse = true): unknown {

View file

@ -219,6 +219,51 @@ export function testStorage<T extends ITelegramStorage>(
expect(await s.getFullPeerById(stubPeerUser.id)).toEqual(stubPeerUser.full) expect(await s.getFullPeerById(stubPeerUser.id)).toEqual(stubPeerUser.full)
expect(await s.getFullPeerById(peerChannel.id)).toEqual(peerChannel.full) expect(await s.getFullPeerById(peerChannel.id)).toEqual(peerChannel.full)
}) })
describe('min peers', () => {
it('should generate *FromMessage constructors from reference messages', async () => {
await s.updatePeers([peerChannel])
await s.saveReferenceMessage(stubPeerUser.id, peerChannel.id, 456)
await s.save?.() // update-related methods are batched, so we need to save
expect(await s.getPeerById(stubPeerUser.id)).toEqual({
_: 'inputPeerUserFromMessage',
peer: peerChannelInput,
msgId: 456,
userId: stubPeerUser.id,
})
})
it('should handle cases when referenced chat is not available', async () => {
// this shouldn't really happen, but the storage should be able to handle it
await s.saveReferenceMessage(stubPeerUser.id, peerChannel.id, 456)
await s.save?.() // update-related methods are batched, so we need to save
expect(await s.getPeerById(stubPeerUser.id)).toEqual(null)
})
it('should return full peer if it gets available', async () => {
await s.updatePeers([peerChannel])
await s.saveReferenceMessage(stubPeerUser.id, peerChannel.id, 456)
await s.save?.() // update-related methods are batched, so we need to save
await s.updatePeers([stubPeerUser])
await s.save?.()
expect(await s.getPeerById(stubPeerUser.id)).toEqual(peerUserInput)
})
it('should handle cases when referenced message is deleted', async () => {
await s.updatePeers([peerChannel])
await s.saveReferenceMessage(stubPeerUser.id, peerChannel.id, 456)
await s.save?.() // update-related methods are batched, so we need to save
await s.deleteReferenceMessages(peerChannel.id, [456])
await s.save?.()
expect(await s.getPeerById(stubPeerUser.id)).toEqual(null)
})
})
}) })
describe('current user', () => { describe('current user', () => {

View file

@ -2,7 +2,7 @@
TL schema and related utils used for mtcute. TL schema and related utils used for mtcute.
Generated from TL layer **166** (last updated on 17.11.2023). Generated from TL layer **166** (last updated on 25.11.2023).
## About ## About

File diff suppressed because one or more lines are too long

View file

@ -1,24 +1,7 @@
// for internal use // some custom types for internal use
---types--- ---types---
dummyUpdate pts:int pts_count:int channel_id:int53 = Update; mtcute.dummyUpdate pts:int pts_count:int channel_id:int53 = Update;
mtcute.dummyInputPeerMinUser user_id:int = InputPeer;
// reactions mtcute.dummyInputPeerMinChannel channel_id:int = InputPeer;
// taken from OLD official
// no longer work on newer layers because they changed this thing entirely
// kept only as a historical reference
// ---types---
//
// updateMessageReactions peer:Peer msg_id:int reactions:MessageReactions = Update;
// messageReactions flags:# min:flags.0?true results:Vector<ReactionCount> = MessageReactions;
// reactionCount flags:# chosen:flags.0?true reaction:string count:int = ReactionCount;
//
// messageReactionsList flags:# count:int reactions:Vector<MessageUserReaction> users:Vector<User> next_offset:flags.0?string = MessageReactionsList;
// messageUserReaction user_id:int reaction:string = MessageUserReaction;
//
// ---functions---
//
// messages.sendReaction flags:# peer:InputPeer msg_id:int reaction:flags.0?string = Updates;
// messages.getMessagesReactions peer:InputPeer id:Vector<int> = Updates;
// messages.getMessageReactionsList flags:# peer:InputPeer id:int reaction:flags.0?string offset:flags.1?string limit:int = MessageReactionsList;

View file

@ -66,6 +66,7 @@
"messageActionInviteToGroupCall": ["users"], "messageActionInviteToGroupCall": ["users"],
"messageEntityMentionName": ["user_id"], "messageEntityMentionName": ["user_id"],
"messageMediaContact": ["user_id"], "messageMediaContact": ["user_id"],
"messageMediaGiveaway": ["channels"],
"messageReplies": ["channel_id"], "messageReplies": ["channel_id"],
"messageReplyStoryHeader": ["user_id"], "messageReplyStoryHeader": ["user_id"],
"messageUserVote": ["user_id"], "messageUserVote": ["user_id"],
@ -122,6 +123,7 @@
"updateChatUserTyping": ["chat_id"], "updateChatUserTyping": ["chat_id"],
"updateDeleteChannelMessages": ["channel_id"], "updateDeleteChannelMessages": ["channel_id"],
"updateGroupCall": ["chat_id"], "updateGroupCall": ["chat_id"],
"updateGroupInvitePrivacyForbidden": ["chat_id"],
"updateInlineBotCallbackQuery": ["user_id"], "updateInlineBotCallbackQuery": ["user_id"],
"updatePinnedChannelMessages": ["channel_id"], "updatePinnedChannelMessages": ["channel_id"],
"updateReadChannelDiscussionInbox": ["channel_id", "broadcast_id"], "updateReadChannelDiscussionInbox": ["channel_id", "broadcast_id"],
@ -130,6 +132,7 @@
"updateReadChannelOutbox": ["channel_id"], "updateReadChannelOutbox": ["channel_id"],
"updateShortChatMessage": ["from_id", "chat_id", "via_bot_id"], "updateShortChatMessage": ["from_id", "chat_id", "via_bot_id"],
"updateShortMessage": ["user_id", "via_bot_id"], "updateShortMessage": ["user_id", "via_bot_id"],
"updateUser": ["user_id"],
"updateUserEmojiStatus": ["user_id"], "updateUserEmojiStatus": ["user_id"],
"updateUserName": ["user_id"], "updateUserName": ["user_id"],
"updateUserPhone": ["user_id"], "updateUserPhone": ["user_id"],

View file

@ -1,6 +1,6 @@
{ {
"name": "@mtcute/tl", "name": "@mtcute/tl",
"version": "166.1.0", "version": "166.2.0",
"description": "TL schema used for mtcute", "description": "TL schema used for mtcute",
"main": "index.js", "main": "index.js",
"author": "Alina Sireneva <alina@tei.su>", "author": "Alina Sireneva <alina@tei.su>",