feat: file-id package to parse, serialize and manipulate tdlib-compatible file ids
This commit is contained in:
parent
1da905ab3d
commit
8b5060d2cd
16 changed files with 1341 additions and 144 deletions
|
@ -53,67 +53,3 @@ export function cloneBuffer(buf: Buffer, start = 0, end = buf.length): Buffer {
|
||||||
buf.copy(ret, 0, start, end)
|
buf.copy(ret, 0, start, end)
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseUrlSafeBase64(str: string): Buffer {
|
|
||||||
str = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
while (str.length % 4) str += '='
|
|
||||||
return Buffer.from(str, 'base64')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeUrlSafeBase64(buf: Buffer): string {
|
|
||||||
return buf
|
|
||||||
.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// telegram has some cursed RLE which only encodes consecutive \x00
|
|
||||||
|
|
||||||
export function telegramRleEncode(buf: Buffer): Buffer {
|
|
||||||
const len = buf.length
|
|
||||||
const ret: number[] = []
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const cur = buf[i]
|
|
||||||
if (cur === 0) {
|
|
||||||
count += 1
|
|
||||||
} else {
|
|
||||||
if (count > 0) {
|
|
||||||
ret.push(0, count)
|
|
||||||
count = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.push(cur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
ret.push(0, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function telegramRleDecode(buf: Buffer): Buffer {
|
|
||||||
const len = buf.length
|
|
||||||
const ret: number[] = []
|
|
||||||
let prev = -1
|
|
||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const cur = buf[i]
|
|
||||||
if (prev === 0) {
|
|
||||||
for (let j = 0; j < cur; j++) {
|
|
||||||
ret.push(prev)
|
|
||||||
}
|
|
||||||
prev = -1
|
|
||||||
} else {
|
|
||||||
if (prev !== -1) ret.push(prev)
|
|
||||||
prev = cur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev !== -1) ret.push(prev)
|
|
||||||
return Buffer.from(ret)
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,11 +3,7 @@ import { expect } from 'chai'
|
||||||
import {
|
import {
|
||||||
buffersEqual,
|
buffersEqual,
|
||||||
cloneBuffer,
|
cloneBuffer,
|
||||||
encodeUrlSafeBase64,
|
|
||||||
parseUrlSafeBase64,
|
|
||||||
randomBytes,
|
randomBytes,
|
||||||
telegramRleDecode,
|
|
||||||
telegramRleEncode,
|
|
||||||
xorBuffer,
|
xorBuffer,
|
||||||
xorBufferInPlace,
|
xorBufferInPlace,
|
||||||
} from '../src/utils/buffer-utils'
|
} from '../src/utils/buffer-utils'
|
||||||
|
@ -115,82 +111,6 @@ describe('cloneBuffer', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('parseUrlSafeBase64', () => {
|
|
||||||
it('should parse url-safe base64', () => {
|
|
||||||
expect(parseUrlSafeBase64('qu7d8aGTeuF6-g').toString('hex')).eq(
|
|
||||||
'aaeeddf1a1937ae17afa'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
it('should parse normal base64', () => {
|
|
||||||
expect(parseUrlSafeBase64('qu7d8aGTeuF6+g==').toString('hex')).eq(
|
|
||||||
'aaeeddf1a1937ae17afa'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('encodeUrlSafeBase64', () => {
|
|
||||||
it('should encode to url-safe base64', () => {
|
|
||||||
expect(
|
|
||||||
encodeUrlSafeBase64(Buffer.from('aaeeddf1a1937ae17afa', 'hex'))
|
|
||||||
).eq('qu7d8aGTeuF6-g')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('telegramRleEncode', () => {
|
|
||||||
it('should not modify input if there are no \\x00', () => {
|
|
||||||
expect(
|
|
||||||
telegramRleEncode(Buffer.from('aaeeff', 'hex')).toString('hex')
|
|
||||||
).eq('aaeeff')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should collapse consecutive \\x00', () => {
|
|
||||||
expect(
|
|
||||||
telegramRleEncode(Buffer.from('00000000aa', 'hex')).toString('hex')
|
|
||||||
).eq('0004aa')
|
|
||||||
expect(
|
|
||||||
telegramRleEncode(
|
|
||||||
Buffer.from('00000000aa000000aa', 'hex')
|
|
||||||
).toString('hex')
|
|
||||||
).eq('0004aa0003aa')
|
|
||||||
expect(
|
|
||||||
telegramRleEncode(Buffer.from('00000000aa0000', 'hex')).toString(
|
|
||||||
'hex'
|
|
||||||
)
|
|
||||||
).eq('0004aa0002')
|
|
||||||
expect(
|
|
||||||
telegramRleEncode(Buffer.from('00aa00', 'hex')).toString('hex')
|
|
||||||
).eq('0001aa0001')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('telegramRleDecode', () => {
|
|
||||||
it('should not mofify input if there are no \\x00', () => {
|
|
||||||
expect(
|
|
||||||
telegramRleDecode(Buffer.from('aaeeff', 'hex')).toString('hex')
|
|
||||||
).eq('aaeeff')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should expand two-byte sequences starting with \\x00', () => {
|
|
||||||
expect(
|
|
||||||
telegramRleDecode(Buffer.from('0004aa', 'hex')).toString('hex')
|
|
||||||
).eq('00000000aa')
|
|
||||||
expect(
|
|
||||||
telegramRleDecode(Buffer.from('0004aa0000', 'hex')).toString('hex')
|
|
||||||
).eq('00000000aa')
|
|
||||||
expect(
|
|
||||||
telegramRleDecode(Buffer.from('0004aa0003aa', 'hex')).toString(
|
|
||||||
'hex'
|
|
||||||
)
|
|
||||||
).eq('00000000aa000000aa')
|
|
||||||
expect(
|
|
||||||
telegramRleDecode(Buffer.from('0004aa0002', 'hex')).toString('hex')
|
|
||||||
).eq('00000000aa0000')
|
|
||||||
expect(
|
|
||||||
telegramRleDecode(Buffer.from('0001aa0001', 'hex')).toString('hex')
|
|
||||||
).eq('00aa00')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// describe('isProbablyPlainText', () => {
|
// describe('isProbablyPlainText', () => {
|
||||||
// it('should return true for buffers only containing printable ascii', () => {
|
// it('should return true for buffers only containing printable ascii', () => {
|
||||||
// expect(
|
// expect(
|
||||||
|
|
39
packages/file-id/README.md
Normal file
39
packages/file-id/README.md
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# `@mtcute/file-id`
|
||||||
|
|
||||||
|
A package that is used internally by `@mtcute/client` to parse, serialize
|
||||||
|
and manipulate TDLib and Bot API compatible File IDs, but can also be used
|
||||||
|
for any other purposes.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
This package exports a number of functions, namely:
|
||||||
|
- `parseFileId()` which parses provided File ID to an object representing its contents
|
||||||
|
- `toFileId()` which serializes provided object containing file info to a File ID
|
||||||
|
- `toUniqueFileId()` which serializes provided object containing file info to a Unique File ID
|
||||||
|
- `fileIdTo*()` which converts a File ID to an input TL object, which can be used
|
||||||
|
in RPC calls etc.
|
||||||
|
|
||||||
|
This package also exports namespace `tdFileId`, which contains all the types
|
||||||
|
used by the library
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
This package uses `@mtcute/core` `BinaryReader` and `BinaryWriter` classes to
|
||||||
|
work with binary streams. Additionally, it depends on `@mtcute/tl` types to
|
||||||
|
allow type-safe code for conversion functions.
|
||||||
|
|
||||||
|
Note that `@mtcute/core` itself depends on `@mtcute/tl`, which might
|
||||||
|
redundantly increase your bundle size in case you don't actually use TL types.
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
This is basically a port of a portion of TDLib APIs, but greatly
|
||||||
|
simplified in usage and made to work seamlessly with the rest of the
|
||||||
|
MTCute APIs.
|
||||||
|
|
||||||
|
This is a list of files from TDLib repository, from which most of the code was taken:
|
||||||
|
- [td/telegram/files/FileManager.cpp](https://github.com/tdlib/td/blob/master/td/telegram/files/FileManager.cpp)
|
||||||
|
- [td/telegram/files/FileLocation.hpp](https://github.com/tdlib/td/blob/master/td/telegram/files/FileLocation.hpp)
|
||||||
|
- [td/telegram/PhotoSizeSource.h](https://github.com/tdlib/td/blob/master/td/telegram/PhotoSizeSource.h)
|
||||||
|
- [td/telegram/PhotoSizeSource.hpp](https://github.com/tdlib/td/blob/master/td/telegram/PhotoSizeSource.hpp)
|
||||||
|
- [td/telegram/Version.h](https://github.com/tdlib/td/blob/master/td/telegram/Version.h)
|
||||||
|
|
||||||
|
Additionally, some of the test cases were taken from a similar Python
|
||||||
|
library, [luckydonald/telegram_file_id](https://github.com/luckydonald/telegram_file_id)
|
18
packages/file-id/package.json
Normal file
18
packages/file-id/package.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@mtcute/file-id",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Support for TDLib and Bot API file ID for MTCute",
|
||||||
|
"author": "Alisa Sireneva <me@tei.su>",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "mocha -r ts-node/register tests/**/*.spec.ts",
|
||||||
|
"docs": "npx typedoc",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mtcute/tl": "^0.0.0",
|
||||||
|
"@mtcute/core": "^0.0.0"
|
||||||
|
}
|
||||||
|
}
|
263
packages/file-id/src/convert.ts
Normal file
263
packages/file-id/src/convert.ts
Normal file
|
@ -0,0 +1,263 @@
|
||||||
|
import { tl } from '@mtcute/tl'
|
||||||
|
import { tdFileId, tdFileId as td } from './types'
|
||||||
|
import { parseFileId } from './parse'
|
||||||
|
import { getBasicPeerType, markedPeerIdToBare } from '@mtcute/core'
|
||||||
|
import FileType = tdFileId.FileType
|
||||||
|
|
||||||
|
type FileId = td.RawFullRemoteFileLocation
|
||||||
|
|
||||||
|
function dialogPhotoToInputPeer(
|
||||||
|
dialog: td.RawPhotoSizeSourceDialogPhoto
|
||||||
|
): tl.TypeInputPeer {
|
||||||
|
const markedPeerId = dialog.id.toJSNumber()
|
||||||
|
const peerType = getBasicPeerType(markedPeerId)
|
||||||
|
const peerId = markedPeerIdToBare(markedPeerId)
|
||||||
|
|
||||||
|
if (peerType === 'user') {
|
||||||
|
return {
|
||||||
|
_: 'inputPeerUser',
|
||||||
|
userId: peerId,
|
||||||
|
accessHash: dialog.accessHash,
|
||||||
|
}
|
||||||
|
} else if (peerType === 'chat') {
|
||||||
|
return {
|
||||||
|
_: 'inputPeerChat',
|
||||||
|
chatId: peerId,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
_: 'inputPeerChannel',
|
||||||
|
channelId: peerId,
|
||||||
|
accessHash: dialog.accessHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file ID or {@link tdFileId.RawFullRemoteFileLocation}
|
||||||
|
* to TL object `inputWebFileLocation`
|
||||||
|
*
|
||||||
|
* @param fileId File ID, either parsed or as a string
|
||||||
|
*/
|
||||||
|
export function fileIdToInputWebFileLocation(
|
||||||
|
fileId: string | FileId
|
||||||
|
): tl.RawInputWebFileLocation {
|
||||||
|
if (typeof fileId === 'string') fileId = parseFileId(fileId)
|
||||||
|
if (fileId.location._ !== 'web')
|
||||||
|
throw new td.ConversionError('inputWebFileLocation')
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'inputWebFileLocation',
|
||||||
|
url: fileId.location.url,
|
||||||
|
accessHash: fileId.location.accessHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file ID or {@link tdFileId.RawFullRemoteFileLocation}
|
||||||
|
* to TL object representing an `InputFileLocation`
|
||||||
|
*
|
||||||
|
* @param fileId File ID, either parsed or as a string
|
||||||
|
*/
|
||||||
|
export function fileIdToInputFileLocation(
|
||||||
|
fileId: string | FileId
|
||||||
|
): tl.TypeInputFileLocation {
|
||||||
|
if (typeof fileId === 'string') fileId = parseFileId(fileId)
|
||||||
|
|
||||||
|
const loc = fileId.location
|
||||||
|
switch (loc._) {
|
||||||
|
case 'web':
|
||||||
|
throw new td.ConversionError('InputFileLocation')
|
||||||
|
case 'photo': {
|
||||||
|
switch (loc.source._) {
|
||||||
|
case 'legacy':
|
||||||
|
if (!fileId.fileReference)
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Expected legacy photo to have file reference'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'inputPhotoLegacyFileLocation',
|
||||||
|
fileReference: fileId.fileReference,
|
||||||
|
id: loc.id,
|
||||||
|
accessHash: loc.accessHash,
|
||||||
|
volumeId: loc.volumeId,
|
||||||
|
localId: loc.localId,
|
||||||
|
secret: loc.source.secret,
|
||||||
|
}
|
||||||
|
case 'thumbnail':
|
||||||
|
if (!fileId.fileReference)
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Expected thumbnail photo to have file reference'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
loc.source.fileType !== FileType.Photo &&
|
||||||
|
loc.source.fileType !== FileType.Thumbnail
|
||||||
|
)
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Expected a thumbnail to have a correct file type'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
_:
|
||||||
|
loc.source.fileType === FileType.Photo
|
||||||
|
? 'inputPhotoFileLocation'
|
||||||
|
: 'inputDocumentFileLocation',
|
||||||
|
fileReference: fileId.fileReference,
|
||||||
|
id: loc.id,
|
||||||
|
accessHash: loc.accessHash,
|
||||||
|
thumbSize: loc.source.thumbnailType,
|
||||||
|
}
|
||||||
|
case 'dialogPhoto':
|
||||||
|
return {
|
||||||
|
_: 'inputPeerPhotoFileLocation',
|
||||||
|
big: loc.source.big,
|
||||||
|
peer: dialogPhotoToInputPeer(loc.source),
|
||||||
|
volumeId: loc.volumeId,
|
||||||
|
localId: loc.localId,
|
||||||
|
}
|
||||||
|
case 'stickerSetThumbnail':
|
||||||
|
return {
|
||||||
|
_: 'inputStickerSetThumb',
|
||||||
|
stickerset: {
|
||||||
|
_: 'inputStickerSetID',
|
||||||
|
id: loc.source.id,
|
||||||
|
accessHash: loc.source.accessHash,
|
||||||
|
},
|
||||||
|
volumeId: loc.volumeId,
|
||||||
|
localId: loc.localId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new td.ConversionError('inputFileLocation')
|
||||||
|
}
|
||||||
|
case 'common': {
|
||||||
|
if (!fileId.fileReference)
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Expected common to have file reference'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fileId.type === FileType.Encrypted) {
|
||||||
|
return {
|
||||||
|
_: 'inputEncryptedFileLocation',
|
||||||
|
id: loc.id,
|
||||||
|
accessHash: loc.accessHash,
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
fileId.type === FileType.Secure ||
|
||||||
|
fileId.type === FileType.SecureRaw
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
_: 'inputSecureFileLocation',
|
||||||
|
id: loc.id,
|
||||||
|
accessHash: loc.accessHash,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
_: 'inputDocumentFileLocation',
|
||||||
|
fileReference: fileId.fileReference,
|
||||||
|
id: loc.id,
|
||||||
|
accessHash: loc.accessHash,
|
||||||
|
thumbSize: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file ID or {@link tdFileId.RawFullRemoteFileLocation}
|
||||||
|
* to TL object `inputDocument`
|
||||||
|
*
|
||||||
|
* @param fileId File ID, either parsed or as a string
|
||||||
|
*/
|
||||||
|
export function fileIdToInputDocument(
|
||||||
|
fileId: string | FileId
|
||||||
|
): tl.RawInputDocument {
|
||||||
|
if (typeof fileId === 'string') fileId = parseFileId(fileId)
|
||||||
|
if (
|
||||||
|
fileId.location._ !== 'common' ||
|
||||||
|
fileId.type === FileType.Secure ||
|
||||||
|
fileId.type === FileType.SecureRaw ||
|
||||||
|
fileId.type === FileType.Encrypted
|
||||||
|
)
|
||||||
|
throw new td.ConversionError('inputDocument')
|
||||||
|
|
||||||
|
if (!fileId.fileReference)
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Expected document to have file reference'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'inputDocument',
|
||||||
|
fileReference: fileId.fileReference,
|
||||||
|
id: fileId.location.id,
|
||||||
|
accessHash: fileId.location.accessHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file ID or {@link tdFileId.RawFullRemoteFileLocation}
|
||||||
|
* to TL object `inputPhoto`
|
||||||
|
*
|
||||||
|
* @param fileId File ID, either parsed or as a string
|
||||||
|
*/
|
||||||
|
export function fileIdToInputPhoto(fileId: string | FileId): tl.RawInputPhoto {
|
||||||
|
if (typeof fileId === 'string') fileId = parseFileId(fileId)
|
||||||
|
if (fileId.location._ !== 'photo')
|
||||||
|
throw new td.ConversionError('inputPhoto')
|
||||||
|
|
||||||
|
if (!fileId.fileReference)
|
||||||
|
throw new td.InvalidFileIdError('Expected photo to have file reference')
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'inputPhoto',
|
||||||
|
fileReference: fileId.fileReference,
|
||||||
|
id: fileId.location.id,
|
||||||
|
accessHash: fileId.location.accessHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file ID or {@link tdFileId.RawFullRemoteFileLocation}
|
||||||
|
* to TL object `inputEncryptedFile`
|
||||||
|
*
|
||||||
|
* @param fileId File ID, either parsed or as a string
|
||||||
|
*/
|
||||||
|
export function fileIdToEncryptedFile(
|
||||||
|
fileId: string | FileId
|
||||||
|
): tl.RawInputEncryptedFile {
|
||||||
|
if (typeof fileId === 'string') fileId = parseFileId(fileId)
|
||||||
|
if (fileId.location._ !== 'common' || fileId.type !== FileType.Encrypted)
|
||||||
|
throw new td.ConversionError('inputEncryptedFile')
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'inputEncryptedFile',
|
||||||
|
id: fileId.location.id,
|
||||||
|
accessHash: fileId.location.accessHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a file ID or {@link tdFileId.RawFullRemoteFileLocation}
|
||||||
|
* to TL object `inputSecureFile`
|
||||||
|
*
|
||||||
|
* @param fileId File ID, either parsed or as a string
|
||||||
|
*/
|
||||||
|
export function fileIdToSecureFile(
|
||||||
|
fileId: string | FileId
|
||||||
|
): tl.RawInputSecureFile {
|
||||||
|
if (typeof fileId === 'string') fileId = parseFileId(fileId)
|
||||||
|
if (
|
||||||
|
fileId.location._ !== 'common' ||
|
||||||
|
(fileId.type !== FileType.Secure && fileId.type !== FileType.SecureRaw)
|
||||||
|
)
|
||||||
|
throw new td.ConversionError('inputSecureFile')
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'inputSecureFile',
|
||||||
|
id: fileId.location.id,
|
||||||
|
accessHash: fileId.location.accessHash,
|
||||||
|
}
|
||||||
|
}
|
5
packages/file-id/src/index.ts
Normal file
5
packages/file-id/src/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './convert'
|
||||||
|
export * from './parse'
|
||||||
|
export * from './serialize'
|
||||||
|
export * from './serialize-unique'
|
||||||
|
export * from './types'
|
251
packages/file-id/src/parse.ts
Normal file
251
packages/file-id/src/parse.ts
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
import { parseUrlSafeBase64, telegramRleDecode } from './utils'
|
||||||
|
import { tdFileId as td } from './types'
|
||||||
|
import { BinaryReader } from '@mtcute/core'
|
||||||
|
|
||||||
|
function parseWebFileLocation(
|
||||||
|
reader: BinaryReader
|
||||||
|
): td.RawWebRemoteFileLocation {
|
||||||
|
return {
|
||||||
|
_: 'web',
|
||||||
|
url: reader.string(),
|
||||||
|
accessHash: reader.long(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePhotoSizeSource(reader: BinaryReader): td.TypePhotoSizeSource {
|
||||||
|
const variant = reader.int32()
|
||||||
|
switch (variant) {
|
||||||
|
case 0 /* LEGACY */:
|
||||||
|
return {
|
||||||
|
_: 'legacy',
|
||||||
|
secret: reader.long(),
|
||||||
|
}
|
||||||
|
case 1 /* THUMBNAIL */: {
|
||||||
|
const fileType = reader.int32()
|
||||||
|
if (fileType < 0 || fileType >= td.FileType.Size)
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Unsupported file type: ${fileType} (${reader.data.toString(
|
||||||
|
'base64'
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const thumbnailType = reader.int32()
|
||||||
|
if (thumbnailType < 0 || thumbnailType > 255) {
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
`Wrong thumbnail type: ${thumbnailType} (${reader.data.toString(
|
||||||
|
'base64'
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'thumbnail',
|
||||||
|
fileType,
|
||||||
|
thumbnailType: String.fromCharCode(thumbnailType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 2 /* DIALOG_PHOTO_SMALL */:
|
||||||
|
case 3 /* DIALOG_PHOTO_BIG */:
|
||||||
|
return {
|
||||||
|
_: 'dialogPhoto',
|
||||||
|
big: variant === 3,
|
||||||
|
id: reader.long(),
|
||||||
|
accessHash: reader.long(),
|
||||||
|
}
|
||||||
|
case 4 /* STICKERSET_THUMBNAIL */:
|
||||||
|
return {
|
||||||
|
_: 'stickerSetThumbnail',
|
||||||
|
id: reader.long(),
|
||||||
|
accessHash: reader.long(),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Unsupported photo size source ${variant} (${reader.data.toString(
|
||||||
|
'base64'
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePhotoFileLocation(
|
||||||
|
reader: BinaryReader,
|
||||||
|
version: number
|
||||||
|
): td.RawPhotoRemoteFileLocation {
|
||||||
|
return {
|
||||||
|
_: 'photo',
|
||||||
|
id: reader.long(),
|
||||||
|
accessHash: reader.long(),
|
||||||
|
volumeId: reader.long(),
|
||||||
|
source:
|
||||||
|
version >= 22
|
||||||
|
? parsePhotoSizeSource(reader)
|
||||||
|
: {
|
||||||
|
_: 'legacy',
|
||||||
|
secret: reader.long(),
|
||||||
|
},
|
||||||
|
localId: reader.int32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommonFileLocation(
|
||||||
|
reader: BinaryReader
|
||||||
|
): td.RawCommonRemoteFileLocation {
|
||||||
|
return {
|
||||||
|
_: 'common',
|
||||||
|
id: reader.long(),
|
||||||
|
accessHash: reader.long(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPersistentIdV23(
|
||||||
|
binary: Buffer,
|
||||||
|
version: number
|
||||||
|
): td.RawFullRemoteFileLocation {
|
||||||
|
if (version < 0 || version > td.CURRENT_VERSION)
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Unsupported file ID v3 subversion: ${version} (${binary.toString(
|
||||||
|
'base64'
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
|
||||||
|
binary = telegramRleDecode(binary)
|
||||||
|
|
||||||
|
const reader = new BinaryReader(binary)
|
||||||
|
|
||||||
|
let fileType = reader.int32()
|
||||||
|
|
||||||
|
const isWeb = !!(fileType & td.WEB_LOCATION_FLAG)
|
||||||
|
const hasFileReference = !!(fileType & td.FILE_REFERENCE_FLAG)
|
||||||
|
|
||||||
|
fileType &= ~td.WEB_LOCATION_FLAG
|
||||||
|
fileType &= ~td.FILE_REFERENCE_FLAG
|
||||||
|
|
||||||
|
if (fileType < 0 || fileType >= td.FileType.Size)
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Unsupported file type: ${fileType} (${binary.toString('base64')})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const dcId = reader.int32()
|
||||||
|
|
||||||
|
let fileReference: Buffer | null = null
|
||||||
|
if (hasFileReference) {
|
||||||
|
fileReference = reader.bytes()
|
||||||
|
if (fileReference.length === 1 && fileReference[0] === 0x23 /* # */) {
|
||||||
|
// "invalid file reference"
|
||||||
|
// see https://github.com/tdlib/td/blob/ed291840d3a841bb5b49457c88c57e8467e4a5b0/td/telegram/files/FileLocation.h#L32
|
||||||
|
fileReference = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let location: td.TypeRemoteFileLocation
|
||||||
|
if (isWeb) {
|
||||||
|
location = parseWebFileLocation(reader)
|
||||||
|
} else {
|
||||||
|
switch (fileType) {
|
||||||
|
case td.FileType.Photo:
|
||||||
|
case td.FileType.ProfilePhoto:
|
||||||
|
case td.FileType.Thumbnail:
|
||||||
|
case td.FileType.EncryptedThumbnail:
|
||||||
|
case td.FileType.Wallpaper: {
|
||||||
|
// location_type = photo
|
||||||
|
location = parsePhotoFileLocation(reader, version)
|
||||||
|
|
||||||
|
// validate
|
||||||
|
switch (location.source._) {
|
||||||
|
case 'thumbnail':
|
||||||
|
if (
|
||||||
|
location.source.fileType !== fileType ||
|
||||||
|
(fileType !== td.FileType.Photo &&
|
||||||
|
fileType !== td.FileType.Thumbnail &&
|
||||||
|
fileType !== td.FileType.EncryptedThumbnail)
|
||||||
|
) {
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Invalid FileType in PhotoRemoteFileLocation Thumbnail'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'dialogPhoto':
|
||||||
|
if (fileType !== td.FileType.ProfilePhoto) {
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Invalid FileType in PhotoRemoteFileLocation DialogPhoto'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'stickerSetThumbnail':
|
||||||
|
if (fileType !== td.FileType.Thumbnail) {
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
'Invalid FileType in PhotoRemoteFileLocation StickerSetThumbnail'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case td.FileType.Video:
|
||||||
|
case td.FileType.VoiceNote:
|
||||||
|
case td.FileType.Document:
|
||||||
|
case td.FileType.Sticker:
|
||||||
|
case td.FileType.Audio:
|
||||||
|
case td.FileType.Animation:
|
||||||
|
case td.FileType.Encrypted:
|
||||||
|
case td.FileType.VideoNote:
|
||||||
|
case td.FileType.SecureRaw:
|
||||||
|
case td.FileType.Secure:
|
||||||
|
case td.FileType.Background:
|
||||||
|
case td.FileType.DocumentAsFile: {
|
||||||
|
// location_type = common
|
||||||
|
location = parseCommonFileLocation(reader)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Invalid file type: ${fileType} (${binary.toString(
|
||||||
|
'base64'
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId,
|
||||||
|
type: fileType,
|
||||||
|
fileReference,
|
||||||
|
location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPersistentIdV2(binary: Buffer) {
|
||||||
|
return fromPersistentIdV23(binary.slice(0, -1), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPersistentIdV3(binary: Buffer) {
|
||||||
|
const subversion = binary[binary.length - 2]
|
||||||
|
return fromPersistentIdV23(binary.slice(0, -2), subversion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TDLib and Bot API compatible File ID
|
||||||
|
*
|
||||||
|
* @param fileId File ID as a base-64 encoded string or Buffer
|
||||||
|
*/
|
||||||
|
export function parseFileId(
|
||||||
|
fileId: string | Buffer
|
||||||
|
): td.RawFullRemoteFileLocation {
|
||||||
|
if (typeof fileId === 'string') fileId = parseUrlSafeBase64(fileId)
|
||||||
|
|
||||||
|
const version = fileId[fileId.length - 1]
|
||||||
|
|
||||||
|
if (version === td.PERSISTENT_ID_VERSION_OLD) {
|
||||||
|
return fromPersistentIdV2(fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version === td.PERSISTENT_ID_VERSION) {
|
||||||
|
return fromPersistentIdV3(fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Unsupported file ID version: ${version} (${fileId.toString('base64')})`
|
||||||
|
)
|
||||||
|
}
|
83
packages/file-id/src/serialize-unique.ts
Normal file
83
packages/file-id/src/serialize-unique.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { tdFileId as td } from './types'
|
||||||
|
import { BinaryWriter } from '@mtcute/core/src/utils/binary/binary-writer'
|
||||||
|
import { encodeUrlSafeBase64, telegramRleEncode } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize an object with information about file
|
||||||
|
* to TDLib and Bot API compatible Unique File ID
|
||||||
|
*
|
||||||
|
* Unique File IDs can't be used to download or reuse files,
|
||||||
|
* but they are globally unique, meaning that the same file will
|
||||||
|
* have the same unique ID regardless of the user who created
|
||||||
|
* this ID (unlike normal File IDs, that also contain user-bound
|
||||||
|
* file access hash)
|
||||||
|
*
|
||||||
|
* @param location Information about file location
|
||||||
|
*/
|
||||||
|
export function toUniqueFileId(
|
||||||
|
location: Omit<td.RawFullRemoteFileLocation, '_'>
|
||||||
|
): string {
|
||||||
|
let type
|
||||||
|
if (location.location._ === 'web') {
|
||||||
|
type = 0
|
||||||
|
} else {
|
||||||
|
switch (location.type) {
|
||||||
|
case td.FileType.Photo:
|
||||||
|
case td.FileType.ProfilePhoto:
|
||||||
|
case td.FileType.Thumbnail:
|
||||||
|
case td.FileType.EncryptedThumbnail:
|
||||||
|
case td.FileType.Wallpaper:
|
||||||
|
type = 1
|
||||||
|
break
|
||||||
|
case td.FileType.Video:
|
||||||
|
case td.FileType.VoiceNote:
|
||||||
|
case td.FileType.Document:
|
||||||
|
case td.FileType.Sticker:
|
||||||
|
case td.FileType.Audio:
|
||||||
|
case td.FileType.Animation:
|
||||||
|
case td.FileType.VideoNote:
|
||||||
|
case td.FileType.Background:
|
||||||
|
case td.FileType.DocumentAsFile:
|
||||||
|
type = 2
|
||||||
|
break
|
||||||
|
case td.FileType.SecureRaw:
|
||||||
|
case td.FileType.Secure:
|
||||||
|
type = 3
|
||||||
|
break
|
||||||
|
case td.FileType.Encrypted:
|
||||||
|
type = 4
|
||||||
|
break
|
||||||
|
case td.FileType.Temp:
|
||||||
|
type = 5
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new td.InvalidFileIdError(
|
||||||
|
`Invalid file type: ${location.type}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let writer: BinaryWriter
|
||||||
|
if (location.location._ === 'photo') {
|
||||||
|
writer = BinaryWriter.alloc(16)
|
||||||
|
writer.int32(type)
|
||||||
|
writer.long(location.location.volumeId)
|
||||||
|
writer.int32(location.location.localId)
|
||||||
|
} else if (location.location._ === 'web') {
|
||||||
|
writer = BinaryWriter.alloc(
|
||||||
|
Buffer.byteLength(location.location.url, 'utf-8') + 8
|
||||||
|
)
|
||||||
|
writer.int32(type)
|
||||||
|
writer.string(location.location.url)
|
||||||
|
} else if (location.location._ === 'common') {
|
||||||
|
writer = BinaryWriter.alloc(12)
|
||||||
|
writer.int32(type)
|
||||||
|
writer.long(location.location.id)
|
||||||
|
} else {
|
||||||
|
throw new td.UnsupportedError(
|
||||||
|
`Unique IDs are not supported for ${(location.location as any)._}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeUrlSafeBase64(telegramRleEncode(writer.result()))
|
||||||
|
}
|
83
packages/file-id/src/serialize.ts
Normal file
83
packages/file-id/src/serialize.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { tdFileId as td } from './types'
|
||||||
|
import { BinaryWriter } from '@mtcute/core/src/utils/binary/binary-writer'
|
||||||
|
import { encodeUrlSafeBase64, telegramRleEncode } from './utils'
|
||||||
|
|
||||||
|
const SUFFIX = Buffer.from([td.CURRENT_VERSION, td.PERSISTENT_ID_VERSION])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize an object with information about file
|
||||||
|
* to TDLib and Bot API compatible File ID
|
||||||
|
*
|
||||||
|
* @param location Information about file location
|
||||||
|
*/
|
||||||
|
export function toFileId(
|
||||||
|
location: Omit<td.RawFullRemoteFileLocation, '_'>
|
||||||
|
): string {
|
||||||
|
const loc = location.location
|
||||||
|
|
||||||
|
let type: number = location.type
|
||||||
|
if (loc._ === 'web') type |= td.WEB_LOCATION_FLAG
|
||||||
|
if (location.fileReference) type |= td.FILE_REFERENCE_FLAG
|
||||||
|
|
||||||
|
const writer = BinaryWriter.alloc(
|
||||||
|
loc._ === 'web'
|
||||||
|
? // overhead of the web file id:
|
||||||
|
// 8-16 bytes header,
|
||||||
|
// 8 bytes for access hash,
|
||||||
|
// up to 4 bytes for url
|
||||||
|
Buffer.byteLength(loc.url, 'utf8') + 32
|
||||||
|
: // longest file ids are around 80 bytes, so i guess
|
||||||
|
// we are safe with allocating 100 bytes
|
||||||
|
100
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.int32(type)
|
||||||
|
writer.int32(location.dcId)
|
||||||
|
if (location.fileReference) {
|
||||||
|
writer.bytes(location.fileReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (loc._) {
|
||||||
|
case 'web':
|
||||||
|
writer.string(loc.url)
|
||||||
|
writer.long(loc.accessHash)
|
||||||
|
break
|
||||||
|
case 'photo':
|
||||||
|
writer.long(loc.id)
|
||||||
|
writer.long(loc.accessHash)
|
||||||
|
writer.long(loc.volumeId)
|
||||||
|
|
||||||
|
switch (loc.source._) {
|
||||||
|
case 'legacy':
|
||||||
|
writer.int32(0)
|
||||||
|
writer.long(loc.source.secret)
|
||||||
|
break
|
||||||
|
case 'thumbnail':
|
||||||
|
writer.int32(1)
|
||||||
|
writer.int32(loc.source.fileType)
|
||||||
|
writer.int32(loc.source.thumbnailType.charCodeAt(0))
|
||||||
|
break
|
||||||
|
case 'dialogPhoto':
|
||||||
|
writer.int32(loc.source.big ? 3 : 2)
|
||||||
|
writer.long(loc.source.id)
|
||||||
|
writer.long(loc.source.accessHash)
|
||||||
|
break
|
||||||
|
case 'stickerSetThumbnail':
|
||||||
|
writer.int32(4)
|
||||||
|
writer.long(loc.source.id)
|
||||||
|
writer.long(loc.source.accessHash)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.int32(loc.localId)
|
||||||
|
break
|
||||||
|
case 'common':
|
||||||
|
writer.long(loc.id)
|
||||||
|
writer.long(loc.accessHash)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeUrlSafeBase64(
|
||||||
|
Buffer.concat([telegramRleEncode(writer.result()), SUFFIX])
|
||||||
|
)
|
||||||
|
}
|
193
packages/file-id/src/types.ts
Normal file
193
packages/file-id/src/types.ts
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
import { BigInteger } from 'big-integer'
|
||||||
|
|
||||||
|
type Long = BigInteger
|
||||||
|
|
||||||
|
export namespace tdFileId {
|
||||||
|
export const PERSISTENT_ID_VERSION_OLD = 2
|
||||||
|
export const PERSISTENT_ID_VERSION = 4
|
||||||
|
|
||||||
|
export const WEB_LOCATION_FLAG = 1 << 24
|
||||||
|
export const FILE_REFERENCE_FLAG = 1 << 25
|
||||||
|
|
||||||
|
export const CURRENT_VERSION = 31
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error occurred while parsing or serializing a File ID
|
||||||
|
*/
|
||||||
|
export class FileIdError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A newer version of File ID is provided, which is
|
||||||
|
* currently not supported by the library.
|
||||||
|
*
|
||||||
|
* Feel free to open an issue on Github!
|
||||||
|
*/
|
||||||
|
export class UnsupportedError extends FileIdError {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File ID was invalid, meaning that something did not
|
||||||
|
* add up while parsing the file ID, or the file ID object
|
||||||
|
* contained invalid data.
|
||||||
|
*/
|
||||||
|
export class InvalidFileIdError extends FileIdError {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provided File ID cannot be converted to that TL object.
|
||||||
|
*/
|
||||||
|
export class ConversionError extends FileIdError {
|
||||||
|
constructor (to: string) {
|
||||||
|
super(`Cannot convert given File ID to ${to}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FileType {
|
||||||
|
Thumbnail,
|
||||||
|
ProfilePhoto,
|
||||||
|
Photo,
|
||||||
|
VoiceNote,
|
||||||
|
Video,
|
||||||
|
Document,
|
||||||
|
Encrypted,
|
||||||
|
Temp,
|
||||||
|
Sticker,
|
||||||
|
Audio,
|
||||||
|
Animation,
|
||||||
|
EncryptedThumbnail,
|
||||||
|
Wallpaper,
|
||||||
|
VideoNote,
|
||||||
|
SecureRaw,
|
||||||
|
Secure,
|
||||||
|
Background,
|
||||||
|
DocumentAsFile,
|
||||||
|
Size,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
// naming convention just like in @mtcute/tl
|
||||||
|
|
||||||
|
// additionally, `_` discriminator is used,
|
||||||
|
// so we can interoperate with normal TL objects
|
||||||
|
// like InputFile just by checking `_`
|
||||||
|
|
||||||
|
// for nested types, we don't bother with full type name
|
||||||
|
// for discriminator since it is really only used internally,
|
||||||
|
// so uniqueness is pretty much guaranteed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This photo is a legacy photo that is
|
||||||
|
* represented simply by a secret number
|
||||||
|
*/
|
||||||
|
export interface RawPhotoSizeSourceLegacy {
|
||||||
|
readonly _: 'legacy'
|
||||||
|
readonly secret: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This photo is a thumbnail, and its size
|
||||||
|
* is provided here as a one-letter string
|
||||||
|
*/
|
||||||
|
export interface RawPhotoSizeSourceThumbnail {
|
||||||
|
readonly _: 'thumbnail'
|
||||||
|
readonly fileType: FileType
|
||||||
|
readonly thumbnailType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This photo is a profile photo of
|
||||||
|
* some peer, and their ID and access
|
||||||
|
* hash are provided here.
|
||||||
|
*/
|
||||||
|
export interface RawPhotoSizeSourceDialogPhoto {
|
||||||
|
readonly _: 'dialogPhoto'
|
||||||
|
readonly big: boolean
|
||||||
|
readonly id: Long
|
||||||
|
readonly accessHash: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This photo is a thumbnail for a a sticker set,
|
||||||
|
* and set's ID and access hash are provided here
|
||||||
|
*/
|
||||||
|
export interface RawPhotoSizeSourceStickerSetThumbnail {
|
||||||
|
readonly _: 'stickerSetThumbnail'
|
||||||
|
readonly id: Long
|
||||||
|
readonly accessHash: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypePhotoSizeSource =
|
||||||
|
| RawPhotoSizeSourceLegacy
|
||||||
|
| RawPhotoSizeSourceThumbnail
|
||||||
|
| RawPhotoSizeSourceDialogPhoto
|
||||||
|
| RawPhotoSizeSourceStickerSetThumbnail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external web file
|
||||||
|
*/
|
||||||
|
export interface RawWebRemoteFileLocation {
|
||||||
|
readonly _: 'web'
|
||||||
|
readonly url: string
|
||||||
|
readonly accessHash: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A photo, that, in addition to ID and access
|
||||||
|
* hash, has its own `source` and detailed
|
||||||
|
* information about photo location on the
|
||||||
|
* servers.
|
||||||
|
*/
|
||||||
|
export interface RawPhotoRemoteFileLocation {
|
||||||
|
readonly _: 'photo'
|
||||||
|
readonly id: Long
|
||||||
|
readonly accessHash: Long
|
||||||
|
readonly volumeId: Long
|
||||||
|
readonly source: TypePhotoSizeSource
|
||||||
|
readonly localId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A common file that is represented as a pair
|
||||||
|
* of ID and access hash
|
||||||
|
*/
|
||||||
|
export interface RawCommonRemoteFileLocation {
|
||||||
|
readonly _: 'common'
|
||||||
|
readonly id: Long
|
||||||
|
readonly accessHash: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypeRemoteFileLocation =
|
||||||
|
| RawWebRemoteFileLocation
|
||||||
|
| RawPhotoRemoteFileLocation
|
||||||
|
| RawCommonRemoteFileLocation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object representing information about
|
||||||
|
* file location, that was either parsed from
|
||||||
|
* TDLib compatible File ID, or will be parsed
|
||||||
|
* to one.
|
||||||
|
*
|
||||||
|
* This type is supposed to be an intermediate step
|
||||||
|
* between TL objects and string file IDs,
|
||||||
|
* and if you are using `@mtcute/client`, you don't
|
||||||
|
* really need to care about this type at all.
|
||||||
|
*/
|
||||||
|
export interface RawFullRemoteFileLocation {
|
||||||
|
readonly _: 'remoteFileLocation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC ID where this file is located
|
||||||
|
*/
|
||||||
|
readonly dcId: number
|
||||||
|
/**
|
||||||
|
* Type of the file
|
||||||
|
*/
|
||||||
|
readonly type: FileType
|
||||||
|
/**
|
||||||
|
* File reference (if any)
|
||||||
|
*/
|
||||||
|
readonly fileReference: Buffer | null
|
||||||
|
/**
|
||||||
|
* Context of the file location
|
||||||
|
*/
|
||||||
|
readonly location: TypeRemoteFileLocation
|
||||||
|
}
|
||||||
|
}
|
63
packages/file-id/src/utils.ts
Normal file
63
packages/file-id/src/utils.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
export function parseUrlSafeBase64(str: string): Buffer {
|
||||||
|
str = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
while (str.length % 4) str += '='
|
||||||
|
return Buffer.from(str, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeUrlSafeBase64(buf: Buffer): string {
|
||||||
|
return buf
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegram has some cursed RLE which only encodes consecutive \x00
|
||||||
|
|
||||||
|
export function telegramRleEncode(buf: Buffer): Buffer {
|
||||||
|
const len = buf.length
|
||||||
|
const ret: number[] = []
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const cur = buf[i]
|
||||||
|
if (cur === 0) {
|
||||||
|
count += 1
|
||||||
|
} else {
|
||||||
|
if (count > 0) {
|
||||||
|
ret.push(0, count)
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.push(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
ret.push(0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function telegramRleDecode(buf: Buffer): Buffer {
|
||||||
|
const len = buf.length
|
||||||
|
const ret: number[] = []
|
||||||
|
let prev = -1
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const cur = buf[i]
|
||||||
|
if (prev === 0) {
|
||||||
|
for (let j = 0; j < cur; j++) {
|
||||||
|
ret.push(prev)
|
||||||
|
}
|
||||||
|
prev = -1
|
||||||
|
} else {
|
||||||
|
if (prev !== -1) ret.push(prev)
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev !== -1) ret.push(prev)
|
||||||
|
return Buffer.from(ret)
|
||||||
|
}
|
178
packages/file-id/tests/parse.spec.ts
Normal file
178
packages/file-id/tests/parse.spec.ts
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
import { describe, it } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { parseFileId } from '../src/parse'
|
||||||
|
import { tdFileId as td } from '../src/types'
|
||||||
|
import bigInt from 'big-integer'
|
||||||
|
|
||||||
|
// test file IDs are partially taken from https://github.com/luckydonald/telegram_file_id
|
||||||
|
|
||||||
|
describe('parsing file ids', () => {
|
||||||
|
const test = (id: string, expected: td.RawFullRemoteFileLocation) => {
|
||||||
|
expect(parseFileId(id)).eql(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('parses common file ids', () => {
|
||||||
|
test(
|
||||||
|
'CAACAgIAAxkBAAEJny9gituz1_V_uSKBUuG_nhtzEtFOeQACXFoAAuCjggfYjw_KAAGSnkgfBA',
|
||||||
|
{
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: Buffer.from(
|
||||||
|
'0100099f2f608adbb3d7f57fb9228152e1bf9e1b7312d14e79',
|
||||||
|
'hex'
|
||||||
|
),
|
||||||
|
location: {
|
||||||
|
_: 'common',
|
||||||
|
accessHash: bigInt('5232780349138767832'),
|
||||||
|
id: bigInt('541175087705905756'),
|
||||||
|
},
|
||||||
|
type: td.FileType.Sticker,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
test(
|
||||||
|
'BQACAgIAAxkBAAEJnzNgit00IDsKd07OdSeanwz8osecYAACdAwAAueoWEicaPvNdOYEwB8E',
|
||||||
|
{
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: Buffer.from(
|
||||||
|
'0100099f33608add34203b0a774ece75279a9f0cfca2c79c60',
|
||||||
|
'hex'
|
||||||
|
),
|
||||||
|
location: {
|
||||||
|
_: 'common',
|
||||||
|
accessHash: bigInt('-4610306729174144868'),
|
||||||
|
id: bigInt('5213102278772264052'),
|
||||||
|
},
|
||||||
|
type: td.FileType.Document,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses thumbnails file ids', () => {
|
||||||
|
test(
|
||||||
|
'AAMCAgADGQEAAQmfL2CK27PX9X-5IoFS4b-eG3MS0U55AAJcWgAC4KOCB9iPD8oAAZKeSK1c8w4ABAEAB20AA1kCAAIfBA',
|
||||||
|
{
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: Buffer.from(
|
||||||
|
'0100099f2f608adbb3d7f57fb9228152e1bf9e1b7312d14e79',
|
||||||
|
'hex'
|
||||||
|
),
|
||||||
|
location: {
|
||||||
|
_: 'photo',
|
||||||
|
accessHash: bigInt('5232780349138767832'),
|
||||||
|
id: bigInt('541175087705905756'),
|
||||||
|
localId: 601,
|
||||||
|
source: {
|
||||||
|
_: 'thumbnail',
|
||||||
|
fileType: td.FileType.Thumbnail,
|
||||||
|
thumbnailType: 'm',
|
||||||
|
},
|
||||||
|
volumeId: bigInt('250829997'),
|
||||||
|
},
|
||||||
|
type: td.FileType.Thumbnail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses profile pictures', () => {
|
||||||
|
// big
|
||||||
|
test('AQADAgATqfDdly4AAwMAA4siCOX_____AAhKowIAAR4E', {
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: null,
|
||||||
|
location: {
|
||||||
|
_: 'photo',
|
||||||
|
accessHash: bigInt.zero,
|
||||||
|
id: bigInt.zero,
|
||||||
|
localId: 172874,
|
||||||
|
source: {
|
||||||
|
_: 'dialogPhoto',
|
||||||
|
id: bigInt('-452451701'),
|
||||||
|
accessHash: bigInt.zero,
|
||||||
|
big: true,
|
||||||
|
},
|
||||||
|
volumeId: bigInt('200116400297'),
|
||||||
|
},
|
||||||
|
type: td.FileType.ProfilePhoto,
|
||||||
|
})
|
||||||
|
|
||||||
|
// small
|
||||||
|
test('AQADAgATqfDdly4AAwIAA4siCOX_____AAhIowIAAR4E', {
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: null,
|
||||||
|
location: {
|
||||||
|
_: 'photo',
|
||||||
|
accessHash: bigInt.zero,
|
||||||
|
id: bigInt.zero,
|
||||||
|
localId: 172872,
|
||||||
|
source: {
|
||||||
|
_: 'dialogPhoto',
|
||||||
|
id: bigInt('-452451701'),
|
||||||
|
accessHash: bigInt.zero,
|
||||||
|
big: false,
|
||||||
|
},
|
||||||
|
volumeId: bigInt('200116400297'),
|
||||||
|
},
|
||||||
|
type: td.FileType.ProfilePhoto,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses channel pictures', () => {
|
||||||
|
// big
|
||||||
|
test('AQADAgATySHBDgAEAwAD0npI3Bb___-wfxjpg7QCPf8pBQABHwQ', {
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: null,
|
||||||
|
location: {
|
||||||
|
_: 'photo',
|
||||||
|
accessHash: bigInt.zero,
|
||||||
|
id: bigInt.zero,
|
||||||
|
localId: 338431,
|
||||||
|
source: {
|
||||||
|
_: 'dialogPhoto',
|
||||||
|
id: bigInt('-1001326609710'),
|
||||||
|
accessHash: bigInt('4396274664911437744'),
|
||||||
|
big: true,
|
||||||
|
},
|
||||||
|
volumeId: bigInt('247538121'),
|
||||||
|
},
|
||||||
|
type: td.FileType.ProfilePhoto,
|
||||||
|
})
|
||||||
|
// small
|
||||||
|
test('AQADAgATySHBDgAEAgAD0npI3Bb___-wfxjpg7QCPf0pBQABHwQ', {
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 2,
|
||||||
|
fileReference: null,
|
||||||
|
location: {
|
||||||
|
_: 'photo',
|
||||||
|
accessHash: bigInt.zero,
|
||||||
|
id: bigInt.zero,
|
||||||
|
localId: 338429,
|
||||||
|
source: {
|
||||||
|
_: 'dialogPhoto',
|
||||||
|
id: bigInt('-1001326609710'),
|
||||||
|
accessHash: bigInt('4396274664911437744'),
|
||||||
|
big: false,
|
||||||
|
},
|
||||||
|
volumeId: bigInt('247538121'),
|
||||||
|
},
|
||||||
|
type: td.FileType.ProfilePhoto,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses older short file ids', () => {
|
||||||
|
test('CAADAQADegAD997LEUiQZafDlhIeAg', {
|
||||||
|
_: 'remoteFileLocation',
|
||||||
|
dcId: 1,
|
||||||
|
fileReference: null,
|
||||||
|
location: {
|
||||||
|
_: 'common',
|
||||||
|
accessHash: bigInt('2166960137789870152'),
|
||||||
|
id: bigInt('1282363671355326586'),
|
||||||
|
},
|
||||||
|
type: td.FileType.Sticker,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
42
packages/file-id/tests/serialize-unique.spec.ts
Normal file
42
packages/file-id/tests/serialize-unique.spec.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { parseFileId } from '../src/parse'
|
||||||
|
import { toUniqueFileId } from '../src/serialize-unique'
|
||||||
|
|
||||||
|
// test file IDs are partially taken from https://github.com/luckydonald/telegram_file_id
|
||||||
|
|
||||||
|
describe('serializing unique file ids', () => {
|
||||||
|
const test = (id: string, expected: string) => {
|
||||||
|
expect(toUniqueFileId(parseFileId(id))).eql(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('serializes unique ids for old file ids', () => {
|
||||||
|
test('CAADAQADegAD997LEUiQZafDlhIeAg', 'AgADegAD997LEQ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes unique ids for common file ids', () => {
|
||||||
|
test(
|
||||||
|
'CAACAgEAAx0CVgtngQACAuFfU1GY9wiRG7A7jlIBbP2yvAostAACegAD997LEUiQZafDlhIeGwQ',
|
||||||
|
'AgADegAD997LEQ'
|
||||||
|
)
|
||||||
|
test(
|
||||||
|
'BQACAgIAAxkBAAEJnzNgit00IDsKd07OdSeanwz8osecYAACdAwAAueoWEicaPvNdOYEwB8E',
|
||||||
|
'AgADdAwAAueoWEg'
|
||||||
|
)
|
||||||
|
test(
|
||||||
|
'AAMCAgADGQEAAQmfM2CK3TQgOwp3Ts51J5qfDPyix5xgAAJ0DAAC56hYSJxo-8105gTAT_bYoy4AAwEAB20AA0JBAAIfBA',
|
||||||
|
'AQADT_bYoy4AA0JBAAI'
|
||||||
|
)
|
||||||
|
test(
|
||||||
|
'CAACAgIAAxkBAAEJny9gituz1_V_uSKBUuG_nhtzEtFOeQACXFoAAuCjggfYjw_KAAGSnkgfBA',
|
||||||
|
'AgADXFoAAuCjggc'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes unique ids for profile pictures', () => {
|
||||||
|
// big
|
||||||
|
test('AQADAgATySHBDgAEAwAD0npI3Bb___-wfxjpg7QCPf8pBQABHwQ', 'AQADySHBDgAE_ykFAAE')
|
||||||
|
// small
|
||||||
|
test('AQADAgATySHBDgAEAgAD0npI3Bb___-wfxjpg7QCPf0pBQABHwQ', 'AQADySHBDgAE_SkFAAE')
|
||||||
|
})
|
||||||
|
})
|
20
packages/file-id/tests/serialize.spec.ts
Normal file
20
packages/file-id/tests/serialize.spec.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { parseFileId, toFileId } from '../src'
|
||||||
|
|
||||||
|
describe('serializing to file ids', () => {
|
||||||
|
it('serializes previously parsed file ids', () => {
|
||||||
|
const test = (fileId: string) => {
|
||||||
|
expect(toFileId(parseFileId(fileId))).eq(fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('CAACAgIAAxkBAAEJny9gituz1_V_uSKBUuG_nhtzEtFOeQACXFoAAuCjggfYjw_KAAGSnkgfBA')
|
||||||
|
test('BQACAgIAAxkBAAEJnzNgit00IDsKd07OdSeanwz8osecYAACdAwAAueoWEicaPvNdOYEwB8E')
|
||||||
|
test('AAMCAgADGQEAAQmfL2CK27PX9X-5IoFS4b-eG3MS0U55AAJcWgAC4KOCB9iPD8oAAZKeSK1c8w4ABAEAB20AA1kCAAIfBA')
|
||||||
|
test('AQADAgATqfDdly4AAwMAA4siCOX_____AAhKowIAAR8E')
|
||||||
|
test('AQADAgATqfDdly4AAwIAA4siCOX_____AAhIowIAAR8E')
|
||||||
|
test('AQADAgATySHBDgAEAwAD0npI3Bb___-wfxjpg7QCPf8pBQABHwQ')
|
||||||
|
test('AQADAgATySHBDgAEAgAD0npI3Bb___-wfxjpg7QCPf0pBQABHwQ')
|
||||||
|
test('CAADAQADegAD997LEUiQZafDlhIeHwQ')
|
||||||
|
})
|
||||||
|
})
|
84
packages/file-id/tests/utils.spec.ts
Normal file
84
packages/file-id/tests/utils.spec.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { describe, it } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import {
|
||||||
|
encodeUrlSafeBase64,
|
||||||
|
parseUrlSafeBase64,
|
||||||
|
telegramRleDecode,
|
||||||
|
telegramRleEncode,
|
||||||
|
} from '../src/utils'
|
||||||
|
|
||||||
|
describe('parseUrlSafeBase64', () => {
|
||||||
|
it('should parse url-safe base64', () => {
|
||||||
|
expect(parseUrlSafeBase64('qu7d8aGTeuF6-g').toString('hex')).eq(
|
||||||
|
'aaeeddf1a1937ae17afa'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('should parse normal base64', () => {
|
||||||
|
expect(parseUrlSafeBase64('qu7d8aGTeuF6+g==').toString('hex')).eq(
|
||||||
|
'aaeeddf1a1937ae17afa'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('encodeUrlSafeBase64', () => {
|
||||||
|
it('should encode to url-safe base64', () => {
|
||||||
|
expect(
|
||||||
|
encodeUrlSafeBase64(Buffer.from('aaeeddf1a1937ae17afa', 'hex'))
|
||||||
|
).eq('qu7d8aGTeuF6-g')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('telegramRleEncode', () => {
|
||||||
|
it('should not modify input if there are no \\x00', () => {
|
||||||
|
expect(
|
||||||
|
telegramRleEncode(Buffer.from('aaeeff', 'hex')).toString('hex')
|
||||||
|
).eq('aaeeff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should collapse consecutive \\x00', () => {
|
||||||
|
expect(
|
||||||
|
telegramRleEncode(Buffer.from('00000000aa', 'hex')).toString('hex')
|
||||||
|
).eq('0004aa')
|
||||||
|
expect(
|
||||||
|
telegramRleEncode(
|
||||||
|
Buffer.from('00000000aa000000aa', 'hex')
|
||||||
|
).toString('hex')
|
||||||
|
).eq('0004aa0003aa')
|
||||||
|
expect(
|
||||||
|
telegramRleEncode(Buffer.from('00000000aa0000', 'hex')).toString(
|
||||||
|
'hex'
|
||||||
|
)
|
||||||
|
).eq('0004aa0002')
|
||||||
|
expect(
|
||||||
|
telegramRleEncode(Buffer.from('00aa00', 'hex')).toString('hex')
|
||||||
|
).eq('0001aa0001')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('telegramRleDecode', () => {
|
||||||
|
it('should not mofify input if there are no \\x00', () => {
|
||||||
|
expect(
|
||||||
|
telegramRleDecode(Buffer.from('aaeeff', 'hex')).toString('hex')
|
||||||
|
).eq('aaeeff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expand two-byte sequences starting with \\x00', () => {
|
||||||
|
expect(
|
||||||
|
telegramRleDecode(Buffer.from('0004aa', 'hex')).toString('hex')
|
||||||
|
).eq('00000000aa')
|
||||||
|
expect(
|
||||||
|
telegramRleDecode(Buffer.from('0004aa0000', 'hex')).toString('hex')
|
||||||
|
).eq('00000000aa')
|
||||||
|
expect(
|
||||||
|
telegramRleDecode(Buffer.from('0004aa0003aa', 'hex')).toString(
|
||||||
|
'hex'
|
||||||
|
)
|
||||||
|
).eq('00000000aa000000aa')
|
||||||
|
expect(
|
||||||
|
telegramRleDecode(Buffer.from('0004aa0002', 'hex')).toString('hex')
|
||||||
|
).eq('00000000aa0000')
|
||||||
|
expect(
|
||||||
|
telegramRleDecode(Buffer.from('0001aa0001', 'hex')).toString('hex')
|
||||||
|
).eq('00aa00')
|
||||||
|
})
|
||||||
|
})
|
19
packages/file-id/tsconfig.json
Normal file
19
packages/file-id/tsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./src"
|
||||||
|
],
|
||||||
|
"typedocOptions": {
|
||||||
|
"name": "@mtcute/file-id",
|
||||||
|
"includeVersion": true,
|
||||||
|
"out": "../../docs/packages/file-id",
|
||||||
|
"listInvalidSymbolLinks": true,
|
||||||
|
"excludePrivate": true,
|
||||||
|
"entryPoints": [
|
||||||
|
"./src/index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue