Initial commit

This commit is contained in:
teidesu 2021-04-08 12:19:38 +03:00
commit cd8ec8309f
184 changed files with 63908 additions and 0 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
trim_trailing_whitespace = true

4
.eslintignore Normal file
View file

@ -0,0 +1,4 @@
private/
docs/
dist/
scripts/

46
.eslintrc.js Normal file
View file

@ -0,0 +1,46 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {
'linebreak-style': ['error', 'unix'],
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'none',
},
singleline: {
delimiter: 'comma',
},
},
],
'@typescript-eslint/no-non-null-assertion': 'off',
'no-debugger': 'off',
'no-empty': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-this-alias': 'off',
'prefer-rest-params': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/adjacent-overload-signatures': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-extra-semi': 'off',
'@typescript-eslint/no-empty-interface': 'off',
},
}

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=lf

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/
private/
.nyc_output/
*.log

8
.prettierignore Normal file
View file

@ -0,0 +1,8 @@
# auto-generated files
src/tl/errors.d.ts
src/tl/errors.js
src/tl/keys.js
src/tl/types.d.ts
src/tl/types.js
src/tl/binary/reader.js
src/tl/binary/writer.js

19
.prettierrc Normal file
View file

@ -0,0 +1,19 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"useTabs": false,
"vueIndentScriptAndStyle": false
}

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# MTCute
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/dwyl/esta/issues)
Work-in-progress library for MTProto in TypeScript.
[🎯 Roadmap (notion.so)](https://www.notion.so/teidesu/MTCute-development-cfccff4fddad4b218f3bea27f784b8b5)
What currently works:
- [x] TCP Connection in NodeJS
- [x] Sending & receiving text messages
- [x] Uploading & downloading files
- [x] HTML & Markdown parse modes
- [x] Type-safe filter system
What is not done yet:
- pretty much everything else
## Setting up for development:
```bash
git clone https://github.com/teidesu/mtcute
cd mtcute
yarn install
npx lerna link
```
## Acknowledgements
Some parts were based on code from these projects:
- [Pyrogram](https://pyrogram.org)
- [Telethon](https://github.com/LonamiWebs/Telethon)
- [TDesktop](https://github.com/telegramdesktop/tdesktop)

11
lerna.json Normal file
View file

@ -0,0 +1,11 @@
{
"command": {
"create": {
"license": "MIT",
"author": "Alisa Sireneva <me@tei.su>"
}
},
"version": "independent",
"npmClient": "yarn",
"useWorkspaces": true
}

58
package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "mtcute",
"private": true,
"version": "1.0.0",
"description": "Cute and type-safe library for MTProto (Telegram API) for browser and NodeJS",
"license": "MIT",
"author": "Alisa Sireneva <me@tei.su>",
"scripts": {
"test": "tsc && mocha dist/tests/**/*.spec.js",
"build": "tsc",
"lint": "eslint packages/**/*.ts",
"generate-schema": "node scripts/generate-schema.js",
"generate-code": "node packages/client/scripts/generate-client.js && node scripts/generate-types.js && node scripts/generate-binary-reader.js && node scripts/generate-binary-writer.js && node scripts/post-build.js",
"generate-all": "npm run generate-schema && npm run generate-code",
"build:doc": "node packages/client/scripts/generate-client.js && typedoc"
},
"dependencies": {
"big-integer": "1.6.48",
"buffer": "^6.0.3",
"debug": "^4.3.1",
"es6-symbol": "^3.1.3",
"events": "3.2.0",
"file-type": "^16.2.0",
"leemon": "6.2.0",
"pako": "2.0.2",
"ts-mixer": "^5.4.0"
},
"devDependencies": {
"@types/chai": "^4.2.14",
"@types/debug": "^4.1.5",
"@types/mocha": "^8.2.0",
"@types/node": "^14.14.22",
"@types/node-forge": "^0.9.7",
"@types/pako": "^1.0.1",
"@types/ws": "^7.4.0",
"@typescript-eslint/eslint-plugin": "^4.15.0",
"@typescript-eslint/parser": "^4.15.0",
"benchmark": "^2.1.4",
"chai": "^4.2.0",
"cheerio": "^1.0.0-rc.5",
"eager-async-pool": "^1.0.0",
"eslint": "^7.19.0",
"eslint-config-prettier": "7.2.0",
"lerna": "^4.0.0",
"mocha": "^8.2.1",
"node-fetch": "^2.6.1",
"node-forge": "^0.10.0",
"node-html-parser": "^3.0.4",
"prettier": "2.2.1",
"ts-node": "^9.1.1",
"typedoc": "^0.20.28",
"typescript": "^4.1.3",
"nyc": "^15.1.0"
},
"workspaces": [
"packages/*"
]
}

View file

@ -0,0 +1,18 @@
{
"name": "@mtcute/client",
"version": "0.0.0",
"description": "High-level API and bot framework for MTProto",
"author": "Alisa Sireneva <me@tei.su>",
"license": "MIT",
"main": "dist/index.js",
"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",
"es6-symbol": "^3.1.3"
}
}

View file

@ -0,0 +1,333 @@
const ts = require('typescript')
const path = require('path')
const fs = require('fs')
const prettier = require('prettier')
// not the best way but who cares lol
const { createWriter } = require('../../tl/scripts/common')
const targetDir = path.join(__dirname, '../src')
async function* getFiles(dir) {
const dirents = await fs.promises.readdir(dir, { withFileTypes: true })
for (const dirent of dirents) {
const res = path.resolve(dir, dirent.name)
if (dirent.isDirectory()) {
yield* getFiles(res)
} else {
yield res
}
}
}
function throwError(ast, file, text) {
console.log(
`An error encountered at ${path.relative(targetDir, file)}:
> ${ast.getText()}
${text}`
)
process.exit(0)
}
async function addSingleMethod(state, fileName) {
const fileFullText = await fs.promises.readFile(fileName, 'utf-8')
const program = ts.createSourceFile(
path.basename(fileName),
fileFullText,
ts.ScriptTarget.ES2018,
true
)
const relPath = path.relative(targetDir, fileName).replace(/\\/g, '/') // replace path delim to unix
function getLeadingComments(ast) {
return (ts.getLeadingCommentRanges(fileFullText, ast.pos) || [])
.map((range) => fileFullText.substring(range.pos, range.end))
.join('\n')
}
function checkForFlag(ast, flag) {
return getLeadingComments(ast)
.split('\n')
.map((i) => i.replace(/^(\/\/|\s*\*+|\/\*\*+\s*)/g, '').trim())
.some((i) => i.startsWith(flag))
}
for (const stmt of program.statements) {
const isCopy = checkForFlag(stmt, '@copy')
if (stmt.kind === ts.SyntaxKind.ImportDeclaration) {
if (!isCopy) continue
if (
!stmt.importClause.namedBindings ||
stmt.importClause.namedBindings.kind !== 264 /* NamedImports */
)
throwError(stmt, fileName, 'Only named imports are supported!')
let module = stmt.moduleSpecifier.text
if (module[0] === '.') {
// relative, need to resolve
const modFullPath = path.join(
path.dirname(fileName),
stmt.moduleSpecifier.text
)
const modPath = path.dirname(modFullPath)
const modName = path.basename(modFullPath)
module = path
.join(path.relative(targetDir, modPath), modName)
.replace(/\\/g, '/') // replace path delim to unix
if (module[0] !== '.') module = './' + module
}
if (module === './client') {
throwError(
stmt,
fileName,
"You can't copy an import from ./client"
)
}
if (!(module in state.imports)) {
state.imports[module] = new Set()
}
for (const el of stmt.importClause.namedBindings.elements) {
state.imports[module].add(el.name.escapedText)
}
} else if (stmt.kind === ts.SyntaxKind.FunctionDeclaration) {
const name = stmt.name.escapedText
if (stmt.body && name in state.methods.used) {
throwError(
stmt.name,
fileName,
`Function name "${name}" was already used in file ${state.methods.used[name]}`
)
}
const isPrivate = name[0] === '_'
const isExported = (stmt.modifiers || []).find(
(mod) => mod.kind === 92 /* ExportKeyword */
)
const isInitialize = checkForFlag(stmt, '@initialize')
if (!isExported && !isPrivate) {
throwError(
isExported,
fileName,
'Public methods MUST be exported.'
)
}
if (isExported && !checkForFlag(stmt, '@internal')) {
throwError(
isExported,
fileName,
'Exported methods must be marked as @internal so TS compiler strips them away.'
)
}
if (isInitialize && isExported) {
throwError(
isExported,
fileName,
'Initialization methods must not be exported'
)
}
if (isInitialize) {
let code = stmt.body.getFullText()
// strip leading { and trailing }
while (code[0] !== '{') code = code.slice(1)
while (code[code.length - 1] !== '}') code = code.slice(0, -1)
code = code.slice(1, -1).trim()
state.init.push(code)
}
if (!isExported) continue
const firstArg = stmt.parameters[0]
if (
isExported &&
(!firstArg ||
(firstArg.type.getText() !== 'TelegramClient' &&
firstArg.type.getText() !== 'BaseTelegramClient'))
)
throwError(
firstArg || stmt.name,
fileName,
`Exported methods must have \`BaseTelegramClient\` or \`TelegramClient\` as their first parameter`
)
const returnsExported = (stmt.body
? ts.getLeadingCommentRanges(fileFullText, stmt.body.pos + 2) ||
(stmt.statements &&
stmt.statements.length &&
ts.getLeadingCommentRanges(
fileFullText,
stmt.statements[0].pos
)) ||
[]
: []
)
.map((range) => fileFullText.substring(range.pos, range.end))
.join('\n')
.includes('@returns-exported')
// overloads
if (stmt.body) {
state.methods.used[name] = relPath
}
if (isExported) {
state.methods.list.push({
from: relPath,
name,
isPrivate,
func: stmt,
comment: getLeadingComments(stmt),
})
const module = `./${relPath.replace(/\.ts$/, '')}`
if (!(module in state.imports)) {
state.imports[module] = new Set()
}
state.imports[module].add(name)
if (returnsExported) {
let returnType = stmt.type.getText()
let m = returnType.match(/^Promise<(.+)>$/)
if (m) returnType = m[1]
state.imports[module].add(returnType)
}
}
} else if (stmt.kind === ts.SyntaxKind.InterfaceDeclaration) {
if (!checkForFlag(stmt, '@extension')) continue
const isExported = (stmt.modifiers || []).find(
(mod) => mod.kind === 92 /* ExportKeyword */
)
if (isExported)
throwError(
isExported,
fileName,
'Extension interfaces must not be imported'
)
if (stmt.heritageClauses && stmt.heritageClauses.length) {
throwError(
stmt.heritageClauses[0],
fileName,
'Extension interfaces must not be extended'
)
}
for (const member of stmt.members || []) {
state.fields.push({
from: relPath,
code: member.getText(),
})
}
} else if (isCopy) {
state.copy.push({ from: relPath, code: stmt.getFullText().trim() })
}
}
}
async function main() {
const output = createWriter('../src/client.ts', __dirname)
const state = {
imports: {},
fields: [],
init: [],
methods: {
used: {},
list: [],
},
copy: [],
}
for await (const file of getFiles(path.join(__dirname, '../src/methods'))) {
if (!file.startsWith('.') && file.endsWith('.ts')) {
await addSingleMethod(state, file)
}
}
output.write(
'/* THIS FILE WAS AUTO-GENERATED */\n' +
"import { BaseTelegramClient } from '@mtcute/core'\n" +
"import { tl } from '@mtcute/tl'"
)
Object.entries(state.imports).forEach(([module, items]) => {
items = [...items]
output.write(`import { ${items.sort().join(', ')} } from '${module}'`)
})
output.write()
state.copy.forEach(({ from, code }) => {
output.write(`// from ${from}\n${code}\n`)
})
output.write('\nexport class TelegramClient extends BaseTelegramClient {')
output.tab()
state.fields.forEach(({ from, code }) => {
output.write(`// from ${from}\nprotected ${code}\n`)
})
output.write('constructor(opts: BaseTelegramClient.Options) {')
output.tab()
output.write('super(opts)')
state.init.forEach((code) => {
output.write(code)
})
output.untab()
output.write('}\n')
state.methods.list.forEach(({ name, isPrivate, func, comment }) => {
// create method that calls that function and passes `this`
// first let's determine the signature
const returnType = func.type ? ': ' + func.type.getText() : ''
const generics = func.typeParameters
? `<${func.typeParameters
.map((it) => it.getFullText())
.join(', ')}>`
: ''
const rawParams = (func.parameters || []).filter(
(it) => !it.type || it.type.getText() !== 'TelegramClient'
)
const parameters = rawParams.map((it) => it.getFullText()).join(', ')
// write comment, but remove @internal mark
comment = comment.replace(/(\n^|\/\*)\s*\*\s*@internal.*/m, '')
if (!comment.match(/\/\*\*?\s*\*\//))
// empty comment, no need to write it
output.write(comment)
output.write(
`${
isPrivate ? 'protected ' : ''
}${name}${generics}(${parameters})${returnType}${
func.body
? `{
return ${name}.apply(this, arguments)
}`
: ''
}`
)
})
output.untab()
output.write('}')
// format the resulting file with prettier
const targetFile = path.join(__dirname, '../src/client.ts')
const prettierConfig = await prettier.resolveConfig(targetFile)
let fullSource = await fs.promises.readFile(targetFile, 'utf-8')
fullSource = await prettier.format(fullSource, {
...(prettierConfig || {}),
filepath: targetFile,
})
await fs.promises.writeFile(targetFile, fullSource)
}
main().catch(console.error)

View file

@ -0,0 +1,800 @@
/* THIS FILE WAS AUTO-GENERATED */
import { BaseTelegramClient } from '@mtcute/core'
import { tl } from '@mtcute/tl'
import { acceptTos } from './methods/auth/accept-tos'
import { checkPassword } from './methods/auth/check-password'
import { getPasswordHint } from './methods/auth/get-password-hint'
import { logOut } from './methods/auth/log-out'
import { recoverPassword } from './methods/auth/recover-password'
import { resendCode } from './methods/auth/resend-code'
import { sendCode } from './methods/auth/send-code'
import { sendRecoveryCode } from './methods/auth/send-recovery-code'
import { signInBot } from './methods/auth/sign-in-bot'
import { signIn } from './methods/auth/sign-in'
import { signUp } from './methods/auth/sign-up'
import { start } from './methods/auth/start'
import { downloadAsBuffer } from './methods/files/download-buffer'
import { downloadToFile } from './methods/files/download-file'
import { downloadAsIterable } from './methods/files/download-iterable'
import { downloadAsStream } from './methods/files/download-stream'
import { UploadedFile } from './types/files/uploaded-file'
import { uploadFile } from './methods/files/upload-file'
import { _findMessageInUpdate } from './methods/messages/find-in-update'
import { getMessages } from './methods/messages/get-messages'
import { _parseEntities } from './methods/messages/parse-entities'
import { sendPhoto } from './methods/messages/send-photo'
import { sendText } from './methods/messages/send-text'
import {
getParseMode,
registerParseMode,
setDefaultParseMode,
unregisterParseMode,
} from './methods/parse-modes/parse-modes'
import { catchUp } from './methods/updates/catch-up'
import {
_dispatchUpdate,
addUpdateHandler,
removeUpdateHandler,
} from './methods/updates/dispatcher'
import { _handleUpdate } from './methods/updates/handle-update'
import { onNewMessage } from './methods/updates/on-new-message'
import { blockUser } from './methods/users/block-user'
import { getCommonChats } from './methods/users/get-common-chats'
import { getMe } from './methods/users/get-me'
import { getUsers } from './methods/users/get-users'
import { resolvePeer } from './methods/users/resolve-peer'
import { IMessageEntityParser } from './parser'
import { Readable } from 'stream'
import {
Chat,
FileDownloadParameters,
InputPeerLike,
MaybeDynamic,
MediaLike,
Message,
Photo,
PropagationSymbol,
ReplyMarkup,
SentCode,
TermsOfService,
UpdateFilter,
UpdateHandler,
UploadFileLike,
User,
filters,
handlers,
} from './types'
import { MaybeArray, MaybeAsync, TelegramConnection } from '@mtcute/core'
export class TelegramClient extends BaseTelegramClient {
// from methods/files/_initialize.ts
protected _downloadConnections: Record<number, TelegramConnection>
// from methods/parse-modes/_initialize.ts
protected _parseModes: Record<string, IMessageEntityParser>
// from methods/parse-modes/_initialize.ts
protected _defaultParseMode: string | null
// from methods/updates/dispatcher.ts
protected _groups: Record<number, UpdateHandler[]>
// from methods/updates/dispatcher.ts
protected _groupsOrder: number[]
constructor(opts: BaseTelegramClient.Options) {
super(opts)
this._downloadConnections = {}
this._parseModes = {}
this._defaultParseMode = null
this._groups = {}
this._groupsOrder = []
}
/**
* Accept the given TOS
*
* @param tosId TOS id
*/
acceptTos(tosId: string): Promise<boolean> {
return acceptTos.apply(this, arguments)
}
/**
* Check your Two-Step verification password and log in
*
* @param password Your Two-Step verification password
* @returns The authorized user
* @throws BadRequestError In case the password is invalid
*/
checkPassword(password: string): Promise<User> {
return checkPassword.apply(this, arguments)
}
/**
* Get your Two-Step Verification password hint.
*
* @returns The password hint as a string, if any
*/
getPasswordHint(): Promise<string | null> {
return getPasswordHint.apply(this, arguments)
}
/**
* Log out from Telegram account and optionally reset the session storage.
*
* When you log out, you can immediately log back in using
* the same {@link TelegramClient} instance.
*
* @param resetSession Whether to reset the session
* @returns On success, `true` is returned
*/
logOut(resetSession = false): Promise<true> {
return logOut.apply(this, arguments)
}
/**
* Recover your password with a recovery code and log in.
*
* @param recoveryCode The recovery code sent via email
* @returns The authorized user
* @throws BadRequestError In case the code is invalid
*/
recoverPassword(recoveryCode: string): Promise<User> {
return recoverPassword.apply(this, arguments)
}
/**
* Re-send the confirmation code using a different type.
*
* The type of the code to be re-sent is specified in the `nextType` attribute of
* {@link SentCode} object returned by {@link sendCode}
*
* @param phone Phone number in international format
* @param phoneCodeHash Confirmation code identifier from {@link SentCode}
*/
resendCode(phone: string, phoneCodeHash: string): Promise<SentCode> {
return resendCode.apply(this, arguments)
}
/**
* Send the confirmation code to the given phone number
*
* @param phone Phone number in international format.
* @returns An object containing information about the sent confirmation code
*/
sendCode(phone: string): Promise<SentCode> {
return sendCode.apply(this, arguments)
}
/**
* Send a code to email needed to recover your password
*
* @returns String containing email pattern to which the recovery code was sent
*/
sendRecoveryCode(): Promise<string> {
return sendRecoveryCode.apply(this, arguments)
}
/**
* Authorize a bot using its token issued by [@BotFather](//t.me/BotFather)
*
* @param token Bot token issued by BotFather
* @returns Bot's {@link User} object
* @throws BadRequestError In case the bot token is invalid
*/
signInBot(token: string): Promise<User> {
return signInBot.apply(this, arguments)
}
/**
* Authorize a user in Telegram with a valid confirmation code.
*
* @param phone Phone number in international format
* @param phoneCodeHash Code identifier from {@link TelegramClient.sendCode}
* @param phoneCode The confirmation code that was received
* @returns
* - If the code was valid and authorization succeeded, the {@link User} is returned.
* - If the given phone number needs to be registered AND the ToS must be accepted,
* an object containing them is returned.
* - If the given phone number needs to be registered, `false` is returned.
* @throws BadRequestError In case the arguments are invalid
* @throws SessionPasswordNeededError In case a password is needed to sign in
*/
signIn(
phone: string,
phoneCodeHash: string,
phoneCode: string
): Promise<User | TermsOfService | false> {
return signIn.apply(this, arguments)
}
/**
* Register a new user in Telegram.
*
* @param phone Phone number in international format
* @param phoneCodeHash Code identifier from {@link TelegramClient.sendCode}
* @param firstName New user's first name
* @param lastName New user's last name
*/
signUp(
phone: string,
phoneCodeHash: string,
firstName: string,
lastName = ''
): Promise<User> {
return signUp.apply(this, arguments)
}
/**
* Start the client in an interactive and declarative manner,
* by providing callbacks for authorization details.
*
* This method handles both login and sign up, and also handles 2FV
*
* All parameters are `MaybeDynamic<T>`, meaning you
* can either supply `T`, or a function that returns `MaybeAsync<T>`
*
* This method is intended for simple and fast use in automated
* scripts and bots. If you are developing a custom client,
* you'll probably need to use other auth methods.
*
*/
start(params: {
/**
* Phone number of the account.
* If account does not exist, it will be created
*/
phone?: MaybeDynamic<string>
/**
* Bot token to use. Ignored if `phone` is supplied.
*/
botToken?: MaybeDynamic<string>
/**
* 2FA password. Ignored if `botToken` is supplied
*/
password?: MaybeDynamic<string>
/**
* Code sent to the phone (either sms, call, flash call or other).
* Ignored if `botToken` is supplied, must be present if `phone` is supplied.
*/
code?: MaybeDynamic<string>
/**
* If passed, this function will be called if provided code or 2FA password
* was invalid. New code/password will be requested later.
*
* If provided `code`/`password` is a constant string, providing an
* invalid one will interrupt authorization flow.
*/
invalidCodeCallback?: (type: 'code' | 'password') => MaybeAsync<void>
/**
* Whether to force code delivery through SMS
*/
forceSms?: boolean
/**
* First name of the user (used only for sign-up, defaults to 'User')
*/
firstName?: MaybeDynamic<string>
/**
* Last name of the user (used only for sign-up, defaults to empty)
*/
lastName?: MaybeDynamic<string>
/**
* By using this method to sign up an account, you are agreeing to Telegram
* ToS. This is required and your account will be banned otherwise.
* See https://telegram.org/tos and https://core.telegram.org/api/terms.
*
* If true, TOS will not be displayed and `tosCallback` will not be called.
*/
acceptTos?: boolean
/**
* Custom method to display ToS. Can be used to show a GUI alert of some kind.
* Defaults to `console.log`
*/
tosCallback?: (tos: TermsOfService) => MaybeAsync<void>
/**
* Custom method that is called when a code is sent. Can be used
* to show a GUI alert of some kind.
* Defaults to `console.log`
*
* @param code
*/
codeSentCallback?: (code: SentCode) => MaybeAsync<void>
/**
* Whether to "catch up" (load missed updates) after authorization.
* Defaults to true.
*/
catchUp?: boolean
}): Promise<User> {
return start.apply(this, arguments)
}
/**
* Download a file and return its contents as a Buffer.
*
* > **Note**: This method _will_ download the entire file
* > into memory at once. This might cause an issue, so use wisely!
*
* @param params File download parameters
*/
downloadAsBuffer(params: FileDownloadParameters): Promise<Buffer> {
return downloadAsBuffer.apply(this, arguments)
}
/**
* Download a remote file to a local file (only for NodeJS).
* Promise will resolve once the download is complete.
*
* @param filename Local file name to which the remote file will be downloaded
* @param params File download parameters
*/
downloadToFile(
filename: string,
params: FileDownloadParameters
): Promise<void> {
return downloadToFile.apply(this, arguments)
}
/**
* Download a file and return it as an iterable, which yields file contents
* in chunks of a given size. Order of the chunks is guaranteed to be
* consecutive.
*
* @param params Download parameters
*/
downloadAsIterable(
params: FileDownloadParameters
): AsyncIterableIterator<Buffer> {
return downloadAsIterable.apply(this, arguments)
}
/**
* Download a file and return it as a Node readable stream,
* streaming file contents.
*
* @param params File download parameters
*/
downloadAsStream(params: FileDownloadParameters): Readable {
return downloadAsStream.apply(this, arguments)
}
/**
* Upload a file to Telegram servers, without actually
* sending a message anywhere. Useful when an `InputFile` is required.
*
* This method is quite low-level, and you should use other
* methods like {@link sendDocument} that handle this under the hood.
*
* @param params Upload parameters
*/
uploadFile(params: {
/**
* Upload file source.
*
* > **Note**: `fs.ReadStream` is a subclass of `stream.Readable` and contains
* > info about file name, thus you don't need to pass them explicitly.
*/
file: UploadFileLike
/**
* File name for the uploaded file. Is usually inferred from path,
* but should be provided for files sent as `Buffer` or stream.
*
* When file name can't be inferred, it falls back to "unnamed"
*/
fileName?: string
/**
* Total file size. Automatically inferred for Buffer, File and local files.
*
* When using with streams, if `fileSize` is not passed, the entire file is
* first loaded into memory to determine file size, and used as a Buffer later.
* This might be a major performance bottleneck, so be sure to provide file size
* when using streams and file size is known (which often is the case).
*/
fileSize?: number
/**
* File MIME type. By default is automatically inferred from magic number
* If MIME can't be inferred, it defaults to `application/octet-stream`
*/
fileMime?: string
/**
* Upload part size (in KB).
*
* By default, automatically selected by file size.
* Must not be bigger than 512 and must not be a fraction.
*/
partSize?: number
/**
* Function that will be called after some part has been uploaded.
*
* @param uploaded Number of bytes already uploaded
* @param total Total file size
*/
progressCallback?: (uploaded: number, total: number) => void
}): Promise<UploadedFile> {
return uploadFile.apply(this, arguments)
}
protected _findMessageInUpdate(res: tl.TypeUpdates): Message {
return _findMessageInUpdate.apply(this, arguments)
}
/**
* Get a single message in chat by its ID
*
* **Note**: this method might return empty message
*
* @param chatId Chat's marked ID, its username, phone or `"me"` or `"self"`
* @param messageId Messages ID
* @param [fromReply=false]
* Whether the reply to a given message should be fetched
* (i.e. `getMessages(msg.chat.id, msg.id, true).id === msg.replyToMessageId`)
*/
getMessages(
chatId: InputPeerLike,
messageId: number,
fromReply?: boolean
): Promise<Message>
/**
* Get messages in chat by their IDs
*
* **Note**: this method might return empty messages
*
* @param chatId Chat's marked ID, its username, phone or `"me"` or `"self"`
* @param messageIds Messages IDs
* @param [fromReply=false]
* Whether the reply to a given message should be fetched
* (i.e. `getMessages(msg.chat.id, msg.id, true).id === msg.replyToMessageId`)
*/
getMessages(
chatId: InputPeerLike,
messageIds: number[],
fromReply?: boolean
): Promise<Message[]>
getMessages(
chatId: InputPeerLike,
messageIds: MaybeArray<number>,
fromReply = false
): Promise<MaybeArray<Message>> {
return getMessages.apply(this, arguments)
}
protected _parseEntities(
text?: string,
mode?: string | null,
entities?: tl.TypeMessageEntity[]
): Promise<[string, tl.TypeMessageEntity[] | undefined]> {
return _parseEntities.apply(this, arguments)
}
/**
* Send a single photo
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param photo Photo contained in the message.
* @param params Additional sending parameters
*/
sendPhoto(
chatId: InputPeerLike,
photo: MediaLike,
params?: {
/**
* Caption for the photo
*/
caption?: string
/**
* Message to reply to. Either a message object or message ID.
*/
replyTo?: number | Message
/**
* Parse mode to use to parse entities before sending
* the message. Defaults to current default parse mode (if any).
*
* Passing `null` will explicitly disable formatting.
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/**
* Whether to send this message silently.
*/
silent?: boolean
/**
* If set, the message will be scheduled to this date.
* When passing a number, a UNIX time in ms is expected.
*/
schedule?: Date | number
/**
* For bots: inline or reply markup or an instruction
* to hide a reply keyboard or to force a reply.
*/
replyMarkup?: ReplyMarkup
/**
* Self-Destruct timer.
* If set, the photo will self-destruct in a given number
* of seconds.
*/
ttlSeconds?: number
/**
* Function that will be called after some part has been uploaded.
* Only used when a file that requires uploading is passed.
*
* @param uploaded Number of bytes already uploaded
* @param total Total file size
*/
progressCallback?: (uploaded: number, total: number) => void
}
): Promise<filters.Modify<Message, { media: Photo }>> {
return sendPhoto.apply(this, arguments)
}
/**
* Send a text message
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param text Text of the message
* @param params Additional sending parameters
*/
sendText(
chatId: InputPeerLike,
text: string,
params?: {
/**
* Message to reply to. Either a message object or message ID.
*/
replyTo?: number | Message
/**
* Parse mode to use to parse entities before sending
* the message. Defaults to current default parse mode (if any).
*
* Passing `null` will explicitly disable formatting.
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/**
* Whether to disable links preview in this message
*/
disableWebPreview?: boolean
/**
* Whether to send this message silently.
*/
silent?: boolean
/**
* If set, the message will be scheduled to this date.
* When passing a number, a UNIX time in ms is expected.
*/
schedule?: Date | number
/**
* For bots: inline or reply markup or an instruction
* to hide a reply keyboard or to force a reply.
*/
replyMarkup?: ReplyMarkup
}
): Promise<filters.Modify<Message, { media: null }>> {
return sendText.apply(this, arguments)
}
/**
* Register a given {@link IMessageEntityParser} as a parse mode
* for messages. When this method is first called, given parse
* mode is also set as default.
*
* @param parseMode Parse mode to register
* @param name Parse mode name. By default is taken from the object.
* @throws MtCuteError When the parse mode with a given name is already registered.
*/
registerParseMode(
parseMode: IMessageEntityParser,
name = parseMode.name
): void {
return registerParseMode.apply(this, arguments)
}
/**
* Unregister a parse mode by its name.
* Will silently fail if given parse mode does not exist.
*
* Also updates the default parse mode to the next one available, if any
*
* @param name Name of the parse mode to unregister
*/
unregisterParseMode(name: string): void {
return unregisterParseMode.apply(this, arguments)
}
/**
* Get a {@link IMessageEntityParser} registered under a given name (or a default one).
*
* @param name Name of the parse mode which parser to get.
* @throws MtCuteError When the provided parse mode is not registered
* @throws MtCuteError When `name` is omitted and there is no default parse mode
*/
getParseMode(name?: string | null): IMessageEntityParser {
return getParseMode.apply(this, arguments)
}
/**
* Set a given parse mode as a default one.
*
* @param name Name of the parse mode
* @throws MtCuteError When given parse mode is not registered.
*/
setDefaultParseMode(name: string): void {
return setDefaultParseMode.apply(this, arguments)
}
/**
* Catch up with the server by loading missed updates.
*
*/
catchUp(): Promise<void> {
return catchUp.apply(this, arguments)
}
protected _dispatchUpdate(
update: tl.TypeUpdate,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>
): Promise<void> {
return _dispatchUpdate.apply(this, arguments)
}
/**
* Add an update handler to a given handlers group
*
* @param handler Update handler
* @param group Handler group index
*/
addUpdateHandler(handler: UpdateHandler, group = 0): void {
return addUpdateHandler.apply(this, arguments)
}
/**
* Remove an update handler (or handlers) from a given
* handler group.
*
* @param handler Update handler to remove, its type or `'all'` to remove all
* @param group Handler group index
*/
removeUpdateHandler(
handler: UpdateHandler | UpdateHandler['type'] | 'all',
group = 0
): void {
return removeUpdateHandler.apply(this, arguments)
}
protected _handleUpdate(update: tl.TypeUpdates): void {
return _handleUpdate.apply(this, arguments)
}
/**
* Register a message handler without any filters.
*
* @param handler Message handler
*/
onNewMessage(
handler: (msg: Message) => MaybeAsync<void | PropagationSymbol>
): void
/**
* Register a message handler with a given filter
*
* @param filter Update filter
* @param handler Message handler
*/
onNewMessage<Mod>(
filter: UpdateFilter<Message, Mod>,
handler: (
msg: filters.Modify<Message, Mod>
) => MaybeAsync<void | PropagationSymbol>
): void
onNewMessage<Mod>(
filter:
| UpdateFilter<Message, Mod>
| ((msg: Message) => MaybeAsync<void | PropagationSymbol>),
handler?: (
msg: filters.Modify<Message, Mod>
) => MaybeAsync<void | PropagationSymbol>
): void {
return onNewMessage.apply(this, arguments)
}
/**
* Block a user
*
* @param id User ID, its username or phone number
* @returns Whether the action was successful
*/
blockUser(id: InputPeerLike): Promise<boolean> {
return blockUser.apply(this, arguments)
}
/**
* Get a list of common chats you have with a given user
*
* @param userId User's ID, username or phone number
* @throws MtCuteInvalidPeerTypeError
*/
getCommonChats(userId: InputPeerLike): Promise<Chat[]> {
return getCommonChats.apply(this, arguments)
}
/**
* Get currently authorized user's full information
*
*/
getMe(): Promise<User> {
return getMe.apply(this, arguments)
}
/**
* Get information about a single user.
*
* @param id User's identifier. Can be ID, username, phone number, `"me"` or `"self"` or TL object
*/
getUsers(id: InputPeerLike): Promise<User>
/**
* Get information about multiple users.
* You can retrieve up to 200 users at once
*
* @param ids Users' identifiers. Can be ID, username, phone number, `"me"`, `"self"` or TL object
*/
getUsers(ids: InputPeerLike[]): Promise<User[]>
getUsers(ids: MaybeArray<InputPeerLike>): Promise<MaybeArray<User>> {
return getUsers.apply(this, arguments)
}
/**
* Get the `InputPeer` of a known peer id.
* Useful when an `InputPeer` is needed.
*
* @param peerId The peer identifier that you want to extract the `InputPeer` from.
*/
resolvePeer(
peerId: InputPeerLike
): Promise<tl.TypeInputPeer | tl.TypeInputUser | tl.TypeInputChannel> {
return resolvePeer.apply(this, arguments)
}
}

View file

@ -0,0 +1,9 @@
export {
MemoryStorage,
JsonFileStorage,
LocalstorageStorage,
} from '@mtcute/core'
export * from './parser'
export * from './types'
export * from './client'

View file

@ -0,0 +1,80 @@
# What is this?
Files in this directory are pre-processed by `generate-client.js`, and `client.ts` is generated from the functions that
are exported in this directory.
Since we need to properly type the copied signatures, there are a few "magic" instructions for the preprocessor that are
used to handle imports. Also, there are a few "magic" instructions to make private methods and extend client fields.
All instructions are used as a one-line comment, like this: `// @copy`
## `@copy`
Can be placed before an import or any other code block.
When placed before import, this import will be copied to `client.ts`, and paths will be adjusted. When there are
multiple copied imports from the same files, they are merged.
When placed before any other block, it will be directly copied before the `TelegramClient` class.
> **Note**: to prevent confusion, messy code and duplication,
> all copied imports should be inside `_imports.ts` file.
Example:
```typescript
// @copy
import { Something } from '../../somewhere'
// @copy
interface SomeGreatInterface { ... }
```
## `@extension`
Used before an `interface` declaration. Fields from that interface will be added as `protected`
to `TelegramClient`.
Example:
```typescript
// @extension
interface AwesomeExtension {
_field1: number
_field2: string
}
```
## `@initialize`
Often you'll want to initialize your `@extension` fields in a constructor. You can do this by using `@initialize`
instruction before a function containing initialization code.
> **Note**: The code from the function is directly copied to the constructor.
> If you are using some custom types, make sure their imports are copied!
Example:
```typescript
// @initialize
function _initializeAwesomeExtension(this: TelegramClient) {
this._field1 = 42
this._field2 = 'uwu'
}
```
## `@returns-exported`
Used as a first statement inside an exported function's body to indicate that this method returns an object of type
which is exported from the same file.
Example:
```typescript
export type FooOrBar = Foo | Bar
export function getFooOrBar(this: TelegramClient): FooOrBar {
// @returns-exported
return new Foo()
}
```

View file

@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// @copy
import { IMessageEntityParser } from '../parser'
// @copy
import { Readable } from 'stream'
// @copy
import {
User,
Chat,
TermsOfService,
SentCode,
MaybeDynamic,
InputPeerLike,
Photo,
UploadedFile,
UploadFileLike,
MediaLike,
FileDownloadParameters,
UpdateHandler,
handlers,
PropagationSymbol,
filters,
UpdateFilter,
Message,
ReplyMarkup,
} from '../types'
// @copy
import { MaybeArray, MaybeAsync, TelegramConnection } from '@mtcute/core'

View file

@ -0,0 +1,30 @@
import { TelegramClient } from '../../client'
import { MtCuteTypeAssertionError } from '../../types'
/**
* Accept the given TOS
*
* @param tosId TOS id
* @internal
*/
export async function acceptTos(
this: TelegramClient,
tosId: string
): Promise<boolean> {
const res = await this.call({
_: 'help.acceptTermsOfService',
id: {
_: 'dataJSON',
data: tosId,
},
})
if (!res)
throw new MtCuteTypeAssertionError(
'acceptTos (@ help.acceptTermsOfService)',
'true',
'false'
)
return true
}

View file

@ -0,0 +1,46 @@
import { TelegramClient } from '../../client'
import { User } from '../../types'
import { computeSrpParams } from '@mtcute/core'
import { assertTypeIs } from '../../utils/type-assertion'
/**
* Check your Two-Step verification password and log in
*
* @param password Your Two-Step verification password
* @returns The authorized user
* @throws BadRequestError In case the password is invalid
* @internal
*/
export async function checkPassword(
this: TelegramClient,
password: string
): Promise<User> {
const res = await this.call({
_: 'auth.checkPassword',
password: await computeSrpParams(
this._crypto,
await this.call({
_: 'account.getPassword',
}),
password
),
})
assertTypeIs(
'checkPassword (@ auth.checkPassword)',
res,
'auth.authorization'
)
assertTypeIs(
'checkPassword (@ auth.checkPassword -> user)',
res.user,
'user'
)
await this.storage.setSelf({
userId: res.user.id,
isBot: false,
})
return new User(this, res.user)
}

View file

@ -0,0 +1,13 @@
import { TelegramClient } from '../../client'
/**
* Get your Two-Step Verification password hint.
*
* @returns The password hint as a string, if any
* @internal
*/
export function getPasswordHint(this: TelegramClient): Promise<string | null> {
return this.call({
_: 'account.getPassword',
}).then((res) => res.hint ?? null)
}

View file

@ -0,0 +1,25 @@
import { TelegramClient } from '../../client'
/**
* Log out from Telegram account and optionally reset the session storage.
*
* When you log out, you can immediately log back in using
* the same {@link TelegramClient} instance.
*
* @param resetSession Whether to reset the session
* @returns On success, `true` is returned
* @internal
*/
export async function logOut(
this: TelegramClient,
resetSession = false
): Promise<true> {
await this.call({ _: 'auth.logOut' })
if (resetSession) {
this.storage.reset()
await this.storage.save?.()
}
return true
}

View file

@ -0,0 +1,39 @@
import { TelegramClient } from '../../client'
import { User } from '../../types'
import { assertTypeIs } from '../../utils/type-assertion'
/**
* Recover your password with a recovery code and log in.
*
* @param recoveryCode The recovery code sent via email
* @returns The authorized user
* @throws BadRequestError In case the code is invalid
* @internal
*/
export async function recoverPassword(
this: TelegramClient,
recoveryCode: string
): Promise<User> {
const res = await this.call({
_: 'auth.recoverPassword',
code: recoveryCode,
})
assertTypeIs(
'recoverPassword (@ auth.recoverPassword)',
res,
'auth.authorization'
)
assertTypeIs(
'recoverPassword (@ auth.recoverPassword -> user)',
res.user,
'user'
)
await this.storage.setSelf({
userId: res.user.id,
isBot: false,
})
return new User(this, res.user)
}

View file

@ -0,0 +1,29 @@
import { SentCode } from '../../types'
import { TelegramClient } from '../../client'
import { normalizePhoneNumber } from '../../utils/misc-utils'
/**
* Re-send the confirmation code using a different type.
*
* The type of the code to be re-sent is specified in the `nextType` attribute of
* {@link SentCode} object returned by {@link sendCode}
*
* @param phone Phone number in international format
* @param phoneCodeHash Confirmation code identifier from {@link SentCode}
* @internal
*/
export async function resendCode(
this: TelegramClient,
phone: string,
phoneCodeHash: string
): Promise<SentCode> {
phone = normalizePhoneNumber(phone)
const res = await this.call({
_: 'auth.resendCode',
phoneNumber: phone,
phoneCodeHash,
})
return new SentCode(res)
}

View file

@ -0,0 +1,27 @@
import { SentCode } from '../../types'
import { TelegramClient } from '../../client'
import { normalizePhoneNumber } from '../../utils/misc-utils'
/**
* Send the confirmation code to the given phone number
*
* @param phone Phone number in international format.
* @returns An object containing information about the sent confirmation code
* @internal
*/
export async function sendCode(
this: TelegramClient,
phone: string
): Promise<SentCode> {
phone = normalizePhoneNumber(phone)
const res = await this.call({
_: 'auth.sendCode',
phoneNumber: phone,
apiId: this._initConnectionParams.apiId,
apiHash: this._apiHash,
settings: { _: 'codeSettings' },
})
return new SentCode(res)
}

View file

@ -0,0 +1,13 @@
import { TelegramClient } from '../../client'
/**
* Send a code to email needed to recover your password
*
* @returns String containing email pattern to which the recovery code was sent
* @internal
*/
export function sendRecoveryCode(this: TelegramClient): Promise<string> {
return this.call({
_: 'auth.requestPasswordRecovery',
}).then((res) => res.emailPattern)
}

View file

@ -0,0 +1,43 @@
import { User } from '../../types'
import { TelegramClient } from '../../client'
import { assertTypeIs } from '../../utils/type-assertion'
/**
* Authorize a bot using its token issued by [@BotFather](//t.me/BotFather)
*
* @param token Bot token issued by BotFather
* @returns Bot's {@link User} object
* @throws BadRequestError In case the bot token is invalid
* @internal
*/
export async function signInBot(
this: TelegramClient,
token: string
): Promise<User> {
const res = await this.call({
_: 'auth.importBotAuthorization',
flags: 0,
apiId: this._initConnectionParams.apiId,
apiHash: this._apiHash,
botAuthToken: token,
})
assertTypeIs(
'signInBot (@ auth.importBotAuthorization)',
res,
'auth.authorization'
)
assertTypeIs(
'signInBot (@ auth.importBotAuthorization -> user)',
res.user,
'user'
)
await this.storage.setSelf({
userId: res.user.id,
isBot: true,
})
await this.storage.save?.()
return new User(this, res.user)
}

View file

@ -0,0 +1,51 @@
import { MtCuteError, User, TermsOfService } from '../../types'
import { TelegramClient } from '../../client'
import { assertTypeIs } from '../../utils/type-assertion'
import { normalizePhoneNumber } from '../../utils/misc-utils'
/**
* Authorize a user in Telegram with a valid confirmation code.
*
* @param phone Phone number in international format
* @param phoneCodeHash Code identifier from {@link TelegramClient.sendCode}
* @param phoneCode The confirmation code that was received
* @returns
* - If the code was valid and authorization succeeded, the {@link User} is returned.
* - If the given phone number needs to be registered AND the ToS must be accepted,
* an object containing them is returned.
* - If the given phone number needs to be registered, `false` is returned.
* @throws BadRequestError In case the arguments are invalid
* @throws SessionPasswordNeededError In case a password is needed to sign in
* @internal
*/
export async function signIn(
this: TelegramClient,
phone: string,
phoneCodeHash: string,
phoneCode: string
): Promise<User | TermsOfService | false> {
phone = normalizePhoneNumber(phone)
const res = await this.call({
_: 'auth.signIn',
phoneNumber: phone,
phoneCodeHash,
phoneCode,
})
if (res._ === 'auth.authorizationSignUpRequired') {
if (res.termsOfService) return new TermsOfService(res.termsOfService)
return false
}
assertTypeIs('signIn (@ auth.signIn -> user)', res.user, 'user')
await this.storage.setSelf({
userId: res.user.id,
isBot: false,
})
await this.storage.save?.()
return new User(this, res.user)
}

View file

@ -0,0 +1,41 @@
import { TelegramClient } from '../../client'
import { normalizePhoneNumber } from '../../utils/misc-utils'
import { assertTypeIs } from '../../utils/type-assertion'
import { User } from '../../types'
/**
* Register a new user in Telegram.
*
* @param phone Phone number in international format
* @param phoneCodeHash Code identifier from {@link TelegramClient.sendCode}
* @param firstName New user's first name
* @param lastName New user's last name
* @internal
*/
export async function signUp(
this: TelegramClient,
phone: string,
phoneCodeHash: string,
firstName: string,
lastName = ''
): Promise<User> {
phone = normalizePhoneNumber(phone)
const res = await this.call({
_: 'auth.signUp',
phoneNumber: phone,
phoneCodeHash,
firstName,
lastName,
})
assertTypeIs('signUp (@ auth.signUp)', res, 'auth.authorization')
assertTypeIs('signUp (@ auth.signUp -> user)', res.user, 'user')
await this.storage.setSelf({
userId: res.user.id,
isBot: false,
})
return new User(this, res.user)
}

View file

@ -0,0 +1,269 @@
import {
MaybeDynamic,
MtCuteArgumentError,
SentCode,
TermsOfService,
User,
MaybeAsync,
} from '../../types'
import { TelegramClient } from '../../client'
import {
resolveMaybeDynamic,
normalizePhoneNumber,
} from '../../utils/misc-utils'
import {
AuthKeyUnregisteredError,
PasswordHashInvalidError,
PhoneCodeEmptyError,
PhoneCodeExpiredError,
PhoneCodeHashEmptyError,
PhoneCodeInvalidError,
SessionPasswordNeededError,
} from '@mtcute/tl/errors'
/**
* Start the client in an interactive and declarative manner,
* by providing callbacks for authorization details.
*
* This method handles both login and sign up, and also handles 2FV
*
* All parameters are `MaybeDynamic<T>`, meaning you
* can either supply `T`, or a function that returns `MaybeAsync<T>`
*
* This method is intended for simple and fast use in automated
* scripts and bots. If you are developing a custom client,
* you'll probably need to use other auth methods.
*
* @internal
*/
export async function start(
this: TelegramClient,
params: {
/**
* Phone number of the account.
* If account does not exist, it will be created
*/
phone?: MaybeDynamic<string>
/**
* Bot token to use. Ignored if `phone` is supplied.
*/
botToken?: MaybeDynamic<string>
/**
* 2FA password. Ignored if `botToken` is supplied
*/
password?: MaybeDynamic<string>
/**
* Code sent to the phone (either sms, call, flash call or other).
* Ignored if `botToken` is supplied, must be present if `phone` is supplied.
*/
code?: MaybeDynamic<string>
/**
* If passed, this function will be called if provided code or 2FA password
* was invalid. New code/password will be requested later.
*
* If provided `code`/`password` is a constant string, providing an
* invalid one will interrupt authorization flow.
*/
invalidCodeCallback?: (type: 'code' | 'password') => MaybeAsync<void>
/**
* Whether to force code delivery through SMS
*/
forceSms?: boolean
/**
* First name of the user (used only for sign-up, defaults to 'User')
*/
firstName?: MaybeDynamic<string>
/**
* Last name of the user (used only for sign-up, defaults to empty)
*/
lastName?: MaybeDynamic<string>
/**
* By using this method to sign up an account, you are agreeing to Telegram
* ToS. This is required and your account will be banned otherwise.
* See https://telegram.org/tos and https://core.telegram.org/api/terms.
*
* If true, TOS will not be displayed and `tosCallback` will not be called.
*/
acceptTos?: boolean
/**
* Custom method to display ToS. Can be used to show a GUI alert of some kind.
* Defaults to `console.log`
*/
tosCallback?: (tos: TermsOfService) => MaybeAsync<void>
/**
* Custom method that is called when a code is sent. Can be used
* to show a GUI alert of some kind.
* Defaults to `console.log`
*
* @param code
*/
codeSentCallback?: (code: SentCode) => MaybeAsync<void>
/**
* Whether to "catch up" (load missed updates).
* Note: you should register your handlers
* before calling `start()`
*
* Defaults to true.
*/
catchUp?: boolean
}
): Promise<User> {
if (!params.phone && !params.botToken)
throw new MtCuteArgumentError(
'Neither phone nor bot token were provided'
)
try {
const me = await this.getMe()
// user is already authorized
if (params.catchUp !== false) await this.catchUp()
return me
} catch (e) {
if (!(e instanceof AuthKeyUnregisteredError)) throw e
}
let phone = params.phone ? await resolveMaybeDynamic(params.phone) : null
if (phone) {
phone = normalizePhoneNumber(phone)
if (!params.code)
throw new MtCuteArgumentError('You must pass `code` to use `phone`')
} else {
const botToken = params.botToken
? await resolveMaybeDynamic(params.botToken)
: null
if (!botToken)
throw new MtCuteArgumentError(
'Either bot token or phone number must be provided'
)
if (params.catchUp !== false) await this.catchUp()
return this.signInBot(botToken)
}
let sentCode = await this.sendCode(phone)
if (params.forceSms && sentCode.type === 'app') {
sentCode = await this.resendCode(phone, sentCode.phoneCodeHash)
}
if (params.codeSentCallback) {
await params.codeSentCallback(sentCode)
} else {
console.log(`The confirmation code has been sent via ${sentCode.type}.`)
}
let has2fa = false
let result: User | TermsOfService | false
for (;;) {
const code = await resolveMaybeDynamic(params.code)
if (!code) throw new PhoneCodeEmptyError()
try {
result = await this.signIn(phone, sentCode.phoneCodeHash, code)
} catch (e) {
if (e instanceof SessionPasswordNeededError) {
has2fa = true
break
} else if (
e instanceof PhoneCodeEmptyError ||
e instanceof PhoneCodeExpiredError ||
e instanceof PhoneCodeHashEmptyError ||
e instanceof PhoneCodeInvalidError
) {
if (typeof params.code !== 'function') {
throw new MtCuteArgumentError('Provided code was invalid')
}
if (params.invalidCodeCallback) {
await params.invalidCodeCallback('code')
} else {
console.log('Invalid code. Please try again')
}
continue
} else throw e
}
// if there was no error, code was valid, so it's either 2fa or signup
break
}
if (has2fa) {
if (!params.password)
throw new MtCuteArgumentError(
'2FA is enabled, but `password` was not provided.'
)
for (;;) {
const password = await resolveMaybeDynamic(params.password)
try {
result = await this.checkPassword(password)
} catch (e) {
if (typeof params.password !== 'function') {
throw new MtCuteArgumentError(
'Provided password was invalid'
)
}
if (e instanceof PasswordHashInvalidError) {
if (params.invalidCodeCallback) {
params.invalidCodeCallback('password')
} else {
console.log('Invalid password. Please try again')
}
continue
} else throw e
}
break
}
}
// to make ts happy
result = result!
if (result instanceof User) {
return result
}
let tosId: string | null = null
if (result instanceof TermsOfService && !params.acceptTos) {
if (params.tosCallback) {
params.tosCallback(result)
} else {
console.log(result.text)
}
tosId = result.id
}
// signup
result = await this.signUp(
phone,
sentCode.phoneCodeHash,
await resolveMaybeDynamic(params.firstName ?? 'User'),
await resolveMaybeDynamic(params.lastName)
)
if (tosId) {
await this.acceptTos(tosId)
}
return result
}

View file

@ -0,0 +1,13 @@
import { TelegramConnection } from '@mtcute/core'
import { TelegramClient } from '../../client'
// @extension
interface FilesExtension {
_downloadConnections: Record<number, TelegramConnection>
}
// @initialize
function _initializeFiles(this: TelegramClient): void {
this._downloadConnections = {}
}

View file

@ -0,0 +1,31 @@
import { FileDownloadParameters, FileLocation } from '../../types'
import { TelegramClient } from '../../client'
/**
* Download a file and return its contents as a Buffer.
*
* > **Note**: This method _will_ download the entire file
* > into memory at once. This might cause an issue, so use wisely!
*
* @param params File download parameters
* @internal
*/
export async function downloadAsBuffer(
this: TelegramClient,
params: FileDownloadParameters
): Promise<Buffer> {
if (
params.location instanceof FileLocation &&
params.location.location instanceof Buffer
) {
return params.location.location
}
const chunks = []
for await (const chunk of this.downloadAsIterable(params)) {
chunks.push(chunk)
}
return Buffer.concat(chunks)
}

View file

@ -0,0 +1,55 @@
import { TelegramClient } from '../../client'
import {
MtCuteUnsupportedError,
FileDownloadParameters,
FileLocation,
} from '../../types'
let fs: any = null
try {
fs = require('fs')
} catch (e) {}
/**
* Download a remote file to a local file (only for NodeJS).
* Promise will resolve once the download is complete.
*
* @param filename Local file name to which the remote file will be downloaded
* @param params File download parameters
* @internal
*/
export function downloadToFile(
this: TelegramClient,
filename: string,
params: FileDownloadParameters
): Promise<void> {
if (!fs)
throw new MtCuteUnsupportedError(
'Downloading to file is only supported in NodeJS'
)
if (
params.location instanceof FileLocation &&
params.location.location instanceof Buffer
) {
// early return for inline files
const buf = params.location.location
return new Promise((resolve, reject) => {
fs.writeFile(filename, buf, (err?: Error) => {
if (err) reject(err)
else resolve()
})
})
}
const output = fs.createWriteStream(filename)
const stream = this.downloadAsStream(params)
return new Promise((resolve, reject) => {
stream
.on('error', reject)
.pipe(output)
.on('finish', resolve)
.on('error', reject)
})
}

View file

@ -0,0 +1,119 @@
import { TelegramClient } from '../../client'
import { determinePartSize } from '../../utils/file-utils'
import { tl } from '@mtcute/tl'
import { FileMigrateError, FilerefUpgradeNeededError } from '@mtcute/tl/errors'
import {
MtCuteArgumentError,
MtCuteUnsupportedError,
FileDownloadParameters,
FileLocation,
} from '../../types'
/**
* Download a file and return it as an iterable, which yields file contents
* in chunks of a given size. Order of the chunks is guaranteed to be
* consecutive.
*
* @param params Download parameters
* @internal
*/
export async function* downloadAsIterable(
this: TelegramClient,
params: FileDownloadParameters
): AsyncIterableIterator<Buffer> {
const partSizeKb =
params.partSize ??
(params.fileSize ? determinePartSize(params.fileSize) : 64)
if (partSizeKb % 4 !== 0)
throw new MtCuteArgumentError(
`Invalid part size: ${partSizeKb}. Must be divisible by 4.`
)
let offset = params.offset ?? 0
if (offset % 4096 !== 0)
throw new MtCuteArgumentError(
`Invalid offset: ${offset}. Must be divisible by 4096`
)
let dcId = params.dcId
let fileSize = params.fileSize
let location = params.location
if (location instanceof FileLocation) {
if (typeof location.location === 'function') {
;(location as tl.Mutable<FileLocation>).location = location.location()
}
if (location.location instanceof Buffer) {
yield location.location
return
}
if (!dcId) dcId = location.dcId
if (!fileSize) fileSize = location.fileSize
location = location.location as any
}
// we will receive a FileMigrateError in case this is invalid
if (!dcId) dcId = this._primaryDc.id
const chunkSize = partSizeKb * 1024
const limit =
params.limit ??
(fileSize
? // derive limit from chunk size, file size and offset
~~((fileSize + chunkSize - offset - 1) / chunkSize)
: // we will receive an error when we have reached the end anyway
Infinity)
let connection = this._downloadConnections[dcId]
if (!connection) {
connection = await this.createAdditionalConnection(dcId)
this._downloadConnections[dcId] = connection
}
const requestCurrent = async (): Promise<Buffer> => {
let result: tl.RpcCallReturn['upload.getFile']
try {
result = await connection.sendForResult({
_: 'upload.getFile',
location: location as tl.TypeInputFileLocation,
offset,
limit: chunkSize,
})
} catch (e) {
if (e instanceof FileMigrateError) {
connection = this._downloadConnections[e.newDc]
if (!connection) {
connection = await this.createAdditionalConnection(e.newDc)
this._downloadConnections[e.newDc] = connection
}
return requestCurrent()
} else if (e instanceof FilerefUpgradeNeededError) {
// todo: implement once messages api is ready
// see: https://github.com/LonamiWebs/Telethon/blob/0e8bd8248cc649637b7c392616887c50986427a0/telethon/client/downloads.py#L99
throw new MtCuteUnsupportedError('File ref expired!')
} else throw e
}
if (result._ === 'upload.fileCdnRedirect') {
throw new MtCuteUnsupportedError(
'Received CDN redirect, which is not supported (yet)'
)
}
return result.bytes
}
for (let i = 0; i < limit; i++) {
const buf = await requestCurrent()
if (buf.length === 0)
// we've reached the end
return
yield buf
offset += chunkSize
params.progressCallback?.(offset, limit)
}
}

View file

@ -0,0 +1,41 @@
import { Readable } from 'stream'
import { TelegramClient } from '../../client'
import { FileLocation, FileDownloadParameters } from '../../types'
import { bufferToStream } from '../../utils/stream-utils'
/**
* Download a file and return it as a Node readable stream,
* streaming file contents.
*
* @param params File download parameters
* @internal
*/
export function downloadAsStream(
this: TelegramClient,
params: FileDownloadParameters
): Readable {
if (
params.location instanceof FileLocation &&
params.location.location instanceof Buffer
) {
return bufferToStream(params.location.location)
}
const ret = new Readable({
async read() {},
})
setTimeout(async () => {
try {
for await (const chunk of this.downloadAsIterable(params)) {
ret.push(chunk)
}
ret.push(null)
} catch (e) {
ret.emit('error', e)
}
}, 0)
return ret
}

View file

@ -0,0 +1,255 @@
import {
bufferToStream,
convertWebStreamToNodeReadable,
readBytesFromStream,
readStreamUntilEnd,
} from '../../utils/stream-utils'
import type { ReadStream } from 'fs'
import { Readable } from 'stream'
import { determinePartSize, isProbablyPlainText } from '../../utils/file-utils'
import { randomUlong } from '../../utils/misc-utils'
import { fromBuffer } from 'file-type'
import { tl } from '@mtcute/tl'
import { MtCuteArgumentError, UploadFileLike, UploadedFile } from '../../types'
import { TelegramClient } from '../../client'
let fs: any = null
let path: any = null
try {
fs = require('fs')
path = require('path')
} catch (e) {}
const debug = require('debug')('mtcute:upload')
/**
* Upload a file to Telegram servers, without actually
* sending a message anywhere. Useful when an `InputFile` is required.
*
* This method is quite low-level, and you should use other
* methods like {@link sendDocument} that handle this under the hood.
*
* @param params Upload parameters
* @internal
*/
export async function uploadFile(
this: TelegramClient,
params: {
/**
* Upload file source.
*
* > **Note**: `fs.ReadStream` is a subclass of `stream.Readable` and contains
* > info about file name, thus you don't need to pass them explicitly.
*/
file: UploadFileLike
/**
* File name for the uploaded file. Is usually inferred from path,
* but should be provided for files sent as `Buffer` or stream.
*
* When file name can't be inferred, it falls back to "unnamed"
*/
fileName?: string
/**
* Total file size. Automatically inferred for Buffer, File and local files.
*
* When using with streams, if `fileSize` is not passed, the entire file is
* first loaded into memory to determine file size, and used as a Buffer later.
* This might be a major performance bottleneck, so be sure to provide file size
* when using streams and file size is known (which often is the case).
*/
fileSize?: number
/**
* File MIME type. By default is automatically inferred from magic number
* If MIME can't be inferred, it defaults to `application/octet-stream`
*/
fileMime?: string
/**
* Upload part size (in KB).
*
* By default, automatically selected by file size.
* Must not be bigger than 512 and must not be a fraction.
*/
partSize?: number
/**
* Function that will be called after some part has been uploaded.
*
* @param uploaded Number of bytes already uploaded
* @param total Total file size
*/
progressCallback?: (uploaded: number, total: number) => void
}
): Promise<UploadedFile> {
// normalize params
let file = params.file
let fileSize = -1 // unknown
let fileName = 'unnamed'
let fileMime = params.fileMime
if (file instanceof Buffer) {
fileSize = file.length
file = bufferToStream(file)
}
if (typeof File !== 'undefined' && file instanceof File) {
fileName = file.name
fileSize = file.size
// file is now ReadableStream
file = file.stream()
}
if (typeof file === 'string') {
if (!fs)
throw new MtCuteArgumentError(
'Local paths are only supported for NodeJS!'
)
file = fs.createReadStream(file)
}
if (fs && file instanceof fs.ReadStream) {
fileName = path.basename((file as ReadStream).path.toString())
fileSize = await new Promise((res, rej) => {
fs.stat(
(file as ReadStream).path.toString(),
(err?: any, stat?: any) => {
if (err) rej(err)
res(stat.size)
}
)
})
// fs.ReadStream is a subclass of Readable, no conversion needed
}
if (
typeof ReadableStream !== 'undefined' &&
file instanceof ReadableStream
) {
file = convertWebStreamToNodeReadable(file)
}
// override file name and mime (if any)
if (params.fileName) fileName = params.fileName
// set file size if not automatically inferred
if (fileSize === -1 && params.fileSize) fileSize = params.fileSize
if (fileSize === -1) {
// load the entire stream into memory
const buffer = await readStreamUntilEnd(file as Readable)
fileSize = buffer.length
file = bufferToStream(buffer)
}
if (!(file instanceof Readable))
throw new MtCuteArgumentError(
'Could not convert input `file` to stream!'
)
const partSizeKb = params.partSize ?? determinePartSize(fileSize)
if (partSizeKb > 512)
throw new MtCuteArgumentError(`Invalid part size: ${partSizeKb}KB`)
const partSize = partSizeKb * 1024
const isBig = fileSize > 10485760 // 10 MB
const hash = this._crypto.createMd5()
const partCount = ~~((fileSize + partSize - 1) / partSize)
debug(
'uploading %d bytes file in %d chunks, each %d bytes',
fileSize,
partCount,
partSize
)
// why is the file id generated by the client?
// isn't the server supposed to generate it and handle collisions?
const fileId = randomUlong()
let pos = 0
for (let idx = 0; idx < partCount; idx++) {
const part = await readBytesFromStream(file, partSize)
if (!part)
throw new MtCuteArgumentError(
`Unexpected EOS (there were only ${idx} parts, but expected ${partCount})`
)
// even though typescript seems to guarantee this, we can't be sure because its js after all
if (!(part instanceof Buffer))
throw new MtCuteArgumentError(`Part ${idx} was not a Buffer!`)
if (part.length > partSize)
throw new MtCuteArgumentError(
`Part ${idx} had invalid size (expected ${partSize}, got ${part.length})`
)
if (idx === 0 && fileMime === undefined) {
const fileType = await fromBuffer(part)
fileMime = fileType?.mime
if (!fileMime) {
// either plain text or random binary gibberish
// make an assumption based on the first 8 bytes
// if all 8 bytes are printable ASCII characters,
// the entire file is probably plain text
const isPlainText = isProbablyPlainText(part.slice(0, 8))
fileMime = isPlainText
? 'text/plain'
: 'application/octet-stream'
}
}
if (!isBig) {
// why md5 only small files?
// big files have more chance of corruption, but whatever
// also isn't integrity guaranteed by mtproto?
await hash.update(part)
}
pos += part.length
// why
const request = isBig
? ({
_: 'upload.saveBigFilePart',
fileId,
filePart: idx,
fileTotalParts: partCount,
bytes: part,
} as tl.upload.RawSaveBigFilePartRequest)
: ({
_: 'upload.saveFilePart',
fileId,
filePart: idx,
bytes: part,
} as tl.upload.RawSaveFilePartRequest)
const result = await this.call(request)
if (!result) throw new Error(`Failed to upload part ${idx}`)
params.progressCallback?.(pos, fileSize)
}
let inputFile: tl.TypeInputFile
if (isBig) {
inputFile = {
_: 'inputFileBig',
id: fileId,
parts: partCount,
name: fileName,
}
} else {
inputFile = {
_: 'inputFile',
id: fileId,
parts: partCount,
name: fileName,
md5Checksum: (await hash.digest()).toString('hex'),
}
}
return {
inputFile,
size: fileSize,
mime: fileMime!,
}
}

View file

@ -0,0 +1,43 @@
import { TelegramClient } from '../../client'
import { tl } from '@mtcute/tl'
import { Message, MtCuteTypeAssertionError } from '../../types'
/** @internal */
export function _findMessageInUpdate(
this: TelegramClient,
res: tl.TypeUpdates
): Message {
if (!(res._ === 'updates' || res._ === 'updatesCombined'))
throw new MtCuteTypeAssertionError(
'_findMessageInUpdate',
'updates | updatesCombined',
res._
)
for (const u of res.updates) {
if (
u._ === 'updateNewMessage' ||
u._ === 'updateNewChannelMessage' ||
u._ === 'updateNewScheduledMessage'
) {
const users: Record<number, tl.TypeUser> = {}
const chats: Record<number, tl.TypeChat> = {}
res.users.forEach((e) => (users[e.id] = e))
res.chats.forEach((e) => (chats[e.id] = e))
return new Message(
this,
u.message,
users,
chats,
u._ === 'updateNewScheduledMessage'
)
}
}
throw new MtCuteTypeAssertionError(
'_findMessageInUpdate (@ -> updates[*])',
'updateNewMessage | updateNewChannelMessage | updateNewScheduledMessage',
'none'
)
}

View file

@ -0,0 +1,92 @@
import { TelegramClient } from '../../client'
import { MaybeArray } from '@mtcute/core'
import { normalizeToInputChannel } from '../../utils/peer-utils'
import { tl } from '@mtcute/tl'
import { Message, InputPeerLike, MtCuteTypeAssertionError } from '../../types'
/**
* Get a single message in chat by its ID
*
* **Note**: this method might return empty message
*
* @param chatId Chat's marked ID, its username, phone or `"me"` or `"self"`
* @param messageId Messages ID
* @param [fromReply=false]
* Whether the reply to a given message should be fetched
* (i.e. `getMessages(msg.chat.id, msg.id, true).id === msg.replyToMessageId`)
* @internal
*/
export async function getMessages(
this: TelegramClient,
chatId: InputPeerLike,
messageId: number,
fromReply?: boolean
): Promise<Message>
/**
* Get messages in chat by their IDs
*
* **Note**: this method might return empty messages
*
* @param chatId Chat's marked ID, its username, phone or `"me"` or `"self"`
* @param messageIds Messages IDs
* @param [fromReply=false]
* Whether the reply to a given message should be fetched
* (i.e. `getMessages(msg.chat.id, msg.id, true).id === msg.replyToMessageId`)
* @internal
*/
export async function getMessages(
this: TelegramClient,
chatId: InputPeerLike,
messageIds: number[],
fromReply?: boolean
): Promise<Message[]>
/** @internal */
export async function getMessages(
this: TelegramClient,
chatId: InputPeerLike,
messageIds: MaybeArray<number>,
fromReply = false
): Promise<MaybeArray<Message>> {
const peer = await this.resolvePeer(chatId)
const isSingle = !Array.isArray(messageIds)
if (isSingle) messageIds = [messageIds as number]
const ids = (messageIds as number[]).map(
(it) =>
({
_: fromReply ? 'inputMessageReplyTo' : 'inputMessageID',
id: it,
} as tl.TypeInputMessage)
)
const res = await this.call(
peer._ === 'inputPeerChannel' || peer._ === 'inputChannel'
? {
_: 'channels.getMessages',
id: ids,
channel: normalizeToInputChannel(peer)!,
}
: {
_: 'messages.getMessages',
id: ids,
}
)
if (res._ === 'messages.messagesNotModified')
throw new MtCuteTypeAssertionError(
'getMessages',
'!messages.messagesNotModified',
res._
)
const users: Record<number, tl.TypeUser> = {}
const chats: Record<number, tl.TypeChat> = {}
res.users.forEach((e) => (users[e.id] = e))
res.chats.forEach((e) => (chats[e.id] = e))
const ret = res.messages.map((msg) => new Message(this, msg, users, chats))
return isSingle ? ret[0] : ret
}

View file

@ -0,0 +1,45 @@
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { normalizeToInputUser } from '../../utils/peer-utils'
const empty: [string, undefined] = ['', undefined]
/** @internal */
export async function _parseEntities(
this: TelegramClient,
text?: string,
mode?: string | null,
entities?: tl.TypeMessageEntity[]
): Promise<[string, tl.TypeMessageEntity[] | undefined]> {
if (!text) {
return empty
}
if (entities) {
// replace mentionName entities with input ones
for (const ent of entities) {
if (ent._ === 'messageEntityMentionName') {
try {
const inputPeer = normalizeToInputUser(
await this.resolvePeer(ent.userId)
)
// not a user
if (!inputPeer) continue
;(ent as any)._ = 'inputMessageEntityMentionName'
;(ent as any).userId = inputPeer
} catch (e) {}
}
}
return [text, entities]
}
if (mode === undefined) {
mode = this._defaultParseMode
}
// either explicitly disabled or no available parser
if (!mode) return [text, []]
return this._parseModes[mode].parse(text)
}

View file

@ -0,0 +1,155 @@
import {
InputPeerLike,
MediaLike,
Message,
BotKeyboard,
ReplyMarkup,
isUploadedFile,
filters,
Photo,
} from '../../types'
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { normalizeToInputPeer } from '../../utils/peer-utils'
import { randomUlong } from '../../utils/misc-utils'
/**
* Send a single photo
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param photo Photo contained in the message.
* @param params Additional sending parameters
* @internal
*/
export async function sendPhoto(
this: TelegramClient,
chatId: InputPeerLike,
photo: MediaLike,
params?: {
/**
* Caption for the photo
*/
caption?: string
/**
* Message to reply to. Either a message object or message ID.
*/
replyTo?: number | Message
/**
* Parse mode to use to parse entities before sending
* the message. Defaults to current default parse mode (if any).
*
* Passing `null` will explicitly disable formatting.
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/**
* Whether to send this message silently.
*/
silent?: boolean
/**
* If set, the message will be scheduled to this date.
* When passing a number, a UNIX time in ms is expected.
*/
schedule?: Date | number
/**
* For bots: inline or reply markup or an instruction
* to hide a reply keyboard or to force a reply.
*/
replyMarkup?: ReplyMarkup
/**
* Self-Destruct timer.
* If set, the photo will self-destruct in a given number
* of seconds.
*/
ttlSeconds?: number
/**
* Function that will be called after some part has been uploaded.
* Only used when a file that requires uploading is passed.
*
* @param uploaded Number of bytes already uploaded
* @param total Total file size
*/
progressCallback?: (uploaded: number, total: number) => void
}
): Promise<filters.Modify<Message, { media: Photo }>> {
if (!params) params = {}
let media: tl.TypeInputMedia
if (typeof photo === 'string' && photo.match(/^https?:\/\//)) {
media = {
_: 'inputMediaPhotoExternal',
url: photo,
ttlSeconds: params.ttlSeconds,
}
} else if (isUploadedFile(photo)) {
media = {
_: 'inputMediaUploadedPhoto',
file: photo.inputFile,
ttlSeconds: params.ttlSeconds,
}
} else if (typeof photo === 'object' && tl.isAnyInputFile(photo)) {
media = {
_: 'inputMediaUploadedPhoto',
file: photo,
ttlSeconds: params.ttlSeconds,
}
} else {
const uploaded = await this.uploadFile({
file: photo,
progressCallback: params.progressCallback,
})
media = {
_: 'inputMediaUploadedPhoto',
file: uploaded.inputFile,
ttlSeconds: params.ttlSeconds,
}
}
const [message, entities] = await this._parseEntities(
params.caption,
params.parseMode,
params.entities
)
const peer = normalizeToInputPeer(await this.resolvePeer(chatId))
const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup)
const res = await this.call({
_: 'messages.sendMedia',
media,
peer,
silent: params.silent,
replyToMsgId: params.replyTo
? typeof params.replyTo === 'number'
? params.replyTo
: params.replyTo.id
: undefined,
randomId: randomUlong(),
scheduleDate: params.schedule
? ~~(
(typeof params.schedule === 'number'
? params.schedule
: params.schedule.getTime()) / 1000
)
: undefined,
replyMarkup,
message,
entities,
})
return this._findMessageInUpdate(res) as any
}

View file

@ -0,0 +1,120 @@
import { TelegramClient } from '../../client'
import { tl } from '@mtcute/tl'
import { inputPeerToPeer, normalizeToInputPeer } from '../../utils/peer-utils'
import { randomUlong } from '../../utils/misc-utils'
import {
InputPeerLike,
Message,
filters,
BotKeyboard,
ReplyMarkup,
} from '../../types'
/**
* Send a text message
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param text Text of the message
* @param params Additional sending parameters
* @internal
*/
export async function sendText(
this: TelegramClient,
chatId: InputPeerLike,
text: string,
params?: {
/**
* Message to reply to. Either a message object or message ID.
*/
replyTo?: number | Message
/**
* Parse mode to use to parse entities before sending
* the message. Defaults to current default parse mode (if any).
*
* Passing `null` will explicitly disable formatting.
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/**
* Whether to disable links preview in this message
*/
disableWebPreview?: boolean
/**
* Whether to send this message silently.
*/
silent?: boolean
/**
* If set, the message will be scheduled to this date.
* When passing a number, a UNIX time in ms is expected.
*/
schedule?: Date | number
/**
* For bots: inline or reply markup or an instruction
* to hide a reply keyboard or to force a reply.
*/
replyMarkup?: ReplyMarkup
}
): Promise<filters.Modify<Message, { media: null }>> {
if (!params) params = {}
const [message, entities] = await this._parseEntities(
text,
params.parseMode,
params.entities
)
const peer = normalizeToInputPeer(await this.resolvePeer(chatId))
const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup)
const res = await this.call({
_: 'messages.sendMessage',
peer,
noWebpage: params.disableWebPreview,
silent: params.silent,
replyToMsgId: params.replyTo
? typeof params.replyTo === 'number'
? params.replyTo
: params.replyTo.id
: undefined,
randomId: randomUlong(),
scheduleDate: params.schedule
? ~~(
(typeof params.schedule === 'number'
? params.schedule
: params.schedule.getTime()) / 1000
)
: undefined,
replyMarkup,
message,
entities,
})
if (res._ === 'updateShortSentMessage') {
const msg: tl.RawMessage = {
_: 'message',
id: res.id,
peerId: inputPeerToPeer(peer),
message,
date: res.date,
out: res.out,
replyMarkup,
entities: res.entities,
}
return new Message(this, msg, {}, {}) as any
}
return this._findMessageInUpdate(res) as any
}

View file

@ -0,0 +1,17 @@
import { IMessageEntityParser } from '../../parser'
import { TelegramClient } from '../../client'
// @extension
interface ParseModesExtension {
_parseModes: Record<string, IMessageEntityParser>
_defaultParseMode: string | null
}
// @initialize
function _initializeParseModes(this: TelegramClient) {
this._parseModes = {}
this._defaultParseMode = null
}
// since IMessageEntityParser is copied here, we don't need to
// worry about marking it with @copy anywhere else.

View file

@ -0,0 +1,88 @@
import { TelegramClient } from '../../client'
import { IMessageEntityParser } from '../../parser'
import { MtCuteError } from '../../types'
/**
* Register a given {@link IMessageEntityParser} as a parse mode
* for messages. When this method is first called, given parse
* mode is also set as default.
*
* @param parseMode Parse mode to register
* @param name Parse mode name. By default is taken from the object.
* @throws MtCuteError When the parse mode with a given name is already registered.
* @internal
*/
export function registerParseMode(
this: TelegramClient,
parseMode: IMessageEntityParser,
name = parseMode.name
): void {
if (name in this._parseModes) {
throw new MtCuteError(
`Parse mode ${name} is already registered. Unregister it first!`
)
}
this._parseModes[name] = parseMode
if (!this._defaultParseMode) {
this._defaultParseMode = name
}
}
/**
* Unregister a parse mode by its name.
* Will silently fail if given parse mode does not exist.
*
* Also updates the default parse mode to the next one available, if any
*
* @param name Name of the parse mode to unregister
* @internal
*/
export function unregisterParseMode(this: TelegramClient, name: string): void {
delete this._parseModes[name]
if (this._defaultParseMode === name) {
this._defaultParseMode = Object.keys(this._defaultParseMode)[0] ?? null
}
}
/**
* Get a {@link IMessageEntityParser} registered under a given name (or a default one).
*
* @param name Name of the parse mode which parser to get.
* @throws MtCuteError When the provided parse mode is not registered
* @throws MtCuteError When `name` is omitted and there is no default parse mode
* @internal
*/
export function getParseMode(
this: TelegramClient,
name?: string | null
): IMessageEntityParser {
if (!name) {
if (!this._defaultParseMode)
throw new MtCuteError('There is no default parse mode')
name = this._defaultParseMode
}
if (name in this._parseModes) {
throw new MtCuteError(`Parse mode ${name} is not registered.`)
}
return this._parseModes[name]
}
/**
* Set a given parse mode as a default one.
*
* @param name Name of the parse mode
* @throws MtCuteError When given parse mode is not registered.
* @internal
*/
export function setDefaultParseMode(this: TelegramClient, name: string): void {
if (name in this._parseModes) {
throw new MtCuteError(`Parse mode ${name} is not registered.`)
}
this._defaultParseMode = name
}

View file

@ -0,0 +1,83 @@
import { TelegramClient } from '../../client'
import { tl } from '@mtcute/tl'
const debug = require('debug')('mtcute:upds')
/**
* Catch up with the server by loading missed updates.
*
* @internal
*/
export async function catchUp(this: TelegramClient): Promise<void> {
// this doesn't work with missed channel updates properly
// todo: fix
const state = await this.storage.getCommonPts()
if (!state) return
let [pts, date] = state
let error: Error | null = null
try {
for (;;) {
const diff = await this.call({
_: 'updates.getDifference',
pts,
date,
qts: 0,
})
if (
diff._ === 'updates.difference' ||
diff._ === 'updates.differenceSlice'
) {
const state =
diff._ === 'updates.difference'
? diff.state
: diff.intermediateState
pts = state.pts
date = state.date
this._handleUpdate({
_: 'updates',
users: diff.users,
chats: diff.chats,
date: state.date,
seq: state.seq,
updates: [
...diff.otherUpdates,
...diff.newMessages.map(
(m) =>
({
_: 'updateNewMessage',
message: m,
pts: 0,
ptsCount: 0,
} as tl.RawUpdateNewMessage)
),
],
})
debug(
'catching up... processed %d updates and %d messages',
diff.otherUpdates.length,
diff.newMessages.length
)
} else {
if (diff._ === 'updates.differenceEmpty') {
date = diff.date
} else if (diff._ === 'updates.differenceTooLong') {
pts = diff.pts
}
break
}
}
} catch (e) {
error = e
debug('error while catching up: ' + error)
}
debug('caught up')
await this.storage.setCommonPts([pts, date])
await this.storage.save?.()
}

View file

@ -0,0 +1,126 @@
import { TelegramClient } from '../../client'
import { tl } from '@mtcute/tl'
import {
UpdateHandler,
Message,
ContinuePropagation,
PropagationSymbol,
StopPropagation,
} from '../../types'
// @extension
interface DispatcherExtension {
_groups: Record<number, UpdateHandler[]>
_groupsOrder: number[]
}
// @initialize
function _initializeDispatcher() {
this._groups = {}
this._groupsOrder = []
}
/**
* @internal
*/
export async function _dispatchUpdate(
this: TelegramClient,
update: tl.TypeUpdate,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>
): Promise<void> {
let message: Message | null = null
if (
update._ === 'updateNewMessage' ||
update._ === 'updateNewChannelMessage' ||
update._ === 'updateNewScheduledMessage' ||
update._ === 'updateEditMessage' ||
update._ === 'updateEditChannelMessage'
) {
message = new Message(this, update.message, users, chats)
}
for (const grp of this._groupsOrder) {
for (const handler of this._groups[grp]) {
let result: void | PropagationSymbol
if (
handler.type === 'raw' &&
(!handler.check ||
(await handler.check(this, update, users, chats)))
) {
result = await handler.callback(this, update, users, chats)
} else if (
handler.type === 'new_message' &&
message &&
(!handler.check || (await handler.check(message, this)))
) {
result = await handler.callback(message, this)
} else continue
if (result === ContinuePropagation) continue
if (result === StopPropagation) return
break
}
}
}
/**
* Add an update handler to a given handlers group
*
* @param handler Update handler
* @param group Handler group index
* @internal
*/
export function addUpdateHandler(
this: TelegramClient,
handler: UpdateHandler,
group = 0
): void {
if (!(group in this._groups)) {
this._groups[group] = []
this._groupsOrder.push(group)
this._groupsOrder.sort((a, b) => a - b)
}
this._groups[group].push(handler)
}
/**
* Remove an update handler (or handlers) from a given
* handler group.
*
* @param handler Update handler to remove, its type or `'all'` to remove all
* @param group Handler group index
* @internal
*/
export function removeUpdateHandler(
this: TelegramClient,
handler: UpdateHandler | UpdateHandler['type'] | 'all',
group = 0
): void {
if (!(group in this._groups)) {
return
}
if (typeof handler === 'string') {
if (handler === 'all') {
delete this._groups[group]
} else {
this._groups[group] = this._groups[group].filter(
(h) => h.type !== handler
)
}
return
}
if (!(handler.type in this._groups[group])) {
return
}
const idx = this._groups[group].indexOf(handler)
if (idx > 0) {
this._groups[group].splice(idx, 1)
}
}

View file

@ -0,0 +1,196 @@
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { ChannelPrivateError } from '@mtcute/tl/errors'
import { MAX_CHANNEL_ID } from '@mtcute/core'
import { normalizeToInputChannel } from '../../utils/peer-utils'
import { extractChannelIdFromUpdate } from '../../utils/misc-utils'
const debug = require('debug')('mtcute:upds')
/**
* @internal
*/
export function _handleUpdate(
this: TelegramClient,
update: tl.TypeUpdates
): void {
;(async () => {
debug('received %s', update._)
// https://github.com/pyrogram/pyrogram/blob/a86656aefcc93cc3d2f5c98227d5da28fcddb136/pyrogram/client.py#L521
if (update._ === 'updates' || update._ === 'updatesCombined') {
const isMin = await this._cachePeersFrom(update)
const users: Record<number, tl.TypeUser> = {}
const chats: Record<number, tl.TypeChat> = {}
update.users.forEach((u) => (users[u.id] = u))
update.chats.forEach((u) => (chats[u.id] = u))
for (const upd of update.updates) {
if (upd._ === 'updateChannelTooLong') {
// what are we supposed to do with this?
debug(
'received updateChannelTooLong for channel %d (pts %d)',
upd.channelId,
upd.pts
)
continue
}
const channelId = extractChannelIdFromUpdate(upd)
const pts = 'pts' in upd ? upd.pts : undefined
const ptsCount = 'ptsCount' in upd ? upd.ptsCount : undefined
const date = 'date' in upd ? upd.date : undefined
if (upd._ === 'updateNewChannelMessage' && isMin) {
// min entities are useless, so we need to fetch actual entities
const msg = upd.message
if (msg._ !== 'messageEmpty') {
let diff:
| tl.RpcCallReturn['updates.getChannelDifference']
| null = null
const channel = normalizeToInputChannel(
await this.resolvePeer(MAX_CHANNEL_ID - channelId!)
)
if (!channel) return
try {
diff = await this.call({
_: 'updates.getChannelDifference',
channel: channel,
filter: {
_: 'channelMessagesFilter',
ranges: [
{
_: 'messageRange',
minId: upd.message.id,
maxId: upd.message.id,
},
],
},
pts: pts! - ptsCount!,
limit: pts!,
})
} catch (e) {
if (!(e instanceof ChannelPrivateError)) throw e
}
if (
diff &&
diff._ !== 'updates.channelDifferenceEmpty'
) {
diff.users.forEach((u) => (users[u.id] = u))
diff.chats.forEach((u) => (chats[u.id] = u))
}
}
}
if (channelId && pts) {
await this.storage.setChannelPts(channelId, pts)
}
if (!channelId && (pts || date)) {
await this.storage.setCommonPts([pts || null, date || null])
}
await this._dispatchUpdate(upd, users, chats)
}
await this.storage.setCommonPts([null, update.date])
// } else if (update._ === 'updateShortMessage') {
// const self = await this.storage.getSelf()
//
// const message: tl.RawMessage = {
// _: 'message',
// out: update.out,
// mentioned: update.mentioned,
// mediaUnread: update.mediaUnread,
// silent: update.silent,
// id: update.id,
// fromId: {
// _: 'peerUser',
// userId: update.out ? self!.userId : update.userId
// },
// peerId: {
// _: 'peerUser',
// userId: update.userId
// },
// fwdFrom: update.fwdFrom,
// viaBotId: update.viaBotId,
// replyTo: update.replyTo,
// date: update.date,
// message: update.message,
// entities: update.entities,
// ttlPeriod: update.ttlPeriod
// }
// } else if (update._ === 'updateShortChatMessage') {
// const message: tl.RawMessage = {
// _: 'message',
// out: update.out,
// mentioned: update.mentioned,
// mediaUnread: update.mediaUnread,
// silent: update.silent,
// id: update.id,
// fromId: {
// _: 'peerUser',
// userId: update.fromId
// },
// peerId: {
// _: 'peerChat',
// chatId: update.chatId
// },
// fwdFrom: update.fwdFrom,
// viaBotId: update.viaBotId,
// replyTo: update.replyTo,
// date: update.date,
// message: update.message,
// entities: update.entities,
// ttlPeriod: update.ttlPeriod
// }
//
} else if (
update._ === 'updateShortMessage' ||
update._ === 'updateShortChatMessage'
) {
await this.storage.setCommonPts([update.pts, update.date])
// these short updates don't contain users & chats,
// so we use updates.getDifference to fetch them
// definitely not the best way, but whatever
const diff = await this.call({
_: 'updates.getDifference',
pts: update.pts - update.ptsCount,
date: update.date,
qts: -1,
})
if (diff._ === 'updates.difference') {
if (diff.newMessages.length) {
const users: Record<number, tl.TypeUser> = {}
const chats: Record<number, tl.TypeChat> = {}
diff.users.forEach((u) => (users[u.id] = u))
diff.chats.forEach((u) => (chats[u.id] = u))
await this._dispatchUpdate(
{
_: 'updateNewMessage',
message: diff.newMessages[0],
pts: update.pts,
ptsCount: update.ptsCount,
},
users,
chats
)
} else if (diff.otherUpdates.length) {
await this._dispatchUpdate(diff.otherUpdates[0], {}, {})
}
}
} else if (update._ === 'updateShort') {
await this._dispatchUpdate(update.update, {}, {})
await this.storage.setCommonPts([null, update.date])
} else if (update._ === 'updatesTooLong') {
debug('got updatesTooLong')
}
})().catch((err) => this._emitError(err))
}

View file

@ -0,0 +1,86 @@
// import { TelegramClient } from '../../client'
// import { UpdateWithEntities } from '../../types/updates/utils'
// import { tl } from '@mtcute/tl'
// import { MAX_CHANNEL_ID } from '@mtcute/core'
//
//
// // @method
// export async function _loadDifference(
// this: TelegramClient,
// update: UpdateWithEntities,
// channelId: number | undefined,
// pts: number | null,
// date?: number
// ): Promise<void> {
// let result:
// | tl.RpcCallReturn['updates.getChannelDifference']
// | tl.RpcCallReturn['updates.getDifference']
//
// if (channelId) {
// let channel: tl.TypeInputChannel | null = null
// try {
// const ent = await this.resolvePeer(MAX_CHANNEL_ID - channelId)
// if (ent._ === 'inputPeerChannel') {
// channel = { ...ent, _: 'inputChannel' }
// }
// } catch (e) {}
// if (!channel) return
//
// if (!pts) {
// // first time, can't get diff. fetch pts instead
// const result = await this.call({
// _: 'channels.getFullChannel',
// channel,
// })
// await this.storage.setChannelPts(
// channelId,
// (result.fullChat as tl.RawChannelFull).pts
// )
// return
// }
//
// result = await this.call({
// _: 'updates.getChannelDifference',
// channel,
// filter: { _: 'channelMessagesFilterEmpty' },
// pts,
// limit: 100,
// force: true
// })
// } else {
// if (!date || !pts) {
// // first time, can't get diff. fetch pts and date instead
// const result = await this.call({ _: 'updates.getState' })
// await this.storage.setCommonPts([result.pts, result.date])
// return
// }
//
// result = await this.call({
// _: 'updates.getDifference',
// pts,
// date,
// qts: 0
// })
// }
//
// if (
// result._ === 'updates.difference' ||
// result._ === 'updates.differenceSlice' ||
// result._ === 'updates.channelDifference' ||
// result._ === 'updates.channelDifferenceTooLong'
// ) {
// const users: Record<number, tl.TypeUser> = {}
// result.users.forEach((u) => (users[u.id] = u))
// const chats: Record<number, tl.TypeChat> = {}
// result.chats.forEach((u) => (chats[u.id] = u))
//
// update._chats = {
// ...update._chats,
// ...chats
// }
// update._users = {
// ...update._users,
// ...users
// }
// }
// }

View file

@ -0,0 +1,47 @@
import { Message } from '../../types/messages/message'
import { PropagationSymbol } from '../../types/updates/propagation'
import { filters, UpdateFilter } from '../../types/updates/filters'
import { MaybeAsync } from '@mtcute/core'
import { TelegramClient } from '../../client'
import { handlers } from '../../types/updates/builders'
/**
* Register a message handler without any filters.
*
* @param handler Message handler
* @internal
*/
export function onNewMessage(
this: TelegramClient,
handler: (msg: Message) => MaybeAsync<void | PropagationSymbol>
): void
/**
* Register a message handler with a given filter
*
* @param filter Update filter
* @param handler Message handler
* @internal
*/
export function onNewMessage<Mod>(
this: TelegramClient,
filter: UpdateFilter<Message, Mod>,
handler: (
msg: filters.Modify<Message, Mod>
) => MaybeAsync<void | PropagationSymbol>
): void
/** @internal */
export function onNewMessage<Mod>(
this: TelegramClient,
filter:
| UpdateFilter<Message, Mod>
| ((msg: Message) => MaybeAsync<void | PropagationSymbol>),
handler?: (
msg: filters.Modify<Message, Mod>
) => MaybeAsync<void | PropagationSymbol>
): void {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: "The call would have succeeded against this implementation, but implementation signatures of overloads are not externally visible"
this.addUpdateHandler(handlers.newMessage(filter, handler))
}

View file

@ -0,0 +1,20 @@
import { InputPeerLike } from '../../types'
import { TelegramClient } from '../../client'
import { normalizeToInputPeer } from '../../utils/peer-utils'
/**
* Block a user
*
* @param id User ID, its username or phone number
* @returns Whether the action was successful
* @internal
*/
export async function blockUser(
this: TelegramClient,
id: InputPeerLike
): Promise<boolean> {
return this.call({
_: 'contacts.block',
id: normalizeToInputPeer(await this.resolvePeer(id)),
})
}

View file

@ -0,0 +1,26 @@
import { InputPeerLike, MtCuteInvalidPeerTypeError } from '../../types'
import { TelegramClient } from '../../client'
import { Chat } from '../../types'
import { normalizeToInputUser } from '../../utils/peer-utils'
/**
* Get a list of common chats you have with a given user
*
* @param userId User's ID, username or phone number
* @throws MtCuteInvalidPeerTypeError
* @internal
*/
export async function getCommonChats(
this: TelegramClient,
userId: InputPeerLike
): Promise<Chat[]> {
const peer = normalizeToInputUser(await this.resolvePeer(userId))
if (!peer) throw new MtCuteInvalidPeerTypeError(userId, 'user')
return this.call({
_: 'messages.getCommonChats',
userId: peer,
maxId: 0,
limit: 100,
}).then((res) => res.chats.map((it) => new Chat(this, it)))
}

View file

@ -0,0 +1,21 @@
import { TelegramClient } from '../../client'
import { User } from '../../types'
import { assertTypeIs } from '../../utils/type-assertion'
/**
* Get currently authorized user's full information
*
* @internal
*/
export function getMe(this: TelegramClient): Promise<User> {
return this.call({
_: 'users.getFullUser',
id: {
_: 'inputUserSelf',
},
}).then((res) => {
assertTypeIs('getMe (@ users.getFullUser -> user)', res.user, 'user')
return new User(this, res.user)
})
}

View file

@ -0,0 +1,56 @@
import { InputPeerLike, User } from '../../types'
import { TelegramClient } from '../../client'
import { MaybeArray } from '@mtcute/core'
import { tl } from '@mtcute/tl'
import { normalizeToInputUser } from '../../utils/peer-utils'
/**
* Get information about a single user.
*
* @param id User's identifier. Can be ID, username, phone number, `"me"` or `"self"` or TL object
* @internal
*/
export async function getUsers(
this: TelegramClient,
id: InputPeerLike
): Promise<User>
/**
* Get information about multiple users.
* You can retrieve up to 200 users at once
*
* @param ids Users' identifiers. Can be ID, username, phone number, `"me"`, `"self"` or TL object
* @internal
*/
export async function getUsers(
this: TelegramClient,
ids: InputPeerLike[]
): Promise<User[]>
/** @internal */
export async function getUsers(
this: TelegramClient,
ids: MaybeArray<InputPeerLike>
): Promise<MaybeArray<User>> {
const isArray = Array.isArray(ids)
if (!isArray) ids = [ids as InputPeerLike]
const inputPeers = ((
await Promise.all(
(ids as InputPeerLike[]).map((it) =>
this.resolvePeer(it).then(normalizeToInputUser)
)
)
).filter(Boolean) as unknown) as tl.TypeInputUser[]
let res = await this.call({
_: 'users.getUsers',
id: inputPeers,
})
res = res.filter((it) => it._ !== 'userEmpty')
return isArray
? res.map((it) => new User(this, it as tl.RawUser))
: new User(this, res[0] as tl.RawUser)
}

View file

@ -0,0 +1,92 @@
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { InputPeerLike, MtCuteNotFoundError } from '../../types'
import { getBasicPeerType, MAX_CHANNEL_ID } from '@mtcute/core'
import bigInt from 'big-integer'
/**
* Get the `InputPeer` of a known peer id.
* Useful when an `InputPeer` is needed.
*
* @param peerId The peer identifier that you want to extract the `InputPeer` from.
* @internal
*/
export async function resolvePeer(
this: TelegramClient,
peerId: InputPeerLike
): Promise<tl.TypeInputPeer | tl.TypeInputUser | tl.TypeInputChannel> {
// for convenience we also accept tl objects directly
if (typeof peerId === 'object') return peerId
if (typeof peerId === 'number') {
const fromStorage = await this.storage.getPeerById(peerId)
if (fromStorage) return fromStorage
}
if (typeof peerId === 'string') {
if (peerId === 'self' || peerId === 'me') return { _: 'inputPeerSelf' }
peerId = peerId.replace(/[@+\s]/g, '')
if (peerId.match(/^\d+$/)) {
// phone number
const fromStorage = await this.storage.getPeerByPhone(peerId)
if (fromStorage) return fromStorage
throw new MtCuteNotFoundError(
`Could not find a peer by phone ${peerId}`
)
} else {
// username
let fromStorage = await this.storage.getPeerByUsername(peerId)
if (fromStorage) return fromStorage
await this.call({
_: 'contacts.resolveUsername',
username: peerId,
})
fromStorage = await this.storage.getPeerByUsername(peerId)
if (fromStorage) return fromStorage
throw new MtCuteNotFoundError(
`Could not find a peer by username ${peerId}`
)
}
}
const peerType = getBasicPeerType(peerId)
if (peerType === 'user') {
await this.call({
_: 'users.getUsers',
id: [
{
_: 'inputUser',
userId: peerId,
accessHash: bigInt.zero,
},
],
})
} else if (peerType === 'chat') {
await this.call({
_: 'messages.getChats',
id: [-peerId],
})
} else if (peerType === 'channel') {
await this.call({
_: 'channels.getChannels',
id: [
{
_: 'inputChannel',
channelId: MAX_CHANNEL_ID - peerId,
accessHash: bigInt.zero,
},
],
})
}
const fromStorage = await this.storage.getPeerById(peerId)
if (fromStorage) return fromStorage
throw new MtCuteNotFoundError(`Could not find a peer by ID ${peerId}`)
}

View file

@ -0,0 +1,40 @@
import { tl } from '@mtcute/tl'
import { MessageEntity } from '../types'
/**
* Interface describing a message entity parser.
* MTCute comes with HTML parser inside `@mtcute/html-parser`
* and MarkdownV2 parser inside `@mtcute/markdown-parser`,
* implemented similar to how they are described
* in the [Bot API documentation](https://core.telegram.org/bots/api#formatting-options).
*
* You are also free to implement your own parser and register it with
* {@link TelegramClient.registerParseMode}.
*/
export interface IMessageEntityParser {
/**
* Default name for the parser.
*
* Used when registering the parser as a fallback value for `name`
*/
name: string
/**
* Parse a string containing some text with formatting to plain text
* and message entities
*
* @param text Formatted text
* @returns A tuple containing plain text and a list of entities
*/
parse(text: string): [string, tl.TypeMessageEntity[]]
/**
* Add formating to the text given the plain text and the entities.
*
* **Note** that `unparse(parse(text)) === text` is not always true!
*
* @param text Plain text
* @param entities Message entities that should be added to the text
*/
unparse(text: string, entities: MessageEntity[]): string
}

View file

@ -0,0 +1,2 @@
export * from './sent-code'
export * from './terms-of-service'

View file

@ -0,0 +1,89 @@
import { tl } from '@mtcute/tl'
import { makeInspectable } from '../utils'
const sentCodeMap: Record<
tl.auth.TypeSentCodeType['_'],
SentCode.DeliveryType
> = {
'auth.sentCodeTypeApp': 'app',
'auth.sentCodeTypeCall': 'call',
'auth.sentCodeTypeFlashCall': 'flash_call',
'auth.sentCodeTypeSms': 'sms',
}
const nextCodeMap: Record<
tl.auth.TypeCodeType['_'],
SentCode.NextDeliveryType
> = {
'auth.codeTypeCall': 'call',
'auth.codeTypeFlashCall': 'flash_call',
'auth.codeTypeSms': 'sms',
}
export namespace SentCode {
/**
* Type describing code delivery type.
* - `app`: Code is delivered via Telegram account
* - `sms`: Code is sent via SMS
* - `call`: Code is sent via voice call
* - `flash_call`: Code is the last 5 digits of the caller's phone number
*/
export type DeliveryType = 'app' | 'sms' | 'call' | 'flash_call'
/**
* Type describing next code delivery type.
* See {@link DeliveryType} for information on values.
*
* Additionally, can be `none` if no more types are available
* (this has never occurred in real life though)
*/
export type NextDeliveryType = Exclude<DeliveryType, 'app'> | 'none'
}
/**
* Information about sent confirmation code
*/
export class SentCode {
/**
* Underlying raw TL object
*/
readonly sentCode: tl.auth.TypeSentCode
constructor(obj: tl.auth.TypeSentCode) {
this.sentCode = obj
}
/**
* Type of currently sent confirmation code
*/
get type(): SentCode.DeliveryType {
return sentCodeMap[this.sentCode.type._]
}
/**
* Type of the confirmation code that will be sent
* if you call {@link TelegramClient.resendCode}.
*/
get nextType(): SentCode.NextDeliveryType {
return this.sentCode.nextType
? nextCodeMap[this.sentCode.nextType._]
: 'none'
}
/**
* Confirmation code identifier used for the next authorization steps
* (like {@link TelegramClient.signIn} and {@link TelegramClient.signUp})
*/
get phoneCodeHash(): string {
return this.sentCode.phoneCodeHash
}
/**
* Delay in seconds to wait before calling {@link TelegramClient.resendCode}
*/
get timeout(): number {
return this.sentCode.timeout ?? 0
}
}
makeInspectable(SentCode)

View file

@ -0,0 +1,48 @@
import { tl } from '@mtcute/tl'
import { MessageEntity } from '../messages'
import { makeInspectable } from '../utils'
/**
* Telegram's Terms of Service returned by {@link TelegramClient.signIn}
*/
export class TermsOfService {
/**
* Underlying raw TL object
*/
readonly tos: tl.help.TypeTermsOfService
constructor(obj: tl.help.TypeTermsOfService) {
this.tos = obj
}
/**
* Terms of Service identifier
*/
get id(): string {
return this.tos.id.data
}
/**
* Terms of Service text
*/
get text(): string {
return this.tos.text
}
private _entities?: MessageEntity[]
/**
* Terms of Service entities text
*/
get entities(): MessageEntity[] {
if (!this._entities) {
this._entities = this.tos.entities
.map((it) => MessageEntity._parse(it))
.filter((it) => it !== null) as MessageEntity[]
}
return this._entities
}
}
makeInspectable(TermsOfService)

View file

@ -0,0 +1 @@
export * from './keyboards'

View file

@ -0,0 +1,315 @@
import { tl } from '@mtcute/tl'
/**
* Reply keyboard markup
*/
export interface ReplyKeyboardMarkup
extends Omit<tl.RawReplyKeyboardMarkup, '_' | 'rows'> {
readonly type: 'reply'
/**
* Two-dimensional array of buttons
*/
readonly buttons: tl.TypeKeyboardButton[][]
}
/**
* Hide previously sent bot keyboard
*/
export interface ReplyKeyboardHide extends Omit<tl.RawReplyKeyboardHide, '_'> {
readonly type: 'reply_hide'
}
/**
* Force the user to send a reply
*/
export interface ReplyKeyboardForceReply
extends Omit<tl.RawReplyKeyboardForceReply, '_'> {
readonly type: 'force_reply'
}
/**
* Inline keyboard markup
*/
export interface InlineKeyboardMarkup {
readonly type: 'inline'
/**
* Two-dimensional array of buttons
*/
readonly buttons: tl.TypeKeyboardButton[][]
}
export type ReplyMarkup =
| ReplyKeyboardMarkup
| ReplyKeyboardHide
| ReplyKeyboardForceReply
| InlineKeyboardMarkup
/**
* Convenience methods wrapping TL
* objects creation for bot keyboard buttons.
*
* You can also use the type-discriminated objects directly.
*
* > **Note**: Button creation functions are intended to be used
* > with inline reply markup, unless stated otherwise
* > in the description.
*/
export namespace BotKeyboard {
/**
* Create an inline keyboard markup
*
* @param buttons Two-dimensional array of buttons
*/
export function inline(
buttons: tl.TypeKeyboardButton[][]
): InlineKeyboardMarkup {
return {
type: 'inline',
buttons,
}
}
/**
* Create a reply keyboard markup
*
* @param buttons Two-dimensional array of buttons
* @param params Additional parameters for the keyboard
*/
export function reply(
buttons: tl.TypeKeyboardButton[][],
params: Omit<ReplyKeyboardMarkup, 'type' | 'buttons'> = {}
): ReplyKeyboardMarkup {
return {
type: 'reply',
buttons,
...params,
}
}
/**
* Hide the previously sent reply keyboard
*
* @param selective
* Whether to remove the keyboard for specific users only. Targets:
* - users that are @mentioned in the text of the Message
* - in case this is a reply, sender of the original message
*/
export function hideReply(selective?: boolean): ReplyKeyboardHide {
return {
type: 'reply_hide',
selective,
}
}
/**
* Force the user to send a reply
*/
export function forceReply(
params: Omit<ReplyKeyboardForceReply, 'type'> = {}
): ReplyKeyboardForceReply {
return {
type: 'force_reply',
...params,
}
}
/**
* Create a text-only keyboard button.
*
* Used for reply keyboards, not inline!
*
* @param text Button text
*/
export function text(text: string): tl.RawKeyboardButton {
return {
_: 'keyboardButton',
text,
}
}
/**
* Create a keyboard button requesting for user's contact.
* Available only for private chats.
*
* Used for reply keyboards, not inline!
*
* @param text Button text
*/
export function requestContact(
text: string
): tl.RawKeyboardButtonRequestPhone {
return {
_: 'keyboardButtonRequestPhone',
text,
}
}
/**
* Create a keyboard button requesting for user's geo location.
* Available only for private chats.
*
* Used for reply keyboards, not inline!
*
* @param text Button text
*/
export function requestGeo(
text: string
): tl.RawKeyboardButtonRequestGeoLocation {
return {
_: 'keyboardButtonRequestGeoLocation',
text,
}
}
/**
* Create a keyboard button requesting the user to create and send a poll.
* Available only for private chats.
*
* Used for reply keyboards, not inline!
*
* @param text Button text
* @param quiz If set, only quiz polls can be sent
*/
export function requestPoll(
text: string,
quiz?: boolean
): tl.RawKeyboardButtonRequestPoll {
return {
_: 'keyboardButtonRequestPoll',
text,
quiz,
}
}
/**
* Create a keyboard button with a link.
*
* @param text Button text
* @param url URL
*/
export function url(text: string, url: string): tl.RawKeyboardButtonUrl {
return {
_: 'keyboardButtonUrl',
text,
url,
}
}
/**
* Create a keyboard button with a link.
*
* @param text Button text
* @param data Callback data (1-64 bytes). String will be converted to `Buffer`
* @param requiresPassword
* Whether the user should verify their identity by entering 2FA password.
* See more: [tl.RawKeyboardButtonCallback#requiresPassword](../../tl/interfaces/index.tl.rawkeyboardbuttoncallback.html#requirespassword)
*/
export function callback(
text: string,
data: string | Buffer,
requiresPassword?: boolean
): tl.RawKeyboardButtonCallback {
return {
_: 'keyboardButtonCallback',
text,
requiresPassword,
data: typeof data === 'string' ? Buffer.from(data) : data,
}
}
/**
* Button to force a user to switch to inline mode.
*
* Pressing the button will prompt the user to select
* one of their chats, open that chat and insert the bots
* username and the specified inline query (if any) in the input field.
*
* @param text Button text
* @param query Inline query (can be empty or omitted)
* @param currentChat
* If set, pressing the button will insert the bot's username
* and the specified inline query in the current chat's input field
*/
export function switchInline(
text: string,
query = '',
currentChat?: boolean
): tl.RawKeyboardButtonSwitchInline {
return {
_: 'keyboardButtonSwitchInline',
samePeer: currentChat,
text,
query,
}
}
/**
* Button to start a game
*
* **Note**: This type of button must always be
* the first button in the first row
*/
export function game(text: string): tl.RawKeyboardButtonGame {
return { _: 'keyboardButtonGame', text }
}
/** @internal */
export function _rowsTo2d(
rows: tl.RawKeyboardButtonRow[]
): tl.TypeKeyboardButton[][] {
return rows.map((it) => it.buttons)
}
/** @internal */
export function _2dToRows(
arr: tl.TypeKeyboardButton[][]
): tl.RawKeyboardButtonRow[] {
return arr.map((row) => ({
_: 'keyboardButtonRow',
buttons: row,
}))
}
/** @internal */
export function _convertToTl(
obj?: ReplyMarkup
): tl.TypeReplyMarkup | undefined {
if (!obj) return obj
if (obj.type === 'reply') {
return {
_: 'replyKeyboardMarkup',
resize: obj.resize,
singleUse: obj.singleUse,
selective: obj.selective,
rows: _2dToRows(obj.buttons),
}
}
if (obj.type === 'reply_hide') {
return {
_: 'replyKeyboardHide',
selective: obj.selective,
}
}
if (obj.type === 'force_reply') {
return {
_: 'replyKeyboardForceReply',
singleUse: obj.singleUse,
selective: obj.selective,
}
}
if (obj.type === 'inline') {
return {
_: 'replyInlineMarkup',
rows: _2dToRows(obj.buttons),
}
}
return undefined
}
}

View file

@ -0,0 +1,75 @@
/**
* Base class for all `@mtcute/client` errors
*/
import { InputPeerLike } from './peers'
export class MtCuteError extends Error {}
/**
* Method invocation was invalid because some argument
* passed was invalid.
*/
export class MtCuteArgumentError extends MtCuteError {}
/**
* Could not find peer by provided information
*/
export class MtCuteNotFoundError extends MtCuteError {}
/**
* Either you requested or the server returned something
* that is not (yet) supported.
*
* Stay tuned for future updates!
*/
export class MtCuteUnsupportedError extends MtCuteError {}
/**
* Server returned something of an unexpected type.
*/
export class MtCuteTypeAssertionError extends MtCuteError {
/**
* Context at which the error occurred.
* Usually a user-friendly string containing name
* of the high-level API method, name of the TL
* RPC method, and path of the entity,
* like this: `signIn (@ auth.signIn -> user)`
*/
context: string
/** Expected TL type */
expected: string
/** Actual TL type */
actual: string
constructor(context: string, expected: string, actual: string) {
super(
`Type assertion error at ${context}: expected ${expected}, but got ${actual}`
)
}
}
/**
* Some method that requires a particular type of peer
* is called, but the resolved peer type is invalid.
*
* For example, when trying to get common chats
* while providing another chat as `userId`
*/
export class MtCuteInvalidPeerTypeError extends MtCuteError {
constructor(peer: InputPeerLike, expected: string) {
super(
`Provided identifier ${JSON.stringify(peer)} is not a ${expected}`
)
}
}
/**
* Trying to access to some property on an "empty" object.
*/
export class MtCuteEmptyError extends MtCuteError {
constructor() {
super('Property is not available on an empty object')
}
}

View file

@ -0,0 +1,130 @@
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { Readable } from 'stream'
import { makeInspectable } from '../utils'
/**
* Information about file location.
*
* Catch-all class for all kinds of Telegram file locations,
* including ones that are embedded directly into the entity.
*/
export class FileLocation {
/**
* Client that was used to create this object
*/
readonly client: TelegramClient
/**
* Location of the file.
*
* Either a TL object declaring remote file location,
* a Buffer containing actual file content (for stripped thumbnails and vector previews),
* or a function that will return either of those.
*
* When a function is passed, it will be lazily resolved the
* first time downloading the file.
*/
readonly location:
| tl.TypeInputFileLocation
| Buffer
| (() => tl.TypeInputFileLocation | Buffer)
/**
* File size in bytes, when available
*/
readonly fileSize?: number
/**
* DC ID of the file, when available
*/
readonly dcId?: number
constructor(
client: TelegramClient,
location:
| tl.TypeInputFileLocation
| Buffer
| (() => tl.TypeInputFileLocation | Buffer),
fileSize?: number,
dcId?: number
) {
this.client = client
this.location = location
this.fileSize = fileSize
this.dcId = dcId
}
/** @internal */
static fromDeprecated(
client: TelegramClient,
peer: tl.TypeInputPeer,
loc: tl.RawFileLocationToBeDeprecated,
dcId?: number,
big?: boolean
): FileLocation {
return new FileLocation(
client,
{
_: 'inputPeerPhotoFileLocation',
peer,
volumeId: loc.volumeId,
localId: loc.localId,
big,
},
undefined,
dcId
)
}
/**
* Download a file and return it as an iterable, which yields file contents
* in chunks of a given size. Order of the chunks is guaranteed to be
* consecutive.
*
* Shorthand for `client.downloadAsStream({ location: this })`
*
* @see TelegramClient.downloadAsIterable
*/
downloadIterable(): AsyncIterableIterator<Buffer> {
return this.client.downloadAsIterable({ location: this })
}
/**
* Download a file and return it as a Node readable stream,
* streaming file contents.
*
* Shorthand for `client.downloadAsStream({ location: this })`
*
* @see TelegramClient.downloadAsStream
*/
downloadStream(): Readable {
return this.client.downloadAsStream({ location: this })
}
/**
* Download a file and return its contents as a Buffer.
*
* Shorthand for `client.downloadAsBuffer({ location: this })`
*
* @see TelegramClient.downloadAsBuffer
*/
downloadBuffer(): Promise<Buffer> {
return this.client.downloadAsBuffer({ location: this })
}
/**
* Download a remote file to a local file (only for NodeJS).
* Promise will resolve once the download is complete.
*
* Shorthand for `client.downloadToFile(filename, { location: this })`
*
* @param filename Local file name
* @see TelegramClient.downloadToFile
*/
downloadToFile(filename: string): Promise<void> {
return this.client.downloadToFile(filename, { location: this })
}
}
makeInspectable(FileLocation, ['fileSize', 'dcId'])

View file

@ -0,0 +1,3 @@
export * from './utils'
export * from './file-location'
export * from './uploaded-file'

View file

@ -0,0 +1,36 @@
import { tl } from '@mtcute/tl'
/**
* Describes a file uploaded to the Telegram servers
* using {@link TelegramClient.uploadFile} method.
*/
export interface UploadedFile {
/**
* Raw TL input file to be used in other methods.
*
* Very low-level stuff, usually you shouldn't care about it.
*/
inputFile: tl.TypeInputFile
/**
* File size in bytes
*/
size: number
/**
* File MIME type, either the one passed or
* the one derived from file contents.
*/
mime: string
}
/** @internal */
export function isUploadedFile(obj: any): obj is UploadedFile {
return (
obj &&
typeof obj === 'object' &&
'inputFile' in obj &&
'size' in obj &&
'mime' in obj
)
}

View file

@ -0,0 +1,82 @@
import { tl } from '@mtcute/tl'
import type { Readable } from 'stream'
import type { ReadStream } from 'fs'
import { UploadedFile } from './uploaded-file'
import { FileLocation } from './file-location'
/**
* Describes types that can be used in {@link TelegramClient.uploadFile}
* method. Can be one of:
* - `Buffer`, which will be interpreted as raw file contents
* - `File` (from the Web API)
* - `string`, which will be interpreted as file path (**Node only!**)
* - `ReadStream` (for NodeJS, from the `fs` module)
* - `ReadableStream` (from the Web API, base readable stream)
* - `Readable` (for NodeJS, base readable stream)
*/
export type UploadFileLike =
| Buffer
| File
| string
| ReadStream
| ReadableStream
| Readable
/**
* Describes types that can be used as an input
* to any methods that send media (like {@link TelegramClient.sendPhoto})
*
* In addition to all the types available in {@link UploadFileLike}, you
* can also pass {@link UploadedFile} returned from {@link TelegramClient.uploadFile},
* raw `tl.TypeInputFile` and URLs to remote files
*/
export type MediaLike = UploadFileLike | UploadedFile | tl.TypeInputFile
export interface FileDownloadParameters {
/**
* File location which should be downloaded
*/
location: tl.TypeInputFileLocation | FileLocation
/**
* Total file size, if known.
* Used to determine upload part size.
* In some cases can be inferred from `file` automatically.
*/
fileSize?: number
/**
* Download part size (in KB).
* By default, automatically selected depending on the file size
* (or 64, if not provided). Must not be bigger than 512,
* must not be a fraction, and must be divisible by 4.
*/
partSize?: number
/**
* DC id from which the file will be downloaded.
*
* If provided DC is not the one storing the file,
* redirection will be handled automatically.
*/
dcId?: number
/**
* Offset in bytes. Must be divisible by 4096 (4 KB).
*/
offset?: number
/**
* Number of chunks (!) of that given size that will be downloaded.
* By default, downloads the entire file
*/
limit?: number
/**
* Function that will be called after some part has been downloaded.
*
* @param uploaded Number of bytes already downloaded
* @param total Total file size (`Infinity` if not available)
*/
progressCallback?: (downloaded: number, total: number) => void
}

View file

@ -0,0 +1,11 @@
export * from './auth'
export * from './bots'
export * from './files'
export * from './media'
export * from './messages'
export * from './peers'
export * from './updates'
export * from './errors'
export { MaybeDynamic } from './utils'
export { MaybeAsync } from '@mtcute/core'

View file

@ -0,0 +1,46 @@
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { RawDocument } from './document'
import { makeInspectable } from '../utils'
/**
* An audio file
*/
export class Audio extends RawDocument {
readonly doc: tl.RawDocument
readonly attr: tl.RawDocumentAttributeAudio
constructor(
client: TelegramClient,
doc: tl.RawDocument,
attr: tl.RawDocumentAttributeAudio
) {
super(client, doc)
this.attr = attr
}
/**
* Duration of the audio in seconds.
*
* May not be accurate since provided by the sender.
*/
get duration(): number {
return this.attr.duration
}
/**
* Performer of the audio track.
*/
get performer(): string | null {
return this.attr.performer ?? null
}
/**
* Title of the audio track.
*/
get title(): string | null {
return this.attr.title ?? null
}
}
makeInspectable(Audio, ['fileSize', 'dcId'])

View file

@ -0,0 +1,43 @@
/**
* A phone contact
*/
import { tl } from '@mtcute/tl'
import { makeInspectable } from '../utils'
export class Contact {
readonly obj: tl.RawMessageMediaContact
constructor(obj: tl.RawMessageMediaContact) {
this.obj = obj
}
/**
* Contact's phone number
*/
get phoneNumber(): string {
return this.obj.phoneNumber
}
/**
* Contact's first name
*/
get firstName(): string {
return this.obj.firstName
}
/**
* Contact's last name
*/
get lastName(): string {
return this.obj.lastName
}
/**
* Contact's user ID in Telegram or `0` if not available
*/
get userId(): number {
return this.obj.userId
}
}
makeInspectable(Contact)

View file

@ -0,0 +1,164 @@
import { tl } from '@mtcute/tl'
import { makeInspectable } from '../utils'
/**
* A dice or another interactive random emoji.
*/
export class Dice {
readonly obj: tl.RawMessageMediaDice
/**
* A simple 6-sided dice.
*
* {@link value} represents its value (1-6)
*/
static readonly TYPE_DICE = '🎲'
/**
* A dart. Telegram dart has 4 rings and middle.
*
* {@link value} represents the position of the dart:
* ![Dart position graph](https://i.imgur.com/iPBm7HG.png)
*/
static readonly TYPE_DART = '🎯'
/**
* A basketball thrown into a hoop.
*
* {@link value} represents the motion of the ball:
* - 1: simple miss (ball hits right part, bounces to the left)
* - 2: first hit the ring, then miss
* - 3: ball gets stuck between the ring and the base
* - 4: first hit the ring, then score
* - 5: direct score
*/
static readonly TYPE_BASKETBALL = '🏀'
/**
* A football thrown to the gate.
*
* {@link value} represents the motion of the ball:
* - 1: flies above the top barbell
* - 2: hits right barbell, then miss
* - 3: direct score in the middle
* - 4: hits left barbell, then score, then hits right barbell
* - 5: score in the top-right corner
*/
static readonly TYPE_FOOTBALL = '⚽️'
/**
* A bowling ball thrown to pins.
*
* Assuming the following identifiers for the pins:
* ```
* 4 5 6
* 2 3
* 1
* ```
*
* {@link value} represents the motion of the ball and pins:
* - 1: the ball touched 6th pin, none are down
* - 2: the ball hit 4th pin, only 4th pin is down.
* - 3: the ball hit 1st pin, pins 1, 2, 5 are down, leaving pins 3, 4, 6
* - 4: the ball hit 1st pin on the right side, all the pins except 2nd and 6th are down
* - 5: the ball hit 3rd pin and all the pins except 2nd are down.
* - 6: the ball hit 1st pin and all pins are down
*/
static readonly TYPE_BOWLING = '🎳'
/**
* A slot machine.
*
* {@link value} represents the result of the machine.
* Value itself is an integer in range `[1, 64]`,
* and is composed out of several parts.
*
* > **Note**: The following information is based on the TDesktop
* > implementation. These are the relevant files:
* > - [`chat_helpers/stickers_dice_pack.cpp`](https://github.com/telegramdesktop/tdesktop/blob/dev/Telegram/SourceFiles/chat_helpers/stickers_dice_pack.cpp)
* > - [`history/view/media/history_view_slot_machine.cpp`](https://github.com/telegramdesktop/tdesktop/blob/dev/Telegram/SourceFiles/history/view/media/history_view_slot_machine.cpp)
*
* Unlike other animated dices, this does not have
* all the possible combinations in the sticker set.
* Instead, `value` is a specially encoded integer that contains
* the information about the indexes.
*
* There are some base parts of the animations:
* - 0th sticker is the base background of the machine
* - 1st sticker is the background of the machine for the "winning" state (i.e. `777`)
* - 2nd sticker is the frame of the machine, including the handle
* - 8th sticker is the "idle" state for the left slot
* - 14th sticker is the "idle" state for the middle slot
* - 20th sticker is the "idle" state for the right slot
*
* The machine result is encoded as 3 concatenated two-bit integers,
* and the resulting integer is incremented by one.
*
* So, to decode the value to its parts, you can use this code:
* ```typescript
* const computePartValue = (val: number, idx: number) => ((val - 1) >> (idx * 2)) & 0x03; // 0..3
* const parts = [
* computePartValue(msg.media.value, 0),
* computePartValue(msg.media.value, 1),
* computePartValue(msg.media.value, 2),
* ]
* ```
*
* Each part of the value corresponds to a particular slot (i.e. part 0 is left slot,
* part 1 is middle, part 2 is right). The slot values are as follows:
* - 0: BAR (displayed as a *BAR* sign on a black background)
* - 1: BERRIES (displayed as berries, similar to emoji 🍇)
* - 2: LEMON (displayed as a lemon, similar to emoji 🍋)
* - 3: SEVEN (displayed as a red digit 7)
*
* Therefore, the winning value (i.e. `777`) is represented as `(3 << 4 | 3 << 2 | 3 << 0) + 1 = 64`
*
* To determine the needed animation parts, you'll need to apply some shifts.
* These are the offsets for the individual symbols:
* - WIN_SEVEN: 0
* - SEVEN: 1
* - BAR: 2
* - BERRIES: 3
* - LEMON: 4
*
* And these are the shifts for each slot:
* - LEFT: 3
* - MIDDLE: 9
* - RIGHT: 15
*
* > WIN_SEVEN is the same as SEVEN, but only used if the machine result is `777` (i.e. `value = 64`),
* > as it contains additional "blinking" animation.
*
* The sticker index is computed as follows: `SHIFTS[SLOT] + OFFSETS[SYM]`.
* For example, berries for the middle slot would be: `SHIFTS[MIDDLE] + OFFSETS[BERRIES] = 9 + 3 = 12`
*
* Currently, this sticker set is used for the machine: [SlotMachineAnimated](https://t.me/addstickers/SlotMachineAnimated)
*/
static readonly TYPE_SLOTS = '🎰'
constructor(obj: tl.RawMessageMediaDice) {
this.obj = obj
}
/**
* An emoji which was originally sent.
*
* See static members of {@link Dice} for a list
* of possible values
*/
get emoji(): string {
return this.obj.emoticon
}
/**
* Emoji's interactive value.
*
* See what this value represents in the corresponding
* type's documentation (in {@link Dice} static fields)
*/
get value(): number {
return this.obj.value
}
}
makeInspectable(Dice)

View file

@ -0,0 +1,41 @@
import { tl } from '@mtcute/tl'
import { Document, RawDocument } from './document'
import { Audio } from './audio'
import { TelegramClient } from '../../client'
import { Video } from './video'
import { Voice } from './voice'
import { Sticker } from './sticker'
/** @ignore */
export function parseDocument(
client: TelegramClient,
doc: tl.RawDocument
): RawDocument {
for (const attr of doc.attributes) {
if (attr._ === 'documentAttributeAudio') {
if (attr.voice) {
return new Voice(client, doc, attr)
} else {
return new Audio(client, doc, attr)
}
}
if (attr._ === 'documentAttributeSticker') {
const sz = doc.attributes.find(
(it) => it._ === 'documentAttributeImageSize'
)! as tl.RawDocumentAttributeImageSize
return new Sticker(client, doc, attr, sz)
}
if (
attr._ === 'documentAttributeVideo' ||
// legacy gif
(attr._ === 'documentAttributeImageSize' &&
doc.mimeType === 'image/gif')
) {
return new Video(client, doc, attr)
}
}
return new Document(client, doc)
}

View file

@ -0,0 +1,107 @@
import { FileLocation } from '../files'
import { tl } from '@mtcute/tl'
import { Thumbnail } from './thumbnail'
import { TelegramClient } from '../../client'
import { makeInspectable } from '../utils'
/**
* A file that is represented as a document in MTProto.
*
* This also includes audios, videos, voices etc.
*/
export class RawDocument extends FileLocation {
/**
* Raw TL object with the document itself
*/
readonly doc: tl.RawDocument
constructor(client: TelegramClient, doc: tl.RawDocument) {
super(
client,
{
_: 'inputDocumentFileLocation',
id: doc.id,
fileReference: doc.fileReference,
accessHash: doc.accessHash,
thumbSize: '',
},
doc.size,
doc.dcId
)
this.doc = doc
}
private _fileName?: string | null
/**
* Original file name, extracted from the document
* attributes.
*/
get fileName(): string | null {
if (this._fileName === undefined) {
const attr = this.doc.attributes.find(
(it) => it._ === 'documentAttributeFilename'
)
this._fileName = attr
? (attr as tl.RawDocumentAttributeFilename).fileName
: null
}
return this._fileName
}
/**
* File MIME type, as defined by the sender.
*/
get mimeType(): string {
return this.doc.mimeType
}
/**
* Date the document was sent
*/
get date(): Date {
return new Date(this.doc.date * 1000)
}
private _thumbnails?: Thumbnail[]
/**
* Available thumbnails, if any.
*
* If there are no thumbnails, the array will be empty.
*/
get thumbnails(): Thumbnail[] {
if (!this._thumbnails) {
this._thumbnails = this.doc.thumbs
? this.doc.thumbs.map(
(sz) => new Thumbnail(this.client, this.doc, sz)
)
: []
}
return this._thumbnails
}
/**
* Get a thumbnail by its type.
*
* Thumbnail types are described in the
* [Telegram docs](https://core.telegram.org/api/files#image-thumbnail-types),
* and are also available as static members of {@link Thumbnail} for convenience.
*
* @param type Thumbnail type
*/
getThumbnail(type: string): Thumbnail | null {
return this.thumbnails.find((it) => it.raw.type === type) ?? null
}
}
/**
* A generic file.
*
* This does not include audios, videos, voices etc.
* and only used for documents without any special
* attributes.
*/
export class Document extends RawDocument {}
makeInspectable(Document, ['fileSize', 'dcId'])

View file

@ -0,0 +1,84 @@
import { tl } from '@mtcute/tl'
import { Photo } from './photo'
import { Video } from './video'
import { TelegramClient } from '../../client'
import { makeInspectable } from '../utils'
export class Game {
readonly game: tl.RawGame
readonly client: TelegramClient
constructor(client: TelegramClient, game: tl.RawGame) {
this.client = client
this.game = game
}
/**
* Unique identifier of the game.
*/
get id(): tl.Long {
return this.game.id
}
/**
* Title of the game
*/
get title(): string {
return this.game.title
}
/**
* Description of the game
*/
get description(): string {
return this.game.description
}
/**
* Unique short name of the game
*/
get shortName(): string {
return this.game.shortName
}
private _photo?: Photo
/**
* Photo that will be displayed in the game message in chats
*/
get photo(): Photo | null {
if (this.game.photo._ === 'photoEmpty') return null
if (!this._photo) {
this._photo = new Photo(this.client, this.game.photo)
}
return this._photo
}
private _animation?: Video | null
/**
* Animation that will be displayed in the game message in chats
*/
get animation(): Video | null {
if (this.game.document?._ !== 'document') return null
if (this._animation === undefined) {
const attr = this.game.document.attributes.find(
(it) => it._ === 'documentAttributeVideo'
) as tl.RawDocumentAttributeVideo | undefined
if (!attr) {
this._animation = null
} else {
this._animation = new Video(
this.client,
this.game.document,
attr
)
}
}
return this._animation
}
}
makeInspectable(Game)

View file

@ -0,0 +1,10 @@
export * from './photo'
export * from './thumbnail'
export * from './document'
export * from './dice'
export * from './audio'
export * from './contact'
export * from './video'
export * from './location'
export * from './voice'
export * from './sticker'

View file

@ -0,0 +1,62 @@
import { makeInspectable } from '../utils'
import { tl } from '@mtcute/tl'
/**
* A point on the map
*/
export class Location {
readonly geo: tl.RawGeoPoint
constructor(geo: tl.RawGeoPoint) {
this.geo = geo
}
/**
* Geo point latitude
*/
get latitude(): number {
return this.geo.lat
}
/**
* Geo point longitude
*/
get longitude(): number {
return this.geo.long
}
/**
* Accuracy radius in meters.
*/
get radius(): number {
return this.geo.accuracyRadius ?? 0
}
}
export class LiveLocation extends Location {
readonly live: tl.RawMessageMediaGeoLive
constructor(live: tl.RawMessageMediaGeoLive) {
super(live.geo as tl.RawGeoPoint)
this.live = live
}
/**
* A direction in which the location moves, in degrees; 1-360
*/
get heading(): number | null {
return this.live.heading ?? null
}
/**
* Validity period of provided geolocation
*/
get period(): number {
return this.live.period
}
// todo: api to subscribe for real-time updates
}
makeInspectable(Location)
makeInspectable(LiveLocation)

View file

@ -0,0 +1,110 @@
import { tl } from '@mtcute/tl'
import { FileLocation } from '../files/file-location'
import { TelegramClient } from '../../client'
import { MtCuteArgumentError } from '../errors'
import { Thumbnail } from './thumbnail'
import { makeInspectable } from '../utils'
/**
* A photo
*/
export class Photo extends FileLocation {
/** Raw TL object */
readonly raw: tl.RawPhoto
/**
* Photo size in bytes
*/
readonly fileSize: number
/**
* DC where the photo is stored
*/
readonly dcId: number
/** Biggest available photo width */
readonly width: number
/** Biggest available photo height */
readonly height: number
constructor(client: TelegramClient, raw: tl.RawPhoto) {
const location = {
_: 'inputPhotoFileLocation',
id: raw.id,
fileReference: raw.fileReference,
accessHash: raw.accessHash,
thumbSize: '',
} as tl.Mutable<tl.RawInputPhotoFileLocation>
let size, width, height: number
const progressive = raw.sizes.find(
(it) => it._ === 'photoSizeProgressive'
) as tl.RawPhotoSizeProgressive | undefined
if (progressive) {
location.thumbSize = progressive.type
size = Math.max(...progressive.sizes)
width = progressive.w
height = progressive.h
} else {
let max: tl.RawPhotoSize | null = null
for (const sz of raw.sizes) {
if (sz._ === 'photoSize' && (!max || sz.size > max.size)) {
max = sz
}
}
if (max) {
location.thumbSize = max.type
size = max.size
width = max.w
height = max.h
} else {
// does this happen at all?
throw new MtCuteArgumentError('Photo does not have any sizes')
}
}
super(client, location, size, raw.dcId)
this.raw = raw
this.width = width
this.height = height
}
/** Date this photo was sent */
get date(): Date {
return new Date(this.raw.date * 1000)
}
private _thumbnails?: Thumbnail[]
/**
* Available thumbnails.
*
* **Note**: This list will also contain the largest thumbnail that is
* represented by the current object.
*/
get thumbnails(): Thumbnail[] {
if (!this._thumbnails) {
this._thumbnails = this.raw.sizes.map(
(sz) => new Thumbnail(this.client, this.raw, sz)
)
}
return this._thumbnails
}
/**
* Get a photo thumbnail by its type.
*
* Thumbnail types are described in the
* [Telegram docs](https://core.telegram.org/api/files#image-thumbnail-types),
* and are also available as static members of {@link Thumbnail} for convenience.
*
* @param type Thumbnail type
*/
getThumbnail(type: string): Thumbnail | null {
return this.thumbnails.find((it) => it.raw.type === type) ?? null
}
}
makeInspectable(Photo, ['fileSize', 'dcId', 'width', 'height'])

View file

@ -0,0 +1,106 @@
import { RawDocument } from './document'
import { TelegramClient } from '../../client'
import { tl } from '@mtcute/tl'
import { makeInspectable } from '../utils'
/**
* A sticker
*/
export class Sticker extends RawDocument {
readonly attr: tl.RawDocumentAttributeSticker
readonly attrSize?: tl.RawDocumentAttributeImageSize
constructor(
client: TelegramClient,
doc: tl.RawDocument,
attr: tl.RawDocumentAttributeSticker,
attrSize?: tl.RawDocumentAttributeImageSize
) {
super(client, doc)
this.attr = attr
this.attrSize = attrSize
}
/**
* Sticker width in pixels
*/
get width(): number {
return this.attrSize?.w ?? 512
}
/**
* Sticker height in pixels
*/
get height(): number {
return this.attrSize?.h ?? 512
}
/**
* Emoji associated with this sticker.
*
* If there is none, empty string is returned.
*
* **Note:** This only contains at most one emoji.
* Some stickers have multiple associated emojis,
* but only one is returned here. This is Telegram's
* limitation! Use {@link getAllEmojis}
*/
get emoji(): string {
return this.attr.alt
}
/**
* Whether the sticker is animated.
*
* Animated stickers are represented as gzipped
* lottie json files, and have MIME `application/x-tgsticker`,
* while normal stickers are WEBP images and have MIME `image/webp`
*/
get isAnimated(): boolean {
return this.mimeType === 'application/x-tgsticker'
}
/**
* Whether this sticker has an associated public sticker set.
*/
get hasStickerSet(): boolean {
return this.attr.stickerset._ === 'inputStickerSetID'
}
/**
* Get the sticker set that this sticker belongs to.
*
* Returns `null` if there's no sticker set.
*/
async getStickerSet(): Promise<tl.messages.RawStickerSet | null> {
if (!this.hasStickerSet) return null
return this.client.call({
_: 'messages.getStickerSet',
stickerset: this.attr.stickerset,
})
}
/**
* Fetch all the emojis that are associated with the current sticker
*
* Returns empty string if the sticker is not associated
* with a sticker pack.
*/
async getAllEmojis(): Promise<string> {
let ret = ''
const set = await this.getStickerSet()
if (!set) return ''
set.packs.forEach((pack) => {
if (pack.documents.some((doc) => doc.eq(this.doc.id))) {
ret += pack.emoticon
}
})
return ret
}
}
makeInspectable(Sticker, ['fileSize', 'dcId'])

View file

@ -0,0 +1,135 @@
import { TelegramClient } from '../../client'
import { FileLocation } from '../files/file-location'
import { tl } from '@mtcute/tl'
import {
inflateSvgPath,
strippedPhotoToJpg,
svgPathToFile,
} from '../../utils/file-utils'
import { MtCuteTypeAssertionError } from '../errors'
import { assertTypeIs } from '../../utils/type-assertion'
import { makeInspectable } from '../utils'
/**
* One size of some thumbnail
*/
export class Thumbnail extends FileLocation {
// see: https://core.telegram.org/api/files#image-thumbnail-types
static readonly THUMB_100x100_BOX = 's'
static readonly THUMB_320x320_BOX = 'm'
static readonly THUMB_800x800_BOX = 'x'
static readonly THUMB_1280x1280_BOX = 'y'
static readonly THUMB_2560x2560_BOX = 'w'
static readonly THUMB_160x160_CROP = 'a'
static readonly THUMB_320x320_CROP = 'b'
static readonly THUMB_640x640_CROP = 'c'
static readonly THUMB_1280x1280_CROP = 'd'
static readonly THUMB_STRIP = 'i'
static readonly THUMB_OUTLINE = 'j'
/**
* Thumbnail size in bytes
*/
readonly fileSize: number
/**
* DC where the thumbnail is stored
*/
readonly dcId: number
readonly raw: tl.TypePhotoSize
/**
* Thumbnail width
* (`NaN` for {@link THUMB_OUTLINE} and {@link THUMB_STRIP})
*/
readonly width: number
/**
* Thumbnail height
* (`NaN` for {@link THUMB_OUTLINE} and {@link THUMB_STRIP})
*/
readonly height: number
private _path?: string
constructor(
client: TelegramClient,
media: tl.RawPhoto | tl.RawDocument,
sz: tl.TypePhotoSize
) {
if (sz._ === 'photoSizeEmpty' || sz._ === 'photoCachedSize')
throw new MtCuteTypeAssertionError(
'Thumbnail#constructor (sz)',
'not (photoSizeEmpty | photoCachedSize)',
sz._
)
let location:
| tl.TypeInputFileLocation
| Buffer
| (() => tl.TypeInputFileLocation | Buffer)
let size, width, height: number
if (sz._ === 'photoStrippedSize') {
location = strippedPhotoToJpg(sz.bytes)
width = height = NaN
size = location.length
} else if (sz._ === 'photoPathSize') {
// lazily
location = () => svgPathToFile(this._path!)
width = height = NaN
size = Infinity // this doesn't really matter
} else {
location = {
_:
media._ === 'photo'
? 'inputPhotoFileLocation'
: 'inputDocumentFileLocation',
id: media.id,
fileReference: media.fileReference,
accessHash: media.accessHash,
thumbSize: sz.type,
}
width = sz.w
height = sz.h
size = sz._ === 'photoSize' ? sz.size : Math.max(...sz.sizes)
}
super(client, location, size, media.dcId)
this.raw = sz
this.width = width
this.height = height
if (sz._ === 'photoPathSize') {
this._path = inflateSvgPath(sz.bytes)
}
}
/**
* Thumbnail type
*/
get type(): string {
return this.raw.type
}
/**
* If {@link raw} is `tl.RawPhotoPathSize` (i.e. `raw.type === Thumbnail.THUMB_OUTLINE`),
* this property will return raw SVG path of the preview.
*
* When downloading path thumbnails, a valid SVG file is returned.
*
* See also: https://core.telegram.org/api/files#vector-thumbnails
*
* @throws MtCuteTypeAssertionError In case {@link raw} is not `tl.RawPhotoPathSize`
*/
get path(): string {
assertTypeIs('Thumbnail#path', this.raw, 'photoPathSize')
return this._path!
}
}
makeInspectable(Thumbnail, ['fileSize', 'dcId', 'width', 'height'], ['path'])

View file

@ -0,0 +1,82 @@
import { RawDocument } from './document'
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { makeInspectable } from '../utils'
/**
* A video, round video message or GIF animation.
*
* **Note:** Legacy GIF animations are also wrapped with this class.
*/
export class Video extends RawDocument {
readonly attr:
| tl.RawDocumentAttributeVideo
| tl.RawDocumentAttributeImageSize
constructor(
client: TelegramClient,
doc: tl.RawDocument,
attr: tl.RawDocumentAttributeVideo | tl.RawDocumentAttributeImageSize
) {
super(client, doc)
this.attr = attr
}
/**
* Video width in pixels
*/
get width(): number {
return this.attr.w
}
/**
* Video height in pixels
*/
get height(): number {
return this.attr.h
}
/**
* Video duration in seconds.
*
* `0` for legacy GIFs
*/
get duration(): number {
return this.attr._ === 'documentAttributeVideo' ? this.attr.duration : 0
}
private _isAnimation?: boolean
/**
* Whether this video is an animated GIF
* (represented either by actual GIF or a silent MP4 video)
*/
get isAnimation(): boolean {
if (!this._isAnimation) {
this._isAnimation =
this.attr._ === 'documentAttributeImageSize' ||
this.doc.attributes.some(
(it) => it._ === 'documentAttributeAnimated'
)
}
return this._isAnimation
}
/**
* Whether this video is a round video message (aka video note)
*/
get isRound(): boolean {
return (
this.attr._ === 'documentAttributeVideo' && !!this.attr.roundMessage
)
}
/**
* Whether this video is a legacy GIF (i.e. its MIME is `image/gif`)
*/
get isLegacyGif(): boolean {
return this.attr._ === 'documentAttributeImageSize'
}
}
makeInspectable(Video, ['fileSize', 'dcId'])

View file

@ -0,0 +1,37 @@
import { RawDocument } from './document'
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { makeInspectable } from '../utils'
/**
* An voice note.
*/
export class Voice extends RawDocument {
readonly doc: tl.RawDocument
readonly attr: tl.RawDocumentAttributeAudio
constructor(
client: TelegramClient,
doc: tl.RawDocument,
attr: tl.RawDocumentAttributeAudio
) {
super(client, doc)
this.attr = attr
}
/**
* Duration of the voice note in seconds.
*/
get duration(): number {
return this.attr.duration
}
/**
* Voice note's waveform
*/
get waveform(): Buffer {
return this.attr.waveform!
}
}
makeInspectable(Voice, ['fileSize', 'dcId'])

View file

@ -0,0 +1,211 @@
import { tl } from '@mtcute/tl'
import { Photo } from './photo'
import { TelegramClient } from '../../client'
import { RawDocument } from './document'
import { parseDocument } from './document-utils'
import { makeInspectable } from '../utils'
/**
* Web page preview.
*
* **Warning**: Documentation of this class also contains my
* personal research about how Telegram
* handles different pages and embeds. **By no means**
* this should be considered the definitive source of truth,
* as this is not documented officially, and only consists
* of my own observations and experiments.
*/
export class WebPage {
readonly client: TelegramClient
readonly raw: tl.RawWebPage
constructor(client: TelegramClient, raw: tl.RawWebPage) {
this.client = client
this.raw = raw
}
/**
* Unique ID of the preview
*/
get id(): tl.Long {
return this.raw.id
}
/**
* Original page URL
*/
get url(): string {
return this.raw.url
}
/**
* URL to be displayed to the user.
*
* Usually a normal URL with stripped protocol and garbage.
*/
get displayUrl(): string {
return this.raw.displayUrl
}
/**
* Type of the preview, taken directly from TL object
*
* Officially documented are:
* `article, photo, audio, video, document, profile, app`,
* but also these are encountered:
* `telegram_user, telegram_bot, telegram_channel, telegram_megagroup`:
*
* - `telegram_*` ones seem to be used for `t.me` links.
* - `article` seems to be used for almost all custom pages with `og:*` tags
* - `photo`, `audio` and `video` seem to be derived from `og:type`,
* and the page itself may contain a preview photo and an embed link
* for the player. This may not correctly represent actual content type:
* Spotify links are `audio`, but SoundCloud are `video`. YouTube links are `video`,
* but tweets with video are `photo`.
* - `document` seem to be used for files downloadable directly from the URL,
* like PDFs, audio files, videos, etc. {@link document} seem to be
* present if `type` is `document`.
* - `profile` doesn't seem to be used
* - `app` doesn't seem to be used
*
* `unknown` is returned if no type is returned in the TL object.
*/
get type(): string {
return this.raw.type || 'unknown'
}
/**
* Page title
*
* Usually defined by `og:site_name` meta tag or website domain
*/
get siteName(): string | null {
return this.raw.siteName ?? null
}
/**
* Page title
*
* Usually defined by `og:title` meta tag or `<title>` tag
*/
get title(): string | null {
return this.raw.title ?? null
}
/**
* Page description
*
* Usually defined by `description` or `og:description` meta tag
*/
get description(): string | null {
return this.raw.description ?? null
}
/**
* Page author
*
* The source for this is unknown, seems to be
* custom-made for services like Twitter.
*
* In official apps this seems to be used as a fallback for description.
*/
get author(): string | null {
return this.raw.author ?? null
}
/**
* The embed URL.
*
* Based on my research, Telegram only allows
* embedding pages from a server-side white-list of domains.
*
* That is, you can't just copy-paste meta tags
* from YouTube to your own domain and expect Telegram
* to return a webpage with embed.
*
* IDK why is that, maybe they are concerned about
* leaking users' IPs to 3rd parties or something
* (but why allow embedding in the first place then?)
*
* Telegram for Android does not show "play" button for
* webpages without embeds, and instead treats it like a simple
* photo (why?).
*
* TDesktop does not support embeds and seems
* to use {@link type} to determine them, and specifically
* [checks](https://github.com/telegramdesktop/tdesktop/blob/3343880ed0e5a86accc7334af54b3470e29ee686/Telegram/SourceFiles/history/view/media/history_view_web_page.cpp#L561)
* for `YouTube` in {@link siteName} to display YouTube icon.
*/
get embedUrl(): string | null {
return this.raw.embedUrl ?? null
}
/**
* Embed type.
*
* Now this is actually stupid.
* As per [official documentation](https://core.telegram.org/constructor/webPage),
* `embed_type` contains «MIME type of the embedded preview, (e.g., text/html or video/mp4)».
* But in fact every time I encountered it it contained a simple string `iframe`.
*
* I couldn't find any usage of this field in official apps either.
*/
get embedType(): string | null {
return this.raw.embedType ?? null
}
/**
* Width of the embed in pixels, 0 if not available.
*/
get embedWidth(): number {
return this.raw.embedWidth || 0
}
/**
* Height of the embed in pixels, 0 if not available.
*/
get embedHeight(): number {
return this.raw.embedHeight || 0
}
private _photo?: Photo | null
/**
* A photo inside this webpage preview.
*
* Used for most of the preview types.
*/
get photo(): Photo | null {
if (this._photo === undefined) {
if (this.raw.photo?._ !== 'photo') {
this._photo = null
} else {
this._photo = new Photo(this.client, this.raw.photo)
}
}
return this._photo
}
private _document?: RawDocument | null
/**
* Document inside this webpage preview.
*
* Seems that this is only used for `document` previews.
*
* Can be a {@link Animation}, {@link Photo}, {@link Video},
* {@link Audio}, {@link Document}.
*/
get document(): RawDocument | null {
if (this._document === undefined) {
if (this.raw.document?._ !== 'document') {
this._document = null
} else {
this._document = parseDocument(this.client, this.raw.document)
}
}
return this._document
}
}
makeInspectable(WebPage)

View file

@ -0,0 +1,2 @@
export * from './message-entity'
export * from './message'

View file

@ -0,0 +1,125 @@
import { tl } from '@mtcute/tl'
import { User } from '../peers/user'
import { makeInspectable } from '../utils'
const entityToType: Partial<
Record<tl.TypeMessageEntity['_'], MessageEntity.Type>
> = {
messageEntityBlockquote: 'blockquote',
messageEntityBold: 'bold',
messageEntityBotCommand: 'bot_command',
messageEntityCashtag: 'cashtag',
messageEntityCode: 'code',
messageEntityEmail: 'email',
messageEntityHashtag: 'hashtag',
messageEntityItalic: 'italic',
messageEntityMention: 'mention',
messageEntityMentionName: 'text_mention',
messageEntityPhone: 'phone_number',
messageEntityPre: 'pre',
messageEntityStrike: 'strikethrough',
messageEntityTextUrl: 'text_link',
messageEntityUnderline: 'underline',
messageEntityUrl: 'url',
}
export namespace MessageEntity {
/**
* Type of the entity. Can be:
* - 'mention': `@username`.
* - 'hashtag': `#hashtag`.
* - 'cashtag': `$USD`.
* - 'bot_command': `/start`.
* - 'url': `https://example.com` (see {@link MessageEntity.url}).
* - 'email': `example@example.com`.
* - 'phone_number': `+42000`.
* - 'bold': **bold text**.
* - 'italic': *italic text*.
* - 'underline': <u>underlined</u> text.
* - 'strikethrough': <s>strikethrough</s> text.
* - 'code': `monospaced` string.
* - 'pre': `monospaced` block (see {@link MessageEntity.language}).
* - 'text_link': for clickable text URLs.
* - 'text_mention': for users without usernames (see {@link MessageEntity.user} below).
* - 'blockquote': A blockquote
*/
export type Type =
| 'mention'
| 'hashtag'
| 'cashtag'
| 'bot_command'
| 'url'
| 'email'
| 'phone_number'
| 'bold'
| 'italic'
| 'underline'
| 'strikethrough'
| 'code'
| 'pre'
| 'text_link'
| 'text_mention'
| 'blockquote'
}
/**
* One special entity in a text message (like mention, hashtag, URL, etc.)
*/
export class MessageEntity {
/**
* Underlying raw TL object
*/
readonly raw: tl.TypeMessageEntity
/**
* Type of the entity. See {@link MessageEntity.Type} for a list of possible values
*/
readonly type: MessageEntity.Type
/**
* Offset in UTF-16 code units to the start of the entity.
*
* Since JS strings are UTF-16, you can use this as-is
*/
readonly offset: number
/**
* Length of the entity in UTF-16 code units.
*
* Since JS strings are UTF-16, you can use this as-is
*/
readonly length: number
/**
* When `type=text_link`, contains the URL that would be opened if user taps on the text
*/
readonly url?: string
/**
* When `type=text_mention`, contains the ID of the user mentioned.
*/
readonly userId?: number
/**
* When `type=pre`, contains the programming language of the entity text
*/
readonly language?: string
static _parse(obj: tl.TypeMessageEntity): MessageEntity | null {
const type = entityToType[obj._]
if (!type) return null
return {
raw: obj,
type,
offset: obj.offset,
length: obj.length,
url: obj._ === 'messageEntityTextUrl' ? obj.url : undefined,
userId:
obj._ === 'messageEntityMentionName' ? obj.userId : undefined,
language: obj._ === 'messageEntityPre' ? obj.language : undefined,
}
}
}
makeInspectable(MessageEntity)

View file

@ -0,0 +1,798 @@
import { User, Chat } from '../peers'
import { tl } from '@mtcute/tl'
import { BotKeyboard, ReplyMarkup } from '../bots'
import { MAX_CHANNEL_ID } from '@mtcute/core'
import {
MtCuteArgumentError,
MtCuteEmptyError,
MtCuteTypeAssertionError,
} from '../errors'
import { TelegramClient } from '../../client'
import { MessageEntity } from './message-entity'
import { makeInspectable } from '../utils'
import {
Audio,
Contact,
Document,
Photo,
Dice,
Video,
Location,
LiveLocation,
Sticker,
Voice,
} from '../media'
import { parseDocument } from '../media/document-utils'
import { Game } from '../media/game'
import { WebPage } from '../media/web-page'
/**
* A message or a service message
*/
export namespace Message {
/** Group was created */
export interface ActionChatCreated {
readonly type: 'chat_created'
/** Group name */
readonly title: string
/** IDs of the users in the group */
readonly users: number[]
}
/** Channel/supergroup was created */
export interface ActionChannelCreated {
readonly type: 'channel_created'
/** Original channel/supergroup title */
readonly title: string
}
/** Chat was migrated to a supergroup with a given ID */
export interface ActionChatMigrateTo {
readonly type: 'chat_migrate_to'
/** Marked ID of the supergroup chat was migrated to */
readonly id: number
}
/** Supergroup was migrated from a chat with a given ID */
export interface ActionChannelMigrateFrom {
readonly type: 'channel_migrate_from'
/** Marked ID of the chat this channel was migrated from */
readonly id: number
/** Old chat's title */
readonly title: string
}
/**
* A message has been pinned.
*
* To get the message itself, use {@link Message.getReplyTo}
*/
export interface ActionMessagePinned {
readonly type: 'message_pinned'
}
/** History was cleared in a private chat. */
export interface ActionHistoryCleared {
readonly type: 'history_cleared'
}
/** Someone scored in a game (usually only used for newly set high scores) */
export interface ActionGameScore {
readonly type: 'game_score'
/** Game ID */
readonly gameId: tl.Long
/** Score */
readonly score: number
}
/** Contact has joined Telegram */
export interface ActionContactJoined {
readonly type: 'contact_joined'
}
/** Group title was changed */
export interface ActionTitleChanged {
readonly type: 'title_changed'
/** New group name */
readonly title: string
}
/** Group photo was changed */
export interface ActionPhotoChanged {
readonly type: 'photo_changed'
/** New group photo */
readonly photo: Photo
}
/** Group photo was deleted */
export interface ActionPhotoDeleted {
readonly type: 'photo_deleted'
}
/** Users were added to the chat */
export interface ActionUsersAdded {
readonly type: 'users_added'
/** IDs of the users that were added */
readonly users: number[]
}
/** User has left the group */
export interface ActionUserLeft {
readonly type: 'user_left'
}
/** User was removed from the group */
export interface ActionUserRemoved {
readonly type: 'user_removed'
/** ID of the user that was removed from the group */
readonly user: number
}
/** User has joined the group via an invite link */
export interface ActionUserJoinedLink {
readonly type: 'user_joined_link'
/** ID of the user who created the link */
readonly inviter: number
}
export interface MessageForwardInfo<
Sender extends User | Chat | string = User | Chat | string
> {
/**
* Date the original message was sent
*/
date: Date
/**
* Sender of the original message (either user or a channel)
* or their name (for users with private forwards)
*/
sender: Sender
/**
* For messages forwarded from channels,
* identifier of the original message in the channel
*/
fromMessageId?: number
/**
* For messages forwarded from channels,
* signature of the post author (if present)
*/
signature?: string
}
export type MessageAction =
| ActionChatCreated
| ActionChannelCreated
| ActionChatMigrateTo
| ActionChannelMigrateFrom
| ActionMessagePinned
| ActionHistoryCleared
| ActionGameScore
| ActionContactJoined
| ActionTitleChanged
| ActionPhotoChanged
| ActionPhotoDeleted
| ActionUsersAdded
| ActionUserLeft
| ActionUserRemoved
| ActionUserJoinedLink
| null
// todo: venue, poll, invoice, successful_payment,
// connected_website
export type MessageMedia =
| Photo
| Dice
| Contact
| Audio
| Voice
| Sticker
| Document
| Video
| Location
| LiveLocation
| Game
| WebPage
| null
}
/**
* A Telegram message.
*/
export class Message {
/** Telegram client that received this message */
readonly client: TelegramClient
/**
* Raw TL object.
*
* > **Note**: In fact, `raw` can also be {@link tl.RawMessageEmpty}.
* > But since it is quite rare, for the simplicity sake
* > we don't bother thinking about it (and you shouldn't too).
* >
* > When the {@link Message} is in fact `messageEmpty`,
* > `.empty` will be true and trying to access properties
* > that are not available will result in {@link MtCuteEmptyError}.
* >
* > The only property that is available on an "empty" message is `.id`
*/
readonly raw: tl.RawMessage | tl.RawMessageService
/** Map of users in this message. Mainly for internal use */
readonly _users: Record<number, tl.TypeUser>
/** Map of chats in this message. Mainly for internal use */
readonly _chats: Record<number, tl.TypeChat>
private _emptyError?: MtCuteEmptyError
constructor(
client: TelegramClient,
raw: tl.TypeMessage,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>,
isScheduled = false
) {
this.client = client
this._users = users
this._chats = chats
// a bit of cheating in terms of types but whatever :shrug:
//
// using exclude instead of `typeof this.raw` because
// TypeMessage might have some other types added, and we'll detect
// that at compile time
this.raw = raw as Exclude<tl.TypeMessage, tl.RawMessageEmpty>
this.empty = raw._ === 'messageEmpty'
if (this.empty) {
this._emptyError = new MtCuteEmptyError()
}
this.isScheduled = isScheduled
}
/**
* Whether the message is empty.
*
* Note that if the message is empty,
* accessing any other property except `id` and `raw`
* will result in {@link MtCuteEmptyError}
*/
readonly empty: boolean
/**
* Whether the message is scheduled.
* If it is, then its {@link date} is set to future.
*/
readonly isScheduled: boolean
/** Unique message identifier inside this chat */
get id(): number {
return this.raw.id
}
/**
* For channel posts, number of views
*
* `null` for service messages and non-post messages.
*/
get views(): number | null {
if (this._emptyError) throw this._emptyError
return this.raw._ === 'message' ? this.raw.views ?? null : null
}
/**
* Whether the message is incoming or outgoing:
* - Messages received from other chats are incoming (`outgoing = false`)
* - Messages sent by you to other chats are outgoing (`outgoing = true`)
* - Messages to yourself (i.e. *Saved Messages*) are incoming (`outgoing = false`)
*/
get outgoing(): boolean {
if (this._emptyError) throw this._emptyError
return this.raw.out!
}
/**
* Multiple media messages with the same grouped ID
* indicate an album or media group
*
* `null` for service messages and non-grouped messages
*/
get groupedId(): tl.Long | null {
if (this._emptyError) throw this._emptyError
return this.raw._ === 'message' ? this.raw.groupedId ?? null : null
}
private _sender?: User | Chat
/**
* Message sender.
*
* Usually is a {@link User}, but can be a {@link Chat}
* in case the message was sent by an anonymous admin,
* or if the message is a forwarded channel post.
*
* If the message was sent by an anonymous admin,
* sender will equal to {@link chat}.
*
* If the message is a forwarded channel post,
* sender is the channel itself.
*/
get sender(): User | Chat {
if (this._emptyError) throw this._emptyError
if (this._sender === undefined) {
const from = this.raw.fromId
if (!from) {
// anon admin, return the chat
this._sender = this.chat
} else if (from._ === 'peerChannel') {
// forwarded channel post
this._sender = new Chat(
this.client,
this._chats[from.channelId]
)
} else if (from._ === 'peerUser') {
this._sender = new User(
this.client,
this._users[from.userId] as tl.RawUser
)
} else
throw new MtCuteTypeAssertionError(
'Message#sender (@ raw.fromId)',
'peerUser | peerChannel',
from._
)
}
return this._sender
}
private _chat?: Chat
/**
* Conversation the message belongs to
*/
get chat(): Chat {
if (this._emptyError) throw this._emptyError
if (this._chat === undefined) {
this._chat = Chat._parseFromMessage(
this.client,
this.raw,
this._users,
this._chats
)
}
return this._chat
}
/**
* Date the message was sent
*/
get date(): Date {
if (this._emptyError) throw this._emptyError
return new Date(this.raw.date * 1000)
}
private _forward?: Message.MessageForwardInfo | null
/**
* If this message is a forward, contains info about it.
*/
get forward(): Message.MessageForwardInfo | null {
if (this._emptyError) throw this._emptyError
if (!this._forward) {
if (this.raw._ !== 'message' || !this.raw.fwdFrom) {
this._forward = null
} else {
const fwd = this.raw.fwdFrom
let sender: User | Chat | string
if (fwd.fromName) {
sender = fwd.fromName
} else if (fwd.fromId) {
if (fwd.fromId._ === 'peerChannel') {
sender = new Chat(
this.client,
this._chats[fwd.fromId.channelId]
)
} else if (fwd.fromId._ === 'peerUser') {
sender = new User(
this.client,
this._users[fwd.fromId.userId] as tl.RawUser
)
} else
throw new MtCuteTypeAssertionError(
'Message#forward (@ raw.fwdFrom.fromId)',
'peerUser | peerChannel',
fwd.fromId._
)
} else {
this._forward = null
return this._forward
}
this._forward = {
date: new Date(fwd.date * 1000),
sender,
fromMessageId: fwd.savedFromMsgId,
signature: fwd.postAuthor,
}
}
}
return this._forward
}
/**
* For replies, the ID of the message that current message
* replies to.
*/
get replyToMessageId(): number | null {
if (this._emptyError) throw this._emptyError
return this.raw.replyTo?.replyToMsgId ?? null
}
/**
* Whether this message contains mention of the current user
*/
get mentioned(): boolean {
if (this._emptyError) throw this._emptyError
return !!this.raw.mentioned
}
private _viaBot: User | null
/**
* If this message is generated from an inline query,
* information about the bot which generated it
*/
get viaBot(): User | null {
if (this._emptyError) throw this._emptyError
if (this._viaBot === undefined) {
if (this.raw._ === 'messageService' || !this.raw.viaBotId) {
this._viaBot = null
} else {
this._viaBot = new User(
this.client,
this._users[this.raw.viaBotId] as tl.RawUser
)
}
}
return this._viaBot
}
/**
* Message text or media caption.
*
* Empty string for service messages
* (you should handle i18n yourself)
*/
get text(): string {
if (this._emptyError) throw this._emptyError
return this.raw._ === 'messageService' ? '' : this.raw.message
}
private _entities?: MessageEntity[]
/**
* Message text/caption entities (may be empty)
*/
get entities(): MessageEntity[] {
if (this._emptyError) throw this._emptyError
if (!this._entities) {
this._entities = []
if (this.raw._ === 'message' && this.raw.entities?.length) {
for (const ent of this.raw.entities) {
const parsed = MessageEntity._parse(ent)
if (parsed) this._entities.push(parsed)
}
}
}
return this._entities
}
private _action?: Message.MessageAction
/**
* Message action. `null` for non-service messages
* or for unsupported events.
*
* For unsupported events, use `.raw.action` directly.
*/
get action(): Message.MessageAction {
if (!this._action) {
if (this.raw._ === 'message') {
this._action = null
} else {
const act = this.raw.action
let action: Message.MessageAction
if (act._ === 'messageActionChatCreate') {
action = {
type: 'chat_created',
title: act.title,
users: act.users,
}
} else if (act._ === 'messageActionChannelCreate') {
action = {
type: 'channel_created',
title: act.title,
}
} else if (act._ === 'messageActionChatMigrateTo') {
action = {
type: 'chat_migrate_to',
id: act.channelId,
}
} else if (act._ === 'messageActionChannelMigrateFrom') {
action = {
type: 'channel_migrate_from',
id: act.chatId,
title: act.title,
}
} else if (act._ === 'messageActionPinMessage') {
action = {
type: 'message_pinned',
}
} else if (act._ === 'messageActionHistoryClear') {
action = {
type: 'history_cleared',
}
} else if (act._ === 'messageActionGameScore') {
action = {
type: 'game_score',
gameId: act.gameId,
score: act.score,
}
} else if (act._ === 'messageActionContactSignUp') {
action = {
type: 'contact_joined',
}
} else if (act._ === 'messageActionChatEditTitle') {
action = {
type: 'title_changed',
title: act.title,
}
} else if (act._ === 'messageActionChatEditPhoto') {
action = {
type: 'photo_changed',
photo: new Photo(this.client, act.photo as tl.RawPhoto),
}
} else if (act._ === 'messageActionChatDeletePhoto') {
action = {
type: 'photo_deleted',
}
} else if (act._ === 'messageActionChatAddUser') {
action = {
type: 'users_added',
users: act.users,
}
} else if (act._ === 'messageActionChatDeleteUser') {
if (
this.raw.fromId?._ === 'peerUser' &&
act.userId === this.raw.fromId.userId
) {
action = {
type: 'user_left',
}
} else {
action = {
type: 'user_removed',
user: act.userId,
}
}
} else if (act._ === 'messageActionChatJoinedByLink') {
action = {
type: 'user_joined_link',
inviter: act.inviterId,
}
} else {
action = null
}
this._action = action
}
}
return this._action
}
private _media?: Message.MessageMedia
/**
* Message media. `null` for text-only and service messages
* and for unsupported media types.
*
* For unsupported media types, use `.raw.media` directly.
*/
get media(): Message.MessageMedia {
if (this._media === undefined) {
if (
this.raw._ === 'messageService' ||
!this.raw.media ||
this.raw.media._ === 'messageMediaEmpty'
) {
this._media = null
} else {
const m = this.raw.media
let media: Message.MessageMedia
if (m._ === 'messageMediaPhoto' && m.photo?._ === 'photo') {
media = new Photo(this.client, m.photo)
} else if (m._ === 'messageMediaDice') {
media = new Dice(m)
} else if (m._ === 'messageMediaContact') {
media = new Contact(m)
} else if (
m._ === 'messageMediaDocument' &&
m.document?._ === 'document'
) {
media = parseDocument(this.client, m.document)
} else if (
m._ === 'messageMediaGeo' &&
m.geo._ === 'geoPoint'
) {
media = new Location(m.geo)
} else if (
m._ === 'messageMediaGeoLive' &&
m.geo._ === 'geoPoint'
) {
media = new LiveLocation(m)
} else if (m._ === 'messageMediaGame') {
media = new Game(this.client, m.game)
} else if (
m._ === 'messageMediaWebPage' &&
m.webpage._ === 'webPage'
) {
media = new WebPage(this.client, m.webpage)
} else {
media = null
}
this._media = media
}
}
return this._media
}
private _markup?: ReplyMarkup | null
/**
* Reply markup provided with this message, if any.
*/
get markup(): ReplyMarkup | null {
if (this._markup === undefined) {
if (this.raw._ === 'messageService' || !this.raw.replyMarkup) {
this._markup = null
} else {
const rm = this.raw.replyMarkup
let markup: ReplyMarkup | null
if (rm._ === 'replyKeyboardHide') {
markup = {
type: 'reply_hide',
selective: rm.selective,
}
} else if (rm._ === 'replyKeyboardForceReply') {
markup = {
type: 'force_reply',
singleUse: rm.singleUse,
selective: rm.selective,
}
} else if (rm._ === 'replyKeyboardMarkup') {
markup = {
type: 'reply',
resize: rm.resize,
singleUse: rm.singleUse,
selective: rm.selective,
buttons: BotKeyboard._rowsTo2d(rm.rows),
}
} else if (rm._ === 'replyInlineMarkup') {
markup = {
type: 'inline',
buttons: BotKeyboard._rowsTo2d(rm.rows),
}
} else markup = null
this._markup = markup
}
}
return this._markup
}
/**
* Generated permalink to this message, only for groups and channels
*
* @throws MtCuteArgumentError In case the chat does not support message links
*/
get link(): string {
if (this.chat.type === 'supergroup' || this.chat.type === 'channel') {
if (this.chat.username) {
return `https://t.me/${this.chat.username}/${this.id}`
} else {
return `https://t.me/c/${MAX_CHANNEL_ID - this.chat.id}/${
this.id
}`
}
}
throw new MtCuteArgumentError(
`Cannot generate message link for ${this.chat.type}`
)
}
/**
* Get the message text formatted with a given parse mode
*
* Shorthand for `client.getParseMode(...).unparse(msg.text, msg.entities)`
*
* @param parseMode Parse mode to use (`null` for default)
*/
unparse(parseMode?: string | null): string {
return this.client
.getParseMode(parseMode)
.unparse(this.text, this.entities)
}
// todo: bound methods https://github.com/pyrogram/pyrogram/blob/701c1cde07af779ab18dbf79a3e626f04fa5d5d2/pyrogram/types/messages_and_media/message.py#L737
/**
* For replies, fetch the message that is being replied.
*
* @throws MtCuteArgumentError In case the message is not a reply
*/
getReplyTo(): Promise<Message> {
if (!this.replyToMessageId)
throw new MtCuteArgumentError('This message is not a reply!')
return this.client.getMessages(this.chat.inputPeer, this.id, true)
}
/**
* Send a message in reply to this message.
*
* By default just sends a message to the same chat,
* to make the reply a "real" reply, pass `visible=true`
*
* @param text Text of the message
* @param visible Whether the reply should be visible
* @param params
*/
replyText(
text: string,
visible = false,
params?: Parameters<TelegramClient['sendText']>[2]
): ReturnType<TelegramClient['sendText']> {
if (visible) {
return this.client.sendText(this.chat.inputPeer, text, {
...(params || {}),
replyTo: this.id,
})
}
return this.client.sendText(this.chat.inputPeer, text, params)
}
}
makeInspectable(Message, ['empty', 'isScheduled'])

View file

@ -0,0 +1,126 @@
import { tl } from '@mtcute/tl'
import { makeInspectable } from '../utils'
/**
* Represents the rights of a normal user in a {@link Chat}.
*/
export class ChatPermissions {
readonly _bannedRights: tl.RawChatBannedRights
constructor(bannedRights: tl.RawChatBannedRights) {
this._bannedRights = bannedRights
}
/**
* Whether users can view messages
*/
get canViewMessages(): boolean {
return !this._bannedRights.viewMessages
}
/**
* Whether users can send text messages,
* contacts, locations and venues
*/
get canSendMessages(): boolean {
return !this._bannedRights.sendMessages
}
/**
* Whether users can send media messages,
* including documents, photos, videos, video notes and voice notes.
*
* Implies {@link canSendMessages}
*/
get canSendMedia(): boolean {
return !this._bannedRights.sendMedia
}
/**
* Whether users can send stickers.
*
* Implies {@link canSendMedia}
*/
get canSendStickers(): boolean {
return !this._bannedRights.sendStickers
}
/**
* Whether users can send GIFs.
*
* Implies {@link canSendMedia}
*/
get canSendGifs(): boolean {
return !this._bannedRights.sendGifs
}
/**
* Whether users can send games.
*
* Implies {@link canSendMedia}
*/
get canSendGames(): boolean {
return !this._bannedRights.sendGames
}
/**
* Whether users can use inline bots.
*
* Implies {@link canSendMedia}
*/
get canUseInline(): boolean {
return !this._bannedRights.sendInline
}
/**
* Whether users can use inline bots.
*
* Implies {@link canSendMedia}
*/
get canAddWebPreviews(): boolean {
return !this._bannedRights.embedLinks
}
/**
* Whether users can send polls.
*
* Implies {@link canSendMessages}
*/
get canSendPolls(): boolean {
return !this._bannedRights.sendPolls
}
/**
* Whether users can change the chat title,
* photo and other settings.
*/
get canChangeInfo(): boolean {
return !this._bannedRights.changeInfo
}
/**
* Whether users can invite other users to the chat
*/
get canInviteUsers(): boolean {
return !this._bannedRights.inviteUsers
}
/**
* Whether users can pin messages
*/
get canPinMessages(): boolean {
return !this._bannedRights.pinMessages
}
/**
* UNIX date until which these permissions are valid,
* or `null` if forever.
*/
get untilDate(): Date | null {
return this._bannedRights.untilDate === 0
? null
: new Date(this._bannedRights.untilDate * 1000)
}
}
makeInspectable(ChatPermissions)

View file

@ -0,0 +1,58 @@
import { tl } from '@mtcute/tl'
import { FileLocation } from '../files/file-location'
import { TelegramClient } from '../../client'
import { makeInspectable } from '../utils'
/**
* A chat photo
*/
export class ChatPhoto {
readonly client: TelegramClient
readonly obj: tl.RawUserProfilePhoto | tl.RawChatPhoto
readonly peer: tl.TypeInputPeer
constructor(
client: TelegramClient,
peer: tl.TypeInputPeer,
obj: tl.RawUserProfilePhoto | tl.RawChatPhoto
) {
this.client = client
this.peer = peer
this.obj = obj
}
private _smallFile?: FileLocation
/** Chat photo file location in small resolution (160x160) */
get small(): FileLocation {
if (!this._smallFile) {
this._smallFile = FileLocation.fromDeprecated(
this.client,
this.peer,
this.obj.photoSmall,
this.obj.dcId
)
}
return this._smallFile
}
private _bigFile?: FileLocation
/** Chat photo file location in big resolution (640x640) */
get big(): FileLocation {
if (!this._bigFile) {
this._bigFile = FileLocation.fromDeprecated(
this.client,
this.peer,
this.obj.photoBig,
this.obj.dcId,
true
)
}
return this._bigFile
}
}
makeInspectable(ChatPhoto)

View file

@ -0,0 +1,414 @@
import { ChatPhoto } from './chat-photo'
import { tl } from '@mtcute/tl'
import { ChatPermissions } from './chat-permissions'
import { TelegramClient } from '../../client'
import { getMarkedPeerId } from '@mtcute/core'
import { MtCuteArgumentError, MtCuteTypeAssertionError } from '../errors'
import { makeInspectable } from '../utils'
export namespace Chat {
/**
* Chat type. Can be:
* - `private`: PM with other users or yourself (Saved Messages)
* - `bot`: PM with a bot
* - `group`: Legacy group
* - `supergroup`: Supergroup
* - `channel`: Broadcast channel
*/
export type Type = 'private' | 'bot' | 'group' | 'supergroup' | 'channel'
}
/**
* A chat.
*/
export class Chat {
/** Telegram client used for this chat */
readonly client: TelegramClient
/**
* Raw peer object that this {@link Chat} represents.
*/
readonly peer: tl.RawUser | tl.RawChat | tl.RawChannel
/**
* Raw full peer object that this {@link Chat} represents.
*/
readonly fullPeer?: tl.TypeUserFull | tl.TypeChatFull
constructor(
client: TelegramClient,
peer: tl.TypeUser | tl.TypeChat,
fullPeer?: tl.TypeUserFull | tl.TypeChatFull
) {
if (!peer) throw new MtCuteArgumentError('peer is not available')
if (!(peer._ === 'user' || peer._ === 'chat' || peer._ === 'channel'))
throw new MtCuteTypeAssertionError(
'Chat#constructor (@ peer)',
'user | chat | channel',
peer._
)
this.client = client
this.peer = peer
this.fullPeer = fullPeer
}
/** Marked ID of this chat */
get id(): number {
return getMarkedPeerId(this.inputPeer)
}
private _inputPeer?: tl.TypeInputPeer
/**
* Chat's input peer
*/
get inputPeer(): tl.TypeInputPeer {
if (!this._inputPeer) {
if (this.peer._ === 'user') {
if (!this.peer.accessHash) {
throw new MtCuteArgumentError(
"Peer's access hash is not available!"
)
}
this._inputPeer = {
_: 'inputPeerUser',
userId: this.peer.id,
accessHash: this.peer.accessHash,
}
} else if (this.peer._ === 'chat') {
this._inputPeer = {
_: 'inputPeerChat',
chatId: this.peer.id,
}
} else if (this.peer._ === 'channel') {
if (!this.peer.accessHash) {
throw new MtCuteArgumentError(
"Peer's access hash is not available!"
)
}
this._inputPeer = {
_: 'inputPeerChannel',
channelId: this.peer.id,
accessHash: this.peer.accessHash,
}
}
}
return this._inputPeer!
}
private _type?: Chat.Type
/** Type of chat */
get type(): Chat.Type {
if (!this._type) {
if (this.peer._ === 'user') {
this._type = this.peer.bot ? 'bot' : 'private'
} else if (this.peer._ === 'chat') {
this._type = 'group'
} else if (this.peer._ === 'channel') {
this._type = this.peer.broadcast ? 'channel' : 'supergroup'
}
}
return this._type!
}
/**
* Whether this chat has been verified by Telegram.
* Supergroups, channels and groups only
*/
get isVerified(): boolean {
return this.peer._ !== 'chat' && this.peer.verified!
}
/**
* Whether this chat has been restricted.
* See {@link restrictions} for details
*/
get isRestricted(): boolean {
return this.peer._ !== 'chat' && this.peer.restricted!
}
/**
* Whether this chat is owned by the current user.
* Supergroups, channels and groups only
*/
get isCreator(): boolean {
return this.peer._ !== 'user' && this.peer.creator!
}
/** Whether this chat has been flagged for scam */
get isScam(): boolean {
return this.peer._ !== 'chat' && this.peer.scam!
}
/** Whether this chat has been flagged for impersonation */
get isFake(): boolean {
return this.peer._ !== 'chat' && this.peer.fake!
}
/** Whether this chat is part of the Telegram support team. Users and bots only */
get isSupport(): boolean {
return this.peer._ === 'user' && this.peer.support!
}
/**
* Title, for supergroups, channels and groups
*/
get title(): string | null {
return this.peer._ !== 'user' ? this.peer.title ?? null : null
}
/**
* Username, for private chats, bots, supergroups and channels if available
*/
get username(): string | null {
return this.peer._ !== 'chat' ? this.peer.username ?? null : null
}
/**
* First name of the other party in a private chat,
* for private chats and bots
*/
get firstName(): string | null {
return this.peer._ === 'user' ? this.peer.firstName ?? null : null
}
/**
* Last name of the other party in a private chat, for private chats
*/
get lastName(): string | null {
return this.peer._ === 'user' ? this.peer.lastName ?? null : null
}
/**
* Get the display name of the chat.
*
* Title for groups and channels,
* name (and last name if available) for users
*/
get displayName(): string {
if (this.peer._ === 'user') {
if (this.peer.lastName)
return this.peer.firstName + ' ' + this.peer.lastName
return this.peer.firstName!
} else {
return this.peer.title
}
}
private _photo?: ChatPhoto
/**
* Chat photo, if any.
* Suitable for downloads only.
*/
get photo(): ChatPhoto | null {
if (
!this.peer.photo ||
(this.peer.photo._ !== 'userProfilePhoto' &&
this.peer.photo._ !== 'chatPhoto')
)
return null
if (!this._photo) {
this._photo = new ChatPhoto(
this.client,
this.inputPeer,
this.peer.photo
)
}
return this._photo
}
/**
* Bio of the other party in a private chat, or description of a
* group, supergroup or channel.
*
* Returned only in {@link TelegramClient.getChat}
*/
get bio(): string | null {
return this.fullPeer?.about ?? null
}
/**
* User's or bot's assigned DC (data center).
* Available only in case the user has set a public profile photo.
*
* **Note**: this information is approximate; it is based on where
* Telegram stores the current chat photo. It is accurate only in case
* the owner has set the chat photo, otherwise it will be the DC assigned
* to the administrator who set the current profile photo.
*/
get dcId(): number | null {
return (this.peer.photo as any)?.dcId ?? null
}
/**
* Chat's permanent invite link, for groups, supergroups and channels.
* Returned only in {@link TelegramClient.getChat}
*/
get inviteLink(): string | null {
return this.fullPeer && this.fullPeer._ !== 'userFull'
? this.fullPeer.exportedInvite?.link ?? null
: null
}
/**
* For supergroups, name of the group sticker set.
* Returned only in {@link TelegramClient.getChat}
*/
get stickerSetName(): string | null {
return this.fullPeer && this.fullPeer._ === 'channelFull'
? this.fullPeer.stickerset?.shortName ?? null
: null
}
/**
* Whether the group sticker set can be changed by you.
* Returned only in {@link TelegramClient.getChat}
*/
get canSetStickerSet(): boolean | null {
return this.fullPeer && this.fullPeer._ === 'channelFull'
? this.fullPeer.canSetStickers ?? null
: null
}
/**
* Chat members count, for groups, supergroups and channels only.
* Returned only in {@link TelegramClient.getChat}
*/
get membersCount(): number | null {
return this.fullPeer && this.fullPeer._ !== 'userFull'
? this.fullPeer._ === 'chatFull'
? this.fullPeer.participants._ === 'chatParticipants'
? this.fullPeer.participants.participants.length
: null
: this.fullPeer._ === 'channelFull'
? this.fullPeer.participantsCount ?? null
: null
: null
}
/**
* The list of reasons why this chat might be unavailable to some users.
* This field is available only in case {@link isRestricted} is `true`
*/
get restrictions(): tl.RawRestrictionReason[] | null {
return this.peer._ !== 'chat'
? this.peer.restrictionReason ?? null
: null
}
private _permissions?: ChatPermissions
/**
* Current user's permissions, for supergroups.
*/
get permissions(): ChatPermissions | null {
if (this.peer._ !== 'channel' || !this.peer.bannedRights) return null
if (!this._permissions) {
this._permissions = new ChatPermissions(this.peer.bannedRights)
}
return this._permissions
}
/**
* Default chat member permissions, for groups and supergroups.
*/
get defaultPermissions(): ChatPermissions | null {
if (this.peer._ === 'user' || !this.peer.defaultBannedRights)
return null
if (!this._permissions) {
this._permissions = new ChatPermissions(
this.peer.defaultBannedRights
)
}
return this._permissions
}
/**
* Distance in meters of this group chat from your location
* Returned only in {@link TelegramClient.getNearbyChats}
*/
readonly distance?: number
private _linkedChat?: Chat
/**
* The linked discussion group (in case of channels)
* or the linked channel (in case of supergroups).
*
* Returned only in {@link TelegramClient.getChat}
*/
get linkedChat(): Chat | null {
return this._linkedChat ?? null
}
/** @internal */
static _parseFromMessage(
client: TelegramClient,
message: tl.RawMessage | tl.RawMessageService,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>
): Chat {
return Chat._parseFromPeer(client, message.peerId, users, chats)
}
/** @internal */
static _parseFromPeer(
client: TelegramClient,
peer: tl.TypePeer,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>
): Chat {
if (peer._ === 'peerUser') {
return new Chat(client, users[peer.userId])
}
if (peer._ === 'peerChat') {
return new Chat(client, chats[peer.chatId])
}
return new Chat(client, chats[peer.channelId])
}
static _parseFull(
client: TelegramClient,
full: tl.messages.RawChatFull | tl.RawUserFull
): Chat {
if (full._ === 'userFull') {
return new Chat(client, full.user, full)
} else {
const fullChat = full.fullChat
let chat: tl.TypeChat | undefined = undefined
let linked: tl.TypeChat | undefined = undefined
for (const c of full.chats) {
if (fullChat.id === c.id) {
chat = c
}
if (
fullChat._ === 'channelFull' &&
fullChat.linkedChatId === c.id
) {
linked = c
}
}
const ret = new Chat(client, chat!, fullChat)
ret._linkedChat = linked ? new Chat(client, linked) : undefined
return ret
}
}
// todo: bound methods https://github.com/pyrogram/pyrogram/blob/a86656aefcc93cc3d2f5c98227d5da28fcddb136/pyrogram/types/user_and_chats/chat.py#L319
}
makeInspectable(Chat)

View file

@ -0,0 +1,31 @@
import { tl } from '@mtcute/tl'
export * from './user'
export * from './chat'
/**
* Peer types that have one-to-one relation to tl.Peer* types.
*/
export type BasicPeerType = 'user' | 'chat' | 'channel'
/**
* More extensive peer types, that differentiate between
* users and bots, channels and supergroups.
*/
export type PeerType = 'user' | 'bot' | 'group' | 'channel' | 'supergroup'
/**
* Type that can be used as an input peer
* to most of the high-level methods. Can be:
* - `number`, representing peer's marked ID
* - `string`, representing peer's username (w/out preceding `@`)
* - `string`, representing user's phone number (only for contacts)
* - `"me"` and `"self"` which will be replaced with the current user/bot
* - Raw TL object
*/
export type InputPeerLike =
| string
| number
| tl.TypeInputPeer
| tl.TypeInputUser
| tl.TypeInputChannel

View file

@ -0,0 +1,336 @@
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { ChatPhoto } from './chat-photo'
import { MtCuteArgumentError } from '../errors'
import { makeInspectable } from '../utils'
export namespace User {
/**
* User's Last Seen & Online status.
* Can be one of the following:
* - `online`, user is online right now.
* - `offline`, user is currently offline.
* - `recently`, user with hidden last seen time who was online between 1 second and 2-3 days ago.
* - `within_week`, user with hidden last seen time who was online between 2-3 and seven days ago.
* - `within_month`, user with hidden last seen time who was online between 6-7 days and a month ago.
* - `long_time_ago`, blocked user or user with hidden last seen time who was online more than a month ago.
* - `bot`, for bots.
*/
export type Status =
| 'online'
| 'offline'
| 'recently'
| 'within_week'
| 'within_month'
| 'long_time_ago'
| 'bot'
}
interface ParsedStatus {
status: User.Status
lastOnline: Date | null
nextOffline: Date | null
}
export class User {
/** Client that this user belongs to */
readonly client: TelegramClient
/**
* Underlying raw TL object
*/
private _user: tl.RawUser
constructor(client: TelegramClient, user: tl.RawUser) {
this.client = client
this._user = user
}
/** Unique identifier for this user or bot */
get id(): number {
return this._user.id
}
/** Whether this user is you yourself */
get isSelf(): boolean {
return this._user.self!
}
/** Whether this user is in your contacts */
get isContact(): boolean {
return this._user.contact!
}
/** Whether you both have each other's contact */
get isMutualContact(): boolean {
return this._user.mutualContact!
}
/** Whether this user is deleted */
get isDeleted(): boolean {
return this._user.deleted!
}
/** Whether this user is a bot */
get isBot(): boolean {
return this._user.bot!
}
/** Whether this user has been verified by Telegram */
get isVerified(): boolean {
return this._user.verified!
}
/**
* Whether this user has been restricted. Bots only.
* See {@link restrictionReason} for details
*/
get isRestricted(): boolean {
return this._user.restricted!
}
/** Whether this user has been flagged for scam */
get isScam(): boolean {
return this._user.scam!
}
/** Whether this user has been flagged for impersonation */
get isFake(): boolean {
return this._user.fake!
}
/** Whether this user is part of the Telegram support team */
get isSupport(): boolean {
return this._user.support!
}
/** User's or bot's first name */
get firstName(): string {
return this._user.firstName!
}
/** User's or bot's last name */
get lastName(): string | null {
return this._user.lastName ?? null
}
private _parsedStatus?: ParsedStatus
private _parseStatus() {
let status: User.Status
let date: Date
const us = this._user.status
if (!us) {
status = 'long_time_ago'
} else if (this._user.bot) {
status = 'bot'
} else if (us._ === 'userStatusOnline') {
status = 'online'
date = new Date(us.expires * 1000)
} else if (us._ === 'userStatusOffline') {
status = 'offline'
date = new Date(us.wasOnline * 1000)
} else if (us._ === 'userStatusRecently') {
status = 'recently'
} else if (us._ === 'userStatusLastWeek') {
status = 'within_week'
} else if (us._ === 'userStatusLastMonth') {
status = 'within_month'
} else {
status = 'long_time_ago'
}
this._parsedStatus = {
status,
lastOnline: status === 'offline' ? date! : null,
nextOffline: status === 'online' ? date! : null,
}
}
/** User's Last Seen & Online status */
get status(): User.Status {
if (!this._parsedStatus) this._parseStatus()
return this._parsedStatus!.status
}
/**
* Last time this user was seen online.
* Only available if {@link status} is `offline`
*/
get lastOnline(): Date | null {
if (!this._parsedStatus) this._parseStatus()
return this._parsedStatus!.lastOnline
}
/**
* Time when this user will automatically go offline.
* Only available if {@link status} is `online`
*/
get nextOffline(): Date | null {
if (!this._parsedStatus) this._parseStatus()
return this._parsedStatus!.nextOffline
}
/** User's or bot's username */
get username(): string | null {
return this._user.username ?? null
}
/** IETF language tag of the user's language */
get language(): string | null {
return this._user.langCode ?? null
}
/**
* User's or bot's assigned DC (data center).
* Available only in case the user has set a public profile photo.
*
* **Note**: this information is approximate; it is based on where
* Telegram stores a user profile pictures and does not by any means tell
* you the user location (i.e. a user might travel far away, but will still connect
* to its assigned DC).
* More info at [Pyrogram FAQ](https://docs.pyrogram.org/faq#what-are-the-ip-addresses-of-telegram-data-centers).
*/
get dcId(): number | null {
return (this._user.photo as any)?.dcId ?? null
}
/** User's phone number */
get phoneNumber(): string | null {
return this._user.phone ?? null
}
/**
* Get this user's input peer for advanced use-cases.
*/
get inputPeer(): tl.TypeInputPeer {
if (!this._user.accessHash)
throw new MtCuteArgumentError(
"user's access hash is not available!"
)
return {
_: 'inputPeerUser',
userId: this._user.id,
accessHash: this._user.accessHash,
}
}
private _photo?: ChatPhoto
/**
* User's or bot's current profile photo, if any.
* Suitable for downloads only
*/
get photo(): ChatPhoto | null {
if (this._user.photo?._ !== 'userProfilePhoto') return null
if (!this._photo) {
this._photo = new ChatPhoto(
this.client,
this.inputPeer,
this._user.photo
)
}
return this._photo
}
/**
* The list of reasons why this bot might be unavailable to some users.
* This field is available only in case *isRestricted* is `true`
*/
get restrictions(): tl.RawRestrictionReason[] | null {
return this._user.restrictionReason ?? null
}
/**
* User's display name.
*
* First name and last name if available,
* only first name otherwise.
*/
get displayName(): string {
if (this.lastName) return `${this.firstName} ${this.lastName}`
return this.firstName
}
/**
* Create a mention for the user.
*
* When available and `text` is omitted, this method will return `@username`.
* Otherwise, text mention is created for the given (or default) parse mode
*
* @param text Text of the mention.
* @param parseMode Parse mode to use when creating mention.
* @example
* ```typescript
* msg.replyText(`Hello, ${msg.sender.mention()`)
* ```
*/
mention(text?: string | null, parseMode?: string | null): string {
if (!text && this.username) {
return `@${this.username}`
}
if (!text) text = this.displayName
if (!parseMode) parseMode = this.client['_defaultParseMode']
return this.client.getParseMode(parseMode).unparse(text, [
{
raw: undefined as any,
type: 'text_mention',
offset: 0,
length: text.length,
userId: this.id,
},
])
}
/**
* Create a permanent mention for this user.
*
* *Permanent* means that this mention will also
* contain user's access hash, so even if the user
* changes their username or the client forgets
* about that user, it can still be mentioned.
*
* This method is only needed when the result will be
* stored somewhere outside current MTCute instance,
* otherwise {@link mention} will be enough.
*
* > **Note**: the resulting text can only be used by clients
* > that support MTCute notation of permanent
* > mention links (`tg://user?id=123&hash=abc`).
* >
* > Both `@mtcute/html-parser` and `@mtcute/markdown-parser` support it.
*
* @param text Mention text
* @param parseMode Parse mode to use when creating mention
*/
permanentMention(text?: string | null, parseMode?: string | null): string {
if (!this._user.accessHash)
throw new MtCuteArgumentError(
"user's access hash is not available!"
)
if (!text) text = this.displayName
if (!parseMode) parseMode = this.client['_defaultParseMode']
// since we are just creating a link and not actual tg entity,
// we can use this hack to create a valid link through our parse mode
return this.client.getParseMode(parseMode).unparse(text, [
{
raw: undefined as any,
type: 'text_link',
offset: 0,
length: text.length,
url: `tg://user?id=${
this.id
}&hash=${this._user.accessHash.toString(16)}`,
},
])
}
}
makeInspectable(User)

View file

@ -0,0 +1,42 @@
import { filters, UpdateFilter } from './filters'
import { Message } from '../messages/message'
import { MaybeAsync } from '@mtcute/core'
import { PropagationSymbol } from './propagation'
import { NewMessageHandler } from './handler'
export namespace handlers {
export function newMessage(
handler: (msg: Message) => MaybeAsync<void | PropagationSymbol>
): NewMessageHandler
export function newMessage<Mod>(
filter: UpdateFilter<Message, Mod>,
handler: (
msg: filters.Modify<Message, Mod>
) => MaybeAsync<void | PropagationSymbol>
): NewMessageHandler
export function newMessage<Mod>(
filter:
| UpdateFilter<Message, Mod>
| ((msg: Message) => MaybeAsync<void | PropagationSymbol>),
handler?: (
msg: filters.Modify<Message, Mod>
) => MaybeAsync<void | PropagationSymbol>
): NewMessageHandler {
// `as any` is pretty ugly, maybe somehow type it???
if (!handler) {
// no filter, just handler
return {
type: 'new_message',
callback: filter as any,
}
}
return {
type: 'new_message',
check: filter as UpdateFilter<Message, Mod>,
callback: handler as any,
}
}
}

View file

@ -0,0 +1,554 @@
import { TelegramClient } from '../../client'
import { MaybeArray, MaybeAsync } from '@mtcute/core'
import { Message } from '../messages'
import { User } from '../peers'
import {
Dice,
Photo,
Audio,
Document,
Contact,
RawDocument,
Location,
LiveLocation,
Sticker,
} from '../media'
import { Video } from '../media/video'
import { Voice } from '../media/voice'
import { Game } from '../media/game'
import { WebPage } from '../media/web-page'
/**
* Type describing a primitive filter, which is a function taking some `Base`
* and a {@link TelegramClient}, checking it against some condition
* and returning a boolean.
*
* If `true` is returned, the filter is considered
* to be matched, and the appropriate update handler function is called,
* otherwise next registered handler is checked.
*
* Additionally, filter might contain a type modification
* to `Base` for better code insights. If it is present,
* it is used to overwrite types (!) of some of the `Base` fields
* to given (note that this is entirely compile-time! object is not modified)
*
* For parametrized filters (like {@link filters.regex}),
* type modification can also be used to add additional fields
* (in case of `regex`, its match array is added to `.match`)
*
* Example without type mod:
* ```typescript
*
* const hasPhoto: UpdateFilter<Message> = msg => msg.media instanceof Photo
*
* // ..later..
* tg.onNewMessage(hasPhoto, async (msg) => {
* // `hasPhoto` filter matched, so we can safely assume
* // that `msg.media` is a Photo.
* //
* // but it is very redundant, verbose and error-rome,
* // wonder if we could make typescript do this automagically and safely...
* await (msg.media as Photo).downloadToFile(`${msg.id}.jpg`)
* })
* ```
*
* Example with type mod:
* ```typescript
*
* const hasPhoto: UpdateFilter<Message, { media: Photo }> = msg => msg.media instanceof Photo
*
* // ..later..
* tg.onNewMessage(hasPhoto, async (msg) => {
* // since `hasPhoto` filter matched,
* // we have applied the modification to `msg`,
* // and `msg.media` now has type `Photo`
* //
* // no more redundancy and type casts!
* await msg.media.downloadToFile(`${msg.id}.jpg`)
* })
* ```
*
* > **Note**: Type modification can contain anything, even totally unrelated types
* > and it is *your* task to keep track that everything is correct.
* >
* > Bad example:
* > ```typescript
* > // we check for `Photo`, but type contains `Audio`. this will be a problem!
* > const hasPhoto: UpdateFilter<Message, { media: Audio }> = msg => msg.media instanceof Photo
* >
* > // ..later..
* > tg.onNewMessage(hasPhoto, async (msg) => {
* > // oops! `msg.media` is `Audio` and does not have `.width`!
* > console.log(msg.media.width)
* > })
* > ```
*
* > **Warning!** Do not use the generics provided in functions
* > like `and`, `or`, etc. Those are meant to be inferred by the compiler!
*/
// we need the second parameter because it carries meta information
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type UpdateFilter<Base, Mod = {}> = (
update: Base,
client: TelegramClient
) => MaybeAsync<boolean>
export namespace filters {
export type Modify<Base, Mod> = Omit<Base, keyof Mod> & Mod
export type Invert<Base, Mod> = {
[P in keyof Mod & keyof Base]: Exclude<Base[P], Mod[P]>
}
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
type ExtractBase<
Filter extends UpdateFilter<any, any>
> = Filter extends UpdateFilter<infer I, any> ? I : never
type ExtractMod<
Base,
Filter extends UpdateFilter<Base, any>
> = Filter extends UpdateFilter<Base, infer I> ? I : never
/**
* Invert a filter by applying a NOT logical operation:
* `not(fn) = NOT fn`
*
* > **Note**: This also inverts type modification, i.e.
* > if the base is `{ field: string | number | null }`
* > and the modification is `{ field: string }`,
* > then the negated filter will have
* > inverted modification `{ field: number | null }`
*
* @param fn Filter to negate
*/
export function not<Base, Mod>(
fn: UpdateFilter<Base, Mod>
): UpdateFilter<Base, Invert<Base, Mod>> {
return (upd, client) => {
const res = fn(upd, client)
if (typeof res === 'boolean') return !res
return res.then((r) => !r)
}
}
/**
* Combine two filters by applying an AND logical operation:
* `and(fn1, fn2) = fn1 AND fn2`
*
* > **Note**: This also combines type modifications, i.e.
* > if the 1st has modification `{ field1: string }`
* > and the 2nd has modification `{ field2: number }`,
* > then the combined filter will have
* > combined modification `{ field1: string, field2: number }`
*
* @param fn1 First filter
* @param fn2 Second filter
*/
export function and<Base, Mod1, Mod2>(
fn1: UpdateFilter<Base, Mod1>,
fn2: UpdateFilter<Base, Mod2>
): UpdateFilter<Base, Mod1 & Mod2> {
return (upd, client) => {
const res1 = fn1(upd, client)
if (typeof res1 === 'boolean') {
if (!res1) return false
return fn2(upd, client)
}
return res1.then((r1) => {
if (!r1) return false
return fn2(upd, client)
})
}
}
/**
* Combine two filters by applying an OR logical operation:
* `or(fn1, fn2) = fn1 OR fn2`
*
* > **Note**: This also combines type modifications in a union, i.e.
* > if the 1st has modification `{ field1: string }`
* > and the 2nd has modification `{ field2: number }`,
* > then the combined filter will have
* > modification `{ field1: string } | { field2: number }`.
* >
* > It is up to the compiler to handle `if`s inside
* > the handler function code, but this works with other
* > logical functions as expected.
*
* @param fn1 First filter
* @param fn2 Second filter
*/
export function or<Base, Mod1, Mod2>(
fn1: UpdateFilter<Base, Mod1>,
fn2: UpdateFilter<Base, Mod2>
): UpdateFilter<Base, Mod1 | Mod2> {
return (upd, cilent) => {
const res1 = fn1(upd, cilent)
if (typeof res1 === 'boolean') {
if (res1) return true
return fn2(upd, cilent)
}
return res1.then((r1) => {
if (r1) return true
return fn2(upd, cilent)
})
}
}
// im pretty sure it can be done simpler (return types of all and any),
// so if you know how - PRs are welcome!
/**
* Combine multiple filters by applying an AND logical
* operation between every one of them:
* `every(fn1, fn2, ..., fnN) = fn1 AND fn2 AND ... AND fnN`
*
* > **Note**: This also combines type modification in a way
* > similar to {@link and}.
* >
* > Using incompatible filters (e.g. using a {@link Message}
* > filter and a {@link CallbackQuery} filter in one `every` call
* > will result in `unknown` and/or `never` types in the combined
* > filter. Watch out for that!
*
* @param fns Filters to combine
*/
export function every<Filters extends any[]>(
...fns: Filters
): UpdateFilter<
ExtractBase<Filters[0]>,
UnionToIntersection<
ExtractMod<ExtractBase<Filters[0]>, Filters[number]>
>
> {
return async (upd, client) => {
for (const fn of fns) {
if (!(await fn(upd, client))) return false
}
return true
}
}
/**
* Combine multiple filters by applying an OR logical
* operation between every one of them:
* `every(fn1, fn2, ..., fnN) = fn1 OR fn2 OR ... OR fnN`
*
* > **Note**: This also combines type modification in a way
* > similar to {@link or}.
* >
* > Using incompatible filters (e.g. using a {@link Message}
* > filter and a {@link CallbackQuery} filter in one `every` call
* > will result in `unknown` and/or `never` types in the combined
* > filter. Watch out for that!
*
* @param fns Filters to combine
*/
export function some<Filters extends any[]>(
...fns: Filters
): UpdateFilter<
ExtractBase<Filters[0]>,
ExtractMod<ExtractBase<Filters[0]>, Filters[number]>
> {
return async (upd, client) => {
for (const fn of fns) {
if (await fn(upd, client)) return true
}
return true
}
}
/**
* Filter messages generated by yourself (including Saved Messages)
*/
export const me: UpdateFilter<Message, { sender: User }> = (msg) =>
(msg.sender instanceof User && msg.sender.isSelf) || msg.outgoing
/**
* Filter messages sent by bots
*/
export const bot: UpdateFilter<Message, { sender: User }> = (msg) =>
msg.sender instanceof User && msg.sender.isBot
/**
* Filter incoming messages.
*
* Messages sent to yourself (i.e. Saved Messages) are also "incoming"
*/
export const incoming: UpdateFilter<Message, { outgoing: false }> = (msg) =>
!msg.outgoing
/**
* Filter outgoing messages.
*
* Messages sent to yourself (i.e. Saved Messages) are **not** "outgoing"
*/
export const outgoing: UpdateFilter<Message, { outgoing: true }> = (msg) =>
msg.outgoing
/**
* Filter messages that are replies to some other message
*/
export const reply: UpdateFilter<Message, { replyToMessageId: number }> = (
msg
) => msg.replyToMessageId !== null
/**
* Filter messages containing some media
*/
export const media: UpdateFilter<
Message,
{ media: Exclude<Message['media'], null> }
> = (msg) => msg.media !== null
/**
* Filter messages containing a photo
*/
export const photo: UpdateFilter<Message, { media: Photo }> = (msg) =>
msg.media instanceof Photo
/**
* Filter messages containing a dice
*/
export const dice: UpdateFilter<Message, { media: Dice }> = (msg) =>
msg.media instanceof Dice
/**
* Filter messages containing a contact
*/
export const contact: UpdateFilter<Message, { media: Contact }> = (msg) =>
msg.media instanceof Contact
/**
* Filter messages containing a document
*
* This will also match media like audio, video, voice
* that also use Documents
*/
export const rawDocument: UpdateFilter<Message, { media: RawDocument }> = (
msg
) => msg.media instanceof RawDocument
/**
* Filter messages containing a document in form of a file
*
* This will not match media like audio, video, voice
*/
export const document: UpdateFilter<Message, { media: Document }> = (msg) =>
msg.media instanceof Document
/**
* Filter messages containing an audio file
*/
export const audio: UpdateFilter<Message, { media: Audio }> = (msg) =>
msg.media instanceof Audio
/**
* Filter messages containing a voice note
*/
export const voice: UpdateFilter<Message, { media: Voice }> = (msg) =>
msg.media instanceof Voice
/**
* Filter messages containing a sticker
*/
export const sticker: UpdateFilter<Message, { media: Sticker }> = (msg) =>
msg.media instanceof Sticker
/**
* Filter messages containing a video.
*
* This includes videos, round messages and animations
*/
export const rawVideo: UpdateFilter<Message, { media: Video }> = (msg) =>
msg.media instanceof Video
/**
* Filter messages containing a simple video.
*
* This does not include round messages and animations
*/
export const video: UpdateFilter<
Message,
{
media: Modify<
Video,
{
isRound: false
isAnimation: false
}
>
}
> = (msg) =>
msg.media instanceof Video &&
!msg.media.isAnimation &&
!msg.media.isRound
/**
* Filter messages containing an animation.
*
* > **Note**: Legacy GIFs (i.e. documents with `image/gif` MIME)
* > are also considered animations.
*/
export const animation: UpdateFilter<
Message,
{
media: Modify<
Video,
{
isRound: false
isAnimation: true
}
>
}
> = (msg) =>
msg.media instanceof Video &&
msg.media.isAnimation &&
!msg.media.isRound
/**
* Filter messages containing a round message (aka video note).
*/
export const roundMessage: UpdateFilter<
Message,
{
media: Modify<
Video,
{
isRound: true
isAnimation: false
}
>
}
> = (msg) =>
msg.media instanceof Video &&
!msg.media.isAnimation &&
msg.media.isRound
/**
* Filter messages containing a location.
*
* This includes live locations
*/
export const location: UpdateFilter<Message, { media: Location }> = (msg) =>
msg.media instanceof Location
/**
* Filter messages containing a live location.
*/
export const liveLocation: UpdateFilter<
Message,
{ media: LiveLocation }
> = (msg) => msg.media instanceof LiveLocation
/**
* Filter messages containing a game.
*/
export const game: UpdateFilter<Message, { media: Game }> = (msg) =>
msg.media instanceof Game
/**
* Filter messages containing a webpage preview.
*/
export const webpage: UpdateFilter<Message, { media: WebPage }> = (msg) =>
msg.media instanceof WebPage
// todo: more filters, see https://github.com/pyrogram/pyrogram/blob/701c1cde07af779ab18dbf79a3e626f04fa5d5d2/pyrogram/filters.py#L191
/**
* Filter messages that match a given regular expression.
*
* When a regex matches, the match array is stored in a
* type-safe extension field `.match` of the {@link Message} object
*
* @param regex Regex to be matched
*/
export const regex = (
regex: RegExp
): UpdateFilter<Message, { match: RegExpMatchArray }> => (msg) => {
const m = msg.text.match(regex)
if (m) {
;(msg as Message & { match: RegExpMatchArray }).match = m
return true
}
return false
}
/**
* Filter messages that match a given regular expression.
*
* When a command matches, the match array is stored in a
* type-safe extension field `.commmand` of the {@link Message} object.
* First element is the command itself, then the arguments
*
* @param commands Command(s) the filter should look for (w/out prefix)
* @param prefixes
* Prefix(es) the filter should look for (default: `/`).
* Can be `null` to disable prefixes altogether
* @param caseSensitive
*/
export const command = (
commands: MaybeArray<string>,
prefixes: MaybeArray<string> | null = '/',
caseSensitive = false
): UpdateFilter<Message, { command: string[] }> => {
if (typeof commands === 'string') commands = [commands]
commands = commands.map((i) => i.toLowerCase())
const argumentsRe = /(["'])(.*?)(?<!\\)\1|(\S+)/g
const unescapeRe = /\\(['"])/
const commandsRe: Record<string, RegExp> = {}
commands.forEach((cmd) => {
commandsRe[cmd] = new RegExp(
`^${cmd}(?:\\s|$)`,
caseSensitive ? '' : 'i'
)
})
if (prefixes === null) prefixes = []
if (typeof prefixes === 'string') prefixes = [prefixes]
return (msg) => {
for (const pref of prefixes!) {
if (!msg.text.startsWith(pref)) continue
const withoutPrefix = msg.text.slice(pref.length)
for (const cmd of commands) {
if (!withoutPrefix.match(commandsRe[cmd])) continue
const match = [cmd]
// we use .replace to iterate over global regex, not to replace the text
withoutPrefix
.slice(cmd.length)
.replace(argumentsRe, ($0, $1, $2, $3) => {
match.push(
($2 || $3 || '').replace(unescapeRe, '$1')
)
return ''
})
;(msg as Message & { command: string[] }).command = match
return true
}
}
return false
}
}
}

View file

@ -0,0 +1,51 @@
import { TelegramClient } from '../../client'
import { MaybeAsync } from '@mtcute/core'
import { tl } from '@mtcute/tl'
import { PropagationSymbol } from './propagation'
import { Message } from '../messages/message'
// todo!
// export type UpdateHandlerType =
// | 'callback_query'
// | 'chat_member_updated'
// | 'chosen_inline_result'
// | 'deleted_messages'
// | 'inline_query'
// | 'poll'
// | 'user_status'
interface BaseUpdateHandler<Type, Handler, Checker> {
type: Type
callback: Handler
check?: Checker
}
type ParsedUpdateHandler<Type, Update> = BaseUpdateHandler<
Type,
(
update: Update,
client: TelegramClient
) => MaybeAsync<void | PropagationSymbol>,
(update: Update, client: TelegramClient) => MaybeAsync<boolean>
>
export type RawUpdateHandler = BaseUpdateHandler<
'raw',
(
client: TelegramClient,
update: tl.TypeUpdate,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>
) => MaybeAsync<void | PropagationSymbol>,
(
client: TelegramClient,
update: tl.TypeUpdate,
users: Record<number, tl.TypeUser>,
chats: Record<number, tl.TypeChat>
) => MaybeAsync<boolean>
>
export type NewMessageHandler = ParsedUpdateHandler<'new_message', Message>
export type UpdateHandler = RawUpdateHandler | NewMessageHandler

View file

@ -0,0 +1,4 @@
export * from './builders'
export * from './filters'
export * from './handler'
export * from './propagation'

View file

@ -0,0 +1,10 @@
const _sym = require('es6-symbol')
export const StopPropagation: unique symbol = _sym.for('mtcute:StopPropagation')
export const ContinuePropagation: unique symbol = _sym.for(
'mtcute:ContinuePropagation'
)
export type PropagationSymbol =
| typeof StopPropagation
| typeof ContinuePropagation

View file

@ -0,0 +1,76 @@
import { MaybeAsync } from '@mtcute/core'
export type MaybeDynamic<T> = MaybeAsync<T> | (() => MaybeAsync<T>)
let util: any | null = null
try {
util = require('util')
} catch (e) {}
// get all property names. unlike Object.getOwnPropertyNames,
// also gets inherited property names
function getAllGettersNames(obj: object): string[] {
const getters: string[] = []
do {
Object.getOwnPropertyNames(obj).forEach((prop) => {
if (
prop !== '__proto__' &&
Object.getOwnPropertyDescriptor(obj, prop)?.get &&
getters.indexOf(prop) === -1
) {
getters.push(prop)
}
})
} while ((obj = Object.getPrototypeOf(obj)))
return getters
}
/**
* Small helper function that adds `toJSON` and `util.custom.inspect`
* methods to a given class based on its getters
*
* > **Note**: This means that all getters must be pure!
* > (getter that caches after its first invocation is also
* > considered pure in this case)
*
* @internal
*/
export function makeInspectable(
obj: Function,
props?: string[],
hide?: string[]
): void {
const getters: string[] = props ? props : []
for (const key of getAllGettersNames(obj.prototype)) {
if (!hide || hide.indexOf(key) === -1) getters.push(key)
}
// dirty hack to set name for inspect result
const proto = new Function(`return function ${obj.name}(){}`)().prototype
obj.prototype.toJSON = function () {
const ret: any = Object.create(proto)
getters.forEach((it) => {
try {
let val = this[it]
if (
val &&
typeof val === 'object' &&
typeof val.toJSON === 'function'
) {
val = val.toJSON()
}
ret[it] = val
} catch (e) {
ret[it] = e.message
}
})
return ret
}
if (util) {
obj.prototype[util.inspect.custom] = obj.prototype.toJSON
}
}

View file

@ -0,0 +1,100 @@
import { MtCuteArgumentError } from '../types'
export function determinePartSize(fileSize: number): number {
if (fileSize <= 104857600) return 128 // 100 MB
if (fileSize <= 786432000) return 256 // 750 MB
if (fileSize <= 2097152000) return 512 // 2000 MB
throw new MtCuteArgumentError('File is too large')
}
/**
* Returns `true` if all bytes in `buf` are printable ASCII characters
*/
export function isProbablyPlainText(buf: Buffer): boolean {
return !buf.some(
(it) =>
!(
(
(it >= 0x20 && it < 0x7f) || // printable ascii range
it === 0x0d || // CR
it === 0x0a || // LF
it === 0x09
) // Tab
)
)
}
// from https://github.com/telegramdesktop/tdesktop/blob/bec39d89e19670eb436dc794a8f20b657cb87c71/Telegram/SourceFiles/ui/image/image.cpp#L225
const JPEG_HEADER = Buffer.from(
'ffd8ffe000104a46494600010100000100010000ffdb004300281c1e231e1928' +
'2321232d2b28303c64413c37373c7b585d4964918099968f808c8aa0b4e6c3a0aad' +
'aad8a8cc8ffcbdaeef5ffffff9bc1fffffffaffe6fdfff8ffdb0043012b2d2d3c35' +
'3c76414176f8a58ca5f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f' +
'8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8ffc0001108000000' +
'0003012200021101031101ffc4001f0000010501010101010100000000000000000' +
'102030405060708090a0bffc400b5100002010303020403050504040000017d0102' +
'0300041105122131410613516107227114328191a1082342b1c11552d1f02433627' +
'282090a161718191a25262728292a3435363738393a434445464748494a53545556' +
'5758595a636465666768696a737475767778797a838485868788898a92939495969' +
'798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4' +
'd5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030' +
'101010101010101010000000000000102030405060708090a0bffc400b511000201' +
'0204040304070504040001027700010203110405213106124151076171132232810' +
'8144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a3536' +
'3738393a434445464748494a535455565758595a636465666768696a73747576777' +
'8797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5' +
'b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f' +
'3f4f5f6f7f8f9faffda000c03010002110311003f00',
'hex'
)
const JPEG_FOOTER = Buffer.from('ffd9', 'hex')
export function strippedPhotoToJpg(stripped: Buffer): Buffer {
if (stripped.length < 3 || stripped[0] !== 1) {
return stripped
}
const result = Buffer.concat([JPEG_HEADER, stripped.slice(3), JPEG_FOOTER])
result[164] = stripped[1]
result[166] = stripped[2]
return result
}
const SVG_LOOKUP =
'AACAAAAHAAALMAAAQASTAVAAAZaacaaaahaaalmaaaqastava.az0123456789-,'
export function inflateSvgPath(encoded: Buffer): string {
let path = 'M'
const len = encoded.length
for (let i = 0; i < len; i++) {
const num = encoded[i]
if (num >= 192) {
// 128 + 64
path += SVG_LOOKUP[num - 192]
} else {
if (num >= 128) {
path += ','
} else if (num >= 64) {
path += '-'
}
path += num & 63
}
}
return path
}
export function svgPathToFile(path: string): Buffer {
return Buffer.from(
'<?xml version="1.0" encoding="utf-8"?>' +
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' +
'viewBox="0 0 512 512" xml:space="preserve">' +
'<path d="' +
path +
'"/>' +
'</svg>'
)
}

View file

@ -0,0 +1,54 @@
export function* chunks<T>(iter: Iterable<T>, size = 100): Iterable<T[]> {
let buf: T[] = []
for (const item of iter) {
buf.push(item)
if (buf.length === size) {
yield buf
buf = []
}
}
if (buf.length) yield buf
}
export function zip<T1, T2>(
iter1: Iterable<T1>,
iter2: Iterable<T2>
): Iterable<[T1, T2]>
export function zip<T1, T2, T3>(
iter1: Iterable<T1>,
iter2: Iterable<T2>,
iter3: Iterable<T3>
): Iterable<[T1, T2, T3]>
export function zip(...iters: Iterable<any>[]): Iterable<any[]>
export function* zip(...iters: Iterable<any>[]): Iterable<any[]> {
const iterables = iters.map((iter) => iter[Symbol.iterator]())
for (;;) {
const row = []
for (const iter of iterables) {
const res = iter.next()
if (res.done) return
row.push(res.value)
}
yield row
}
}
export async function groupByAsync<T, K extends string | number>(
items: Iterable<T>,
keyer: (item: T) => Promise<K>
): Promise<Record<K, T[]>> {
const ret: Record<K, T[]> = {} as any
for (const item of items) {
const key = await keyer(item)
if (!ret[key]) ret[key] = []
ret[key].push(item)
}
return ret
}

View file

@ -0,0 +1,37 @@
import { MaybeDynamic, MtCuteError } from '../types'
import { BigInteger } from 'big-integer'
import { randomBytes } from '@mtcute/core/dist/utils/buffer-utils'
import { bufferToBigInt } from '@mtcute/core/dist/utils/bigint-utils'
import { tl } from '@mtcute/tl'
export const EMPTY_BUFFER = Buffer.alloc(0)
export function normalizePhoneNumber(phone: string): string {
phone = phone.trim().replace(/[+()\s-]/g, '')
if (!phone.match(/^\d+$/)) throw new MtCuteError('Invalid phone number')
return phone
}
export async function resolveMaybeDynamic<T>(val: MaybeDynamic<T>): Promise<T> {
return val instanceof Function ? await val() : await val
}
export function randomUlong(): BigInteger {
return bufferToBigInt(randomBytes(8))
}
export function extractChannelIdFromUpdate(
upd: tl.TypeUpdate
): number | undefined {
// holy shit
return 'channelId' in upd
? upd.channelId
: 'message' in upd &&
typeof upd.message !== 'string' &&
'peerId' in upd.message &&
upd.message.peerId &&
'channelId' in upd.message.peerId
? upd.message.peerId.channelId
: undefined
}

View file

@ -0,0 +1,79 @@
import { tl } from '@mtcute/tl'
// helpers to normalize result of `resolvePeer` function
export function normalizeToInputPeer(
res: tl.TypeInputPeer | tl.TypeInputUser | tl.TypeInputChannel
): tl.TypeInputPeer {
if (tl.isAnyInputPeer(res)) return res
if (res._ === 'inputChannelEmpty' || res._ === 'inputUserEmpty') {
return { _: 'inputPeerEmpty' }
}
if (res._ === 'inputUser') {
return { ...res, _: 'inputPeerUser' }
}
if (res._ === 'inputUserSelf') {
return { _: 'inputPeerSelf' }
}
if (res._ === 'inputChannel') {
return { ...res, _: 'inputPeerChannel' }
}
if (res._ === 'inputChannelFromMessage') {
return { ...res, _: 'inputPeerChannelFromMessage' }
}
if (res._ === 'inputUserFromMessage') {
return { ...res, _: 'inputPeerUserFromMessage' }
}
return res as never
}
export function normalizeToInputUser(
res: tl.TypeInputPeer | tl.TypeInputUser | tl.TypeInputChannel
): tl.TypeInputUser | null {
if (tl.isAnyInputUser(res)) return res
if (res._ === 'inputPeerUser') {
return { ...res, _: 'inputUser' }
}
if (res._ === 'inputPeerUserFromMessage') {
return { ...res, _: 'inputUserFromMessage' }
}
return null
}
export function normalizeToInputChannel(
res: tl.TypeInputPeer | tl.TypeInputUser | tl.TypeInputChannel
): tl.TypeInputChannel | null {
if (tl.isAnyInputChannel(res)) return res
if (res._ === 'inputPeerChannel') {
return { ...res, _: 'inputChannel' }
}
if (res._ === 'inputPeerChannelFromMessage') {
return { ...res, _: 'inputChannelFromMessage' }
}
return null
}
export function inputPeerToPeer(inp: tl.TypeInputPeer): tl.TypePeer {
if (inp._ === 'inputPeerUser' || inp._ === 'inputPeerUserFromMessage')
return { _: 'peerUser', userId: inp.userId }
if (inp._ === 'inputPeerChannel' || inp._ === 'inputPeerChannelFromMessage')
return { _: 'peerChannel', channelId: inp.channelId }
if (inp._ === 'inputPeerChat') return { _: 'peerChat', chatId: inp.chatId }
return inp as never
}

View file

@ -0,0 +1,111 @@
import { Readable, ReadableOptions } from 'stream'
// taken from https://github.com/JCCR/web-streams-node, licensed under Apache 2.0
class NodeReadable extends Readable {
private _webStream: ReadableStream
private _reader: ReadableStreamDefaultReader
private _reading: boolean
private _doneReading?: Function
constructor(webStream: ReadableStream, opts?: ReadableOptions) {
super(opts)
this._webStream = webStream
this._reader = webStream.getReader()
this._reading = false
}
_read() {
if (this._reading) {
return
}
this._reading = true
const doRead = () => {
this._reader.read().then((res) => {
if (this._doneReading) {
this._reading = false
this._reader.releaseLock()
this._doneReading()
}
if (res.done) {
this.push(null)
this._reading = false
this._reader.releaseLock()
return
}
if (this.push(res.value)) {
return doRead()
} else {
this._reading = false
this._reader.releaseLock()
}
})
}
doRead()
}
_destroy(err: Error | null, callback: (error?: Error | null) => void) {
if (this._reading) {
const promise = new Promise((resolve) => {
this._doneReading = resolve
})
promise.then(() => this._handleDestroy(err, callback))
} else {
this._handleDestroy(err, callback)
}
}
_handleDestroy(
err: Error | null,
callback: (error?: Error | null) => void
) {
this._webStream.cancel()
super._destroy(err, callback)
}
}
export function convertWebStreamToNodeReadable(
webStream: ReadableStream,
opts?: ReadableOptions
): Readable {
return new NodeReadable(webStream, opts)
}
export async function readStreamUntilEnd(stream: Readable): Promise<Buffer> {
const chunks = []
while (stream.readable) {
chunks.push(await stream.read())
}
return Buffer.concat(chunks)
}
export function bufferToStream(buf: Buffer): Readable {
return new Readable({
read() {
this.push(buf)
this.push(null)
},
})
}
export async function readBytesFromStream(
stream: Readable,
size: number
): Promise<Buffer | null> {
if (stream.readableEnded) return null
let res = stream.read(size)
if (!res) {
return new Promise((resolve) => {
stream.on('readable', function handler() {
res = stream.read(size)
if (res) {
stream.off('readable', handler)
resolve(res)
}
})
})
}
return res
}

View file

@ -0,0 +1,12 @@
import { MtCuteTypeAssertionError } from '../types'
import { tl } from '@mtcute/tl'
export function assertTypeIs<T extends tl.TlObject, K extends T['_']>(
context: string,
obj: T,
expected: K
): asserts obj is tl.FindByName<T, K> {
if (obj._ !== expected) {
throw new MtCuteTypeAssertionError(context, expected, obj._)
}
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"./src"
],
"typedocOptions": {
"name": "@mtcute/client",
"includeVersion": true,
"out": "../../docs/packages/client",
"listInvalidSymbolLinks": true,
"excludePrivate": true,
"entryPoints": [
"./src/index.ts"
]
}
}

View file

@ -0,0 +1,16 @@
{
"name": "@mtcute/core",
"version": "0.0.0",
"description": "Core functions and base MTProto client",
"author": "Alisa Sireneva <me@tei.su>",
"license": "MIT",
"main": "dist/index.js",
"scripts": {
"test": "mocha -r ts-node/register tests/**/*.spec.ts",
"build": "tsc",
"docs": "npx typedoc"
},
"dependencies": {
"@mtcute/tl": "^0.0.0"
}
}

View file

@ -0,0 +1,683 @@
import { tl } from '@mtcute/tl'
import {
CryptoProviderFactory,
ICryptoProvider,
defaultCryptoProviderFactory,
} from './utils/crypto'
import {
TransportFactory,
defaultReconnectionStrategy,
ReconnectionStrategy,
defaultTransportFactory,
TelegramConnection,
} from './network'
import { PersistentConnectionParams } from './network/persistent-connection'
import {
defaultProductionDc,
defaultProductionIpv6Dc,
} from './utils/default-dcs'
import {
FloodTestPhoneWaitError,
FloodWaitError,
InternalError,
NetworkMigrateError,
PhoneMigrateError,
RpcError,
SlowmodeWaitError,
UserMigrateError,
} from '@mtcute/tl/errors'
import { sleep } from './utils/misc-utils'
import { addPublicKey } from './utils/crypto/keys'
import { ITelegramStorage, MemoryStorage } from './storage'
import { getAllPeersFrom, MAX_CHANNEL_ID } from './utils/peer-utils'
import bigInt from 'big-integer'
const debug = require('debug')('mtcute:base')
export namespace BaseTelegramClient {
export interface Options {
/**
* API ID from my.telegram.org
*/
apiId: number | string
/**
* API hash from my.telegram.org
*/
apiHash: string
/**
* Telegram storage to use.
* If omitted, {@link MemoryStorage} is used
*/
storage?: ITelegramStorage
/**
* Cryptography provider factory to allow delegating
* crypto to native addon, worker, etc.
*/
crypto?: CryptoProviderFactory
/**
* Whether to use IPv6 datacenters
* (IPv6 will be preferred when choosing a DC by id)
* (default: false)
*/
useIpv6?: boolean
/**
* Primary DC to use for initial connection.
* This does not mean this will be the only DC used,
* nor that this DC will actually be primary, this only
* determines the first DC the library will try to connect to.
* Can be used to connect to other networks (like test DCs).
*
* When session already contains primary DC, this parameter is ignored.
* Defaults to Production DC 2.
*/
primaryDc?: tl.RawDcOption
/**
* Additional options for initConnection call.
* `apiId` and `query` are not available and will be ignored.
* Omitted values will be filled with defaults
*/
initConnectionOptions?: Partial<
Omit<tl.RawInitConnectionRequest, 'apiId' | 'query'>
>
/**
* Transport factory to use in the client.
* Defaults to platform-specific transport: WebSocket on the web, TCP in node
*/
transport?: TransportFactory
/**
* Reconnection strategy.
* Defaults to simple reconnection strategy: first 0ms, then up to 5s (increasing by 1s)
*/
reconnectionStrategy?: ReconnectionStrategy<PersistentConnectionParams>
/**
* Maximum duration of a flood_wait that will be waited automatically.
* Flood waits above this threshold will throw a FloodWaitError.
* Set to 0 to disable. Can be overridden with `throwFlood` parameter in call() params
*
* @default 10000
*/
floodSleepThreshold?: number
/**
* Maximum number of retries when calling RPC methods.
* Call is retried when InternalError or FloodWaitError is encountered.
* Can be set to Infinity.
*
* @default 5
*/
rpcRetryCount?: number
/**
* If true, every single API call will be wrapped with `tl.invokeWithoutUpdates`,
* effectively disabling the server-sent events for the clients.
* May be useful in some cases.
*
* Note that this only wraps calls made with `.call()` within the primary
* connection. Additional connections and direct `.sendForResult()` calls
* must be wrapped manually.
*
* @default false
*/
disableUpdates?: boolean
/**
* If true, RPC errors will have a stack trace of the initial `.call()`
* or `.sendForResult()` call position, which drastically improves
* debugging experience.<br>
* If false, they will have a stack trace of MTCute internals.
*
* Internally this creates a stack capture before every RPC call
* and stores it until the result is received. This might
* use a lot more memory than normal, thus can be disabled here.
*
* @default true
*/
niceStacks?: boolean
}
}
export class BaseTelegramClient {
/**
* `initConnection` params taken from {@link BaseTelegramClient.Options.initConnectionOptions}.
*/
protected readonly _initConnectionParams: tl.RawInitConnectionRequest
/**
* Crypto provider taken from {@link BaseTelegramClient.Options.crypto}
*/
protected readonly _crypto: ICryptoProvider
/**
* Transport factory taken from {@link BaseTelegramClient.Options.transport}
*/
protected readonly _transportFactory: TransportFactory
/**
* Telegram storage taken from {@link BaseTelegramClient.Options.storage}
*/
readonly storage: ITelegramStorage
/**
* API hash taken from {@link BaseTelegramClient.Options.apiHash}
*/
protected readonly _apiHash: string
/**
* "Use IPv6" taken from {@link BaseTelegramClient.Options.useIpv6}
*/
protected readonly _useIpv6: boolean
/**
* Reconnection strategy taken from {@link BaseTelegramClient.Options.reconnectionStrategy}
*/
protected readonly _reconnectionStrategy: ReconnectionStrategy<PersistentConnectionParams>
/**
* Flood sleep threshold taken from {@link BaseTelegramClient.Options.floodSleepThreshold}
*/
protected readonly _floodSleepThreshold: number
/**
* RPC retry count taken from {@link BaseTelegramClient.Options.rpcRetryCount}
*/
protected readonly _rpcRetryCount: number
/**
* "Disable updates" taken from {@link BaseTelegramClient.Options.disableUpdates}
*/
protected readonly _disableUpdates: boolean
/**
* Primary DC taken from {@link BaseTelegramClient.Options.primaryDc},
* loaded from session or changed by other means (like redirecting).
*/
protected _primaryDc: tl.RawDcOption
private _niceStacks: boolean
private _keepAliveInterval: NodeJS.Timeout
private _lastRequestTime = 0
private _floodWaitedRequests: Record<string, number> = {}
private _config?: tl.RawConfig
private _cdnConfig?: tl.RawCdnConfig
private _additionalConnections: TelegramConnection[] = []
// not really connected, but rather "connect() was called"
private _connected = false
private _onError?: (err: Error) => void
/**
* The primary {@link TelegramConnection} that is used for
* most of the communication with Telegram.
*
* Methods for downloading/uploading files may create additional connections as needed.
*/
primaryConnection: TelegramConnection
/**
* Method which is called every time the client receives a new update.
*
* User of the class is expected to override it and handle the given update
*
* @param update Raw update object sent by Telegram
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _handleUpdate(update: tl.TypeUpdates): void {}
/**
* Method which is called for every object
*
* @param obj
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _processApiResponse(obj: tl.TlObject): void {}
constructor(opts: BaseTelegramClient.Options) {
const apiId =
typeof opts.apiId === 'string' ? parseInt(opts.apiId) : opts.apiId
if (isNaN(apiId))
throw new Error('apiId must be a number or a numeric string!')
this._transportFactory = opts.transport ?? defaultTransportFactory
this._crypto = (opts.crypto ?? defaultCryptoProviderFactory)()
this.storage = opts.storage ?? new MemoryStorage()
this._apiHash = opts.apiHash
this._useIpv6 = !!opts.useIpv6
this._primaryDc =
opts.primaryDc ??
(this._useIpv6 ? defaultProductionIpv6Dc : defaultProductionDc)
this._reconnectionStrategy =
opts.reconnectionStrategy ?? defaultReconnectionStrategy
this._floodSleepThreshold = opts.floodSleepThreshold ?? 10000
this._rpcRetryCount = opts.rpcRetryCount ?? 5
this._disableUpdates = opts.disableUpdates ?? false
this._niceStacks = opts.niceStacks ?? true
let deviceModel = 'mtcute on '
if (typeof process !== 'undefined' && typeof require !== 'undefined') {
const os = require('os')
deviceModel += `${os.type()} ${os.arch()} ${os.release()}`
} else if (typeof navigator !== 'undefined') {
deviceModel += navigator.userAgent
} else deviceModel += 'unknown'
this._initConnectionParams = {
_: 'initConnection',
deviceModel,
systemVersion: '1.0',
appVersion: '1.0.0',
systemLangCode: 'en',
langPack: '', // "langPacks are for official apps only"
langCode: 'en',
...(opts.initConnectionOptions ?? {}),
apiId,
query: null,
}
}
private _cleanupPrimaryConnection(forever = false): void {
if (forever && this.primaryConnection) this.primaryConnection.destroy()
if (this._keepAliveInterval) clearInterval(this._keepAliveInterval)
}
private _setupPrimaryConnection(): void {
this._cleanupPrimaryConnection(true)
this.primaryConnection = new TelegramConnection({
crypto: this._crypto,
initConnection: this._initConnectionParams,
transportFactory: this._transportFactory,
dc: this._primaryDc,
reconnectionStrategy: this._reconnectionStrategy,
})
this.primaryConnection.on('usable', async () => {
this._keepAliveInterval = setInterval(async () => {
await this.storage.save?.()
// according to telethon, "We need to send some content-related request at least hourly
// for Telegram to keep delivering updates, otherwise they will just stop even if we're connected.
// Do so every 30 minutes"
if (Date.now() - this._lastRequestTime > 1800_000) {
try {
await this.call({ _: 'updates.getState' })
} catch (e) {
if (!(e instanceof RpcError)) {
this.primaryConnection.reconnect()
}
}
}
}, 60_000)
if (!this.primaryConnection._initConnectionCalled) {
// initial call that will be wrapped with initConnection
await this.call({ _: 'help.getConfig' }).catch((err) =>
this.primaryConnection.emit('error', err)
)
}
// on reconnection we need to call updates.getState so Telegram
// knows we still want the updates
if (!this._disableUpdates) {
try {
await this.call({ _: 'updates.getState' })
} catch (e) {
if (!(e instanceof RpcError)) {
this.primaryConnection.reconnect()
}
}
}
})
this.primaryConnection.on('update', (update) => {
this._handleUpdate(update)
})
this.primaryConnection.on('wait', () =>
this._cleanupPrimaryConnection()
)
this.primaryConnection.on('key-change', async (key) => {
this.storage.setAuthKeyFor(this._primaryDc.id, key)
await this.storage.save?.()
})
this.primaryConnection.on('error', (err) => this._emitError(err))
}
/**
* Initialize the connection to the primary DC.
*
* You shouldn't usually call this method directly as it is called
* implicitly the first time you call {@link call}.
*/
async connect(): Promise<void> {
await this.storage.load?.()
const primaryDc = await this.storage.getDefaultDc()
if (primaryDc !== null) this._primaryDc = primaryDc
this._connected = true
this._setupPrimaryConnection()
this.primaryConnection.authKey = await this.storage.getAuthKeyFor(
this._primaryDc.id
)
this.primaryConnection.connect()
}
/**
* Close all connections and finalize the client.
*/
async close(): Promise<void> {
this._cleanupPrimaryConnection(true)
// close additional connections
this._additionalConnections.forEach((conn) => conn.destroy())
await this.storage.save?.()
await this.storage.destroy?.()
}
/**
* Utility function to find the DC by its ID.
*
* @param id Datacenter ID
* @param cdn Whether the needed DC is a CDN DC
*/
async getDcById(id: number, cdn = false): Promise<tl.RawDcOption> {
if (!this._config) {
this._config = await this.call({ _: 'help.getConfig' })
}
if (cdn && !this._cdnConfig) {
this._cdnConfig = await this.call({ _: 'help.getCdnConfig' })
for (const key of this._cdnConfig.publicKeys) {
await addPublicKey(this._crypto, key.publicKey)
}
}
if (this._useIpv6) {
// first try to find ipv6 dc
const found = this._config.dcOptions.find(
(it) => it.id === id && it.cdn === cdn && it.ipv6
)
if (found) return found
}
const found = this._config.dcOptions.find(
(it) => it.id === id && it.cdn === cdn
)
if (found) return found
throw new Error(`Could not find${cdn ? ' CDN' : ''} DC ${id}`)
}
/**
* Change primary DC and write that fact to the storage.
* Will immediately reconnect to another DC.
*
* @param newDc New DC or its ID
*/
async changeDc(newDc: tl.RawDcOption | number): Promise<void> {
if (typeof newDc === 'number') {
newDc = await this.getDcById(newDc)
}
this._primaryDc = newDc
await this.storage.setDefaultDc(newDc)
await this.storage.save?.()
this.primaryConnection.changeDc(newDc)
}
/**
* Make an RPC call to the primary DC.
* This method handles DC migration, flood waits and retries automatically.
*
* If you want more low-level control, use
* `primaryConnection.sendForResult()` (which is what this method wraps)
*
* This method is still quite low-level and you shouldn't use this
* when using high-level API provided by `@mtcute/client`.
*
* @param message RPC method to call
* @param params Additional call parameters
*/
async call<T extends tl.RpcMethod>(
message: T,
params?: {
throwFlood: boolean
}
): Promise<tl.RpcCallReturn[T['_']]> {
if (!this._connected) {
await this.connect()
}
// do not send requests that are in flood wait
if (message._ in this._floodWaitedRequests) {
const delta = Date.now() - this._floodWaitedRequests[message._]
if (delta <= 3000) {
// flood waits below 3 seconds are "ignored"
delete this._floodWaitedRequests[message._]
} else if (delta <= this._floodSleepThreshold) {
await sleep(delta)
delete this._floodWaitedRequests[message._]
} else {
throw new FloodWaitError(delta / 1000)
}
}
if (this._disableUpdates) {
message = {
_: 'invokeWithoutUpdates',
query: message,
} as any // who cares
}
this._lastRequestTime = Date.now()
let lastError: Error | null = null
const stack = this._niceStacks ? new Error().stack : undefined
for (let i = 0; i < this._rpcRetryCount; i++) {
try {
const res = await this.primaryConnection.sendForResult(
message,
stack
)
await this._cachePeersFrom(res)
return res
} catch (e) {
lastError = e
if (e instanceof InternalError) {
debug('Telegram is having internal issues: %s', e)
continue
}
if (
e instanceof FloodWaitError ||
e instanceof SlowmodeWaitError ||
e instanceof FloodTestPhoneWaitError
) {
if (!(e instanceof SlowmodeWaitError)) {
// SLOW_MODE_WAIT is chat-specific, not request-specific
this._floodWaitedRequests[message._] =
Date.now() + e.seconds * 1000
}
// In test servers, FLOOD_WAIT_0 has been observed, and sleeping for
// such a short amount will cause retries very fast leading to issues
if (e.seconds === 0) {
e.seconds = 1
}
if (
params?.throwFlood !== true &&
e.seconds <= this._floodSleepThreshold
) {
debug('Flood wait for %d seconds', e.seconds)
await sleep(e.seconds * 1000)
continue
}
}
if (
e instanceof PhoneMigrateError ||
e instanceof UserMigrateError ||
e instanceof NetworkMigrateError
) {
debug('Migrate error, new dc = %d', e.newDc)
await this.changeDc(e.newDc)
continue
}
throw e
}
}
throw lastError
}
/**
* Creates an additional connection to a given DC.
* This will use auth key for that DC that was already stored
* in the session, or generate a new auth key by exporting
* authorization from primary DC and importing it to the new DC.
* New connection will use the same crypto provider, `initConnection`,
* transport and reconnection strategy as the primary connection
*
* This method is quite low-level and you shouldn't usually care about this
* when using high-level API provided by `@mtcute/client`.
*
* @param dcId DC id, to which the connection will be created
* @param cdn Whether that DC is a CDN DC
* @param inactivityTimeout
* Inactivity timeout for the connection (in ms), after which the transport will be closed.
* Note that connection can still be used normally, it's just the transport which is closed.
* Defaults to 5 min
*/
async createAdditionalConnection(
dcId: number,
cdn = false,
inactivityTimeout = 300_000
): Promise<TelegramConnection> {
const dc = await this.getDcById(dcId, cdn)
const connection = new TelegramConnection({
dc,
crypto: this._crypto,
initConnection: this._initConnectionParams,
transportFactory: this._transportFactory,
reconnectionStrategy: this._reconnectionStrategy,
inactivityTimeout,
})
connection.on('error', (err) => this._emitError(err))
connection.authKey = await this.storage.getAuthKeyFor(dc.id)
connection.connect()
if (!connection.authKey) {
debug('exporting auth to DC %d', dcId)
const auth = await this.call({
_: 'auth.exportAuthorization',
dcId,
})
await connection.sendForResult({
_: 'auth.importAuthorization',
id: auth.id,
bytes: auth.bytes,
})
// connection.authKey was already generated at this point
this.storage.setAuthKeyFor(dc.id, connection.authKey)
await this.storage.save?.()
}
this._additionalConnections.push(connection)
return connection
}
/**
* Destroy a connection that was previously created using {@link createAdditionalConnection}.
* Passing any other connection will not have any effect.
*
* @param connection Connection created with {@link createAdditionalConnection}
*/
async destroyAdditionalConnection(
connection: TelegramConnection
): Promise<void> {
const idx = this._additionalConnections.indexOf(connection)
if (idx === -1) return
await connection.destroy()
this._additionalConnections.splice(idx, 1)
}
onError(handler: (err: Error) => void): void {
this._onError = handler
}
protected _emitError(err: Error): void {
if (this._onError) {
this._onError(err)
} else {
console.error(err)
}
}
/**
* Adds all peers from a given object to entity cache in storage.
* Returns boolean indicating whether there were any `min` entities.
*/
protected async _cachePeersFrom(obj: any): Promise<boolean> {
let isMin = false
const parsedPeers: ITelegramStorage.PeerInfo[] = []
for (const peer of getAllPeersFrom(obj)) {
if ('min' in peer && peer.min) {
isMin = true
continue
}
if (peer._ === 'user') {
parsedPeers.push({
id: peer.id,
accessHash: peer.accessHash!,
username: peer.username?.toLowerCase() ?? null,
phone: peer.phone ?? null,
type: peer.bot ? 'bot' : 'user',
updated: 0,
})
} else if (peer._ === 'chat' || peer._ === 'chatForbidden') {
parsedPeers.push({
id: -peer.id,
accessHash: bigInt.zero,
username: null,
phone: null,
type: 'group',
updated: 0,
})
} else if (peer._ === 'channel' || peer._ === 'channelForbidden') {
parsedPeers.push({
id: MAX_CHANNEL_ID - peer.id,
accessHash: peer.accessHash!,
username:
peer._ === 'channel'
? peer.username?.toLowerCase() ?? null
: null,
phone: null,
type: peer.broadcast ? 'channel' : 'supergroup',
updated: 0,
})
}
}
await this.storage.updatePeers(parsedPeers)
return isMin
}
}

Some files were not shown because too many files have changed in this diff Show more