Initial commit
This commit is contained in:
commit
cd8ec8309f
184 changed files with 63908 additions and 0 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
4
.eslintignore
Normal file
|
@ -0,0 +1,4 @@
|
|||
private/
|
||||
docs/
|
||||
dist/
|
||||
scripts/
|
46
.eslintrc.js
Normal file
46
.eslintrc.js
Normal 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
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=lf
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
private/
|
||||
.nyc_output/
|
||||
|
||||
*.log
|
8
.prettierignore
Normal file
8
.prettierignore
Normal 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
19
.prettierrc
Normal 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
36
README.md
Normal 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
11
lerna.json
Normal 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
58
package.json
Normal 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/*"
|
||||
]
|
||||
}
|
18
packages/client/package.json
Normal file
18
packages/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
333
packages/client/scripts/generate-client.js
Normal file
333
packages/client/scripts/generate-client.js
Normal 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)
|
800
packages/client/src/client.ts
Normal file
800
packages/client/src/client.ts
Normal 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)
|
||||
}
|
||||
}
|
9
packages/client/src/index.ts
Normal file
9
packages/client/src/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
MemoryStorage,
|
||||
JsonFileStorage,
|
||||
LocalstorageStorage,
|
||||
} from '@mtcute/core'
|
||||
|
||||
export * from './parser'
|
||||
export * from './types'
|
||||
export * from './client'
|
80
packages/client/src/methods/README.md
Normal file
80
packages/client/src/methods/README.md
Normal 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()
|
||||
}
|
||||
```
|
32
packages/client/src/methods/_imports.ts
Normal file
32
packages/client/src/methods/_imports.ts
Normal 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'
|
30
packages/client/src/methods/auth/accept-tos.ts
Normal file
30
packages/client/src/methods/auth/accept-tos.ts
Normal 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
|
||||
}
|
46
packages/client/src/methods/auth/check-password.ts
Normal file
46
packages/client/src/methods/auth/check-password.ts
Normal 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)
|
||||
}
|
13
packages/client/src/methods/auth/get-password-hint.ts
Normal file
13
packages/client/src/methods/auth/get-password-hint.ts
Normal 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)
|
||||
}
|
25
packages/client/src/methods/auth/log-out.ts
Normal file
25
packages/client/src/methods/auth/log-out.ts
Normal 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
|
||||
}
|
39
packages/client/src/methods/auth/recover-password.ts
Normal file
39
packages/client/src/methods/auth/recover-password.ts
Normal 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)
|
||||
}
|
29
packages/client/src/methods/auth/resend-code.ts
Normal file
29
packages/client/src/methods/auth/resend-code.ts
Normal 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)
|
||||
}
|
27
packages/client/src/methods/auth/send-code.ts
Normal file
27
packages/client/src/methods/auth/send-code.ts
Normal 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)
|
||||
}
|
13
packages/client/src/methods/auth/send-recovery-code.ts
Normal file
13
packages/client/src/methods/auth/send-recovery-code.ts
Normal 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)
|
||||
}
|
43
packages/client/src/methods/auth/sign-in-bot.ts
Normal file
43
packages/client/src/methods/auth/sign-in-bot.ts
Normal 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)
|
||||
}
|
51
packages/client/src/methods/auth/sign-in.ts
Normal file
51
packages/client/src/methods/auth/sign-in.ts
Normal 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)
|
||||
}
|
41
packages/client/src/methods/auth/sign-up.ts
Normal file
41
packages/client/src/methods/auth/sign-up.ts
Normal 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)
|
||||
}
|
269
packages/client/src/methods/auth/start.ts
Normal file
269
packages/client/src/methods/auth/start.ts
Normal 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
|
||||
}
|
13
packages/client/src/methods/files/_initialize.ts
Normal file
13
packages/client/src/methods/files/_initialize.ts
Normal 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 = {}
|
||||
}
|
31
packages/client/src/methods/files/download-buffer.ts
Normal file
31
packages/client/src/methods/files/download-buffer.ts
Normal 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)
|
||||
}
|
55
packages/client/src/methods/files/download-file.ts
Normal file
55
packages/client/src/methods/files/download-file.ts
Normal 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)
|
||||
})
|
||||
}
|
119
packages/client/src/methods/files/download-iterable.ts
Normal file
119
packages/client/src/methods/files/download-iterable.ts
Normal 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)
|
||||
}
|
||||
}
|
41
packages/client/src/methods/files/download-stream.ts
Normal file
41
packages/client/src/methods/files/download-stream.ts
Normal 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
|
||||
}
|
255
packages/client/src/methods/files/upload-file.ts
Normal file
255
packages/client/src/methods/files/upload-file.ts
Normal 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!,
|
||||
}
|
||||
}
|
43
packages/client/src/methods/messages/find-in-update.ts
Normal file
43
packages/client/src/methods/messages/find-in-update.ts
Normal 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'
|
||||
)
|
||||
}
|
92
packages/client/src/methods/messages/get-messages.ts
Normal file
92
packages/client/src/methods/messages/get-messages.ts
Normal 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
|
||||
}
|
45
packages/client/src/methods/messages/parse-entities.ts
Normal file
45
packages/client/src/methods/messages/parse-entities.ts
Normal 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)
|
||||
}
|
155
packages/client/src/methods/messages/send-photo.ts
Normal file
155
packages/client/src/methods/messages/send-photo.ts
Normal 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
|
||||
}
|
120
packages/client/src/methods/messages/send-text.ts
Normal file
120
packages/client/src/methods/messages/send-text.ts
Normal 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
|
||||
}
|
17
packages/client/src/methods/parse-modes/_initialize.ts
Normal file
17
packages/client/src/methods/parse-modes/_initialize.ts
Normal 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.
|
88
packages/client/src/methods/parse-modes/parse-modes.ts
Normal file
88
packages/client/src/methods/parse-modes/parse-modes.ts
Normal 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
|
||||
}
|
83
packages/client/src/methods/updates/catch-up.ts
Normal file
83
packages/client/src/methods/updates/catch-up.ts
Normal 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?.()
|
||||
}
|
126
packages/client/src/methods/updates/dispatcher.ts
Normal file
126
packages/client/src/methods/updates/dispatcher.ts
Normal 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)
|
||||
}
|
||||
}
|
196
packages/client/src/methods/updates/handle-update.ts
Normal file
196
packages/client/src/methods/updates/handle-update.ts
Normal 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))
|
||||
}
|
86
packages/client/src/methods/updates/load-difference.ts
Normal file
86
packages/client/src/methods/updates/load-difference.ts
Normal 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
|
||||
// }
|
||||
// }
|
||||
// }
|
47
packages/client/src/methods/updates/on-new-message.ts
Normal file
47
packages/client/src/methods/updates/on-new-message.ts
Normal 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))
|
||||
}
|
20
packages/client/src/methods/users/block-user.ts
Normal file
20
packages/client/src/methods/users/block-user.ts
Normal 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)),
|
||||
})
|
||||
}
|
26
packages/client/src/methods/users/get-common-chats.ts
Normal file
26
packages/client/src/methods/users/get-common-chats.ts
Normal 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)))
|
||||
}
|
21
packages/client/src/methods/users/get-me.ts
Normal file
21
packages/client/src/methods/users/get-me.ts
Normal 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)
|
||||
})
|
||||
}
|
56
packages/client/src/methods/users/get-users.ts
Normal file
56
packages/client/src/methods/users/get-users.ts
Normal 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)
|
||||
}
|
92
packages/client/src/methods/users/resolve-peer.ts
Normal file
92
packages/client/src/methods/users/resolve-peer.ts
Normal 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}`)
|
||||
}
|
40
packages/client/src/parser/index.ts
Normal file
40
packages/client/src/parser/index.ts
Normal 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
|
||||
}
|
2
packages/client/src/types/auth/index.ts
Normal file
2
packages/client/src/types/auth/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './sent-code'
|
||||
export * from './terms-of-service'
|
89
packages/client/src/types/auth/sent-code.ts
Normal file
89
packages/client/src/types/auth/sent-code.ts
Normal 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)
|
48
packages/client/src/types/auth/terms-of-service.ts
Normal file
48
packages/client/src/types/auth/terms-of-service.ts
Normal 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)
|
1
packages/client/src/types/bots/index.ts
Normal file
1
packages/client/src/types/bots/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './keyboards'
|
315
packages/client/src/types/bots/keyboards.ts
Normal file
315
packages/client/src/types/bots/keyboards.ts
Normal 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 bot‘s
|
||||
* 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
|
||||
}
|
||||
}
|
75
packages/client/src/types/errors.ts
Normal file
75
packages/client/src/types/errors.ts
Normal 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')
|
||||
}
|
||||
}
|
130
packages/client/src/types/files/file-location.ts
Normal file
130
packages/client/src/types/files/file-location.ts
Normal 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'])
|
3
packages/client/src/types/files/index.ts
Normal file
3
packages/client/src/types/files/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './utils'
|
||||
export * from './file-location'
|
||||
export * from './uploaded-file'
|
36
packages/client/src/types/files/uploaded-file.ts
Normal file
36
packages/client/src/types/files/uploaded-file.ts
Normal 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
|
||||
)
|
||||
}
|
82
packages/client/src/types/files/utils.ts
Normal file
82
packages/client/src/types/files/utils.ts
Normal 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
|
||||
}
|
11
packages/client/src/types/index.ts
Normal file
11
packages/client/src/types/index.ts
Normal 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'
|
46
packages/client/src/types/media/audio.ts
Normal file
46
packages/client/src/types/media/audio.ts
Normal 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'])
|
43
packages/client/src/types/media/contact.ts
Normal file
43
packages/client/src/types/media/contact.ts
Normal 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)
|
164
packages/client/src/types/media/dice.ts
Normal file
164
packages/client/src/types/media/dice.ts
Normal 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)
|
41
packages/client/src/types/media/document-utils.ts
Normal file
41
packages/client/src/types/media/document-utils.ts
Normal 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)
|
||||
}
|
107
packages/client/src/types/media/document.ts
Normal file
107
packages/client/src/types/media/document.ts
Normal 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'])
|
84
packages/client/src/types/media/game.ts
Normal file
84
packages/client/src/types/media/game.ts
Normal 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)
|
10
packages/client/src/types/media/index.ts
Normal file
10
packages/client/src/types/media/index.ts
Normal 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'
|
62
packages/client/src/types/media/location.ts
Normal file
62
packages/client/src/types/media/location.ts
Normal 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)
|
110
packages/client/src/types/media/photo.ts
Normal file
110
packages/client/src/types/media/photo.ts
Normal 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'])
|
106
packages/client/src/types/media/sticker.ts
Normal file
106
packages/client/src/types/media/sticker.ts
Normal 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'])
|
135
packages/client/src/types/media/thumbnail.ts
Normal file
135
packages/client/src/types/media/thumbnail.ts
Normal 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'])
|
82
packages/client/src/types/media/video.ts
Normal file
82
packages/client/src/types/media/video.ts
Normal 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'])
|
37
packages/client/src/types/media/voice.ts
Normal file
37
packages/client/src/types/media/voice.ts
Normal 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'])
|
211
packages/client/src/types/media/web-page.ts
Normal file
211
packages/client/src/types/media/web-page.ts
Normal 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)
|
2
packages/client/src/types/messages/index.ts
Normal file
2
packages/client/src/types/messages/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './message-entity'
|
||||
export * from './message'
|
125
packages/client/src/types/messages/message-entity.ts
Normal file
125
packages/client/src/types/messages/message-entity.ts
Normal 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)
|
798
packages/client/src/types/messages/message.ts
Normal file
798
packages/client/src/types/messages/message.ts
Normal 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'])
|
126
packages/client/src/types/peers/chat-permissions.ts
Normal file
126
packages/client/src/types/peers/chat-permissions.ts
Normal 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)
|
58
packages/client/src/types/peers/chat-photo.ts
Normal file
58
packages/client/src/types/peers/chat-photo.ts
Normal 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)
|
414
packages/client/src/types/peers/chat.ts
Normal file
414
packages/client/src/types/peers/chat.ts
Normal 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)
|
31
packages/client/src/types/peers/index.ts
Normal file
31
packages/client/src/types/peers/index.ts
Normal 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
|
336
packages/client/src/types/peers/user.ts
Normal file
336
packages/client/src/types/peers/user.ts
Normal 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)
|
42
packages/client/src/types/updates/builders.ts
Normal file
42
packages/client/src/types/updates/builders.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
554
packages/client/src/types/updates/filters.ts
Normal file
554
packages/client/src/types/updates/filters.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
51
packages/client/src/types/updates/handler.ts
Normal file
51
packages/client/src/types/updates/handler.ts
Normal 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
|
4
packages/client/src/types/updates/index.ts
Normal file
4
packages/client/src/types/updates/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './builders'
|
||||
export * from './filters'
|
||||
export * from './handler'
|
||||
export * from './propagation'
|
10
packages/client/src/types/updates/propagation.ts
Normal file
10
packages/client/src/types/updates/propagation.ts
Normal 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
|
76
packages/client/src/types/utils.ts
Normal file
76
packages/client/src/types/utils.ts
Normal 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
|
||||
}
|
||||
}
|
100
packages/client/src/utils/file-utils.ts
Normal file
100
packages/client/src/utils/file-utils.ts
Normal 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>'
|
||||
)
|
||||
}
|
54
packages/client/src/utils/iter-utils.ts
Normal file
54
packages/client/src/utils/iter-utils.ts
Normal 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
|
||||
}
|
37
packages/client/src/utils/misc-utils.ts
Normal file
37
packages/client/src/utils/misc-utils.ts
Normal 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
|
||||
}
|
79
packages/client/src/utils/peer-utils.ts
Normal file
79
packages/client/src/utils/peer-utils.ts
Normal 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
|
||||
}
|
111
packages/client/src/utils/stream-utils.ts
Normal file
111
packages/client/src/utils/stream-utils.ts
Normal 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
|
||||
}
|
12
packages/client/src/utils/type-assertion.ts
Normal file
12
packages/client/src/utils/type-assertion.ts
Normal 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._)
|
||||
}
|
||||
}
|
19
packages/client/tsconfig.json
Normal file
19
packages/client/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
16
packages/core/package.json
Normal file
16
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
683
packages/core/src/base-client.ts
Normal file
683
packages/core/src/base-client.ts
Normal 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
Loading…
Reference in a new issue