diff --git a/.config/tsconfig.build.json b/.config/tsconfig.build.json deleted file mode 100644 index bd168a0e..00000000 --- a/.config/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../tsconfig.json", - "exclude": [ - "../**/*.test.ts", - "../**/*.test-utils.ts", - "../**/__fixtures__/**" - ] -} diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml index 8a07ff4c..9895a28c 100644 --- a/.forgejo/workflows/docs.yaml +++ b/.forgejo/workflows/docs.yaml @@ -1,15 +1,14 @@ -name: Docs +name: Build and deploy docs on: push: - branches: - - master - pull_request: - branches: [ master ] + branches: [master] + paths: + - 'docs/**' workflow_dispatch: concurrency: - group: pages + group: docs cancel-in-progress: false jobs: @@ -17,21 +16,27 @@ jobs: runs-on: node22 steps: - uses: actions/checkout@v4 - - uses: ./.forgejo/actions/init - - name: Build docs + - uses: pnpm/action-setup@v2 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: docs + - name: Build with VitePress + working-directory: docs run: | - pnpm run docs - touch docs/.nojekyll - echo "ref.mtcute.dev" > docs/CNAME + pnpm run build + touch .vitepress/dist/.nojekyll + echo mtcute.dev > .vitepress/dist/CNAME echo "ignore-workspace-root-check=true" >> .npmrc - curl -O https://gist.githubusercontent.com/j0nl1/7f9b5210c9e6ecbabe322baa16dcb5db/raw/760de77327bf83671cfb6bd4e64181299ba26113/typedoc-fix-cf.mjs - node typedoc-fix-cf.mjs docs - name: Deploy - # do not run on forks and releases - if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.actor == 'desu-bot' + # do not run on forks + if: github.repository == 'teidesu/mtcute' uses: https://github.com/cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy docs --project-name=mtcute-apiref + command: pages deploy docs/.vitepress/dist --project-name=mtcute-docs diff --git a/.forgejo/workflows/typedoc.yaml b/.forgejo/workflows/typedoc.yaml new file mode 100644 index 00000000..5d321196 --- /dev/null +++ b/.forgejo/workflows/typedoc.yaml @@ -0,0 +1,34 @@ +name: Build and deploy typedoc + +on: + push: + branches: + - master + pull_request: + branches: [master] + workflow_dispatch: + +concurrency: + group: typedoc + cancel-in-progress: false + +jobs: + build: + runs-on: node22 + steps: + - uses: actions/checkout@v4 + - uses: ./.forgejo/actions/init + - name: Build docs + run: | + pnpm run docs + touch dist/typedoc/.nojekyll + echo "ref.mtcute.dev" > dist/typedoc/CNAME + echo "ignore-workspace-root-check=true" >> .npmrc + - name: Deploy + # only run on releases + if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.actor == 'desu-bot' + uses: https://github.com/cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist/typedoc --project-name=mtcute-apiref diff --git a/.gitignore b/.gitignore index cc1e7c88..c6906aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ private/ .vscode *.log -# docs are generated in ci -docs - coverage .rollup.cache *.tsbuildinfo diff --git a/build.config.js b/build.config.js index b01338d3..57302c8a 100644 --- a/build.config.js +++ b/build.config.js @@ -69,6 +69,7 @@ export default { }, }, typedoc: { + out: 'dist/typedoc', excludePackages: [ '@mtcute/tl', '@mtcute/create-bot', diff --git a/docs/.editorconfig b/docs/.editorconfig new file mode 100644 index 00000000..5257de37 --- /dev/null +++ b/docs/.editorconfig @@ -0,0 +1,16 @@ +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 + +[*.md] +indent_size = 2 +max_line_length = 0 +trim_trailing_whitespace = false \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100755 index 00000000..3df26ab4 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,13 @@ +pids +logs +node_modules +npm-debug.log +coverage/ +run +dist +cache +.DS_Store +.nyc_output +.basement +config.local.js +basement_dist diff --git a/docs/.vitepress/components/EmbedPost.vue b/docs/.vitepress/components/EmbedPost.vue new file mode 100644 index 00000000..114d0abf --- /dev/null +++ b/docs/.vitepress/components/EmbedPost.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/docs/.vitepress/components/Tag.vue b/docs/.vitepress/components/Tag.vue new file mode 100755 index 00000000..38552cb9 --- /dev/null +++ b/docs/.vitepress/components/Tag.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/docs/.vitepress/components/VImg.vue b/docs/.vitepress/components/VImg.vue new file mode 100755 index 00000000..7eb716ca --- /dev/null +++ b/docs/.vitepress/components/VImg.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 00000000..189c579c --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,132 @@ +import { defineConfig, HeadConfig } from "vitepress"; +import markdownItFootnotes from "markdown-it-footnote"; + +// https://vitepress.dev/reference/site-config +export default ({ mode }) => defineConfig({ + title: "mtcute", + description: "mtcute documentation", + lastUpdated: true, + head: [ + ["meta", { name: "theme-color", content: "#e9a1d9" }], + ["meta", { name: "apple-mobile-web-app-capable", content: "yes" }], + [ + "meta", + { name: "apple-mobile-web-app-status-bar-style", content: "black" }, + ], + ['link', { rel: 'icon', href: '/mtcute-logo.png' }], + ['link', { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fredoka:wght@500&text=mtcute' }], + ...(mode === 'production' ? [ + ['script', { async: '', src: 'https://zond.tei.su/script.js', 'data-website-id': '968f50a2-4cf8-4e31-9f40-1abd48ba2086' }] as HeadConfig + ] : []), + ], + transformHtml(code) { + if (mode !== 'production') return code + + // this is a hack but whatever + return code.replace( + '', + '' + ) + }, + themeConfig: { + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: "Guide", link: "/guide/" }, + { text: "Reference", link: "//ref.mtcute.dev" }, + ], + socialLinks: [ + { icon: "github", link: "https://github.com/mtcute" }, + { + icon: { + svg: `Telegram`, + }, + link: "https://t.me/mt_cute", + }, + ], + search: { + provider: "local", + }, + editLink: { + pattern: "https://github.com/mtcute/docs/edit/master/:path", + }, + outline: { + level: "deep", + }, + + sidebar: { + "/guide/": [ + { + text: "Getting started", + items: [ + { text: "Quick start", link: "/guide/" }, + { text: "Signing in", link: "/guide/intro/sign-in" }, + { text: "Updates", link: "/guide/intro/updates" }, + { text: "Errors", link: "/guide/intro/errors" }, + { + text: "MTProto vs Bot API", + link: "/guide/intro/mtproto-vs-bot-api", + }, + { text: "FAQ", link: "/guide/intro/faq" }, + ], + }, + { + text: "Topics", + items: [ + { text: "Peers", link: "/guide/topics/peers" }, + { text: "Storage", link: "/guide/topics/storage" }, + { text: "Transport", link: "/guide/topics/transport" }, + { text: "Parse modes", link: "/guide/topics/parse-modes" }, + { text: "Files", link: "/guide/topics/files" }, + { text: "Keyboards", link: "/guide/topics/keyboards" }, + { text: "Inline mode", link: "/guide/topics/inline-mode" }, + { text: "Conversation", link: "/guide/topics/conversation" }, + { text: "Raw API", link: "/guide/topics/raw-api" }, + ], + }, + { + text: "Dispatcher", + items: [ + { text: "Intro", link: "/guide/dispatcher/intro" }, + { text: "Handlers", link: "/guide/dispatcher/handlers" }, + { text: "Filters", link: "/guide/dispatcher/filters" }, + { + text: "Groups & Propagation", + link: "/guide/dispatcher/groups-propagation", + }, + { text: "Errors", link: "/guide/dispatcher/errors" }, + { text: "Middlewares", link: "/guide/dispatcher/middlewares" }, + { text: "Inline mode", link: "/guide/dispatcher/inline-mode" }, + { text: "State", link: "/guide/dispatcher/state" }, + { text: "Rate limit", link: "/guide/dispatcher/rate-limit" }, + { text: "Child Dispatchers", link: "/guide/dispatcher/children" }, + { text: "Scenes", link: "/guide/dispatcher/scenes" }, + { text: "Dependency Injection", link: "/guide/dispatcher/di" }, + ], + }, + { + text: 'Advanced', + items: [ + { text: "Tree-shaking", link: "/guide/advanced/treeshaking" }, + { text: "Workers", link: "/guide/advanced/workers" }, + { text: "Converting sessions", link: "/guide/advanced/session-convert" }, + { text: "Network middlewares", link: "/guide/advanced/net-middlewares" }, + ] + } + ], + }, + + footer: { + message: "mtcute is not affiliated with Telegram.", + copyright: + 'This documentation is licensed under CC BY 4.0
' + + 'Logo by @dotvhs
' + + '© Copyright 2021-present, teidesu ❤️', + }, + }, + + markdown: { + config: (md) => { + md.use(markdownItFootnotes); + }, + }, +}); diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue new file mode 100644 index 00000000..dd917d32 --- /dev/null +++ b/docs/.vitepress/theme/Layout.vue @@ -0,0 +1,70 @@ + + + + + \ No newline at end of file diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 00000000..1c13943e --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,30 @@ +// https://vitepress.dev/guide/custom-theme +import type { Theme } from 'vitepress' +import DefaultTheme from 'vitepress/theme' + +import vitepressBackToTop from 'vitepress-plugin-back-to-top' +import 'vitepress-plugin-back-to-top/dist/style.css' + +// @ts-ignore +import EmbedPost from '../components/EmbedPost.vue' +// @ts-ignore +import VImg from '../components/VImg.vue' +// @ts-ignore +import Tag from '../components/Tag.vue' +// @ts-ignore +import Layout from './Layout.vue' + +import './style.css' + +export default { + extends: DefaultTheme, + Layout, + enhanceApp({ app, router, siteData }) { + app.component('v-img', VImg) + app.component('EmbedPost', EmbedPost) + app.component('Tag', Tag) + vitepressBackToTop({ + threshold: 300 + }) + } +} satisfies Theme diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 00000000..162007aa --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,192 @@ +/** + * Customize default theme styling by overriding CSS variables: + * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css + */ + +/** + * Colors + * + * Each colors have exact same color scale system with 3 levels of solid + * colors with different brightness, and 1 soft color. + * + * - `XXX-1`: The most solid color used mainly for colored text. It must + * satisfy the contrast ratio against when used on top of `XXX-soft`. + * + * - `XXX-2`: The color used mainly for hover state of the button. + * + * - `XXX-3`: The color for solid background, such as bg color of the button. + * It must satisfy the contrast ratio with pure white (#ffffff) text on + * top of it. + * + * - `XXX-soft`: The color used for subtle background such as custom container + * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors + * on top of it. + * + * The soft color must be semi transparent alpha channel. This is crucial + * because it allows adding multiple "soft" colors on top of each other + * to create a accent, such as when having inline code block inside + * custom containers. + * + * - `default`: The color used purely for subtle indication without any + * special meanings attched to it such as bg color for menu hover state. + * + * - `brand`: Used for primary brand colors, such as link text, button with + * brand theme, etc. + * + * - `tip`: Used to indicate useful information. The default theme uses the + * brand color for this by default. + * + * - `warning`: Used to indicate warning to the users. Used in custom + * container, badges, etc. + * + * - `danger`: Used to show error, or dangerous message to the users. Used + * in custom container, badges, etc. + * -------------------------------------------------------------------------- */ + + :root { + --vp-c-default-1: var(--vp-c-gray-1); + --vp-c-default-2: var(--vp-c-gray-2); + --vp-c-default-3: var(--vp-c-gray-3); + --vp-c-default-soft: var(--vp-c-gray-soft); + + --vp-c-brand-1: #b975a5; + --vp-c-brand-2: #d990c3; + --vp-c-brand-3: #f69ddc; + --vp-c-brand-soft: var(--vp-c-indigo-soft); + + --vp-c-tip-1: var(--vp-c-brand-1); + --vp-c-tip-2: var(--vp-c-brand-2); + --vp-c-tip-3: var(--vp-c-brand-3); + --vp-c-tip-soft: var(--vp-c-brand-soft); + + --vp-c-warning-1: var(--vp-c-yellow-1); + --vp-c-warning-2: var(--vp-c-yellow-2); + --vp-c-warning-3: var(--vp-c-yellow-3); + --vp-c-warning-soft: var(--vp-c-yellow-soft); + + --vp-c-danger-1: var(--vp-c-red-1); + --vp-c-danger-2: var(--vp-c-red-2); + --vp-c-danger-3: var(--vp-c-red-3); + --vp-c-danger-soft: var(--vp-c-red-soft); +} + +/** + * Component: Button + * -------------------------------------------------------------------------- */ + +:root { + --vp-button-brand-border: transparent; + --vp-button-brand-text: var(--vp-c-white); + --vp-button-brand-bg: var(--vp-c-brand-3); + --vp-button-brand-hover-border: transparent; + --vp-button-brand-hover-text: var(--vp-c-white); + --vp-button-brand-hover-bg: var(--vp-c-brand-2); + --vp-button-brand-active-border: transparent; + --vp-button-brand-active-text: var(--vp-c-white); + --vp-button-brand-active-bg: var(--vp-c-brand-1); +} + +/** + * Component: Home + * -------------------------------------------------------------------------- */ + +:root { + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient( + 45deg, + #f69ddc 60%, + #c890d6 + ); + + --vp-home-hero-image-background-image: linear-gradient( + -45deg, + #c890d6 50%, + #f69ddc 50% + ); + --vp-home-hero-image-filter: blur(44px) opacity(0.2); +} + +/** + * Component: Custom Block + * -------------------------------------------------------------------------- */ + +:root { + --vp-custom-block-tip-border: transparent; + --vp-custom-block-tip-text: var(--vp-c-text-1); + --vp-custom-block-tip-bg: var(--vp-c-brand-soft); + --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); +} + +/** + * Component: Algolia + * -------------------------------------------------------------------------- */ + +.DocSearch { + --docsearch-primary-color: var(--vp-c-brand-1) !important; +} + +.VPFooter a { + color: var(--vp-c-brand-1); +} + +.content-container iframe { + border: 0; + width: 100%; +} + +.custom-block.tip code { + color: var(--vp-c-brand-1) +} + +.vp-doc a, +.custom-block.tip a { + color: var(--vp-c-brand-2); + text-decoration: none; +} + +.vp-doc a:hover, +.custom-block.tip a:hover { + text-decoration: underline; +} + +.vp-doc a:active, +.custom-block.tip a:active { + color: var(--vp-c-brand-1); +} + +.vp-doc code { + word-break: keep-all; + white-space: nowrap; +} + +.vp-doc table { + display: table; +} + +:root { + scrollbar-gutter: stable; +} + +.medium-zoom-overlay, +.medium-zoom-image--opened { + z-index: 999; +} + +.main .name .clip { + font-family: Fredoka, sans-serif; + font-weight: 500; +} + +.dark .go-to-top { + background-color: var(--vp-c-bg-elv); +} + +.dark .go-to-top:hover { + background-color: var(--vp-c-bg-alt); +} + +.index-footnote { + display: block; + text-align: center; + margin-top: 32px; +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..d9ee50fd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# mtcute docs + +Documentation website for [mtcute](https://github.com/mtcute/mtcute), +built with [VitePress](https://vitepress.dev/). + +## Development + +```bash +pnpm install --frozen-lockfile +pnpm run dev +``` diff --git a/docs/guide/advanced/net-middlewares.md b/docs/guide/advanced/net-middlewares.md new file mode 100644 index 00000000..e1a5b450 --- /dev/null +++ b/docs/guide/advanced/net-middlewares.md @@ -0,0 +1,154 @@ +# Network middlewares + +In some cases it may make sense to intercept *all* outgoing requests and control the request flow. + +## Default middlewares + +By default, mtcute uses two middlewares: flood-waiter and internal errors handler. +The combined default middleware is exported in `networkMiddlewares.basic`, and can be configured as follows: + +```ts +const tg = new TelegramClient({ + ..., + network: { + middlewares: networkMiddlewares.basic({ + floodWaiter: { maxWait: 5000, maxRetries: 5 }, + internalErrors: { maxRetries: 5 } + }) + } +}) +``` + +Flood-waiter and internal errors handler middlewares themselves are exported under +`networkMiddlewares.floodWaiter` and `networkMiddlewares.internalErrorsHandler` respectively. + +## Writing middlewares + +Middleware is simply an async function that takes `ctx` and `next` as arguments. + +The `ctx` object contains information about the RPC call, including the request itself and +any additional parameters that were passed along, and `next` function can be used to call the +next middleware in the chain, returning the call result (or an [error](#errors-in-middlewares)): + +```ts +const myMiddleware: RpcCallMiddleware = async (ctx, next) => { + if (ctx.request._ === 'help.getConfig') { + return myConfig + } + + return next(ctx) +} +``` + +::: info +If you are familiar with grammY/telegraf or koa middlewares, +you might find the `ctx, next` syntax familiar. +Indeed, these middlewares were heavily inspired by them. + +However, they work slightly different here, as the task is slightly different too. + +Unlike grammY-style middlewares, `next` *can* be called multiple times, +and the last pseudo-"middleware" in the chain will actually execute +the request contained in the `ctx` (instead of being a no-op). + +And because of that, `ctx` is always passed explicitly, +allowing to execute multiple different requests from a single middleware. +::: + +### Errors in middlewares + +To improve performance, RPC errors in middlewares are monadic, meaning that an RPC error is +considered a valid result. + +To check if the call resulted in an error, you can use `isTlRpcError` handler: + +```ts +const myMiddleware: RpcCallMiddleware = async (ctx, next) => { + const res = await next(ctx) + + if (isTlRpcError(res) && res.errorMessage === 'PEER_ID_INVALID') { + logPeerIdInvalid(ctx.request) + } + + return res +} +``` + +You can also use `networkMiddlewares.onRpcError` helper to create a middleware that only handles RPC errors: + +```ts +const client = new TelegramClient({ + ..., + network: { + middlewares: [ + networkMiddlewares.onRpcError(async (ctx, error) => { + if (error.errorMessage === 'PEER_ID_INVALID') { + logPeerIdInvalid(ctx.request) + } + }), + networkMiddlewares.basic() + ] + } +}) +``` + +### Modifying request + +In some cases, it might make sense to modify the request before sending. + +One way to do so is to overwrite the `ctx` fields: + +```ts +const myMiddleware: RpcCallMiddleware = async (ctx, next) => { + if (ctx.request._ === 'users.getFullUser') { + ctx.request.id = { _: 'inputUserSelf' } + } + + return next(ctx) +} +``` + +Alternatively, you can construct your own context: + +```ts +const myMiddleware: RpcCallMiddleware = async (ctx, next) => { + if (ctx.request._ === 'users.getFullUser') { + return next({ + manager: ctx.manager, + params: ctx.params, + request: { + _: 'users.getFullUser', + id: { _: 'inputUserSelf' } + } + }) + } + + return next(ctx) +} +``` + +### Applying middlewares + +Once you're done writing your middleware, you need to connect it to the client. +That's done by passing an array to the `middlewares` option, like this: + +```ts +const tg = new TelegramClient({ + ..., + network: { + middlewares: [ + myMiddleware, + myOtherMiddleware, + // You'll probably also want to include all the default + // middlewares, as passing this option overrides them. + ...networkMiddlewares.basic() + ] + } +}) +``` + +::: info +**Middleware order matters**, which is why we include the basic middlewares last — +we want `myMiddleware` and `myOtherMiddleware` to also benefit from them +(i.e. have flood waits and internal errors handled) +::: \ No newline at end of file diff --git a/docs/guide/advanced/session-convert.md b/docs/guide/advanced/session-convert.md new file mode 100644 index 00000000..5616a03e --- /dev/null +++ b/docs/guide/advanced/session-convert.md @@ -0,0 +1,118 @@ +# Converting sessions + +If you're coming from another library, you might already have a session +lying around. + +mtcute provides a way to convert sessions from some other libraries +to mtcute's format in `@mtcute/convert` package. + +::: warning +Please, **only use this to convert your own sessions**. + +**DO NOT** use this to convert stolen sessions or sessions you don't own. +Please be a decent person. +::: + +## [Telethon v1.x](https://github.com/LonamiWebs/Telethon) + +> Telethon v2 seems to have removed the ability to export sessions, +> so it's currently not supported + +```ts +import { convertFromTelethonSession } from '@mtcute/convert' + +const client = new TelegramClient({ ... }) +await client.importSession(convertFromTelethonSession("...")) +``` + +## [Pyrogram](https://github.com/pyrogram/pyrogram) + +```ts +import { convertFromPyrogramSession } from '@mtcute/convert' + +const client = new TelegramClient({ ... }) +await client.importSession(convertFromPyrogramSession("...")) +``` + +## [GramJS](https://github.com/gram-js/gramjs) + +```ts +import { convertFromGramjsSession } from '@mtcute/convert' + +const client = new TelegramClient({ ... }) +await client.importSession(convertFromGramjsSession("...")) +``` + +## [MTKruto](https://github.com/MTKruto/MTKruto) + +```ts +import { convertFromMtkrutoSession } from '@mtcute/convert' + +const client = new TelegramClient({ ... }) +await client.importSession(convertFromMtkrutoSession("...")) +``` + +## Backwards + +If you need to convert a session from mtcute to another library, you can use the `convertTo*` functions instead: + +```ts +console.log(convertToTelethonSession(await client.exportSession())) +``` + +## String-to-string + +Once converted, you can use `writeStringSession` to convert the session to a string: + +```ts +console.log(writeStringSession(convertFromTelethonSession("..."))) +``` + +## Manual + +If your library is not supported, you can still convert the session manually. + +In the most simple case, you'll only need `auth_key` and data center information: + +```ts +const dc = { + id: 2, + ipAddress: '149.154.167.41', + port: 443, +} + +await client.importSession({ + version: 3, + testMode: false, + primaryDcs: { main: dc, media: dc }, + authKey: new Uint8Array([ /* ... */ ]), +}) +``` + +### Data center information + +If you only know DC ID and not the IP address, you can use the mappings from `@mtcute/convert` to resolve it: + +```ts +import { DC_MAPPING_PROD } from '@mtcute/convert' + +const dc = DC_MAPPING_PROD[2] +``` + +If you don't know such information at all, you can just always use the DC 2 (as above), and mtcute will handle the rest + +### User information + +If you happen to know some information about the user logged in, it might help to provide it as well: + +```ts +await client.importSession({ + ..., + self: { + userId: 777000, + isBot: false, + isPremium: false, + usernames: [], + } +}) +``` diff --git a/docs/guide/advanced/treeshaking.md b/docs/guide/advanced/treeshaking.md new file mode 100644 index 00000000..1d4d7e88 --- /dev/null +++ b/docs/guide/advanced/treeshaking.md @@ -0,0 +1,34 @@ +# Tree-shaking + +Being a ESM-first library, mtcute supports tree-shaking out of the box. + +This means that you can import only the parts of the library that you need, +and the bundler will remove all the unused code. + +## Usage + +To start using tree-shaking, there are a few things to keep in mind: +- Do not use `TelegramClient`. Use `BaseTelegramClient` instead, and import the needed methods. + + For example, instead of this: + ```ts + import { TelegramClient } from '@mtcute/web' + + const tg = new TelegramClient({ ... }) + + await tg.sendText(...) + ``` + + you should use this: + + ```ts + import { BaseTelegramClient } from '@mtcute/web' + import { sendText } from '@mtcute/web/methods.js' + + const tg = new BaseTelegramClient({ ... }) + + await sendText(tg, ...) + ``` + +- TL serialization is currently not tree-shakeable, because it is done via a global map of constructors. + There's no ETA on when (or whether at all) this will be changed, so there *will* be ~300 KB of non-shakeable code. \ No newline at end of file diff --git a/docs/guide/advanced/workers.md b/docs/guide/advanced/workers.md new file mode 100644 index 00000000..63e6463a --- /dev/null +++ b/docs/guide/advanced/workers.md @@ -0,0 +1,109 @@ +# Workers + +To facilitate parallel processing and avoid blocking the main thread, mtcute +supports extractnig the heavy lifting to the workers. This is especially useful +in the browser, where the main thread is often busy with rendering and other tasks. + +::: warning +Workers support is still experimental and may have some rough edges. +If something doesn't work in a worker, but works when used directly, please open an issue. +::: + +## Browser + +`@mtcute/web` package exports a `TelegramWorker` and `TelegramWorkerPort` classes, +which can be used to create workers and communicate with them. + +To create a worker, use the `TelegramWorker` class: + +```ts +import { BaseTelegramClient, TelegramWorker } from '@mtcute/web' + +const tg = new BaseTelegramClient({ + apiId: 123456, + apiHash: '...', +}) + +new TelegramWorker({ + client: tg, +}) +``` + +To communicate with the worker, use the `TelegramWorkerPort` class and pass an instance of +`Worker` (or `SharedWorker`) to it: + +```ts +import { TelegramWorkerPort } from '@mtcute/web' + +const port = new TelegramWorkerPort({ + worker: new Worker( + new URL('./worker.ts', import.meta.url), + { type: 'module' }, + }) +}) +``` + +## Node.js + +On the surface, the API is largely the same, but is slightly different under the hood +and uses `worker_threads` instead of web workers. + +The worker is created the same way, but using `TelegramWorker` class from `@mtcute/node`: + +```ts +import { BaseTelegramClient, TelegramWorker } from '@mtcute/node' + +const tg = new BaseTelegramClient({ + apiId: 123456, + apiHash: '...', +}) + +new TelegramWorker({ + client: tg, +}) +``` + +Then, to communicate with the worker, use the `TelegramWorkerPort` class and pass an instance of `Worker` to it: + +```ts +import { Worker } from 'worker_threads' +import { TelegramWorkerPort } from '@mtcute/node' + +const port = new TelegramWorkerPort({ + worker: new Worker( + new URL('./worker.js', import.meta.url), + { type: 'module' }, + ), +}) +``` + + +## Usage + +`TelegramWorkerPort` is a drop-in replacement for `BaseTelegramClient`, and since it +implements `ITelegramClient`, you can pass it to any method that expects a client: + +```ts +import { sendText } from '@mtcute/web/methods.js' + +await sendText(port, 'me', 'Hello from worker!') +``` + +Alternatively, you can pass the port as a cliant to `TelegramClient` +to bind it to all methods (not recommended in browser, see [Tree-shaking](./treeshaking.md)): + +```ts +const tg = new TelegramClient({ client: port }) + +await tg.sendText('me', 'Hello from worker!') +``` + +## Other runtimes + +In other runtimes it may also make sense to use workers. +If your runtime supports web workers, you can use the `@mtcute/web` package to create workers - it should work just fine. + +Otherwise, Please refer to +[Web](https://github.com/mtcute/mtcute/blob/master/packages/web/src/worker.ts)/[Node.js](https://github.com/mtcute/mtcute/blob/master/packages/node/src/worker.ts) +for the platform-specific worker implementations, and use them as a reference to create your own worker implementation. + diff --git a/docs/guide/dispatcher/children.md b/docs/guide/dispatcher/children.md new file mode 100755 index 00000000..a6000972 --- /dev/null +++ b/docs/guide/dispatcher/children.md @@ -0,0 +1,81 @@ +# Child Dispatchers + +Child dispatchers is an elegant way to divide logic in your application. + +Child dispatcher is an isolated dispatcher with its own dispatcher groups, +propagation and handlers that do not interfere with other dispatchers (the only +exception being `StopChildrenPropagation`) + +## Creating a child + +```ts +const child = Dispatcher.child() +``` + +Then you can register your handlers to `child`. + +## Adding a child + +Dispatcher on its own does nothing, it needs to be bound to a parent +to become a child dispatcher. That is done simply by calling `addChild`: + +```ts +dp.addChild(child) +``` + +Only dispatchers that are not bound to a Client can be used as a child. +This also means that a dispatcher can only be a child to one dispatcher, +i.e. the following code **will not work**: + +```ts +dp.addChild(child) +otherDp.addChild(child) // error! +``` + +However, you can use `.clone()` method to make this work: + +```ts +dp.addChild(child) +otherDp.addChild(child.clone()) // ok +``` + +## Removing a child + +When building some kind of modular architecture, it is useful to also +remove a child dispatcher. Luckily, it is just as easy: + +```ts +dp.removeChild(child) +``` + +Do note, however, that if you are using a cloned dispatcher, +calling `removeChild` on the original dispatcher will do nothing. +Instead, you have to store the reference to the cloned dispatcher: + +```ts +const childClone = child.clone() +otherDp.addChild(childClone) + +// later +otherDp.removeChild(childClone) +``` + +## Extending + +Instead of using child dispatchers, you can extend the existing dispatcher +with another one: + +```ts +dp.extend(child) +``` + +Note that in this case, `child` **will not** be isolated, and its handler +groups, children, scenes, etc. will be merged to the original dispatcher. +If `child` contains scenes with already registered names, they will be +overwritten. + +Extending will not work if the child is using a custom storage or +a custom key delegate. + +Using a dispatcher after it was `.extend()`-ed into another dispatcher +is undefined behaviour and should be avoided. diff --git a/docs/guide/dispatcher/di.md b/docs/guide/dispatcher/di.md new file mode 100644 index 00000000..d343c66d --- /dev/null +++ b/docs/guide/dispatcher/di.md @@ -0,0 +1,43 @@ +# Dependency Injection + +When scaling up your bot to multiple files, you may find it useful to inject dependencies +into the children dispatchers instead of having to pass them around manually. + +`@mtcute/dispatcher` provides a simple service locator that you can use to inject dependencies: + +```ts +// for typescript, you need to declare the dependencies +declare module '@mtcute/dispatcher' { + interface DispatcherDependencies { + db: Database + } +} + +// create a root dispatcher +const dp = Dispatcher.for(tg) + +// inject the database +dp.inject('db', new Database()) +// or +dp.inject({ db: new Database() }) + +// and then add a child dispatcher +import { childDispatcher } from './file2' +dp.addChild(childDispatcher) + +// file2.ts +const dp = Dispatcher.child() + +dp.onNewMessage(async (ctx) => { + // the dependencies are available in dp.deps + const db = dp.deps.db + await db.saveMessage(ctx.message) +}) + +export const childDispatcher = dp +``` + +::: info +You can only inject dependencies into the root dispatcher (the one created with `Dispatcher.for`), +and they will be available in *all* children dispatchers. +::: \ No newline at end of file diff --git a/docs/guide/dispatcher/errors.md b/docs/guide/dispatcher/errors.md new file mode 100755 index 00000000..659b0f90 --- /dev/null +++ b/docs/guide/dispatcher/errors.md @@ -0,0 +1,142 @@ +# Handling errors + +We've already briefly touched handling errors +in dispatchers in the Getting Started section, +but let's dive a bit deeper. + +## Registering a handler + +An error handler for a Dispatcher is simply a function +that is called whenever an error is thrown inside +one of the handlers. + +For convenience, that function has access to the error itself, +the parsed update and the [state](./state) (if applicable): + +```ts +dp.onNewMessage( + filters.command('do_stuff'), + async (msg) => { + throw new Error('Some error') + } +) + +dp.onError(async (error, update, state) => { + if (update.name === 'new_message') { + await update.data.replyText(`Error: ${error.message}`) + + return true + } + + return false +}) +``` + +Error handler function is expected to return `boolean` indicating whether +the update was handled. If it was not, it will be propagated to Client. + +`update` is an object that contains 2 fields: `name` and `data`. + +`name` is simply update name (`new_message`, `edit_message`, etc.; +see [Handlers](handlers.html)), and `data` is the respective object. + + +## What errors are not handled + +Errors inside [Raw update handlers](handlers.html#raw-updates) are not handled +by the error handler. Any other errors within the same dispatcher +(both handlers and filters) are handled by it. + +## Propagation + +### To Client + +If the error handler is not registered, throws an error or returns `false`, +the error is propagated to Client's [error handler](../intro/errors.html#client-errors). +Obviously, in Client's error handler you won't have access to the update +that caused this error: + +```ts +dp.onNewMessage( + filters.command('do_stuff'), + async (msg) => { + throw new Error('Some error') + } +) + +tg.onError((err) => { + // will be called since there's no `dp.onError` + console.log(err) +}) +``` + +### Within the Dispatcher + +When an error is thrown by one of the handlers, propagation within this +dispatcher stops (the same way as if it returned `StopPropagation`): + +```ts +dp.onNewMessage( + filters.command('do_stuff'), + async (msg) => { + throw new Error('Some error') + } +) + +dp.onNewMessage( + async (msg) => { + // will not reach + } +) +``` + +### To parent/children + +Errors are **not** propagated to parent dispatcher or to any of the children +dispatchers: + +```ts +const dp = new Dispatcher(tg) +const dp1 = new Dispatcher() +const dp2 = new Dispatcher() + +dp.addChild(dp1) +dp1.addChild(dp2) + +// dp --child--> dp1 --child--> dp2 + +dp.onError(() => console.log('DP caught error')) +dp1.onError(() => console.log('DP1 caught error')) +dp2.onError(() => console.log('DP2 caught error')) + +dp1.onNewMessage(() => { throw new Error() }) + +// Only "DP1 caught error" will ever be printed +``` + +However, if you need that behaviour, you can use `propagateErrorToParent`: + +```ts +const dp = new Dispatcher(tg) +const dp1 = new Dispatcher() +const dp2 = new Dispatcher() + +dp.addChild(dp1) +dp1.addChild(dp2) + +// dp --child--> dp1 --child--> dp2 + +dp.onError(() => console.log('DP caught error')) +dp1.onError(() => { + console.log('DP1 caught error') + + return dp1.propagateErrorToParent(...arguments) +}) +dp2.onError(() => console.log('DP2 caught error')) + +dp1.onNewMessage(() => { throw new Error() }) + +// "DP1 caught error" and "DP caught error" will be printed for each new message +``` + + diff --git a/docs/guide/dispatcher/filters.md b/docs/guide/dispatcher/filters.md new file mode 100755 index 00000000..1be0167d --- /dev/null +++ b/docs/guide/dispatcher/filters.md @@ -0,0 +1,331 @@ +# Filters + +Filters is a powerful concept that allows handlers to only process some of +the updates and not every single one. + +mtcute comes with a lot of filters for different cases, and you can +write your own as well. + +## Common filters + +For the full reference, see `filters` namespace in the [API reference](https://ref.mtcute.dev/modules/_mtcute_dispatcher.filters.html) + +For many updates, `filters.userId` is supported which filters by user ID(s) +that issued the update (i.e. message sender, poll voter, etc.): + +```ts +dp.onNewMessage( + filters.userId(12345678), + async (msg) => { + // ... + } +) +``` + +There's also `filters.chatId` that checks for the chat ID instead: +```ts +dp.onNewMessage( + filters.chatId(-100123456789), + async (msg) => { + // ... + } +) +``` + +`filters.chat` allows you to filter by chat type(s): + +```ts +dp.onNewMessage( + filters.chat('private'), + async (msg) => { + // ... + } +) +``` + +For every media type, there's a filter that will only match that media type: +`filters.photo`, `filters.video`, etc: + +```ts +dp.onNewMessage( + filters.photo, + async (msg) => { + await msg.replyText('Great photo though') + } +) +``` + +For service messages, there's `filters.action` that allows filtering by +service message action type(s): + +```ts +dp.onNewMessage( + filters.action('chat_created'), + async (msg) => { + await msg.answerText(`${msg.user.mention()} created ${msg.action.title}`) + } +) +``` + +There are also `filters.command` and `filters.regex` that add information +about the match into the update object: + +```ts +dp.onNewMessage( + filters.command('start'), + async (msg) => { + if (msg.command[1] === 'from_inline') { + await msg.answerText('Thanks for using inline mode!') + } + } +) + +dp.onNewMessage( + filters.regex(/^I'?m (\S+)/i), + async (msg) => { + await msg.replyText(`Hi ${msg.match[1]}, I'm Dad!`) + } +) +``` + +For ChatMember updates, you can use `filters.chatMember` to filter by +change type: + +```ts +dp.onChatMemberUpdate( + filters.chatMember('joined'), + async (upd: ChatMemberUpdate) => { + await upd.chat.sendText(`${upd.user.mention()}, welcome to the chat!`) + } +) +``` + +You can also use `filters.chatMemberSelf` to filter for actions that +were issued by the current user: + +```ts +dp.onChatMemberUpdate( + filters.and( + filters.chatMemberSelf, + filters.chatMember('joined'), + ), + async (upd: ChatMemberUpdate) => { + await addChatToDatabase(upd.chat) + } +) +``` + +## Type modification + +Some filters like `filters.photo` only match in case `msg.media` is a `Photo`, +and it makes sense to make the handler aware of that and avoid redundant checks +in your code. + +This is true for most of the built-in filters: + +```ts +dp.onNewMessage( + async (msg) => { + // msg.media is Photo | Video | ... | null + } +) + +dp.onNewMessage( + filters.media, + async (msg) => { + // msg.media is Photo | Video | ... + } +) + +dp.onNewMessage( + filters.photo, + async (msg) => { + // msg.media is Photo + } +) +``` + +## Combining filters + +Filters on their own are already pretty powerful, but you can also +combine and negate them. + +This also modifies the [type modification](#type-modification) accordingly. + +### Negating + +To negate a filter, use `filters.not`: + +```ts +dp.onNewMessage( + filters.photo, + async (msg) => { + // msg.media is Photo + } +) + +dp.onNewMessage( + filters.not(filters.photo), + async (msg) => { + // msg.media is Exclude + } +) +``` + +### Logical addition + +Logical addition (i.e. OR operator) is supported with `filters.or` + +```ts +dp.onNewMessage( + filters.or(filters.video, filters.photo), + async (msg) => { + // msg.media is Photo | Video + } +) +``` + +### Logical multiplication + +Logical multiplication (i.e. AND operator) is supported with `filters.and`. + +```ts +dp.onNewMessage( + filters.and(filters.chat('private'), filters.photo), + async (msg) => { + // msg.media is Photo + } +) +``` + +## Custom filters + +Sometimes pre-existing filters are just not enough. Then, you +can write a custom filter. + +### Simple custom filter + +Under the hood, filters are simply functions, so you can do the following: + +```ts +dp.onNewMessage( + (msg) => msg.sender.isVerified, + async (msg) => { + // ... + } +) +``` + +It can even be asynchronous: + +```ts +dp.onNewMessage( + async (msg) => await shouldProcessMessage(msg.id), + async (msg) => { + // ... + } +) +``` + +### Parameters + +To accept parameters in your custom filter, simply create a function +that returns a filter: + +```ts +const usernameRegex = (regex: RegExp): UpdateFilter => + (msg) => { + const m = msg.sender.username?.match(regex) + + return !!m + } + +dp.onNewMessage( + usernameRegex(/some_regex/), + async (msg) => { + // ... + } +) +``` + +### Adding type modification + +You can also add type modification to your custom filter: + +```ts +const fromChat: UpdateFilter = + (msg) => msg.sender.type === 'chat' + +dp.onNewMessage( + fromChat, + async (msg) => { + // msg.sender is Chat + } +) +``` + +::: warning +This is used as-is, it is not checked if the filter actually +checks for the given modification due to TypeScript limitations. +::: + +### Additional fields + +You can add additional fields to the update using type +modifications. This may be useful in multiple cases, for example, +to add the result of the parametrized filter: + +```ts +const usernameRegex = (regex: RegExp): UpdateFilter< + Message, + { usernameMatch: RegExpMatchArray } +> => (msg) => { + const m = msg.sender.username?.match(regex) + + if (m) { + ;(obj as any).usernameMatch = m + return true + } + return false +} + +dp.onNewMessage( + usernameRegex(/some_regex/), + async (msg) => { + // msg.usernameMatch is RegExpMatchArray + } +) +``` + +Or, you can add some additional fields similar to a middleware +in other frameworks: + +```ts +const loadSenderFromDb: UpdateFilter = + async (msg) => { + ;(msg as any).senderDb = await db.loadUser(msg.sender.id) + return true + } + +dp.onNewMessage( + filters.and(filters.chat('private'), loadSenderFromDb), + async (msg) => { + // msg.senderDb is UserModel + } +) +``` + +::: warning +While this is possible, this is **strongly not recommended**. + +Instead, do this right inside the handler code: + +```ts +dp.onNewMessage( + filters.chat('private'), + async (msg) => { + const senderDb = await db.loadUser(msg.sender.id) + } +) +``` +::: diff --git a/docs/guide/dispatcher/groups-propagation.md b/docs/guide/dispatcher/groups-propagation.md new file mode 100755 index 00000000..37a426a1 --- /dev/null +++ b/docs/guide/dispatcher/groups-propagation.md @@ -0,0 +1,191 @@ +# Groups and propagation + +When you register multiple handlers with conflicting filters, +only the one registered the first will be executed, to avoid +handling the same update twice. + +This is sometimes undesirable, and to handle these you can use +either [handler groups](#groups), or [propagation symbols](#propagation) + +::: tip +You can also use a [child Dispatcher](children.html). In this page, however, +it is assumed that all handlers are registered to one dispatcher. +::: + +## Groups + +Your first option to handle the same update multiple times is by +using a separate handler group. + +Handler groups are identified with a single number (group `0` is the +default group) and are processed within the same dispatcher one-by-one +in order (`..., -2, -1, 0, 1, 2, ...`). + +For example, consider the following code + +```ts +dp.onNewMessage( + filters.or(filters.text, filters.sticker), + async (msg) => { + console.log('Text or sticker') + } +) + +dp.onNewMessage( + filters.text, + async (msg) => { + console.log('Text only') + } +) +``` + +In this code, the second handler will never be executed, because +the first one handles `text` messages as well. + +To make the Dispatcher execute the second handler, you can +register it to a different group: + +```ts +dp.onNewMessage( + filters.text, + async (msg) => { + console.log('Text only') + }, + 1 +) +``` + +In this case, this handler will be executed *after* the first one. +Alternatively, you can pass a negative number to make the second +handler execute *before* the first one: + +```ts +dp.onNewMessage( + filters.text, + async (msg) => { + console.log('Text only') + }, + -1 +) +``` + +Group can also be set using `addUpdateHandler`: + +```ts +dp.addUpdateHandler({ ... }, 1) +``` + +## Propagation + +To customize the behaviour even further, you can use propagation symbols +that `@mtcute/dispatcher` exports in `PropagationAction` enum. + +### Stop propagation + +To prevent the update from being handled by any other handlers +within the same dispatcher, you can use `PropagationAction.Stop`: + +```ts +dp.onNewMessage( + filters.or(filters.text, filters.sticker), + async (msg) => { + console.log('Text or sticker') + + return PropagationAction.Stop + } +) + +dp.onNewMessage( + filters.text, + async (msg) => { + console.log('Text only') + }, + 1 +) +``` + +In the above code, second handler *will not* be executed even though it is +in a separate group. + +This will not, however, prevent the handlers from a +[child Dispatcher](children.html) to be executed. + +### Stop children propagation + +A bit ahead of ourselves, since we haven't covered child +Dispatchers yet, but the idea is pretty simple. + +`PropagationAction.StopChidlren` is very similar to the previous one, but also +prevents the handlers from child dispatchers to be executed: + +```ts +dp.onNewMessage( + filters.or(filters.text, filters.sticker), + async (msg) => { + console.log('Text or sticker') + + return PropagationAction.StopChidlren + } +) + +const dp1 = new Dispatcher() +dp.addChild(dp1) + +dp1.onNewMessage( + filters.text, + async (msg) => { + console.log('Text only') + } +) +``` + +In the above code, second executor will not be called, even though +it is in a child dispatcher. + +### Continue propagation + +As an alternative to [groups](#groups), you can use `PropagationAction.Continue` +symbol. It makes the dispatcher continue propagating this update within +the same group even though some handler from that group was already executed: + +```ts +dp.onNewMessage( + filters.or(filters.text, filters.sticker), + async (msg) => { + console.log('Text or sticker') + + return PropagationAction.Continue + } +) + +dp.onNewMessage( + filters.text, + async (msg) => { + console.log('Text only') + } +) +``` + +In the above code, the second dispatcher will be called for text messages +even though the first one also matches them. + +Note that `Continue` only works within the same handler group. + +## Raw updates + +As mentioned earlier, raw updates are handled independently of parsed +updates, and they have their own groups and propagation chain. + +This means that in the following example both handlers will be called, +despite returning `Stop` action: + +```ts +dp.onNewMessage(() => { + return PropagationAction.Stop +}) + +dp.onRawUpdate( + (cl, upd) => upd._ === 'updateNewMessage', + () => { ... } +) +``` diff --git a/docs/guide/dispatcher/handlers.md b/docs/guide/dispatcher/handlers.md new file mode 100755 index 00000000..1d57dacf --- /dev/null +++ b/docs/guide/dispatcher/handlers.md @@ -0,0 +1,402 @@ +# Handlers + +Dispatcher can process a lot of different update types, +and for each of them you can register as many handlers +as you want. + +For each of the handler types, there are 2 ways you can add +a handler - either using a specialized method, or +with `addUpdateHandler` method, as [described below](#addupdatehandler) + +See also: [Reference](https://ref.mtcute.dev/classes/_mtcute_dispatcher.Dispatcher.html) + +::: warning +Do not add or remove handlers inside of another handler, +this may lead to undefined behaviour. +::: + +## New message + +Whenever a new message is received by the bot, or a message is sent +by another client (mostly used for users), `new_message` handlers are +dispatched: + +```ts +dp.onNewMessage(async (upd) => { + await upd.answerText('Hey!') +}) +``` + +## Edit message + +Whenever a message is edited (and client receives an update about that*), +`edit_message` handlers are dispatched: + +```ts +dp.onEditMessage(async (upd) => { + await upd.replyText('Yes.') +}) +``` + +* Telegram might decide not to send these updates +in case this message is old enough. + +## Message group + +When message grouping is enabled (see [here](/guide/intro/updates.md#message-grouping)), +`message_group` handlers are dispatched when a media group (aka album) is received: + +```ts +dp.onMessageGroup(async (upd) => { + await upd.replyText('Thanks for the media!') +}) +``` + +## Delete message + +Whenever a message is deleted (and client receives an update about that*), +`delete_message` handlers are dispatched. Note that these updates +only contain message ID(s), and not its contents, and it is up to you +to check what that ID corresponds to. + +```ts +dp.onDeleteMessage(async (upd) => { + if (upd.messageIds.includes(42)) { + console.log('Magic message deleted :c') + } +}) +``` + +Note that for private chats, this does not include +user's ID, so you may want to implement some caching +if you need that info. + +* Telegram might decide not to send these updates +in case this message is old enough. + +## Chat member + +Whenever chat member status is changed in a channel/supergroup where the bot +is an administrator, `chat_member` handlers are dispatched. + +```ts +dp.onChatMemberUpdate(async (upd) => { + console.log(`${upd.user.displayName} ${upd.type} by ${upd.actor.displayName}`) +}) +``` + +::: tip +You can filter by update type using `filters.chatMember`: + +```ts +dp.onChatMemberUpdate( + filters.chatMember('joined'), + async (upd) => { + await upd.client.sendText(upd.chat, `${upd.user.mention()}, welcome to the chat!`) + } +) +``` +::: + +## Inline query + +Whenever an inline query is sent by user to your bot, `inline_query` +handlers are dispatched: + +```ts +dp.onInlineQuery(async (upd) => { + await upd.answer([], { + switchPm: { + text: 'Hello!', + parameter: 'inline_hello' + } + }) +}) +``` + +You can learn more about inline queries in [Inline Mode](./inline-mode.html) section. + +## Chosen inline result + +When a user selects an inline result, and assuming that you have +**inline feedback** feature enabled, `chosen_inline_result` handlers +are dispatched: + +```ts +dp.onChosenInlineResult(async (upd) => { + await upd.editMessage({ + text: `${result.user.displayName}, thanks for using inline!` + }) +}) +``` + +As mentioned, these updates are only sent by Telegram when you +have enabled **inline feedback** feature. You can enable it +in [@BotFather](https://t.me/botfather). + +It is however noted by Telegram that this should only be used +for statistical purposes, and even if probability setting is 100%, +not all chosen inline results may be reported +([source](https://core.telegram.org/api/bots/inline#inline-feedback)). + +## Callback query + +Whenever user clicks on a [callback button](../topics/keyboards.html#inline-keyboards), +`callback_query` or `inline_callback_query` handlers are dispatched, based on the origin of the message: + +```ts +dp.onCallbackQuery(async (upd) => { + await upd.answer({ text: '🌸' }) +}) + +dp.onInlineCallbackQuery(async (upd) => { + await upd.answer({ text: '🌸' }) +}) +``` + +For messages sent normally by the bot (e.g. using `sendText`), `callback_query` handlers are dispatched. +For messages sent from an inline query (e.g. inside `onInlineQuery`), `inline_callback_query` handlers are dispatched. + +## Poll update + +Whenever a poll state is updated (stopped, anonymous user has voted, etc.), +`poll` handlers are dispatched: + +```ts +dp.onPollUpdate(async (upd) => { + // do something +}) +``` + +Bots only receive updates about polls that they have sent. + +Note that due to Telegram limitation, sometimes the update does not +include the poll itself, and mtcute creates a "stub" poll, +that is missing most of the information (including question text, +answers text, missing flags like `quiz`, etc.). Number of votes per-answer +is always there, though. + +If you need that missing information, you will need to implement +caching by yourself. Bot API (strictly speaking, TDLib) does it internally +and thus is able to provide all the needed information autonomously. +This is not implemented in mtcute yet. + +## Poll vote + +When a user votes in a public poll, `poll_vote` handlers are dispatched: + +```ts +dp.onPollVote(async (upd) => { + upd.user.sendText('Thanks for voting!') +}) +``` + +Bots only receive updates about polls that they have sent. + +This update currently doesn't contain information about the poll, +only the poll ID, so if you need that info, +you'll have to implement caching yourself. + + +## User status + +When a user's online status changes (e.g. user goes offline), +and client receives an update about that*, +`user_status` handlers are dispatched: + +```ts +dp.onUserStatusUpdate(async (upd) => { + console.log(`User ${upd.userId} is now ${upd.status}`) +}) +``` + +* Telegram might decide not to send these updates +in many cases, for example: you don't have an active PM +with this user, this user is from a large chat that +you aren't currently chatting in, etc. + +## User typing + +When a user's typing status changes, +and client receives an update about that*, +`user_status` handlers are dispatched: + +```ts +dp.onUserTyping(async (upd) => { + console.log(`${upd.userId} is ${upd.status} in ${upd.chatId}`) +}) +``` + +* Telegram might decide not to send these updates +in many cases, for example: you haven't talked +to this user for some time, that user/chat is archived, etc. + +## History read + +When history is read in a chat (either by the other party or by you from another client), +and client receives an update about that, `history_read` handlers are dispatched: + +```ts +dp.onHistoryRead(async (upd) => { + console.log(`History read in ${upd.chatId} up to ${upd.maxReadId}`) +}) +``` + +## Bot stopped + +When a user clicks "Stop bot" button in the bot's profile, +`bot_stopped` handlers are dispatched: + +```ts +dp.onBotStopped(async (upd) => { + console.log(`Bot stopped by ${upd.user.id}`) +}) +``` + +## Join requests + +These updates differ depending on whether the currently logged in user is a bot or not. + +### Bot + +When a user requests to join a group/channel where the current bot is an admin, +`bot_chat_join_request` handlers are dispatched: + +```ts +dp.onBotChatJoinRequest(async (upd) => { + console.log(`User ${upd.user.id} wants to join ${upd.chat.id}`) + + if (upd.user.id === DUROV) { + await upd.decline() + } +}) +``` + +### User + +When a user requests to join a group/channel where the current user is an admin, +`chat_join_request` handlers are dispatched: + +```ts +dp.onChatJoinRequest(async (upd) => { + console.log(`User ${upd.recentRequesters[0].id} wants to join ${upd.chatId}`) +}) +``` + +These updates contain less information than bot join requests, +and additional info should be fetched manually if needed + +## Pre-checkout query + +When a user clicks "Pay" button, `pre_checkout_query` handlers are dispatched: + +```ts +dp.onPreCheckoutQuery(async (upd) => { + await upd.approve() +}) +``` + +## Story updates + +When a story is posted or modified, `story` handlers are dispatched: + +```ts +dp.onStoryUpdate(async (upd) => { + console.log(`${upd.peer.id} posted or modified a story!`) +}) +``` + +## Delete story updates + +When a story is deleted, `delete_story` handlers are dispatched: + +```ts +dp.onDeleteStory(async (upd) => { + console.log(`${upd.peer.id} deleted story ${upd.storyId}!`) +}) +``` + +## Raw updates + +Dispatcher only implements the most commonly used updates, +but you can still add a handler for a custom MTProto update: + +```ts +dp.onRawUpdate( + (cl, upd) => upd._ === 'updateUserName', + async ( + client: TelegramClient, + update_: tl.TypeUpdate, + peers: PeersIndex, + ) => { + const update = update_ as tl.RawUpdateUserName + // ... + } +) +``` + +Note that the signature is slightly different: since the update +is not parsed by the library, client, raw update and entities +are provided as-is. + +Raw update handlers are dispatched independently of parsed update handlers +([learn more](groups-propagation.html#raw-updates)). + +## `addUpdateHandler` + +Another way to register a handler is to use `addUpdateHandler` method. + +It supports all the updates that have a specialized methods, and this +method is actually used by `on*` under the hood. It accepts an object, +containing update type, its handler and optionally a `check` function, +which checks if the update should be handled by this handler +(basically a [Filter](filters.html)): + +```ts +dp.addUpdateHandler({ + type: 'new_message', + callback: async (msg) => { + ... + }, + check: filters.media +}) +``` + +This may be useful in case you are loading your handlers dynamically. + +## Removing handlers + +It might be useful to remove a previously added handler. + +### Removing a single handler + +To remove a single handler, it must have been added using `addUpdateHandler`, +and then that object should be passed to `removeUpdateHandler`: + +```ts +const handler = { ... } +dp.addUpdateHandler(handler) + +// later +dp.removeUpdateHandler(handler) +``` + +Handlers are matched by the object that wraps them, because +the same function may be used for multiple different handlers. + +### Removing handlers by type + +To remove all handlers that have the given type, pass this type +to `removeUpdateHandler`: + +```ts +dp.removeUpdateHandler('new_message') +``` + +### Removing all handlers + +Pass `all` to `removeUpdateHandler`: + +```ts +dp.removeUpdateHandler('all') +``` diff --git a/docs/guide/dispatcher/inline-mode.md b/docs/guide/dispatcher/inline-mode.md new file mode 100755 index 00000000..25322cda --- /dev/null +++ b/docs/guide/dispatcher/inline-mode.md @@ -0,0 +1,731 @@ +# Inline mode + +Users can interact with bots using inline queries, by starting a message +with bot's username and then typing their query. + +You can learn more about them in +[Bot API docs](https://core.telegram.org/bots/inline) + + + +## Handling inline queries + +To handle inline queries to your bot, simply use `onInlineQuery`: + +```ts +dp.onInlineQuery(async (query) => { + // ... +}) +``` + +You can also use `filters.regex` to filter based on the query text: + +```ts +dp.onInlineQuery( + filters.regex(/^cats /), + async (query) => { + // ... + } +) +``` + +## Answering to inline queries + +Answering an inline query means sending results of the query to Telegram. +The results may contain any media, including articles, photos, videos, +stickers, etc, and can also be heterogeneous, which means that you can use +different media types in results for the same query. + +To answer a query, simply use `.answer()` and pass an array that +contains [results](#results): + +```ts +dp.onInlineQuery(async (query) => { + query.answer([...]) +}) +``` + +::: tip +Clients wait about 10 seconds for results, after which they assume +the bot timed out. Make sure not to do heavy computations there! +::: + +## Results + +Every inline result must contain a **unique** result ID, which +can be later used in [chosen inline result updates](#chosen-inline-results) +updates to determine which one was chosen. + +Media inside inline results *must* be provided via either HTTP URL, or +re-used from Telegram (e.g. using [File IDs](/guide/topics/files.md#file-ids)). +Telegram does not allow uploading new files directly to inline results. + +When choosing a result, by default, a message containing the respective +result is sent, but the message contents can be [customized](#custom-message) +using `message` field. + +Telegram supports a bunch of result types, and mtcute supports sending all of +them as an inline result. In the below examples we'll be using direct URLs +to content, but you can also use File IDs instead. + +### Article + +An article is a result that contains a title, description and *optionally* +a thumbnail and a URL: + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.article( + 'RESULT_ID', + { + title: 'Article title', + description: 'Article description', + thumb: 'https://example.com/image.jpg', + url: 'https://example.com/some-article.html' + } + ) + ]) +}) +``` + +When choosing this result, by default, a message with the following +text is sent (Handlebars syntax is used here): + +```handlebars +{{#if url}} +{{title}} +{{else}} +{{title}} +{{/if}} +{{#if description}} +{{description}} +{{/if}} +``` + +For the above example, this would result in the following message: + + + +### GIF + +You can send an animated GIF (either real GIF, or an MP4 without sound) +as a result. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.gif( + 'RESULT_ID', + 'https://media.tenor.com/videos/98bf1db10cb172aae086b09ae88ebf22/mp4' + ) + ]) +}) +``` + +You can also add title and description, however only some clients display them +(e.g. Telegram Desktop doesn't, screenshot below is from Telegram for Android) + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.gif( + 'RESULT_ID', + 'https://media.tenor.com/videos/98bf1db10cb172aae086b09ae88ebf22/mp4', + { + title: 'GIF title', + description: 'GIF description', + } + ) + ]) +}) +``` + +### Video + +You can send an MP4 video as an inline result. + +When sending by direct URL, there's a file size limit of 20 MB, +and you *must* provide a thumbnail, otherwise the result will be ignored. +Thumbnail is only used until the video file is cached by Telegram, and is then +overridden by Telegram-generated video thumbnail. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.video( + 'RESULT_ID', + 'https://amvnews.ru/index.php?go=Files&file=down&id=1858&alt=4', + { + thumb: + 'https://amvnews.ru/images/news098/1257019986-Bad-Apple21_5.jpg', + title: 'Video title', + description: 'Video description', + } + ) + ]) +}) +``` + +Alternatively, you can send a video by its URL (e.g. from YouTube) using +`isEmbed: true`: + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.video( + 'RESULT_ID', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + { + isEmbed: true, + thumb: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg', + title: 'Video title', + description: 'Video description', + } + ) + ]) +}) +``` + +Choosing such result would send a message containing URL to that video: + + + +### Audio + +You can send an MPEG audio file as an inline result. + +When sending by direct URL, there's a file size limit of 20 MB. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.audio( + 'RESULT_ID', + 'https://vk.com/mp3/cc_ice_melts.mp3', + { + performer: 'Griby', + title: 'Tayet Lyod', + } + ) + ]) +}) +``` + +::: tip NOTE +Performer, title and other meta can't be changed once the file is +cached by Telegram (they will still be displayed in the results, +but not in the message). To avoid caching when sending by URL, add +a random query parameter (e.g. `?notgcache`), which will make +Telegram think this is a new file. +::: + +### Voice + +You can send an OGG file as a voice note inline result. + +Waveform seems to only be generated for OGG files encoded with OPUS, +so if it is not generated, try re-encoding your file with OPUS. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.voice( + 'RESULT_ID', + 'https://tei.su/test_voice.ogg', + { + title: 'Voice title', + } + ) + ]) +}) +``` + +### Photo + +You can send an image as an inline result. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg' + ) + ]) +}) +``` + +You can also add title and description, however only some clients display them +(e.g. Telegram Desktop doesn't, screenshot below is from Telegram for Android) + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg', + { + title: 'Photo title', + description: 'Photo description', + } + ) + ]) +}) +``` + +### Sticker + +You can send a sticker as an inline result. You can't send a +sticker by URL ([Telegram limitation](https://t.me/tdlibchat/17923)), +only by File ID. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.sticker( + 'RESULT_ID', + 'CAACAgIAAxk...JtzysqiUK3IAQ' + ) + ]) +}) +``` + +### File + +You can send a file as an inline result. + +Due to Telegram limitations, when using URLs, you can only send PDF +and ZIP files, and must set `mime` accordingly +(`application/pdf` and `application/zip` MIMEs respectively). +With File IDs, you can send any file. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.file( + 'RESULT_ID', + 'https://file-examples-com.github.io/uploads/2017/10/file-sample_150kB.pdf', + { + mime: 'application/pdf', + title: 'File title', + description: 'File description' + } + ) + ]) +}) +``` + +### Geolocation + +You can send a geolocation as an inline result. + +By default, Telegram generates `thumb` for result based on the +location provided. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.geo( + 'RESULT_ID', + { + latitude: 55.751999, + longitude: 37.617734, + title: 'Kremlin' + } + ), + ]) +}) +``` + +### Venue + +You can send a venue as an inline result. + +By default, Telegram generates `thumb` for result based on the +location provided. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.venue( + 'RESULT_ID', + { + latitude: 55.751999, + longitude: 37.617734, + title: 'Kremlin', + address: 'Red Square' + } + ), + ]) +}) +``` + +### Contact + +You can send a contact as an inline result. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.contact( + 'RESULT_ID', + { + firstName: 'Alice', + phone: '+79001234567', + thumb: 'https://avatars.githubusercontent.com/u/86301490' + } + ), + ]) +}) +``` + +### Games + +Finally, you can send a game as an inline result. + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.game('RESULT_ID', 'game_short_name'), + ]) +}) +``` + + +## Custom message + +By default, mtcute generates a message based on result contents. However, +you can override the message that will be sent using `message` + +### Text + +Instead of media or default article message, you may want to send a custom +text message: + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg', + { + message: BotInlineMessage.text( + 'Ha-ha, just kidding. No Renge for you :p' + ) + } + ) + ]) +}) +``` + +### Media + +You can customize media message (for photos, videos, voices, documents, etc.) +with custom caption, keyboard, etc: + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg', + { + message: BotInlineMessage.media({ text: 'Nyanpasu!' }), + } + ) + ]) +}) +``` + +Sadly, Telegram does not allow sending another media instead of the one +you provided in the result (however, you *could* use web previews to +accomplish similar results) + +### Geolocation + +Instead of sending the default message, you can send geolocation, +or even live geolocation: + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg', + { + // or BotInlineMessage.geoLive + message: BotInlineMessage.geo({ + latitude: 55.751999, + longitude: 37.617734, + }), + } + ) + ]) +}) +``` + +### Venue + +Instead of sending the default message, you can send a venue + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg', + { + message: BotInlineMessage.venue({ + latitude: 55.751999, + longitude: 37.617734, + title: 'Kremlin', + address: 'Red Square' + }), + } + ) + ]) +}) +``` + +### Contact + +Instead of sending the default message, you can send a contact + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.photo( + 'RESULT_ID', + 'https://nyanpa.su/renge.jpg', + { + message: BotInlineMessage.contact({ + firstName: 'Alice', + phone: '+79001234567', + }), + } + ) + ]) +}) +``` + +### Game + +For inline results containing a game, you can customize keyboard +under the message ([learn more](../topics/keyboards.html)): + +```ts +dp.onInlineQuery(async (query) => { + query.answer([ + BotInline.game('RESULT_ID', 'test', { + message: BotInlineMessage.game({ + replyMarkup: ... + }) + }), + ]) +}) +``` + +## Switch to PM + +Some bots may benefit from switching to PM with the bot +for some action (e.g. logging in with your account). +For that, you can use `switchPm` button along with your results: + + + +```ts +dp.onInlineQuery(async (query) => { + query.answer([], { + switchPm: { + text: 'Log in', + parameter: 'login_inline' + } + }) +}) + +dp.onNewMessage(filters.deeplink('login_inline'), async (msg) => { + await msg.answerText('Thanks for logging in!') +}) +``` + +## Chosen inline results + +When a user selects an inline result, and assuming that you have +**inline feedback** feature enabled, an update is received, +which can be handled: + +```ts +dp.onChosenInlineResult(async (result) => { + await result.editMessage({ + text: `${result.user.displayName}, thanks for using inline!` + }) +}) +``` + +You can use `filters.regex` to filter by chosen result ID: + +```ts +dp.onChosenInlineResult( + filters.regex(/^CATS_/), + async (result) => { + // ... + } +) +``` + +These updates are only sent by Telegram when you +have enabled **inline feedback** feature. You can enable it +in [@BotFather](https://t.me/botfather). + +It is however noted by Telegram that this should only be used +for statistical purposes, and even if probability setting is 100%, +not all chosen inline results may be reported +([source](https://core.telegram.org/api/bots/inline#inline-feedback)). + + +## Editing inline message + +You can edit an inline message in Chosen inline query update handlers, and +in Callback query updates: + + +::: tip NOTE +In the below examples, it is assumed that callback query +originates from an inline message. +::: + +```ts +dp.onChosenInlineResult(async (result) => { + await result.editMessage({...}) +}) + +dp.onCallbackQuery(async (query) => { + await query.editMessage({...}) +}) +``` + +You can also save inline message ID and edit the message later: + +```ts +function updateMessageLater(msgId: string) { + setTimeout(() => { + tg.editInlineMessage(msgId, {...}) + .catch(console.error) + }, 5000) +} + +dp.onChosenInlineResult(async (result) => { + updateMessageLater(result.messageIdStr) +}) + +dp.onCallbackQuery(async (query) => { + updateMessageLater(query.inlineMessageIdStr) +}) +``` + +::: tip +`messageIdStr` and `inlineMessageIdStr` contain string representation +of the inline message ID, to simplify its storage. + +You can also use the `messageId` and `inlineMessageId` respectively, +which contain a TL object, in case you need to use Raw API. In mtcute, +they are interchangeable. +::: + +You can also edit media inside inline messages, and even upload new media +directly to them (unlike Bot API): + +```ts +dp.onChosenInlineResult(async (result) => { + const link = await getDownloadLink(result.id) + + await result.editMessage({ + media: InputMedia.audio(await fetch(link)) + }) +}) +``` diff --git a/docs/guide/dispatcher/intro.md b/docs/guide/dispatcher/intro.md new file mode 100755 index 00000000..fbf33497 --- /dev/null +++ b/docs/guide/dispatcher/intro.md @@ -0,0 +1,20 @@ +# Introduction + +We've already briefly [touched](../intro/updates.html) on what Dispatcher is, +but as a quick reminder: Dispatcher is a class that processes +updates from the client and *dispatches* them to the registered handlers. + +It is implemented in `@mtcute/dispatcher` package + +## Setting up + +To use a dispatcher, you need to first create a bound dispatcher +using `Dispatcher.for` method: + +```ts +import { Dispatcher } from '@mtcute/dispatcher' + +const tg = new TelegramClient({...}) +const dp = Dispatcher.for(tg) +``` + diff --git a/docs/guide/dispatcher/middlewares.md b/docs/guide/dispatcher/middlewares.md new file mode 100755 index 00000000..49f721b1 --- /dev/null +++ b/docs/guide/dispatcher/middlewares.md @@ -0,0 +1,67 @@ +# Middlewares + +Dispatcher provides basic middleware functionality. It is not as extensible +as middlewares in frameworks likes [Telegraf](https://github.com/telegraf/telegraf), +and that is by design, since we already have filters and propagation using which +you can achieve pretty much the same. + +Middlewares are only called for parsed updates. + +## Middleware types + +Dispatcher supports 2 middlewares: pre-update and post-update. + +### Pre-update + +**Pre-update** middleware is called right before an update is going +to be dispatched, and can be used to skip that update altogether. + +```ts +dp.onPreUpdate((upd) => { + // randomly skip 10% of updates + if (Math.random() < 0.1) + return PropagationAction.Stop +}) +``` + +### Post-update + +**Post-update** middleware is called after an update was processed +by the dispatcher. Whether the update was handled is also provided here: + +```ts +dp.onPostUpdate((handled, upd) => { + if (handled) { + console.log(`handled ${upd.name}`) + } +}) +``` + +## Additional context + +You can add some additional context in the pre-update middleware, +and that context will also be available in post-update middleware +and error handler: + +```ts +interface TimerContext { + start: number +} + +dp.onPreUpdate((upd) => { + upd.start = Date.now() +}) + +dp.onPostUpdate((handled, upd) => { + if (handled) { + console.log(`handled ${upd.name} in ${Date.now() - upd.start} ms`) + } +}) + +dp.onError((err, upd) => { + console.log(`error for ${upd.name} after ${Date.now() - upd.start} ms`) +}) +``` + +Note that *currently* you can't access that context from handlers or filters +(see [mtcute#4](https://github.com/mtcute/mtcute/issues/4)). diff --git a/docs/guide/dispatcher/rate-limit.md b/docs/guide/dispatcher/rate-limit.md new file mode 100755 index 00000000..aeeb84d5 --- /dev/null +++ b/docs/guide/dispatcher/rate-limit.md @@ -0,0 +1,61 @@ +# Rate limit + +You may want to limit access to certain commands or handlers +in your bot, for example to avoid flood limits. + +For that, you can use rate-limiting feature of the Dispatcher. +Rate limiting is built into update state object, and all you have to do +is to call `.rateLimit` function: + +```ts +dp.onNewMessage( + filters.command('some_expensive_command'), + async (msg, state) => { + try { + // 1 request every 15 seconds + await state.rateLimit('some_expensive_command', 1, 15) + } catch (e) { + if (e instanceof RateLimitError) { + await msg.replyText('Try again later') + } + throw e + } + + const result = doSomeExpensiveComputations() + await msg.replyText(result) + } +) +``` + +In the above example, we use `some_expensive_command` as a key for the +rate limit. This allows you to have multiple independent rate limits. + +When the rate limit is exceeded, `rateLimit` throws `RateLimitError`, which +also contains `.reset` field with the Unix time when the rate limit will be +reset. + +## Throttle + +In some cases you might want to throttle a used instead of rate-limiting them, +and that is exactly what `.throttle` does. When a rate limit is reached, +it waits until the rate limit is replenished and then returns: + +```ts +dp.onNewMessage( + filters.start, + async (msg, state) => { + // no more than 2 rps per user + await state.throttle('start', 2, 1) + + await msg.replyText('Hi!') + } +) +``` + +`throttle` returns tuple containing number of requests left until +the user hits the limit, and when the limit will be reset: + +```ts +const [left, reset] = await state.throttle('some_expensive_command', 1, 15) +await msg.replyText(`${left} requests left. Reset at ${new Date(reset).toString()}`) +``` \ No newline at end of file diff --git a/docs/guide/dispatcher/scenes.md b/docs/guide/dispatcher/scenes.md new file mode 100755 index 00000000..f17ea2a4 --- /dev/null +++ b/docs/guide/dispatcher/scenes.md @@ -0,0 +1,245 @@ +# Scenes + +Scene is basically a child dispatcher with a name. It is not used by default, +unless you explicitly **enter** into it, after which **all** (supported*) +updates will be redirected to the scene, and not processed as usual. + +This is particularly useful with FSM, since it allows users to +enter independent "dialogues" with the bot. + + + +

* Only updates that can be keyed are supported

+ +## Creating a scene + +A scene is created by using `Dispatcher.scene`: + +```ts +interface SceneState { ... } + +const dp = Dispatcher.scene('scene-name') +// add handlers to `dp` + +export const SomeScene = dp + +// then in the main file: +dp.addScene(SomeScene) +``` + +If you don't use state within your scene, just don't pass anything: + +```ts +const scene = new Dispatcher() +``` + +::: tip +Scenes should only be added to the root dispatcher. +::: + +Scene names can't start with `$` (dollar sign), since it is reserved +for internal FSM needs. Other than that, you can use any name. + +## Entering a scene + +To enter a scene or change current scene, use `state.enter` and pass the scene instance: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.enter(SomeScene) +}) +``` + +You can also pass some initial state to the scene: +```ts +dp.onNewMessage(async (msg, state) => { + await state.enter(SomeScene, { with: { foo: 'bar' } }) +}) +``` + +By default, new scene will be used starting from the next update, +but in some cases you may want it to be used immediately. + +To make the dispatcher immediately dispatch the update to the newly +entered scene, use `PropagationAction.ToScene`: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.enter(SomeScene) + return PropagationAction.ToScene +}) +``` + +## Exiting a scene + +To exit from the current scene, use `state.exit`: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.exit() +}) +``` + +To make the dispatcher immediately dispatch the update to the +root dispatcher, use `PropagationAction.ToScene`: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.exit() + return PropagationAction.ToScene +}) +``` + +Entering another scene will also exit the current one. + +## Isolated state + +By default, scenes have their own, fully isolated FSM state, +which is (by default) destroyed as soon as the user leaves the scene. +This is more clean than using the global state, and also allows +scenes to have their own state type. + +However, in some cases, you may want to access global FSM state. +This is possible with `getGlobalState`: + +```ts +dp.onNewMessage(async (msg, state) => { + const local = await state.get() + + const globalState = await dp.getGlobalState(msg) + const global = await globalState.get() +}) +``` + +Alternatively, you can disable isolated storage for FSM altogether and use +global state directly: + +```ts +const dp = new Dispatcher() +// add handlers to `dp` + +export const SomeScene = dp + +// in the main file: +dp.addScene(SomeScene, /* scoped: */ false) +``` + +In this case, `scene` can't have state type other than `BotState` (i.e. the +one used by the parent), and it will not be reset when the user +leaves the scene. + + +## Wizard scenes + +A commonly used pattern for scenes is a step-by-step wizard. + +To simplify their creation, mtcute implements `WizardScene`, +which is simply a Dispatcher with an additional method: `addStep`. + +Every step is an `onNewMessage` handler that is filtered by the current +step, which is stored in wizard's FSM state. In each step, you can +choose either to `WizardAction.Stay` in the same step, proceed to the +`WizardAction.Next` step, or `WizardAction.Exit` the wizard altogether. + +You can also return a `number` to jump to some step (ordering starts from 0). + +Additionally, wizard provides `onCurrentStep` filter that filters for updates that +happened *after* the last triggered step. + + +A simple example: + +```ts +interface RegForm { + name?: string +} + +const wizard = new WizardScene('REGISTRATION') + +wizard.addStep(async (msg) => { + await msg.answerText('What is your name?', { + replyMarkup: BotKeyboard.inline([[BotKeyboard.callback('Skip', 'SKIP')]]), + }) + + return WizardSceneAction.Next +}) + +wizard.onCallbackQuery(filters.and(wizard.onCurrentStep(), filters.equals('SKIP')), async (upd, state) => { + await state.merge({ name: 'Anonymous' }) + await wizard.skip(state) + + await upd.client.sendText(upd.chatId, 'Alright, "Anonymous" then\n\nNow enter your email') +}) + +wizard.addStep(async (msg, state) => { + // simple validation + if (msg.text.length < 3) { + await msg.replyText('Invalid name!') + return WizardSceneAction.Stay + } + + await state.set({ name: msg.text.trim() }) + await msg.answerText('Enter your email') + + return WizardSceneAction.Next +}) + +wizard.addStep(async (msg, state) => { + const { name } = (await state.get())! + + console.log({ name, email: msg.text }) + + await msg.answerText('Thanks!') + return WizardSceneAction.Exit +}) +``` + +If you are using some custom state, you may want to set the default +state for the wizard: + +```ts +wizard.setDefaultState({ name: 'Ivan' }) +``` + +By default, `{}` is used as the default state. + +## Transition updates + +Whenever you `.enter()` or `.exit()` a scene, the dispatcher will also emit +a transition update, which can be caught by using `onSceneTransition`: + +```ts +scene.onSceneTransition(async (upd, state) => { + console.log(`Transition from ${upd.previousScene} to SomeScene`) +}) +``` + +These handlers are called **before** any of the scene's handlers are called, +even if `PropagationAction.ToScene` is used, and can be used to cancel the transition: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.enter(SomeScene) + return PropagationAction.ToScene +}) + +SomeScene.onSceneTransition(async (upd, state) => { + await state.exit() + return PropagationAction.Stop +}) + +SomeScene.onNewMessage(async (msg, state) => { + await msg.replyText('This will never be called') +}) +``` + +The update which triggered the transition is passed to the handler, and +you can use it to decide whether to cancel the transition or not: + +```ts +SomeScene.onSceneTransition(async (upd, state) => { + if (upd.message.text === 'cancel') { + return PropagationAction.Stop + } +}) +``` diff --git a/docs/guide/dispatcher/state.md b/docs/guide/dispatcher/state.md new file mode 100755 index 00000000..df429fdd --- /dev/null +++ b/docs/guide/dispatcher/state.md @@ -0,0 +1,271 @@ +# State + +Finite State Machine (**FSM** for short, or simply **State**) is a commonly used concept +when developing bots that allows the bot to "remember" its state, +which in turn makes the bot more interactive and user-friendly. + + + +::: tip +FSM is even more useful when used with [Scenes](scenes.html) +::: + +## Setup + +Dispatcher natively supports FSM. To set it up, simply pass +a [storage](#storage) to the constructor: + +```ts +interface BotState { ... } + +const dp = Dispatcher.for(tg, { + storage: new MemoryStateStorage() +}) +// or, for children +const dp = Dispatcher.child() +``` + +You **must** provide some state type in order to use FSM (in the example above, +`BotState`). You *can* use `any`, but this is not recommended. + +Then, the update state argument will be available in every handler +that supports FSM (that is: `new_message`, `edit_message`, `message_group`, `callback_query`) +as well as to their filters: + +```ts +dp.onNewMessage(async (msg, state) => { + // ... +}) +``` + +::: warning +Type parameter for `Dispatcher` (in this case, `BotState`) is only +used as a *hint* for the compiler. + +It is not checked at runtime. +::: + +## Getting current state + +To retrieve the current state, use `state.get`: + +```ts +dp.onNewMessage(async (msg, state) => { + const current = await state.get() +}) +``` + +By default, if there's no state stored, `null` is returned. +However, you can provide the default fallback state, +which will be used instead: + +```ts +dp.onNewMessage(async (msg, state) => { + const current = await state.get({ ... }) + // or a function + const current = await state.get(() => ({ ... })) +}) +``` + +## Updating state + +To update the state, use `state.set`: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.set({ ... }) +}) +``` + +You can also set a TTL, after which the newly set state +will be considered "stale" and removed: + +```ts +dp.onNewMessage(async (msg, state) => { + // ttl = 1 hour + await state.set({ ... }, 3600) +}) +``` + +You can also modify the existing state by only +providing the modification (under the hood, the library will +fetch the current state automatically): + +```ts +dp.onNewMessage(async (msg, state) => { + await state.merge({ ... }) +}) +``` + +If the state can be empty, make sure to pass the default state, +otherwise an error will be thrown: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.merge({ ... }, defaultState) +}) +``` + +## Removing state + +To remove currently stored state, use `state.delete`: + +```ts +dp.onNewMessage(async (msg, state) => { + await state.delete() +}) +``` + +## Related filters + +As mentioned above, state (and its type!) is also available to the filters, +so you can make [custom filters](filters.html#custom-filters) that use it: + +```ts +dp.onNewMessage( + (msg, state) => state.get().then((res) => res?.action === 'ENTER_PASSWORD'), + async (msg, state) => { + // ... + } +) +``` + +However, the above isn't very clean, so the library provides `filters.state`: + +```ts +dp.onNewMessage( + filters.state((state) => state.action === 'ENTER_PASSWORD'), + async (msg, state: UpdateState) => { + const current = await state.get() + // or, if you have strict null checks + const current = (await state.get())! + } +) +``` + +Note that here we explicitly pass inner type, because due to TypeScript +limitations, we can't automatically derive state type from the predicate. + +`filters.state` *does not* match empty state, instead, +use `filters.stateEmpty`: + +```ts +dp.onNewMessage( + filters.stateEmpty, + async (msg, state) => { + // ... + } +) +``` + +## Keying + +FSM may look like magic, but in fact it is not. Under the hood, +user's state is stored in the [storage](#storage), and the key is derived +from the update object. + +By default, `defaultStateKeyDelegate` is used, which derives +the key as follows: +- If private chat, `msg.chat.id` +- If group chat, `msg.chat.id + '_' + msg.sender.id` +- If channel, `msg.chat.id` +- If callback query from a non-inline message: + - If in private chat (i.e. `upd.chatType === 'user'`), `upd.user.id` + - If in group/channel/supergroup (i.e. `upd.chatType !== 'user'`), + `upd.chatId + '_' + upd.user.id` + +This is meant to be a pretty opinionated default, but you can use custom +keying mechanism too, if you want: + +```ts +const customKey = (upd) => ... + +const dp = new Dispatcher(tg, storage, customKey) +// or, locally for a child dispatcher: +const dp = new Dispatcher(customKey) +``` + + +## Getting state from outside + +In some cases, you may need to access the state (and maybe even alter it) +outside of handlers, in the handler that does not support state, or using a different key. + +You can do so by using `.getState`: + +```ts +const state = await dp.getState(await msg.getReply()) +// you can also pass User/Chat instances: +const state = await dp.getState(msg.sender) +// and then, for example +await state.delete() +``` + +When providing an object, dispatcher will use its own keying +mechanism(s). You can provide a key manually to avoid that: + +```ts +const target = msg.getReply() +const state = dp.getState(defaultStateKeyDelegate(target)) +// or even manually +const state = await dp.getState(`${target.chat.id}`) +``` + +You can also provide a totally custom key +to store arbitrary data: + +```ts +// tip: prefix the key with $ and then something unique +// to avoid clashing with FSM and Scenes +const state = await dp.getState(`$internal-user-pref:${userId}`) +``` + +::: warning +`getState` **DOES NOT** guarantee type of the state, +because it can not determine the origin of state key. + +By default, it uses dispatcher's state type, but you can also +override this with type parameter: + +```ts +const state = await dp.getState(...) +``` +::: + +## Storage + +Storage is the backend used by Dispatcher to store state related information. +A storage is a class that implements [`IStateStorageProvider`](https://ref.mtcute.dev/types/_mtcute_dispatcher.IStateStorageProvider.html). + +```ts +const dp = Dispatcher.for(tg, { storage: new MemoryStorage() }) +// or, locally for a child dispatcher: +const dp = Dispatcher.child({ storage: new MemoryStorage() }) +``` + +### SQLite storage + +You can re-use your existing SQLite storage for FSM: + +```ts +import { SqliteStorage } from '@mtcute/sqlite' +import { SqliteStateStorage } from '@mtcute/dispatcher' + +const storage = new SqliteStorage('my-account') +const tg = new TelegramClient({ ..., storage }) + +const dp = Dispatcher.for(tg, { + storage: SqliteStateStorage.from(storage) +}) +``` + +Alternatively, you can create a new SQLite storage specifically for FSM: + +```ts +import { SqliteStorageDriver } from '@mtcute/sqlite' +import { SqliteStateStorage } from '@mtcute/dispatcher' + +const dp = Dispatcher.for(tg, { + storage: new SqliteStateStorage(new SqliteStorageDriver('my-state')) +}) +``` \ No newline at end of file diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100755 index 00000000..4435299a --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,170 @@ +# Quick start + +This is a quick guide on how to get mtcute up and running as fast as possible. + +## Node.js + +For bots in Node.js, there's a special package that scaffolds a project for you: + +```bash +pnpm create @mtcute/bot my-awesome-bot +``` + +Just follow the instructions and you'll get a working bot in no time! + +### Manually + +For existing projects, you'll probably want to add it manually, though. + +> **Note**: mtcute is currently targeting TypeScript 5.0. +> If you are using an older version of TypeScript, please consider upgrading. + +1. Get your API ID and Hash at + [https://my.telegram.org/apps](https://my.telegram.org/apps). +2. Install `@mtcute/node` package: + +```bash +pnpm add @mtcute/node +``` + +3. Import the package and create a client: + +```ts +import { TelegramClient, html } from '@mtcute/node' + +const tg = new TelegramClient({ + apiId: API_ID, + apiHash: 'API_HASH' +}) + +const self = await tg.start({ ... } +console.log(`Logged in as ${self.displayName}`) + +await tg.sendText('self', html`Hello from MTCute!`) +``` +4. That's literally it! Happy hacking 🚀 + +### Native crypto addon +mtcute also provides `@mtcute/crypto-node` package, that implements +a native Node.js addon for crypto functions used in MTProto. + +Using this addon improves overall library performance (especially when uploading/downloading files), +so it is advised that you install it as well: + +```bash +pnpm add @mtcute/crypto-node +``` + +When using `@mtcute/node`, native addon is loaded automatically, +no extra steps are required. + +## Bun + +Experimental support for Bun is provided in `@mtcute/bun` package, and +Bun is also supported in `@mtcute/create-bot`. + +```bash +bun create @mtcute/bot my-awesome-bot +# or add to an existing project +bun add @mtcute/bun +``` + +## Deno + +Experimental support for Deno is provided in `@mtcute/deno` package, which is published +to the [jsr.io](https://jsr.io) registry: + +```ts +import { TelegramClient } from 'jsr:@mtcute/deno' + +const tg = new TelegramClient({ + apiId: 123456, + apiHash: '0123456789abcdef0123456789abcdef', + storage: 'my-account' // will use sqlite-based storage +}) + +await tg.start() +``` + +```bash +deno run -A --unstable-ffi your-script.ts +``` + +Deno is also supported in `@mtcute/create-bot`, which is only available in npm: + +```bash +deno run -A npm:@mtcute/create-bot my-awesome-bot +``` + +## Browser + +For browsers, it is recommended to use [vite](https://vitejs.dev). +Webpack is probably also fine, but you may need to do some extra configuration. + +For usage in browsers, mtcute provides an `@mtcute/web` package: + +```bash +pnpm add @mtcute/web +``` + +::: info +For vite, you'll need to deoptimize `@mtcute/wasm` (see [vite#8427](https://github.com/vitejs/vite/issues/8427)): +```ts +// in vite.config.ts +export default defineConfig({ + optimizeDeps: { + exclude: ['@mtcute/wasm'] + } +}) +``` +::: + +Then, you can use it as you wish: + +```ts +import { TelegramClient } from '@mtcute/web' + +const tg = new TelegramClient({ + apiId: 123456, + apiHash: '0123456789abcdef0123456789abcdef', + storage: 'my-account' // will use IndexedDB-based storage +}) + +tg.call({ _: 'help.getConfig' }).then((res) => console.log(res)) +``` + +See also: [Tree-shaking](/guide/advanced/treeshaking.md) + +## Other runtimes + +mtcute strives to be as runtime-agnostic as possible, so it should work in any environment that supports +some basic ES2020 features. + +In case your runtime of choice is not listed above, you can try using `@mtcute/core` directly + +You will need to provide your own implementations of storage, networking and crypto - feel free to take a +look at web/node implementations for reference (or even extend them to better fit your needs, e.g. if some runtime +only partially supports some Node.js APIs). + +```ts +import { TelegramClient } from '@mtcute/core/client.js' +import { setPlatform } from '@mtcute/core/platform.js' + +setPlatform(new MyPlatform()) + +const tg = new TelegramClient({ + ..., + storage: new MyStorage(), + crypto: new MyCrypto() + transport: () => new MyTransport(), +}) +``` + +::: info +You only need to call `setPlatform` once, before creating any clients. +Platform is set once globally and cannot be changed afterwards. +It is safe to call `setPlatform` multiple times, as long as the constructor is the same - it will be ignored if the platform is already set. + +A good starting point might be to use [WebPlatform](https://ref.mtcute.dev/classes/_mtcute_web.WebPlatform.html), +since it implements everything in portable JavaScript. +::: \ No newline at end of file diff --git a/docs/guide/intro/errors.md b/docs/guide/intro/errors.md new file mode 100755 index 00000000..9806b421 --- /dev/null +++ b/docs/guide/intro/errors.md @@ -0,0 +1,148 @@ +# Handling errors + +> There are two ways to write error-free programs; only the third one works +> +> © Alan J. Perlis + +Errors are an inevitable part of any software development, especially +when working with external APIs, and it is important to know how to handle them. + +## RPC Errors + +Almost any RPC call can result in an RPC error +(like `FLOOD_WAIT_%d`, `CHAT_ID_INVALID`, etc.). + +All these RPC errors are instances of `tl.RpcError`. + +Sadly, JavaScript does not provide a nice syntax to handle different kinds +of errors, so you will need to write a bit of boilerplate: + +```ts +try { + // your code // +} catch (e) { + if (tl.RpcError.is(e, 'FLOOD_WAIT_%d')) { + // handle... + } else throw e +} +``` + +::: tip +mtcute automatically handles flood waits smaller than `floodWaitThreshold` +by sleeping for that amount of seconds. +::: + +### Unknown errors + +Sometimes, Telegram with return an error which is not documented (yet). +In this case, it will still be an `RpcError`, but will have `.unknown = true` + +If you are feeling generous and want to help improve the docs for everyone, +you can opt into sending unknown errors to [danog](https://github.com/danog)'s +[error reporting service](https://rpc.pwrtelegram.xyz/). + +This is fully anonymous (except maybe IP) and is only used to improve the library +and developer experience for everyone working with MTProto. + +To enable, pass `enableErrorReporting: true` to the client options: + +```ts +const tg = new TelegramClient({ + ... + enableErrorReporting: true +}) +``` + +### Errors with parameters + +Some errors (like `FLOOD_WAIT_%d`) also contain a parameter. +This parameter is available as error's field (in this case in `.seconds` field) +after checking for error type using `.is()`: + +```ts +try { + // your code // +} catch (e) { + if (tl.RpcError.is(e, 'FLOOD_WAIT_%d')) { + await new Promise((res) => setTimeout(res, e.seconds)) + } else throw e +} +``` + + +## mtcute errors + +mtcute has a group of its own errors that are used to indicate +that the provided input is invalid, or that the server +returned something weird. + +All these errors are subclassed from `MtcuteError`: + +| Name | Description | Package | +|---|---|---| +| `MtArgumentError` | Some argument passed to the method appears to be incorrect in some way | core +| `MtSecurityError` | Something isn't right with security of the connection | core +| `MtUnsupportedError` | Server returned something that mtcute does not support (yet). Should not normally happen, and if it does, feel free to [open an issue](https://github.com/mtcute/mtcute/issues/new). | core +| `MtTypeAssertionError`| Server returned some type, but mtcute expected it to be another type. Usually means a bug on mtcute side, so feel free to [open an issue](https://github.com/mtcute/mtcute/issues/new). +| `MtTimeoutError` | Timeout for the request has been reached | core +| `MtPeerNotFoundError` | Only thrown by `resolvePeer`. Means that mtcute wasn't able to find a peer for a given `InputPeerLike`. | client +| `MtMessageNotFoundError` | mtcute hasn't been able to find a message by the given parameters | client +| `MtInvalidPeerTypeError` | mtcute expected another type of peer (e.g. you provided a user, but a channel was expected). | client +| `MtEmptyError` | You tried to access some property that is not available on the object | client + +## Client errors + +Even though these days internet is much more stable than before, +stuff like "Error: Connection reset" still happens. + +Also, there might be some client-level error that happened internally +(e.g. error while processing updates). + +You can handle these errors using `TelegramClient#onError`: + +```ts +const tg = new TelegramClient(...) + +tg.onError((err, conn) => { + if (conn) { + // `err` is the error + // `conn` is the connection where the error happened + console.log(err, conn) + } + + // `err` is not a connection-related error + console.log(err) +}) +``` + +::: tip +mtcute handles reconnection and stuff automatically, so you don't need to +call `.connect()` again! + +This should primarily be used for logging and debugging +::: + +## Dispatcher errors + +[Learn more in Dispatcher section](../dispatcher/errors.html). + +Unhandled errors that had happened inside dispatcher's handlers +can be handled as well: + +```ts +const dp = new Dispatcher() + +dp.onError((error, update, state) => { + console.log(error) + + // to indicate that the error was handled + return true +}) +``` + +Dispatcher errors are **local**, meaning that they only trigger +error handler within the current dispatcher, and do not propagate +to parent/children. They also stop propagation within this dispatcher. + +If there is no dispatcher error handler, but an error still occurs, +the error is propagated to `TelegramClient` (`conn` will be `undefined`). diff --git a/docs/guide/intro/faq.md b/docs/guide/intro/faq.md new file mode 100755 index 00000000..eeeb5e5d --- /dev/null +++ b/docs/guide/intro/faq.md @@ -0,0 +1,230 @@ +# FAQ + +> except the only person who has ever asked them was my alter ego + +Miscellaneous questions about the library and Telegram API as a whole +that don't really belong to any other topic. + +If you are new to the library or Telegram API, you can skip this page +for now and return later. + +## What is mtcute? + +mtcute is a TypeScript library and framework for MTProto clients and bots. + +It was written from scratch, however some logic was borrowed from TDLib and +similar projects like [Pyrogram](https://github.com/pyrogram/pyrogram) and +[Telethon](https://github.com/LonamiWebs/Telethon) + +## Can I use it with JavaScript? + +While you surely can, this is **not recommended**, since you would lose +any benefits TypeScript gives, including type checking and smart suggestions. + +## How old is mtcute? + +mtcute is pretty late to the party. Work on the library started +in early 2021, was first published on GitHub in spring, and the first +alpha release was published in the end of October 2023. + +## Why mtcute? + +First of all, apart from mtcute, there aren't many libraries for +MTProto in TypeScript (and even JS, for that matter). + +There's [@mtproto/core](https://github.com/alik0211/mtproto-core), which is extremely +low-level and not TS-friendly; there's [GramJS](https://github.com/gram-js/gramjs), +which is a port of Telethon, and pretty much nothing else (at the moment of writing). + +mtcute tries to provide a simple, elegant and type-safe API even for advanced +use cases, while also achieving good performance, staying up-to-date +with the current schema and providing near-complete documentation. + +## Why are API keys needed for bots? + +Because Telegram requires you to. Bot API internally is just a +TDLib instance running with its own API ID and hash for the connection. + +## Webhooks? + +Webhooks are only used by Bot API because there's no persistent connection +to the client to send updates. + +mtcute, on the other hand, has a persistent server connection, and receives +updates from Telegram. This is **not** polling, because the updates are +sent by Telegram, and not requested by the library. + +## What are the IPs of the DCs? + +The primary DC (and primary Test DC) is always available at +[my.telegram.org](https://my.telegram.org). + +List of other DCs is fetched by the client on demand. You can use +this code (it doesn't even require authorization): + + + +```ts +tg.call({ _: 'help.getConfig' }) + .then((res) => console.log(res.dcOptions)) +``` + +## How to migrate an account? + +You can't. + +Even though Telegram docs state that the server might decide to do that, +this feature was confirmed to be unimplemented yet by Levin (source unavailable). + +## Why does it work slower sometimes? + +Because of Telegram's infrastructure. + +Firstly, supergroups reside in the same DC as the (original) creator, +and if it is not the same as yours, it must first be +passed through that DC, which incurs some delay. + +In the worst case, you, creator and other user are all in different DCs, +and it takes some time before the client receives an update about that. + +The same goes for text-mentioning a user from another DC. It takes time for +the server to check access hash for that user since it is +stored in another DC, and thus sending a message takes more time. + +Another reason is that updates in supergroups are sent in order of priority +([source](https://docs.pyrogram.org/faq#why-is-my-client-reacting-slowly-in-supergroups), unverified): + +1. Creator +2. Administrators +3. Bots +4. Mentioned users +5. Recently online users +6. Everyone else + +This is **not** affected by the library, and we can't do anything about it. +This can also be reproduced in TDLib and Bot API. + +Your best bet on improving update latency is to: + - Use `openChat` method on the client to open chats you are interested in + - Periodically call `setOffline(false)` to tell Telegram that you are online + - Use an account in the same DC as the peer you are interacting with + +## Why do I get PEER_ID_INVALID/MtPeerNotFoundError? + +First, make sure that the ID you pass is actually correct. + +If it is, then probably the problem is that you haven't met this +peer in the current session. As described in [Peers section](../topics/peers.html), +you need access hash to interact with the user, which is only sent by the server. + +Think of how you find peers in normal clients - you search for usernames, +open them from dialogs, messages, members lists, etc. The same goes for +mtcute - you need to encounter the user before you can interact with them. + +Some ideas on how you can fix this: + - Use [`findDialogs`](https://ref.mtcute.dev/classes/_mtcute_core.highlevel_client.TelegramClient.html#findDialogs) method + to iterate over all dialogs and find the one you need + - Use a username/phone number instead of ID + - Use [`getMessages`](https://ref.mtcute.dev/classes/_mtcute_core.highlevel_client.TelegramClient.html#getMessages) method + and fetch some message by the user (so mtcute caches the access hash) + - ...and a lot more ways to "meet" a user without interacting with them + +### Why do I get "Peer ... is not found in local cache"? + +For a similar reason. In some cases, Telegram will send an [incomplete](../topics/peers.html#incomplete-peers) +peer object, which is not enough to interact with the user. + +mtcute tries its best to fill in the missing fields on demand +(whenever you use `.resolvePeer` or any other method that uses it under the hood), +but sometimes it unfortunately fails. There isn't much we can do about it :c + +By the way, there's an [`isPeerAvailable`](https://ref.mtcute.dev/classes/_mtcute_core.highlevel_client.TelegramClient.html#isPeerAvailable) +method that you can use to check if a peer is available, that *never* does any network requests. +Do note, however, that it is prone to false negatives, meaning that `resolvePeer` *might* still work +if that method returns `false`, but should always work if it returns `true`. + +## Why is my verification code expired? + +That's probably because you have sent it to someone. + +To protect users from potential scammers, Telegram checks if the +outgoing message contains the verification code, and if it does, +immediately revokes it. + +If you actually want to share it, consider somehow scrambling it, +for example: `12345` → `one two three four f1ve` + +## How to avoid flood errors? + +Write code with care, make less requests and do not abuse Telegram. + +Nobody knows the exact reason why flood waits occur, and that is intentional. +Publicly available information often contradicts with other, +so there is no definitive answer. + +You might find helpful information in +[this article](https://telegra.ph/So-your-bot-is-rate-limited-01-26). + +This might also be an issue with the library. Currently mtcute doesn't do a lot of caching, +though it is on the roadmap. Particularly, issues may arise if your bot is high-load. + +If you receive transport error -429, however, +please [let us know](https://t.me/mt_cute), so we can investigate further. + +## How to not get banned? + +Do not abuse Telegram. If you use the API for spamming, flooding, +faking counters or similar, you *will* be banned. + +Accounts created using unofficial API clients are automatically +put under observation to prevent violation of the ToS +([source](https://core.telegram.org/api/obtaining_api_id#using-the-api-id)). + +In some cases, even logging in with an unofficial client to an +account created using an official one may trigger the system + +If you are planning to implement an active userbot, be extra careful, +avoid using VOIP numbers and try to minimize server load generated +(for example, implement local caching and rate limiting). + +Anya from [@theyforcedme](https://t.me/theyforcedme) has shared her thoughts on this +in Pyrogram chat: + + + +## I was banned, help! + +The library only does the things you told it to do. If you abuse Telegram, +then the ban is justified. + +If you still have access to the phone number used when registering +the account, you can try contacting the Telegram support and ask them +to recover your account: +[recover@telegram.org](mailto:recover@telegram.org). + +If you don't, then welp. Create a new account and be *even more* careful. + + +## How to know when I'm banned + +When an account is banned, any network request will fail with `USER_DEACTIVATED_BAN`, +even simple ones like `getMe`. + +You can also use a [network middleware](../advanced/net-middlewares.md#errors-in-middlewares) +to set up a client-wide listener for this error: + +```ts +const tg = new TelegramClient({ + ..., + network: { + middlewares: [ + networkMiddlewares.onRpcError((ctx, error) => { + if (error.errorMessage === 'USER_DEACTIVATED_BAN') { + console.log('account was banned :c') + tg.close() + } + }), + networkMiddlewares.basic() + ] + } +}) \ No newline at end of file diff --git a/docs/guide/intro/mtproto-vs-bot-api.md b/docs/guide/intro/mtproto-vs-bot-api.md new file mode 100755 index 00000000..a32ec9b4 --- /dev/null +++ b/docs/guide/intro/mtproto-vs-bot-api.md @@ -0,0 +1,74 @@ +# MTProto vs. Bot API + +Unlike many existing libraries and frameworks for Telegram in +TypeScript/JavaScript, mtcute uses MTProto, and not Bot API. + +This allows mtcute to be much more flexible and support +many features that Bot API does not. + +## What is Bot API? + +[**Telegram Bot API**](https://core.telegram.org/bots/api), or simply +**Bot API**, is an HTTP(s) interface provided and hosted by Telegram +that allows developers to build bots. + +Under the hood, Bot API uses TDLib (which in turn uses MTProto API), +and simply provides HTTP methods that closely correlate with TDLib methods. + +## What is MTProto API? + +[MTProto](https://core.telegram.org/mtproto) is the custom protocol +invented by Nikolai Durov and his team, consisting of six ACM champions, +half of them Ph.Ds in math. It took them about two years +to roll out the current version of MTProto +([source](https://news.ycombinator.com/item?id=6916860)). +It is used to communicate between clients and Telegram servers. + +On the surface, MTProto API is basically an RPC, +where MTProto and TL are used behind the scenes +to serialize, encrypt and process the requests. +~~*Sounds like VK API with extra steps, right?*~~ + +## Why MTProto? + +MTProto clients (like mtcute) connect directly to Telegram, removing +the need to use additional transport layers like HTTP, polling or webhooks. +This means **less overhead**, since the data is sent directly to you, +and not passed through the Bot API server and then sent to you via HTTP: + + + +Apart from smaller overhead, using MTProto has many other advantages, including: + +| | Bot API | MTProto | +|---|---|---| +| Userbots | Only bots | Both bots and users. +| Files | 20 MB download, 50 MB upload. | No limits (except global limit of 2000 MB) +| Objects | Often brief and non-exhaustive | Exposes anything you can think of +| Updates | Only a limited subset | Updates about virtually anything that had happened +| Errors | Often non-informative (e.g. slow mode is the same as flood wait) and not machine-readable | Informative and simple to use +| Version | Receives updates slower | Updates with the TL layer +| Compatibility | Updates randomly, you have to prepare for the upcoming breaking changes.
Attempts to stay backwards-compatible, leading to weird hacks | You can stay on older version for as long as you need + +> **Note**: the above table assumes official Bot API instance, hosted at `api.telegram.org`. +> Self-hosted and/or custom Bot API instances bypass some of these limitations. + +## Why not MTProto? + +Everything has its drawbacks though. + +Using mtcute instead of Bot API (or TDLib) for high-load bots might currently +not be the best idea, since TDLib caches basically everything, while mtcute doesn't. +This is our primary focus for the upcoming releases, though. + +If your bot is high-load, and you receive errors like 500 and 429, this +definitely means a problem on mtcute side. Please +[let us know](https://t.me/mt_cute), so we can +investigate further. + +Another drawback is that due to mtcute being an enthusiast project, +it does not offer the same level of robustness and support as Bot API, +and is missing some features that Bot API has. \ No newline at end of file diff --git a/docs/guide/intro/sign-in.md b/docs/guide/intro/sign-in.md new file mode 100755 index 00000000..714adf36 --- /dev/null +++ b/docs/guide/intro/sign-in.md @@ -0,0 +1,222 @@ +# Signing in + +::: warning +Before we start, **please do not** use mtcute to abuse Telegram services +or harm other users by any means (including spamming, scraping, +scamming, etc.) + +Bots are meant to help people, not hurt them, so +**please** make sure you don't :) +::: + + +## API Keys + +Before you can use this library (or any other MTProto library, for that matter), +you need to obtain API ID and API Hash from Telegram: + +1. Go to [https://my.telegram.org/apps](https://my.telegram.org/apps) + and log in with your Telegram account +2. Fill out the form to create a new application + +::: tip +You can leave URL field empty. +App name and short name can (currently) be changed later. + +Note that you **will not** be able to create +another application using the same account. +::: + +3. Press *Create Application*, and you'll see your `api_id` and `api_hash`. + +:::warning +Be careful with your API hash. It can not be revoked. +::: + +## Signing in + +Now that we have got our API keys, we can sign in into our account: + +```ts +// Replace with your own values +const tg = new TelegramClient({ + apiId: API_ID, + apiHash: 'API_HASH' +}) + +const self = await tg.start({ + phone: () => tg.input('Phone > '), + code: () => tg.input('Code > '), + password: () => tg.input('Password > ') +}) +console.log(`Logged in as ${self.displayName}`) +``` + +::: tip +`tg.input` is a tiny wrapper over `readline` module in Node.js, +that will ask you for input in the console. + +It's not available in `@mtcute/core`, since it is platform-agnostic +::: + + +## Signing in as a bot + +You can also use mtcute for bots (created via [@BotFather](https://t.me/BotFather)). +You will still need API ID and Hash, though: + +```ts{10} +// Replace with your own values +const tg = new TelegramClient({ + apiId: API_ID, + apiHash: 'API_HASH' +}) + +const self = await tg.start({ + botToken: '12345678:0123456789abcdef0123456789abcdef' +}) +console.log(`Logged in as ${self.displayName}`) +``` + +## Storing your API ID and Hash + +In the examples above, we hard-coded the API keys. It works +fine, but it is better to not keep that kind of stuff in the code, +let alone publish them to public repositories. + +Instead, it is a good practice to use environment variables +and a `.env` that will contain them. +You can load it then using [dotenv-cli](https://npmjs.org/package/dotenv-cli): + +```bash +# .env +API_ID=123456 +API_HASH=0123456789abcdef0123456789abcdef +``` + +```ts +const tg = new TelegramClient({ + apiId: process.env.API_ID, + apiHash: process.env.API_HASH +}) +``` + +```bash +dotenv ts-node your-file.ts +``` + +## Using a proxy + +When using Node.js, you can also connect to Telegram through proxy. +This is particularly useful in countries like Iran or Russia, where +Telegram might be limited. + +To learn how to set up a connection through proxy, +refer to [Transport](../topics/transport.html#http-s-proxy-transport) documentation + +## Manual sign in + +So far we've only discussed the `.start` helper method. + +While they do provide some flexibility and convenience, they are not always +suitable for every use case. For example, when building a web application, +you might want to use a different way to ask for user input. + +In that case, you can use the underlying sign-in methods directly + + + +First, check if you are already signed in: + +```ts +async function checkSignedIn() { + try { + // Try calling any method that requires authorization + // (getMe is the simplest one and likely the most useful, + // but you can use any other) + return await tg.getMe() + } catch (e) { + if (tl.RpcError.is(e, 'AUTH_KEY_UNREGISTERED')) { + // Not signed in, continue + return null + } else { + // Some other error, rethrow + throw e + } + } +} +``` + +If you are not signed in, you should first use `.sendCode` method: + +```ts +// phone can be in pretty much any format, +// mtcute will automatically normalize it +const phone = '+7 999 123 4567' +const code = await tg.sendCode({ phone }) +``` + +The `code` object will contain [information about the sent code](https://ref.mtcute.dev/classes/_mtcute_core.index.SentCode.html), +including the `phoneCodeHash` that you will need to use later. + +Now, you need to ask the user for the code and call `.signIn` method: + +```ts +const code = '12345' // code from user input + +const user = await tg.signIn({ + phone, + phoneCodeHash: code.phoneCodeHash, + phoneCode: code +}) +``` + +This method may either return right away, or throw one of: + - `SESSION_PASSWORD_NEEDED` if the account has 2FA enabled, and you should [enter the password](#handling-2fa) + - `PHONE_CODE_INVALID` if the code entered was invalid + - `PHONE_CODE_EXPIRED` if the code has expired, and you should [resend it](#resending-code) + +### Handling 2FA + +If the account has 2FA enabled, you will need to ask the user for the password, and then call `.checkPassword`: + +```ts +const password = 'hunter2' // password from user input + +const user = await tg.checkPassword(password) +``` + +In case of invalid password, this method will throw `PASSWORD_HASH_INVALID`. + +### Resending code + +In some cases, the code may expire before the user enters it, or it may never arrive, +so you may want to resend it. To do that, you can use `.resendCode` method: + +```ts +code = await tg.resendCode({ + phone, + phoneCodeHash: code.phoneCodeHash +}) +``` + +::: tip +You can know beforehand the type of the next code that will be sent +using the [`code.nextType`](https://ref.mtcute.dev/classes/_mtcute_core.index.SentCode.html#nextType) field +::: + +### Updates + +If you want the updates to be processed, you should manually use `startUpdatesLoop` once you are signed in: + +```ts +if (await checkSignedIn()) { + tg.startUpdatesLoop() +} else { + // some sign in logic + + tg.startUpdatesLoop() +} +``` + +`tg.start` method does this automatically for you. \ No newline at end of file diff --git a/docs/guide/intro/updates.md b/docs/guide/intro/updates.md new file mode 100755 index 00000000..1ab14383 --- /dev/null +++ b/docs/guide/intro/updates.md @@ -0,0 +1,171 @@ +# Getting updates + +## What is an Update? + +Updates are events that happen in Telegram and sent +to the clients. This includes events about a new message, joined chat member, +inline keyboard callbacks, etc. + +In a bot environment, a [Dispatcher](/guide/dispatcher/intro.html) is normally used to handle the updates. + +### Don't need them? + +Sometimes, you might not really need any updates. +Then, just disable them in `TelegramClient` parameters: + +```ts +const tg = new TelegramClient({ + // ... + disableUpdates: true +}) +``` + +## Setting up + +The parameters themselves will be explained a bit below, for now let's just focus on how they are passed. + +`TelegramClient` has updates handling turned on by default, and you can configure it using `updates` parameter + +```ts +const tg = new TelegramClient({ + // ... + updates: { + messageGroupingInterval: 250, + catchUp: true + } +}) +``` + +The updates themselves are dispatched on the client as events (see [reference](https://ref.mtcute.dev/classes/_mtcute_core.index.TelegramClient.html#on)): +```ts +tg.on('new_message', (msg) => { + console.log(msg.text) +}) + +// You can also handle any supported update at once: +tg.on('update', (upd) => { + if (upd.name === 'new_message') { + console.log(upd.data.text) + } +}) + +// As well as raw MTProto updates: +tg.on('raw_update', (upd, users, chats) => { + console.log(upd._) +}) +``` + +::: tip +Client events are based on EventEmitter. It expects handlers to be synchronous, +so if you want to do something async, make sure to also handle the errors: + +```ts +tg.on('new_message', async (msg) => { + try { + await msg.answerText('test') + } catch (e) { + console.error(e) + } +}) +``` +::: + +### Missed updates + +When your client is offline, updates are still stored by Telegram, +and can be fetched later (client "catches up" with the updates). + +When back online, mtcute may "catch up", fetch any missed updates and +process them. To do that, pass `catchUp: true` parameter as shown above: + +### Message grouping + +As you may already know, albums handling in Telegram is not very trivial, as they are sent by the server +as separate messages. To make it easier to handle them, you may opt into grouping them automatically. + +To do that, pass `messageGroupingInterval` as shown above. It is a number of milliseconds to wait +for the next message in the album. If the next message is not received in that time, the album is +considered complete and dispatched as a single `message_group` object. + +The recommended value is `250` ms. + +::: warning +This **will** introduce delays of up to `messageGroupingInterval` ms for every message with media groups, +and may sometimes break ordering. Use with caution. +::: + +## Opening chats + +For Telegram to properly send updates for channels (e.g. for channels that you are not a member of, +and for more consistent updates for channels that you are a member of), you need to open them first. + +This is done by calling `openChat` method: + +```ts +await tg.openChat('durov') +``` + +Once you're done, you can close the chat by calling `closeChat`: + +```ts +await tg.closeChat('durov') +``` + +::: danger +Opening a chat with `openChat` method will make the library make additional requests every so often. + +Which means that you should **avoid opening more than 5-10 chats at once**, as it will probably trigger +server-side limits and you might start getting transport errors or even get banned. + +If missing *some* updates from *some* channels (or having them arrive a bit late) is acceptable for you, +you might want to consider not opening them at all. You will still receive updates for channels +you are a member of, but they might be delayed. +::: + +## Dispatcher + +Dispatcher is a class that dispatches client events to registered handlers, +while also filtering and propagating them as needed. + +You can think of it as some sort of framework that allows you to do everything +more declaratively and handles most of the boilerplate related to state. + +Dispatcher is provided by `@mtcute/dispatcher` package. + +### Registering + +Dispatcher is quite a powerful thing, and we will explore it in-depth in +a separate section. For now, let's just register a dispatcher and add a simple handler: + +```ts +const tg = new TelegramClient(...) +const dp = new Dispatcher(tg) + +dp.onNewMessage(async (msg) => { + await msg.forwardTo('me') +}) + +await tg.start() +``` + +Pretty simple, right? We have registered a "new message" handler and made +it forward any new messages to "Saved Messages". + +### Filters + +Example above is pretty useless in real world, though. Most of the +time you will want to *filter* the events, and only react to some of them. + +Let's make our code only handle messages containing media: + +```ts +dp.onNewMessage( + filters.media, + async (msg) => { + await msg.forwardTo('me') + } +) +``` + +Filters can do a lot more than that, and +we will cover them further [later](../dispatcher/filters.html). diff --git a/docs/guide/topics/conversation.md b/docs/guide/topics/conversation.md new file mode 100755 index 00000000..39951c45 --- /dev/null +++ b/docs/guide/topics/conversation.md @@ -0,0 +1,126 @@ +# Conversation + +A conversation is an object that represents some chat and all messages +inside it since the start of the conversation until it is stopped. + + + +::: warning +**DO NOT** use conversations to interact with users. Conversations are +designed to be used in a one-shot fashion, and will not work properly when used with users, +as they don't remember any state. + +This may change in the future, but for now you should use [scenes](/guide/dispatcher/scenes) instead. +::: + +## Usage + +Create a `Conversation` object and pass `TelegramClient` and the peer you want +there: + +```ts +const conv = new Conversation(tg, 'stickers') +``` + +Then, use `.with()`, and inside it you can interact with the other party: + +```ts +await conv.with(async () => { + await conv.sendText('Hello!') + await conv.waitForResponse() +}) +``` + +::: tip +`.with()` is a simple wrapper that automatically calls +`.start()` and `.stop()` for you, essentially this: + +```ts +await conv.start() +try { + // ... code ... +} catch (e) {} +conv.stop() +``` + +Calling `.stop()` is vitally important, failing to do so *will* lead +to memory leaks, so use `.with()` whenever possible. +::: + +## Sending messages + +To send messages, use `conv.sendText`, `conv.sendMedia` and `conv.sendMediaGroup` +methods: + +```ts +await conv.sendText('Hello!') +await conv.sendMedia('BQACAgEAAx...Z2mGB8E') +await conv.sendMediaGroup(['BQACAgEAAx...Z2mGB8E', 'BQACAgEAAx...Z2mGB8E']) +``` + +**DO NOT** use client methods like `tg.sendText`, because conversation state +won't properly be updated. + +## Waiting for events + +Currently, `Conversation` supports waiting for new messages, edits and read +acknowledgments: + +```ts +await conv.sendText('Hello!') +await conv.waitForNewMessage() + +await conv.sendText('Hello!') +await conv.waitForRead() + +await conv.sendText('Hello!') +await conv.waitForNewMessage() +await conv.sendText('Now edit that') +await conv.waitForEdit() +``` + +### Smarter waiting + +Instead of `waitForNewMessage`, you can use `waitForResponse` +or `waitForReply`. + +`waitForResponse` will wait for a message which was sent strictly +after the given message (by default, the last one) + +`waitForReply` will wait for a message which is a reply to the +given message (by default, the last one) + +### Timeouts + +By default, for every `waitFor*` method a timeout of 15 seconds is applied. +If the event does not occur within those, `MtTimeoutError` is thrown. + +This is a pretty generous default when interacting with bots. +However, if you are interacting with other people (which you shouldn't, use +[scenes](/guide/dispatcher/scenes) instead), you may want to raise the timeout or +disable it altogether. + +To do that, you can pass `timeout` parameter to the functions: + +```ts +await conv.waitForNewMessage(filters.any, 60_000) // 60 sec timeout +await conv.waitForResponse(filters.any, { timeout: null }) // disable timeout +``` + +### Filters + +You can apply dispatcher filters to the `waitFor*` methods: + +```ts +await conv.waitForNewMessage(filters.regex(/welcome/i)) +``` + +Since dispatcher filters are simple functions, you can also use custom filters: + +```ts +await conv.waitForResponse((msg) => msg.id > 42) +``` + +If a newly received message (or other update) does not match the filter, +it will be ignored. diff --git a/docs/guide/topics/files.md b/docs/guide/topics/files.md new file mode 100755 index 00000000..efd84bff --- /dev/null +++ b/docs/guide/topics/files.md @@ -0,0 +1,261 @@ +# Files + +Working with files and media is important in almost any bot, +and mtcute makes it very simple. + +## Downloading files + +To download a file, just use `downloadIterable`, `downloadStream`, +`downloadBuffer`or `downloadToFile` method on the object that represents a file, for example: + +```ts +tg.on('new_message', async (msg) => { + if (msg.media?.type === 'photo') { + await tg.downloadToFile('download.jpg', msg.media) + } +}) +``` + +Or in case you don't have the object, you can download a file +by its [File ID](#file-ids): + +```ts +await tg.downloadToFile('download.jpg', 'AgACAgEAAxkBA...33gAgABHwQ') +``` + +::: tip +`downloadToFile` is only available for Node.js. +Other methods are environment agnostic. +::: + +## Uploading files + +In Telegram, files are primarily used to back a media +(a photo, a chat avatar, a document, etc.) + +Client methods (like `setProfilePhoto`) accept `InputFileLike`, which is +a type containing information on how to upload a certain file, or +to re-use an existing file (see [File IDs](#file-ids)). + +`InputFileLike` can be one of: + - `Buffer`, in this case contents of the buffer will be uploaded as file + - Readable stream (both.js and Web are supported) + - `File` from the Web API + - `Response` from `window.fetch` or `node-fetch` + - `UploadedFile`, see [Uploading files manually](#uploading-files-manually) + - `string` with URL: `https://example.com/file.jpg` (only supported sometimes) + - `string` with file path (only Node.js): `file:path/to/file.jpg` (note the `file:` prefix) + - `string` with [File ID](#file-ids) + +```ts +await tg.setProfilePhoto('photo', Buffer.from(...)) +await tg.setProfilePhoto('photo', fs.createReadableStream('assets/renge.jpg')) +await tg.setProfilePhoto('photo', await fetch('https://nyanpa.su/renge.jpg')) +await tg.setProfilePhoto('photo', await tg.uploadFile(...)) +await tg.setProfilePhoto('photo', 'file:assets/renge.jpg') +await tg.setProfilePhoto('photo', 'BQACAgEAAx...Z2mGB8E') + +// setProfilePhoto and some other methods don't support URLs (TG limitation) +// await tg.setProfilePhoto('photo', 'https://nyanpa.su/renge.jpg') +``` + +## Sending media + +As mentioned earlier, most of the time file is used as a media in a Message. +Sending media is incredibly easy with mtcute - you simply +call `sendMedia` and provide `InputMediaLike`. + +`InputMediaLike` can be constructed manually, or using one of the +builder functions exported in [`InputMedia` namespace](https://ref.mtcute.dev/modules/_mtcute_core.index.InputMedia.html): + +```ts +await tg.sendMedia('me', InputMedia.photo('file:assets/welcome.jpg')) + +await tg.sendMedia('me', InputMedia.auto('BQACAgEAAx...Z2mGB8E', { + caption: 'Backup of the project' +})) + +// when using InputMedia.auto with file IDs and not using +// additonal parameters like `caption`, you can simply pass it as a string +await tg.sendMedia('me', 'CAADAgADLgZZCKNgg2JpAg') +``` + +First argument is `InputFileLike`, so you can use +[any supported type](#files) when using it. + +Using `InputMedia` instead of separate methods (like in Bot API) allows easily +switching to `sendMediaGroup`: + +```ts +await tg.sendMediaGroup('me', [ + InputMedia.photo('file:assets/welcome.jpg'), + InputMedia.auto('AgACAgEAAxkBA...6D2AgABHwQ'), + 'AgACAgEAAxkBA...33gAgABHwQ', +]) +``` + +::: tip +Even though you *technically can* pass media groups +of different types, do not mix them up (you can mix photos and videos), +since that would result in a server-sent error. +::: + +## File IDs + +::: tip +File IDs are implemented in `@mtcute/file-id` package, which +can be easily used outside of mtcute. +::: + +If you ever worked with the Bot API, you probably already know what File ID +is. If you don't, it is a unique string identifier that is used to represent +files already stored on Telegram servers. + +It comes in very handy when dealing with files programmatically, since +storing a simple string is much easier than storing a lot of different +TL objects, parsing them, normalizing, converting, etc. + +mtcute File IDs are a port of TDLib's File IDs (which are also used in Bot API), +which means they are **100% interoperable** with TDLib and Bot API. + +They do have some limitations though: + - You can't get a File ID until you upload a file and use it somewhere, e.g. + as a message media (or you can use [uploadMedia](https://ref.mtcute.dev/classes/_mtcute_core.highlevel_client.TelegramClient.html#uploadMedia)). + - When sending by File ID, you can't change type of the file + (i.e. if this is a video, you can't send it as a document) or any meta + information like duration. + - File ID cannot be used for thumbnails. + - When sending a photo by File ID of one of its sizes, all sizes will be re-used. + - File ID is unique per-user and can't be used on another account + (however, some developers seem to bypass this with userbots). + - For user accounts, File ID expire (after ~24-48 hours). + - The same file may have different *valid* File IDs. + +::: details Why is this? +All of the above points can be explained fairly easily, but to understand +them you'll need to have some understanding of how files in MTProto work. + + - File ID contains document/photo ID and file reference, which are not + available until that file is uploaded and used somehow, or + `messages.uploadMedia` method is used. + - Photos and documents are completely different types in MTProto. + As for documents, when using `inputDocument`, Telegram [does not allow](https://corefork.telegram.org/constructor/inputDocument) + setting new document attributes, and re-uses the attributes from the + original document. They contain file type (video/audio/voice/etc), + file name, duration (for video/audio), and so on. + - For thumbnails, Telegram [requires](https://corefork.telegram.org/constructor/inputMediaUploadedDocument) + clients to use `InputFile`, which can only contain newly uploaded files. + - When sending photos, File ID is transformed to [inputPhoto](https://corefork.telegram.org/constructor/inputPhoto), + which does not contain information about sizes. When downloading, however, + it is transformed to [inputPhotoFileLocation](https://corefork.telegram.org/constructor/inputPhotoFileLocation), + which does contain `thumbSize` parameter + - File IDs contain what is known as a File reference, which is unique per-user. + It seems that bots can sometimes use any file reference for files that + they have recently encountered, but this is pretty unreliable and should not be used. + - File reference [may expire](https://core.telegram.org/api/file_reference), + making the File ID unusable. Telegram claims that this does not happen to bots though + ([source](https://core.telegram.org/bots/faq#can-i-count-on-file-ids-to-be-persistent)) + - File reference or File ID format might change over time. +::: + +File ID is available in `.fileId` field: + +```ts +tg.on('new_message', async (msg) => { + if (msg.media?.type === 'photo') { + console.log(msg.media.fileId) + } +}) +``` + +### Unique File ID + +This is also a concept ported from TDLib. It is similar to +File ID, but built in such a way that it uniquely defines +some file, i.e. the same file always has the same Unique File ID, +and different files have different Unique File IDs. + +It can't be used to download a file, but it is the same +for different users/bots. + +Unique File ID is available in `.uniqueFileId` field: + +```ts +tg.on('new_message', async (msg) => { + if (msg.media?.type === 'photo') { + console.log(msg.media.uniqueFileId) + } +}) +``` +## Uploading files manually + +::: tip +This method is rarely needed outside mtcute, simply +because most of the methods already handle uploading +automatically, and for Raw API you can use +`normalizeInputFile` and `normalizeInputMedia` +::: + +To upload files, Client provides a simple method `uploadFile`. +It has a bunch of options, but the only required one is `file`. + +### Uploading a local file + +To upload a local file from Node.js, you can either provide file path, +or a readable stream (note that here you don't need `file:` prefix): + +```ts +await tg.uploadFile({ file: 'assets/renge.jpg' }) +// or +await tg.uploadFile({ file: fs.createReadStream('assets/renge.jpg') }) +``` + + +### Uploading from `Buffer` + +To upload a `Buffer` as a file, simply pass it as `file`: + +```ts +const data = Buffer.from(...) +await tg.uploadFile({ file: data }) +``` + +### Uploading from stream + +To upload from a stream, pass it as `file`, and provide file size +whenever possible: + +```ts +await tg.uploadFile({ + file: stream, + fileSize: streamExpectedLength +}) +``` + +### Uploading from the Internet + +To upload a file from the Internet, you can use `window.fetch` +and simply pass the response object: + +```ts +await tg.uploadFile({ file: await fetch('https://nyanpa.su/renge.jpg') }) +``` + +If you are using some other library for HTTP(S), it probably also supports +returning streams, but you'll have to extract meta from the response object +manually. Rough example for [axios](https://npmjs.com/package/axios): + +```ts +async function uploadFileAxios(tg: TelegramClient, config: AxiosRequestConfig) { + const response = await axios({ ...config, responseType: 'stream' }) + return tg.uploadFile({ + file: response.data, + fileSize: parseInt(response.headers['content-length'] || 0), + fileMime: response.headers['content-type'], + // fileName: ... + }) +} + +const file = await uploadFileAxios(tg, { url: 'https://nyanpa.su/renge.jpg' }) +await tg.sendMedia('me', InputMedia.photo(file)) +``` diff --git a/docs/guide/topics/inline-mode.md b/docs/guide/topics/inline-mode.md new file mode 100755 index 00000000..7b669ad9 --- /dev/null +++ b/docs/guide/topics/inline-mode.md @@ -0,0 +1,55 @@ +# Inline mode + +Users can interact with bots using inline queries, by starting +a message with bot's username and then typing their query. + +## Implementing inline mode + +First, you'll need to enable inline mode in [@BotFather](https://t.me/botfather), +either in `/mybots` or with `/setinline` + +Then, you can use Dispatcher to [implement inline mode](../dispatcher/inline-mode.html) +for your bot. + +Instead of Dispatcher, you can also use client events (however you will miss +features that Dispatcher provides): + +```ts +tg.on('inline_query', async (query) => { + await query.answer([]) +}) +``` + +## Using inline mode + +As a user, you can use inline mode just like with a normal client. + +It is currently not implemented as a Client method, but you can use +[Raw API](raw-api.html): + +```ts +const chat = await tg.resolvePeer('me') + +const results = await tg.call({ + _: 'messages.getInlineBotResults', + bot: toInputUser(await tg.resolvePeer('music'))!, + peer: chat, + query: 'vivaldi', + offset: '' +}, { throw503: true }) +``` + +Then, for example, to send the first result: + +```ts +const first = results.results[0] + +const res = await tg.call({ + _: 'messages.sendInlineBotResult', + peer: chat, + randomId: randomLong(), + queryId: results.queryId, + id: first.id +}) +tg.handleClientUpdate(res, true) +``` diff --git a/docs/guide/topics/keyboards.md b/docs/guide/topics/keyboards.md new file mode 100755 index 00000000..5bc38b4b --- /dev/null +++ b/docs/guide/topics/keyboards.md @@ -0,0 +1,301 @@ +# Keyboards + +You probably already know what a keyboard is, and if you +don't, check the [Bots documentation](https://core.telegram.org/bots#keyboards) +by Telegram. + +## Sending a keyboard + +When developing bots, a common feature that many developers use +is sending custom keyboards to their users - be it inline or reply. + +In both cases, this is done by providing `replyMarkup` parameter +when using `sendText` or similar methods. It accepts plain +JavaScript object, but you can also use builder functions +from `BotKeyboard` namespace. + +In mtcute, buttons are represented as a two-dimensional array. + +## Reply keyboards + +Reply keyboard is a keyboard that is shown under user's writebar. +When user taps on some button, a message is sent containing this +button's text. + + + +```ts +await tg.sendText('username', 'Awesome keyboard!', { + replyMarkup: BotKeyboard.reply([ + [BotKeyboard.text('First button')], + [BotKeyboard.text('Second button')], + ]) +}) +``` + +You can only use the following button types with reply keyboards: + +| Name | Type | Notes | +| ------------------- | ---------------------------- | ----------------------- | +| Text-only | `BotKeyboard.text` | | +| Request contact | `BotKeyboard.requestContact` | only for private chats. | +| Request geolocation | `BotKeyboard.requestGeo` | only for private chats. | +| Request poll | `BotKeyboard.requestPoll` | only for private chats. | + +Using any other will result in an error by Telegram. + +You can also instruct the client to hide a previously +sent reply keyboard: + +```ts +await tg.sendText('username', 'No more keyboard :p', { + replyMarkup: BotKeyboard.hideReply() +}) +``` + +Or, ask the user to reply to this message with custom text: + +```ts +await tg.sendText('username', 'What is your name?', { + replyMarkup: BotKeyboard.forceReply() +}) +``` + + +## Inline keyboards + +Inline keyboard is a keyboard that is shown under the message. +When user taps on some button, a client does some action that particular +button instructs it to do. + + + +```ts +await tg.sendText('username', 'Awesome keyboard!', { + replyMarkup: BotKeyboard.inline([ + [BotKeyboard.callback('First button', 'btn:1')], + [BotKeyboard.callback('Second button', 'btn:2')], + ]) +}) +``` + +You can only use the following button types with inline keyboards: + +| Name | Type | Notes | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------- | +| Callback | `BotKeyboard.callback` | When clicked a callback query will be sent to the bot. | +| URL | `BotKeyboard.url` | When clicked the client will open the given URL. | +| Switch inline | `BotKeyboard.switchInline` | When clicked the client will open an inline query to this bot with the given query. | +| "Play game" | `BotKeyboard.game` | Must be the first one, must be used with `InputMedia.game` as the media. | +| "Pay" | `BotKeyboard.pay` | Must be the first one, must be used with `InputMedia.invoice` as the media. | +| Seamless login | `BotKeyboard.urlAuth` | [Learn more](https://corefork.telegram.org/constructor/inputKeyboardButtonUrlAuth) | +| WebView | `BotKeyboard.webView` | [Learn more](https://corefork.telegram.org/api/bots/webapps) | +| Open user | `BotKeyboard.userProfile` | When clicked the client will open the given user's profile | +| Request peer | `BotKeyboard.requestPeer` | When clicked the client will ask the user to choose a peer and will send a message with [`ActionPeerChosen`](https://ref.mtcute.dev/interfaces/_mtcute_core.index.ActionPeerChosen.html) | + +Using any other will result in an error by Telegram. + +## Keyboard builder + +Sometimes 2D array is a bit too low-level, and thus mtcute provides an +easy-to-use builder for the keyboards. + +Once created using `BotKeyboard.builder()`, you can `push` buttons there, +and then get it either `asInline` or `asReply`: + +```ts +const markup = BotKeyboard.builder() + .push(BotKeyboard.text('Button 1')) + .push(BotKeyboard.text('Button 2')) + .asReply() + +// Result: +// [ Button 1 ] +// [ Button 2 ] +``` + +You can also push a button conditionally, or even use a function: + +```ts +const markup = BotKeyboard.builder() + .push(BotKeyboard.text('Button 1')) + .push(isAdmin && BotKeyboard.text('Button 2')) + .push(() => BotKeyboard.text('Button 3')) + .asReply() + +// Result: +// [ Button 1 ] +// [ Button 2 ] (only if admin) +// [ Button 3 ] +``` + +When `push`-ing multiple buttons at once, they will be wrapped after a certain +number of buttons added (default: 3): + +```ts +const markup = BotKeyboard.builder() + .push( + BotKeyboard.text('Button 1'), + BotKeyboard.text('Button 2'), + BotKeyboard.text('Button 3'), + BotKeyboard.text('Button 4'), + ) + .asReply() + +// Result: +// [ Button 1 ] [ Button 2 ] [ Button 3 ] +// [ Button 4 ] +``` + +Or, you can add entire rows at once without them getting wrapped +(and even populate them from a function!): +```ts +const markup = BotKeyboard.builder() + .row( + BotKeyboard.text('1'), + BotKeyboard.text('2'), + BotKeyboard.text('3'), + BotKeyboard.text('4'), + ) + .row((row) => { + for (let i = 5; i <= 8; i++ ) { + row.push(BotKeyboard.text(`${i}`)) + } + }) + .asReply() + +// Result: +// [ 1 ] [ 2 ] [ 3 ] [ 4 ] +// [ 5 ] [ 6 ] [ 7 ] [ 8 ] +``` + +## Callback data builders + +Writing, parsing and checking callback data manually gets tiring +quite fast. Luckily, mtcute provides a tool that does the heavy stuff for you, +called Callback data builder. + + + +### Creating a builder + +Consider a simple bot that has some posts to display to user, +and the user can switch between them using inline buttons. + +First, let's declare a builder for the button: + +```ts +const PostButton = new CallbackDataBuilder('post', 'id', 'action') +``` + +Here, `post` is the *prefix*, which will be prepended to all callback +data strings generated by this builder to disambiguate. Make sure to use +something unique! + +`id` and `action` are *fields* which will be parsed/serialized to the callback +data string in that particular order. Only include important stuff there, since +callback data is limited to 64 characters! + +::: tip +Callback data builders are meant to be static, so it is best +to declare them in a separate file and import from other files. +::: + +### Creating buttons + +Now that we have the builder, we can use `.build` method to add buttons +to the messages: + +```ts +await msg.answerText('...', { + replyMarkup: BotKeyboard.inline([ + [ + BotKeyboard.callback( + 'Post title', + PostButton.build({ id: 1, action: 'view' }) + ) + ] + ]) +}) +``` + +The above code will produce the following callback data in that button: + +``` +post:1:view +``` + +### Handling clicks + +Our button is currently rather useless, since we haven't registered +a handler for it just yet. We can use `.filter` method of our builder +to create a filter to suit our needs: + +```ts +dp.onCallbackQuery(PostButton.filter({ action: 'view' }), async (upd) => { + const post = await getPostById(upd.match.id) + if (!post) { + await upd.answer({ text: 'Not found!' }) + return + } + + await upd.editMessage({ + text: post.text + }) +}) +``` + +`.filter` not only handles parsing and checking, but also provides +`.match` extension field that contains the parsed data, and you can use it +inside your code. + +## Using a keyboard + +When using mtcute as a client, you may want to use some +keyboard that was attached to some message. + +```ts +dp.onNewMessage(async (msg: Message) => { + const markup = msg.markup + + switch (markup.type) { + // see below + } +}) +``` + +If type is `hide_reply`, there is (obviously) nothing to do except +to hide the current reply keyboard from the UI (if applicable). + +If type is `force_reply`, just send a message in reply to this message: + +```ts +await msg.replyText('Some text') +``` + +If type is `reply` or `inline`, then there are some buttons available. +You can find the one you need, and then act accordingly: + +```ts +const buttons = markup.buttons +const buttonINeed = BotKeyboard.findButton(buttons, 'Button text') + +switch (buttonINeed._) { + // see below +} +``` + +`buttonINeed` will be a plain TL object of type +[KeyboardButton](https://corefork.telegram.org/type/KeyboardButton). + +### Emulating a click + +See [Telegram docs](https://core.telegram.org/api/bots/buttons#pressing-buttons) on this topic. diff --git a/docs/guide/topics/parse-modes.md b/docs/guide/topics/parse-modes.md new file mode 100755 index 00000000..39112a96 --- /dev/null +++ b/docs/guide/topics/parse-modes.md @@ -0,0 +1,107 @@ +# Parse modes + +You may be familiar with parse modes from the Bot API. Indeed, +the idea is pretty much the same - parse mode defines the syntax to use +for formatting entities in messages. + +However, there's a major difference – in mtcute, the client doesn't know anything about +how the parse modes are implemented. Instead, it just accepts an object containing +the `text` and `entities` fields, and sends it to the server: + +```ts +await tg.sendText('self', { + text: 'Hi, User!', + entities: [ + { _: 'messageEntityBold', offset: 4, length: 4 } + ] +}) +``` + +Of course, passing this object manually is not very convenient, +so mtcute provides a set of *parsers* that can be used to convert +a string with entities to this structure. + +For convenience, mtcute itself provides two parsers – for Markdown and HTML. +They are both implemented as separate packages, and they themselves are tagged template literals, +which makes it very easy to interpolate variables into the message. + + +## Markdown + +Markdown parser is implemented in `@mtcute/markdown-parser` package: + +```ts +import { md } from '@mtcute/markdown-parser' + +dp.onNewMessage(async (msg) => { + await msg.answerText(md`Hello, **${msg.sender.username}**`) +}) +``` + +**Note**: the syntax used by this parser is **not** compatible +with Bot API's Markdown or MarkdownV2 syntax. +See [documentation](https://ref.mtcute.dev/modules/_mtcute_markdown_parser.html) +to learn about the syntax. + +## HTML + +HTML parser is implemented in `@mtcute/html-parser` package: + +```ts +import { html } from '@mtcute/html-parser' + +dp.onNewMessage(async (msg) => { + await msg.answerText(html`Hello, ${msg.sender.username}`) +}) +``` + +**Note**: the syntax used by this parser is **not** +compatible with Bot API's HTML syntax. +See [documentation](https://ref.mtcute.dev/modules/_mtcute_html_parser.html) +to learn about the syntax. + +## Interpolation + +Both parsers support interpolation of variables into the message, +as can be seen in the examples above. + +Both parsers support the following types of interpolation: +- `string` - **will not** be parsed, and appended to plain text as-is +- `number` - will be converted to string and appended to plain text as-is +- `TextWithEntities` or `MessageEntity` - will add the text and its entities to the output. + This is the type returned by `md` and `html` themselves, so you can even mix and match them: + ```ts + const greeting = (user) => html`${user.displayName}` + const text = md`**Hello**, ${user}!` + ``` +- falsy value (i.e. `null`, `undefined`, `false`) - will be ignored + +### Unsafe interpolation + +In some cases, you may already have a string with entities, and want to parse it to entities. + +In this case, you can use the method as a function: + +```ts +const text = 'Hello, **User**!' + +await tg.sendText('self', md(text)) +// or even +await tg.sendText('self', md`${md(text)} What's new?`) +``` + +## Un-parsing + +Both HTML and Markdown parsers also provide an `unparse` method, +which can be used to convert the message back to the original text: + +```ts +import { html } from '@mtcute/html-parser' + +const msg = await tg.sendText('Hi, User!', { parseMode: 'html' }) + +console.log(msg.text) +// Hi, User! +console.log(html.unparse()) +// Hi, User! +``` diff --git a/docs/guide/topics/peers.md b/docs/guide/topics/peers.md new file mode 100755 index 00000000..de2ed8c7 --- /dev/null +++ b/docs/guide/topics/peers.md @@ -0,0 +1,258 @@ +# Peers + +One of the most important concepts in MTProto is the "peer". +Peer is an object that defines a user, a chat or a channel, and +is widely used within the APIs. + +## What is a Peer? + +In MTProto, there are 2 types representing a peer: +[Peer](https://core.telegram.org/type/Peer) and [InputPeer](https://core.telegram.org/type/InputPeer) + +[Peer](https://core.telegram.org/type/Peer) +defines the peer type (user/chat/channel) and its ID, and is usually +returned by the server inside some other object (like Message). + +[InputPeer](https://core.telegram.org/type/InputPeer) +defines the peer by providing its type, ID and access hash. +Access hashes are a mechanism designed to prevent users from accessing +peers that they never met. These objects are mostly used when sending +RPC queries to Telegram. + +> There are also `InputUser` and `InputChannel` that are used +> to prevent clients from passing incorrect peers +> (e.g. restricting a user in a legacy group chat). +> +> They are basically the same, and you should only care about +> them when using Raw APIs, so we'll skip them for now. + +::: tip +In MTProto, you'll often see `InputSomething` and `Something` types. + +This simply means that the former should be used when making requests, +and the latter is sent by the server back. +::: + +## Chats and Channels + +As you may have noticed, in MTProto there are only three types of peers: +users, chats and channels. However, things are not as simple as you may imagine, +so let's dive a bit deeper. + +**[Chat](https://core.telegram.org/class/chat)** is a legacy group. The one which is +created by default when you use "Create group" button in official clients. +Official clients refer to them as "Groups" + +**[Channel](https://core.telegram.org/class/channel)** is anything that is not a user, +nor a legacy group. Supergroups, actual broadcast channels and broadcast groups +are all represented in MTProto as a **Channel** with a different set of flags: + - A **broadcast channel** is a [Channel](https://core.telegram.org/class/channel) where `.broadcast === true` + - A **supergroup** (also referred to as megagroup) is a + [Channel](https://core.telegram.org/class/channel) where `.megagroup === true` + - A **forum** is a supergroup where `.forum === true` + - A **broadcast group** (also referred to as gigagroup) is a + [Channel](https://core.telegram.org/class/channel) where `.gigagroup === true`. + They are basically a **supergroup** where default permissions disallow + sending messages and cannot be changed ([src](https://t.me/tdlibchat/15164)). + +Official clients only use "Channel" when referring to *broadcast channels*. + +Chats are still used *(probably?)* because they are enough for most people's +needs, and are also lighter on server resources. However, chats are missing +many important features for public communities, like: usernames, +custom admin rights, per-user restrictions, event log and more. + +Official clients silently "migrate" legacy groups to supergroups +(actually channels) whenever the user wants to use a feature not supported +by the Chat, like setting a username. Channel cannot be migrated back to Chat. + +### Chat in mtcute + +In addition to the mess described above, mtcute also has a [Chat](https://ref.mtcute.dev/classes/_mtcute_core.index.Chat.html) type. +It is used to represent anything where there can be messages, +including users, legacy groups, supergroups, channels, etc. + +It is mostly inspired by [Bot API's Chat](https://core.telegram.org/bots/api#chat) +type, and works in a very similar way. + +## Fetching peers + +Often, you'll need to interact with a peer later, when the respective +`User` or `Chat` object is no longer available. + +Of course, manually storing peers along with their access hash is very tedious. +That is why mtcute handles it for you! Peers and their access hashes are +automatically stored inside the storage you provided, and can be accessed +at any time later. + +This way, to get an InputPeer, you simply need +to call `resolvePeer` method and provide a Peer + +```ts +const peer = await tg.resolvePeer({ _: 'peerUser', userId: 777000 }) +``` + +But still, this is very tedious, so you can pass +many more than just a `Peer` object to `resolvePeer`: + - Peer's [marked ID](#marked-ids) + - Peer's username + - Peer's phone number (will only work with contacts) + - `"me"` or `"self"` to refer to yourself (current user) + - `Peer`, `InputPeer`, `InputUser` and `InputChannel` objects + - `Chat`, `User` or generally anything where `.inputPeer: InputPeer` is available + +```ts +const peer = await tg.resolvePeer(-1001234567890) +const peer = await tg.resolvePeer("durovschat") +const peer = await tg.resolvePeer("+79001234567") +const peer = await tg.resolvePeer("me") +``` + +## `InputPeerLike` + +However, you will only really need to use `resolvePeer` method +when you are working with the Raw APIs. High-level methods +use `InputPeerLike`, a special type used to represent an input peer. + +In fact, we have already covered it - it is the very type that `resolvePeer` +takes as its argument: +```ts +async function resolvePeer(peer: InputPeerLike): Promise +``` + +Client methods implicitly call `resolvePeer` to convert `InputPeerLike` +to input peer and use it for the MTProto API call. + +::: tip +Whenever possible, pass `Chat`, `InputPeer` or `"me"/"self"` as `InputPeerLike`, since +this avoids redundant storage and/or API calls. + +When not possible, use their marked IDs, since most of the time +it is the cheapest way to fetch an `InputPeer`. + +For smaller-scale scripts, you *can* use usernames and phone numbers. +They are also cached in the storage, but might require additional API +call if they are not. Also, not every user has a username, and only +your contacts can be fetched by the phone number. + +```ts +// ❌ BAD +await tg.sendText(msg.sender.username!, ...) +await tg.sendText(msg.sender.phone!, ...) + +// 🧐 BETTER +await tg.sendText(msg.sender.id, ...) + +// ✅ GOOD +await tg.sendText(msg.sender.inputPeer, ...) +await tg.sendText(msg.sender, ...) +``` +::: + +## Marked IDs + +As you may have noticed, both `Peer` and `InputPeer` contain +peer type, but when using client methods and Bot API, +you don't specify it manually. + +`Peer` and `InputPeer` contain what is called as a "bare" ID, the ID +inside that particular peer type. Bare IDs between different peer types +may (and do!) collide, and that is why Marked IDs are used. + +Marked ID is a slightly transformed variant of the bare ID to +unambiguously define both peer type and peer ID with a single integer +(currently, it fits into JS number, but later we may be forced to move +to 64-bit integers). + +This was first introduced in TDLib, and was since adopted by many +third-party libraries, including mtcute. + + +::: tip +The concept described below is implemented and exported in utils, +see [getBasicPeerType](https://ref.mtcute.dev/functions/_mtcute_core.index.getBasicPeerType.html), +[getMarkedPeerId](https://ref.mtcute.dev/functions/_mtcute_core.index.getMarkedPeerId.html), +::: + +It works as follows: + - `User` IDs are kept as-is (`123 -> 123`) + - `Chat` IDs are negated (`123 -> -123`) + - `Channel` IDs are subtracted from `-1000000000000` (`1234567890 -> -1001234567890`) + + Some sources may say that it's simply prepending `-100` to the ID, + but that's not entirely true, since channel ID may be not 10 digits long. + +This way, you can easily determine peer type: + +```ts +const MIN_CHANNEL_ID = -1002147483647 +const MAX_CHANNEL_ID = -1000000000000 +const MIN_CHAT_ID = -2147483647 +const MAX_USER_ID = 2147483647 + +if (peer < 0) { + if (MIN_CHAT_ID <= peer) return 'chat' + if (MIN_CHANNEL_ID <= peer && peer < MAX_CHANNEL_ID) return 'channel' +} else if (0 < peer && peer <= MAX_USER_ID) { + return 'user' +} +``` + +And then, to convert it back to bare ID, use the exact same operation +(it works in two directions). + +## Incomplete peers + +In some cases, mtcute may not be able to immediately provide you with complete information +about a user/chat (see [min constructors](https://core.telegram.org/api/min) in MTProto docs). + +This currently only seems to happen for `msg.sender` and `msg.chat` fields for non-bot accounts in large chats, +so if you're only ever going to work with bots, you can safely ignore this section (for now?). + +::: tip +Complete peers ≠ [full peers](https://ref.mtcute.dev/classes/_mtcute_core.highlevel_client.TelegramClient.html#getFullChat)! + +- Incomplete are seen in updates in rare cases, and are missing some fields (e.g. username) +- Complete peers are pretty much all the other peer objects you get in updates +- Full peers are objects with additional information (e.g. bio) which you should request explicitly +::: + +For such chats, the server may send "min" constructors, which contain incomplete information about the user/chat. +To avoid blocking the updates loop, and since the missing information is not critical, mtcute will return an incomplete +peer object, which is *good enough* for most cases. + +For example, such `User` objects may have the following data missing or have incorrect values: + - `.username` may be missing + - `.photo` may be missing with some privacy settings + - online status may be incorrect + - and probably more (for more info please consult the official docs for [user](https://core.telegram.org/constructor/user) + and [channel](https://core.telegram.org/constructor/channel)) + +The user itself is still usable, though. If you need to get the missing information, you can call +`getUsers`/`getChat` method, which will return a complete `User`/`Chat` object. + +```ts +const user = ... // incomplete user from somewhere +const [completeUser] = await tg.getUsers(user) +``` + +If you are using [Dispatcher](/guide/dispatcher/intro.md), you can use `.getCompleteSender()` or `.getCompleteChat()` methods instead: + +```ts +dp.onNewMessage(async (msg) => { + const sender = await msg.getCompleteSender() + const chat = await msg.getCompleteChat() +}) +``` + +Or you can use `withCompleteSender` and `withCompleteChat` middleware-like filters: + +```ts +dp.onNewMessage( + filters.withCompleteSender(filters.sender('user')) + async (msg) => { + const user = msg.sender + // user is guaranteed to be complete + } +) +``` \ No newline at end of file diff --git a/docs/guide/topics/raw-api.md b/docs/guide/topics/raw-api.md new file mode 100755 index 00000000..e7650152 --- /dev/null +++ b/docs/guide/topics/raw-api.md @@ -0,0 +1,198 @@ +# Raw API + +mtcute implements a lot of methods to simplify using the +Telegram APIs. However, it does not cover the entirety of the API, +and in that case, you can resort to using the MTProto APIs directly. + +::: warning +When using MTProto API directly, you will have to manually +implement any checks, arguments normalization, parsing, etc. + +Whenever possible, use client methods instead! +::: + +## Calling MTProto API + +Before you can call some method, you need to know *what* to call and *how* to +call it. To do that, please refer to [TL Reference](https://core.telegram.org/methods). + +Then, simply pass method name and arguments to `.call()` method: + +```ts +const result = await tg.call({ + _: 'account.checkUsername', + username: 'finally_water' +}) +``` + +Thanks to TypeScript, the request object is strictly typed, +and the return value also has the correct type: + +```ts +const result = await tg.call({ + _: 'account.checkUsername', + username: 42 // error: must be a string +}) + +result.ok // error: boolean does not have `ok` property +``` + +## Common parameters + +In MTProto APIs, there are some parameters that are +often encountered in different methods, and are +briefly described below: + +| Name | Description | Safe default value | +|---|---|---| +| `hash` | Hash of the previously stored content, used to avoid re-fetching the content that is not modified. Methods that use this parameter have `*NotModified` class as one of the possible return types. It is not returned if `hash=0`. | `0` +| `offset` | Offset for pagination | `0` +| `limit` | Maximum number of items for pagination, max. limit is different for every method. | `0`, this will usually default to ~20 +| `randomId` | Random message ID to avoid sending the same message. | `randomLong()` (exported by `@mtcute/core/utils.js`) + +Learn more about pagination in [Telegram docs](https://core.telegram.org/api/offsets) + +## Resolving peers + +To fetch a value for fields that require `InputPeer`, use `resolvePeer` method. +If you need `InputUser` or `InputChannel`, you can use `to*` functions +respectively: + +```ts +const result = await tg.call({ + _: 'channels.reportSpam', + channel: toInputChannel(await tg.resolvePeer(...)), + userId: toInputUser(await tg.resolvePeer(...)), + id: [1, 2, 3] +}) +``` + +These functions will throw in case the peer is of wrong type + +## Handling Updates + +Some RPC methods return `Updates` type. For these methods, +it is important that they are properly handled by Updates manager. +This is done by calling `tg.handleClientUpdate` method: + +```ts +const res = await tg.call({ _: 'contacts.addContact', ... }) +tg.handleClientUpdate(res) +``` + +::: tip +Calling `tg.handleClientUpdate` will not dispatch all the updates contained +in that object. This is sometimes undesirable, and can be avoided +by passing `false` as the second argument: + +```ts +tg.handleClientUpdate(res, false) +``` +::: + +::: details Why is this important? +When RPC method returns `Updates`, it might have newer PTS and SEQ values. +If it does, passing it to `handleClientUpdate` makes the library aware of those +new updates. Otherwise, library would have to re-fetch them the next +time an update is encountered. + +Also, in case PTS/SEQ values are bigger than the next expected value, +an *update gap* is detected and missing updates will be fetched. +::: + +### Dummy updates + +Some methods return not updates, but a class like +[messages.affectedHistory](https://corefork.telegram.org/constructor/messages.affectedHistory). + +They also contain PTS values, and should also be handled. +But since this is not an update, it can't be passed directly +to `handleClientUpdate`, and instead a "dummy" update is created: + +```ts +const res = await this.call({ + _: 'messages.deleteMessages', + id: [1, 2, 3], +}) +const upd = createDummyUpdate(res.pts, res.ptsCount) +tg.handleClientUpdate(upd) +``` + +Or, in case this PTS is related to a channel: + +```ts +const channel = toInputChannel(peer) +const res = await this.call({ + _: 'channels.deleteMessages', + channel, + id: [1, 2, 3], +}) +const upd = createDummyUpdate(res.pts, res.ptsCount, channel.channelId) +tg.handleClientUpdate(upd) +``` + +## Files and media + +To get an `InputFile` from `InputFileLike`, use `_normalizeInputFile`: + +```ts +const file = 'file:theme.txt' + +const res = await tg.call({ + _: 'account.uploadTheme', + file: await tg._normalizeInputFile(file), + ... +}) +``` + +To get an `InputMedia` from `InputMediaLike`, use `_normalizeInputMedia`: + +```ts +const file = InputMedia.auto('BQACAgEAAx...Z2mGB8E') + +const res = await tg.call({ + _: 'messages.uploadMedia', + media: await tg._normalizeInputMedia(file), + ... +}) +``` + +## Message entities + +To simplify processing message entities, client has a special method: +```ts +_parseEntities( + text?: string | FormattedString, + mode?: string | null, + entities?: tl.TypeMessageEntity[] +): Promise<[string, tl.TypeMessageEntity[] | undefined]> +``` + +Here, `text` is user-provided text which may have formatted entities, +`mode` is the chosen parse mode (or `null` to disable), and `entities` +is the override message entities provided by the user. + +If `FormattedString` is passed (e.g. md\`\*\*Hello!**\`), +`mode` parameter is ignored. + +It returns a tuple containing text without any entities, and the entities +themselves (if applicable). If `text` was not provided, empty string +is returned. + +## Fully custom requests + +mtcute also allows you to send fully custom requests to the server. +This is useful if you want to use some undocumented or yet-unreleased APIs +and don't want to patch the library or use the TL schema override mechanism. + +You can use the `mtcute.customRequest` pseudo-method for that: + +```ts +const res = await tg.call({ + _: 'mtcute.customRequest', + bytes: Buffer.from('11223344', 'hex'), +}) +``` + +`bytes` will be send as-is to the server, and the response will be returned as a `Uint8Array` +for you to handle on your own (it might be useful to look into [`@mtcute/tl-runtime` package](https://ref.mtcute.dev/modules/_mtcute_tl_runtime.html)) diff --git a/docs/guide/topics/storage.md b/docs/guide/topics/storage.md new file mode 100755 index 00000000..2c7dbf25 --- /dev/null +++ b/docs/guide/topics/storage.md @@ -0,0 +1,174 @@ +# Storage + +Storage is a very important aspect of the library, +which should not be overlooked. It is primarily used to +handle caching and authorization (you wouldn't want to +log in every time, right?). + +## In-memory storage + +The simplest way to store data is to store it in-memory +and never persist it anywhere, and this is exactly +what `MemoryStorage` does. + +```ts{4} +import { MemoryStorage } from '@mtcute/core' + +const tg = new TelegramClient({ + storage: new MemoryStorage() +}) +``` + +::: warning +It is highly advised that you use some kind of persisted storage! + +With in-memory storage, you will need to re-authorize every time +(assuming you don't use [session strings](#session-strings)), +and also caching won't work past a single run. +::: + +## SQLite storage + +The preferred storage for a Node.js application is the one using SQLite, +because it does not require loading the entire thing into memory, and +is also faster than simply reading/writing a file. + +mtcute implements it in a separate package, `@mtcute/sqlite`, and internally +uses [better-sqlite3](https://www.npmjs.com/package/better-sqlite3) + +```ts{4} +import { SqliteStorage } from '@mtcute/sqlite' + +const tg = new TelegramClient({ + storage: new SqliteStorage('my-account.session') +}) +``` + +::: tip +If you are using `@mtcute/node`, SQLite storage is the default, +and you can simply pass a string with file name instead +of instantiating `SqliteStorage` manually: + +```ts +const tg = new TelegramClient({ + storage: 'my-account.session' +}) +``` +::: + +To improve performance, `@mtcute/sqlite` by default uses +WAL mode ([Learn more](https://github.com/JoshuaWise/better-sqlite3/blob/master/docs/performance.md)). + +When using WAL, along with your SQLite file there may also +be `-shm` and `-wal` files. If you don't like seeing those files, +instead of disabling WAL altogether, consider putting your storage in a folder +(i.e. `new SqliteStorage('storage/my-account')`). + +If you are in fact having problems with WAL mode, you can disable it +with `disableWal` parameter. + + +## IndexedDB storage + +The preferred storage for a Web application is the one using IndexedDB, +which is basically a browser's version of SQLite. + +```ts{4} +import { IdbStorage } from '@mtcute/web' + +const tg = new TelegramClient({ + storage: new IdbStorage('my-account') +}) +``` + +::: tip +In the browser, IndexedDB storage is the default, +and you can simply pass a string with file name instead +of instantiating `IdbStorage` manually: + +```ts +const tg = new TelegramClient({ + storage: 'my-account' +}) +``` +::: + + +## Session strings + +Sometimes it might be useful to export storage data to a string, and +import it later to another storage. For example, when deploying userbot +applications to a server, where you'll be using another storage. + +To generate a session string, simply call `exportSession`: + +```ts +await tg.start() +console.log(await tg.exportSession()) +``` + +This will output a fairly long string (about 400 chars) to your console, +which can then be imported: + +```ts +const tg = new TelegramClient({...}) + +await tg.importSession(SESSION_STRING) +// or +await tg.start({ session: SESSION_STRING }) +``` + +You can import session into any storage, including in-memory storage. +This may be useful when deploying to services like [Heroku](https://www.heroku.com), +where their ephemeral file system makes it impossible to use file-based storage. + +::: warning +Anyone with this string will be able to authorize as you and do anything. +Treat this as your password, and **never give it away**! + +In case you have accidentally leaked this string, make sure to revoke +this session in account settings: "Privacy & Security" > "Active sessions" > +find the one containing "mtcute" > Revoke, or, in case this is a bot, +revoke bot token with [@BotFather](https://t.me/botfather) + +Also note that you can't log in with the same session +string from multiple IPs at once, and that would immediately +revoke that session. +::: + +::: details What is included? +You might be curious about the information that the session +string includes, and why is it so long. + +Most of the string is occupied by 256 bytes long +MTProto authorization key, which, when Base64 encoded, +results in **344** characters. Additionally, information +about user (their ID and whether the user is a bot) and their DC +is included, which results in an average of **407** characters +::: + +## Implementing custom storage + +The easiest way to implement a custom storage would be to make a subclass of `MemoryStorage`, +or check the [source code of SqliteStorage](https://github.com/mtcute/mtcute/blob/master/packages/sqlite/src/index.ts) +and implement something similar with your DB of choice. + +### Architecture + +A storage provider in mtcute is composed of: +- **Driver**: the core of the storage, which handles reading and writing data to the storage and implements + lifecycle methods like `load` and `save`. Driver also manages migrations for the storage, however the migrations + themselves are not part of the driver, but are registered separately by repositories +- **Repository**: a set of methods to read and write data of a specific entity to the storage, allowing for + more efficient and organized access to the data. Repositories are registered in the driver and are used to + access the data in the storage + +Such composable architecture allows for custom storages to implement a specific set of repositories, +and to reuse the same driver for different providers. + +In mtcute, these sets of repositories are defined: +- [IMtStorageProvider](https://ref.mtcute.dev/types/_mtcute_core.index.IMtStorageProvider.html), used by `BaseTelegramClient` for low-level + MTProto data storage +- [ITelegramStorageProvider](https://ref.mtcute.dev/interfaces/_mtcute_core.index.ITelegramStorageProvider.html), used by `TelegramClient` for basic caching + and update handling operations required for the client to work +- [IStateStorageProvider](https://ref.mtcute.dev/types/_mtcute_dispatcher.IStateStorageProvider.html), used by `Dispatcher` for FSM and Scenes storage diff --git a/docs/guide/topics/transport.md b/docs/guide/topics/transport.md new file mode 100755 index 00000000..546ba7b9 --- /dev/null +++ b/docs/guide/topics/transport.md @@ -0,0 +1,137 @@ +# Transport + +Transport is a way for mtcute to communicate with Telegram servers. + +mtcute comes bundled with TCP and WebSocket transport, and also +supports proxies via additional packages. + +## TCP transport + +TCP transport is the default transport for Node.js, and is implemented +using `net.Socket` in `@mtcute/node`: + +```ts{5} +import { TcpTransport } from '@mtcute/node' + +const tg = new TelegramClient({ + // ... + transport: () => new TcpTransport() +}) +``` + +::: tip +In Node.js it is used automatically, you don't need to pass this explicitly +::: + +## WebSocket transport + +WebSocket transport is mostly used for the browser, +but can also be used in Node.js. + +It is implemented in `@mtcute/web`: + +```ts{5} +import { WebSocketTransport } from '@mtcute/web' + +const tg = new TelegramClient({ + // ... + transport: () => new WebSocketTransport() +}) +``` + +::: tip +In browser, it is used automatically, you don't need to pass this explicitly +::: + +## HTTP(s) Proxy transport + +To access Telegram via HTTP(s) proxy, you can use +`HttpProxyTcpTransport`, which is provided +by `@mtcute/http-proxy` (Node.js only): + +```bash +pnpm add @mtcute/http-proxy +``` + +```ts{5-8} +import { HttpProxyTcpTransport } from '@mtcute/http-proxy' + +const tg = new TelegramClient({ + // ... + transport: () => new HttpProxyTcpTransport({ + host: '127.0.0.1', + port: 8080 + }) +}) +``` + +## SOCKS4/5 Proxy transport + +To access Telegram via SOCKS4/5 proxy, you can use +`SocksTcpTransport`, which is provided +by `@mtcute/socks-proxy` (Node.js only): + +```bash +pnpm add @mtcute/socks-proxy +``` + +```ts{5-8} +import { SocksTcpTransport } from '@mtcute/socks-proxy' + +const tg = new TelegramClient({ + // ... + transport: () => new SocksTcpTransport({ + host: '127.0.0.1', + port: 8080 + }) +}) +``` + +## MTProxy transport + +To access Telegram via MTProxy (MTProto proxy), you can use +`MtProxyTcpTransport`, which is provided by `@mtcute/mtproxy` (Node.js only): + +```bash +pnpm add @mtcute/mtproxy +``` + +```ts{5-8} +import { MtProxyTcpTransport } from '@mtcute/mtproxy' + +const tg = new TelegramClient({ + // ... + transport: () => new MtProxyTcpTransport({ + host: '127.0.0.1', + port: 8080, + secret: '0123456789abcdef0123456789abcdef' + }) +}) +``` + +::: tip +mtcute supports all kinds of MTProxies, including the newer ones +with Fake TLS ⚡️ +::: + +## Changing transport at runtime + +It is possible to change transport at runtime. For example, this +could be used to change proxy used to connect to Telegram. + +To change the transport, simply call `changeTransport`: + +```ts +tg.changeTransport(() => new MtProxyTcpTransport({...})) +``` + +## Implementing custom transport + +When targeting an environment which is not supported already, +you can implement a custom transport on your own. In fact, it is +much simpler than it sounds! + +You can check out source code for the bundled transports +to get the basic idea +[here](https://github.com/mtcute/mtcute/tree/master/packages/core/src/network/transports), +and re-use any packet codecs that are included. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..f59e54f0 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "mtcute" + tagline: Modern TypeScript library for MTProto + image: + src: /mtcute-logo.png + actions: + - theme: brand + text: Quick Start → + link: /guide/ + - theme: alt + text: API Reference + link: //ref.mtcute.dev + +features: + - icon: 🍰 + title: Simple + details: mtcute hides all the complexity and provides a clean and modern API + - icon: ✨ + title: Compatible + details: mtcute supports almost everything Bot API does, and even more! + - icon: 🍡 + title: Lightweight + details: Running instance uses less than 50 MB of RAM*. + - icon: 🛡️ + title: Type-safe + details: Most of the APIs (including MTProto) are strictly typed to help your workflow + - icon: ⚙️ + title: Hackable + details: Almost every aspect of the library is customizable, including networking and storage + - icon: 🕙 + title: Up-to-date + details: mtcute uses the latest TL schema to provide the newest features as soon as possible +--- + + +* Tested on a personal account and a few small-load bots + \ No newline at end of file diff --git a/docs/package.json b/docs/package.json new file mode 100755 index 00000000..cdb8092a --- /dev/null +++ b/docs/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mtcute/docs", + "version": "1.0.0", + "description": "mtcute documentation", + "devDependencies": { + "vitepress": "1.0.0-rc.24", + "vue": "^3.3.7" + }, + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "dependencies": { + "markdown-it-footnote": "^3.0.3", + "medium-zoom": "^1.0.8", + "vitepress-plugin-back-to-top": "^1.0.1" + }, + "packageManager": "pnpm@9.5.0" +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 00000000..74b9b9d5 --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,995 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + markdown-it-footnote: + specifier: ^3.0.3 + version: 3.0.3 + medium-zoom: + specifier: ^1.0.8 + version: 1.0.8 + vitepress-plugin-back-to-top: + specifier: ^1.0.1 + version: 1.0.1 + devDependencies: + vitepress: + specifier: 1.0.0-rc.24 + version: 1.0.0-rc.24(@algolia/client-search@4.20.0)(search-insights@2.9.0) + vue: + specifier: ^3.3.7 + version: 3.3.7 + +packages: + + '@algolia/autocomplete-core@1.9.3': + resolution: {integrity: sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==} + + '@algolia/autocomplete-plugin-algolia-insights@1.9.3': + resolution: {integrity: sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.9.3': + resolution: {integrity: sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.9.3': + resolution: {integrity: sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/cache-browser-local-storage@4.20.0': + resolution: {integrity: sha512-uujahcBt4DxduBTvYdwO3sBfHuJvJokiC3BP1+O70fglmE1ShkH8lpXqZBac1rrU3FnNYSUs4pL9lBdTKeRPOQ==} + + '@algolia/cache-common@4.20.0': + resolution: {integrity: sha512-vCfxauaZutL3NImzB2G9LjLt36vKAckc6DhMp05An14kVo8F1Yofb6SIl6U3SaEz8pG2QOB9ptwM5c+zGevwIQ==} + + '@algolia/cache-in-memory@4.20.0': + resolution: {integrity: sha512-Wm9ak/IaacAZXS4mB3+qF/KCoVSBV6aLgIGFEtQtJwjv64g4ePMapORGmCyulCFwfePaRAtcaTbMcJF+voc/bg==} + + '@algolia/client-account@4.20.0': + resolution: {integrity: sha512-GGToLQvrwo7am4zVkZTnKa72pheQeez/16sURDWm7Seyz+HUxKi3BM6fthVVPUEBhtJ0reyVtuK9ArmnaKl10Q==} + + '@algolia/client-analytics@4.20.0': + resolution: {integrity: sha512-EIr+PdFMOallRdBTHHdKI3CstslgLORQG7844Mq84ib5oVFRVASuuPmG4bXBgiDbcsMLUeOC6zRVJhv1KWI0ug==} + + '@algolia/client-common@4.20.0': + resolution: {integrity: sha512-P3WgMdEss915p+knMMSd/fwiHRHKvDu4DYRrCRaBrsfFw7EQHon+EbRSm4QisS9NYdxbS04kcvNoavVGthyfqQ==} + + '@algolia/client-personalization@4.20.0': + resolution: {integrity: sha512-N9+zx0tWOQsLc3K4PVRDV8GUeOLAY0i445En79Pr3zWB+m67V+n/8w4Kw1C5LlbHDDJcyhMMIlqezh6BEk7xAQ==} + + '@algolia/client-search@4.20.0': + resolution: {integrity: sha512-zgwqnMvhWLdpzKTpd3sGmMlr4c+iS7eyyLGiaO51zDZWGMkpgoNVmltkzdBwxOVXz0RsFMznIxB9zuarUv4TZg==} + + '@algolia/logger-common@4.20.0': + resolution: {integrity: sha512-xouigCMB5WJYEwvoWW5XDv7Z9f0A8VoXJc3VKwlHJw/je+3p2RcDXfksLI4G4lIVncFUYMZx30tP/rsdlvvzHQ==} + + '@algolia/logger-console@4.20.0': + resolution: {integrity: sha512-THlIGG1g/FS63z0StQqDhT6bprUczBI8wnLT3JWvfAQDZX5P6fCg7dG+pIrUBpDIHGszgkqYEqECaKKsdNKOUA==} + + '@algolia/requester-browser-xhr@4.20.0': + resolution: {integrity: sha512-HbzoSjcjuUmYOkcHECkVTwAelmvTlgs48N6Owt4FnTOQdwn0b8pdht9eMgishvk8+F8bal354nhx/xOoTfwiAw==} + + '@algolia/requester-common@4.20.0': + resolution: {integrity: sha512-9h6ye6RY/BkfmeJp7Z8gyyeMrmmWsMOCRBXQDs4mZKKsyVlfIVICpcSibbeYcuUdurLhIlrOUkH3rQEgZzonng==} + + '@algolia/requester-node-http@4.20.0': + resolution: {integrity: sha512-ocJ66L60ABSSTRFnCHIEZpNHv6qTxsBwJEPfYaSBsLQodm0F9ptvalFkHMpvj5DfE22oZrcrLbOYM2bdPJRHng==} + + '@algolia/transporter@4.20.0': + resolution: {integrity: sha512-Lsii1pGWOAISbzeyuf+r/GPhvHMPHSPrTDWNcIzOE1SG1inlJHICaVe2ikuoRjcpgxZNU54Jl+if15SUCsaTUg==} + + '@babel/helper-string-parser@7.22.5': + resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.22.20': + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.23.0': + resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.23.0': + resolution: {integrity: sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@3.5.2': + resolution: {integrity: sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA==} + + '@docsearch/js@3.5.2': + resolution: {integrity: sha512-p1YFTCDflk8ieHgFJYfmyHBki1D61+U9idwrLh+GQQMrBSP3DLGKpy0XUJtPjAOPltcVbqsTjiPFfH7JImjUNg==} + + '@docsearch/react@3.5.2': + resolution: {integrity: sha512-9Ahcrs5z2jq/DcAvYtvlqEBHImbm4YJI8M9y0x6Tqg598P40HTEkX7hsMcIuThI+hTFxRGZ9hll0Wygm2yEjng==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + '@types/linkify-it@3.0.4': + resolution: {integrity: sha512-hPpIeeHb/2UuCw06kSNAOVWgehBLXEo0/fUs0mw3W2qhqX89PI2yvok83MnuctYGCPrabGIoi0fFso4DQ+sNUQ==} + + '@types/markdown-it@13.0.5': + resolution: {integrity: sha512-QhJP7hkq3FCrFNx0szMNCT/79CXfcEgUIA3jc5GBfeXqoKsk3R8JZm2wRXJ2DiyjbPE4VMFOSDemLFcUTZmHEQ==} + + '@types/mdurl@1.0.4': + resolution: {integrity: sha512-ARVxjAEX5TARFRzpDRVC6cEk0hUIXCCwaMhz8y7S1/PxU6zZS1UMjyobz7q4w/D/R552r4++EhwmXK1N2rAy0A==} + + '@types/web-bluetooth@0.0.18': + resolution: {integrity: sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw==} + + '@vitejs/plugin-vue@4.3.1': + resolution: {integrity: sha512-tUBEtWcF7wFtII7ayNiLNDTCE1X1afySEo+XNVMNkFXaThENyCowIEX095QqbJZGTgoOcSVDJGlnde2NG4jtbQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.3.7': + resolution: {integrity: sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==} + + '@vue/compiler-dom@3.3.7': + resolution: {integrity: sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==} + + '@vue/compiler-sfc@3.3.7': + resolution: {integrity: sha512-7pfldWy/J75U/ZyYIXRVqvLRw3vmfxDo2YLMwVtWVNew8Sm8d6wodM+OYFq4ll/UxfqVr0XKiVwti32PCrruAw==} + + '@vue/compiler-ssr@3.3.7': + resolution: {integrity: sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==} + + '@vue/devtools-api@6.5.1': + resolution: {integrity: sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==} + + '@vue/reactivity-transform@3.3.7': + resolution: {integrity: sha512-APhRmLVbgE1VPGtoLQoWBJEaQk4V8JUsqrQihImVqKT+8U6Qi3t5ATcg4Y9wGAPb3kIhetpufyZ1RhwbZCIdDA==} + + '@vue/reactivity@3.3.7': + resolution: {integrity: sha512-cZNVjWiw00708WqT0zRpyAgduG79dScKEPYJXq2xj/aMtk3SKvL3FBt2QKUlh6EHBJ1m8RhBY+ikBUzwc7/khg==} + + '@vue/runtime-core@3.3.7': + resolution: {integrity: sha512-LHq9du3ubLZFdK/BP0Ysy3zhHqRfBn80Uc+T5Hz3maFJBGhci1MafccnL3rpd5/3wVfRHAe6c+PnlO2PAavPTQ==} + + '@vue/runtime-dom@3.3.7': + resolution: {integrity: sha512-PFQU1oeJxikdDmrfoNQay5nD4tcPNYixUBruZzVX/l0eyZvFKElZUjW4KctCcs52nnpMGO6UDK+jF5oV4GT5Lw==} + + '@vue/server-renderer@3.3.7': + resolution: {integrity: sha512-UlpKDInd1hIZiNuVVVvLgxpfnSouxKQOSE2bOfQpBuGwxRV/JqqTCyyjXUWiwtVMyeRaZhOYYqntxElk8FhBhw==} + peerDependencies: + vue: 3.3.7 + + '@vue/shared@3.3.7': + resolution: {integrity: sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==} + + '@vueuse/core@10.5.0': + resolution: {integrity: sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A==} + + '@vueuse/integrations@10.5.0': + resolution: {integrity: sha512-fm5sXLCK0Ww3rRnzqnCQRmfjDURaI4xMsx+T+cec0ngQqHx/JgUtm8G0vRjwtonIeTBsH1Q8L3SucE+7K7upJQ==} + peerDependencies: + async-validator: '*' + axios: '*' + change-case: '*' + drauu: '*' + focus-trap: '*' + fuse.js: '*' + idb-keyval: '*' + jwt-decode: '*' + nprogress: '*' + qrcode: '*' + sortablejs: '*' + universal-cookie: '*' + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@10.5.0': + resolution: {integrity: sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw==} + + '@vueuse/shared@10.5.0': + resolution: {integrity: sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg==} + + algoliasearch@4.20.0: + resolution: {integrity: sha512-y+UHEjnOItoNy0bYO+WWmLWBlPwDjKHW6mNHrPi0NkuhpQOOEbrkwQH/wgKFDLh7qlKjzoKeiRtlpewDPDG23g==} + + ansi-sequence-parser@1.1.1: + resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==} + + csstype@3.1.2: + resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + focus-trap@7.5.4: + resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jsonc-parser@3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + + magic-string@0.30.5: + resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} + engines: {node: '>=12'} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it-footnote@3.0.3: + resolution: {integrity: sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w==} + + medium-zoom@1.0.8: + resolution: {integrity: sha512-CjFVuFq/IfrdqesAXfg+hzlDKu6A2n80ZIq0Kl9kWjoHh9j1N9Uvk5X0/MmN0hOfm5F9YBswlClhcwnmtwz7gA==} + + minisearch@6.2.0: + resolution: {integrity: sha512-BECkorDF1TY2rGKt9XHdSeP9TP29yUbrAaCh/C03wpyf1vx3uYcP/+8XlMcpTkgoU0rBVnHMAOaP83Rc9Tm+TQ==} + + nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.18.1: + resolution: {integrity: sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==} + + rollup@3.29.4: + resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + search-insights@2.9.0: + resolution: {integrity: sha512-bkWW9nIHOFkLwjQ1xqVaMbjjO5vhP26ERsH9Y3pKr8imthofEFIxlnOabkmGcw6ksRj9jWidcI65vvjJH/nTGg==} + + shiki@0.14.5: + resolution: {integrity: sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==} + + source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + vite@4.5.0: + resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitepress-plugin-back-to-top@1.0.1: + resolution: {integrity: sha512-fQUzhkV6SGEmYcO56fv6Jbz5ifseAZuoi+lPDGg0c8gpMVHgqs/D1GbfX4tbvUAtuFNv4kz88wuQRK024iZ2sQ==} + + vitepress@1.0.0-rc.24: + resolution: {integrity: sha512-RpnL8cnOGwiRlBbrYQUm9sYkJbtyOt/wYXk2diTcokY4yvks/5lq9LuSt+MURWB6ZqwpSNHvTmxgaSfLoG0/OA==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4.3.2 + postcss: ^8.4.31 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vscode-oniguruma@1.7.0: + resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} + + vscode-textmate@8.0.0: + resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + + vue-demi@0.14.6: + resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue@3.3.7: + resolution: {integrity: sha512-YEMDia1ZTv1TeBbnu6VybatmSteGOS3A3YgfINOfraCbf85wdKHzscD6HSS/vB4GAtI7sa1XPX7HcQaJ1l24zA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@algolia/autocomplete-core@1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)(search-insights@2.9.0)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)(search-insights@2.9.0) + '@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)(search-insights@2.9.0)': + dependencies: + '@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0) + search-insights: 2.9.0 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)': + dependencies: + '@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0) + '@algolia/client-search': 4.20.0 + algoliasearch: 4.20.0 + + '@algolia/autocomplete-shared@1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)': + dependencies: + '@algolia/client-search': 4.20.0 + algoliasearch: 4.20.0 + + '@algolia/cache-browser-local-storage@4.20.0': + dependencies: + '@algolia/cache-common': 4.20.0 + + '@algolia/cache-common@4.20.0': {} + + '@algolia/cache-in-memory@4.20.0': + dependencies: + '@algolia/cache-common': 4.20.0 + + '@algolia/client-account@4.20.0': + dependencies: + '@algolia/client-common': 4.20.0 + '@algolia/client-search': 4.20.0 + '@algolia/transporter': 4.20.0 + + '@algolia/client-analytics@4.20.0': + dependencies: + '@algolia/client-common': 4.20.0 + '@algolia/client-search': 4.20.0 + '@algolia/requester-common': 4.20.0 + '@algolia/transporter': 4.20.0 + + '@algolia/client-common@4.20.0': + dependencies: + '@algolia/requester-common': 4.20.0 + '@algolia/transporter': 4.20.0 + + '@algolia/client-personalization@4.20.0': + dependencies: + '@algolia/client-common': 4.20.0 + '@algolia/requester-common': 4.20.0 + '@algolia/transporter': 4.20.0 + + '@algolia/client-search@4.20.0': + dependencies: + '@algolia/client-common': 4.20.0 + '@algolia/requester-common': 4.20.0 + '@algolia/transporter': 4.20.0 + + '@algolia/logger-common@4.20.0': {} + + '@algolia/logger-console@4.20.0': + dependencies: + '@algolia/logger-common': 4.20.0 + + '@algolia/requester-browser-xhr@4.20.0': + dependencies: + '@algolia/requester-common': 4.20.0 + + '@algolia/requester-common@4.20.0': {} + + '@algolia/requester-node-http@4.20.0': + dependencies: + '@algolia/requester-common': 4.20.0 + + '@algolia/transporter@4.20.0': + dependencies: + '@algolia/cache-common': 4.20.0 + '@algolia/logger-common': 4.20.0 + '@algolia/requester-common': 4.20.0 + + '@babel/helper-string-parser@7.22.5': {} + + '@babel/helper-validator-identifier@7.22.20': {} + + '@babel/parser@7.23.0': + dependencies: + '@babel/types': 7.23.0 + + '@babel/types@7.23.0': + dependencies: + '@babel/helper-string-parser': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + '@docsearch/css@3.5.2': {} + + '@docsearch/js@3.5.2(@algolia/client-search@4.20.0)(search-insights@2.9.0)': + dependencies: + '@docsearch/react': 3.5.2(@algolia/client-search@4.20.0)(search-insights@2.9.0) + preact: 10.18.1 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.5.2(@algolia/client-search@4.20.0)(search-insights@2.9.0)': + dependencies: + '@algolia/autocomplete-core': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)(search-insights@2.9.0) + '@algolia/autocomplete-preset-algolia': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0) + '@docsearch/css': 3.5.2 + algoliasearch: 4.20.0 + search-insights: 2.9.0 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@types/linkify-it@3.0.4': {} + + '@types/markdown-it@13.0.5': + dependencies: + '@types/linkify-it': 3.0.4 + '@types/mdurl': 1.0.4 + + '@types/mdurl@1.0.4': {} + + '@types/web-bluetooth@0.0.18': {} + + '@vitejs/plugin-vue@4.3.1(vite@4.5.0)(vue@3.3.7)': + dependencies: + vite: 4.5.0 + vue: 3.3.7 + + '@vue/compiler-core@3.3.7': + dependencies: + '@babel/parser': 7.23.0 + '@vue/shared': 3.3.7 + estree-walker: 2.0.2 + source-map-js: 1.0.2 + + '@vue/compiler-dom@3.3.7': + dependencies: + '@vue/compiler-core': 3.3.7 + '@vue/shared': 3.3.7 + + '@vue/compiler-sfc@3.3.7': + dependencies: + '@babel/parser': 7.23.0 + '@vue/compiler-core': 3.3.7 + '@vue/compiler-dom': 3.3.7 + '@vue/compiler-ssr': 3.3.7 + '@vue/reactivity-transform': 3.3.7 + '@vue/shared': 3.3.7 + estree-walker: 2.0.2 + magic-string: 0.30.5 + postcss: 8.4.31 + source-map-js: 1.0.2 + + '@vue/compiler-ssr@3.3.7': + dependencies: + '@vue/compiler-dom': 3.3.7 + '@vue/shared': 3.3.7 + + '@vue/devtools-api@6.5.1': {} + + '@vue/reactivity-transform@3.3.7': + dependencies: + '@babel/parser': 7.23.0 + '@vue/compiler-core': 3.3.7 + '@vue/shared': 3.3.7 + estree-walker: 2.0.2 + magic-string: 0.30.5 + + '@vue/reactivity@3.3.7': + dependencies: + '@vue/shared': 3.3.7 + + '@vue/runtime-core@3.3.7': + dependencies: + '@vue/reactivity': 3.3.7 + '@vue/shared': 3.3.7 + + '@vue/runtime-dom@3.3.7': + dependencies: + '@vue/runtime-core': 3.3.7 + '@vue/shared': 3.3.7 + csstype: 3.1.2 + + '@vue/server-renderer@3.3.7(vue@3.3.7)': + dependencies: + '@vue/compiler-ssr': 3.3.7 + '@vue/shared': 3.3.7 + vue: 3.3.7 + + '@vue/shared@3.3.7': {} + + '@vueuse/core@10.5.0(vue@3.3.7)': + dependencies: + '@types/web-bluetooth': 0.0.18 + '@vueuse/metadata': 10.5.0 + '@vueuse/shared': 10.5.0(vue@3.3.7) + vue-demi: 0.14.6(vue@3.3.7) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/integrations@10.5.0(focus-trap@7.5.4)(vue@3.3.7)': + dependencies: + '@vueuse/core': 10.5.0(vue@3.3.7) + '@vueuse/shared': 10.5.0(vue@3.3.7) + focus-trap: 7.5.4 + vue-demi: 0.14.6(vue@3.3.7) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@10.5.0': {} + + '@vueuse/shared@10.5.0(vue@3.3.7)': + dependencies: + vue-demi: 0.14.6(vue@3.3.7) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + algoliasearch@4.20.0: + dependencies: + '@algolia/cache-browser-local-storage': 4.20.0 + '@algolia/cache-common': 4.20.0 + '@algolia/cache-in-memory': 4.20.0 + '@algolia/client-account': 4.20.0 + '@algolia/client-analytics': 4.20.0 + '@algolia/client-common': 4.20.0 + '@algolia/client-personalization': 4.20.0 + '@algolia/client-search': 4.20.0 + '@algolia/logger-common': 4.20.0 + '@algolia/logger-console': 4.20.0 + '@algolia/requester-browser-xhr': 4.20.0 + '@algolia/requester-common': 4.20.0 + '@algolia/requester-node-http': 4.20.0 + '@algolia/transporter': 4.20.0 + + ansi-sequence-parser@1.1.1: {} + + csstype@3.1.2: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + estree-walker@2.0.2: {} + + focus-trap@7.5.4: + dependencies: + tabbable: 6.2.0 + + fsevents@2.3.3: + optional: true + + jsonc-parser@3.2.0: {} + + magic-string@0.30.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + mark.js@8.11.1: {} + + markdown-it-footnote@3.0.3: {} + + medium-zoom@1.0.8: {} + + minisearch@6.2.0: {} + + nanoid@3.3.6: {} + + picocolors@1.0.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + preact@10.18.1: {} + + rollup@3.29.4: + optionalDependencies: + fsevents: 2.3.3 + + search-insights@2.9.0: {} + + shiki@0.14.5: + dependencies: + ansi-sequence-parser: 1.1.1 + jsonc-parser: 3.2.0 + vscode-oniguruma: 1.7.0 + vscode-textmate: 8.0.0 + + source-map-js@1.0.2: {} + + tabbable@6.2.0: {} + + to-fast-properties@2.0.0: {} + + vite@4.5.0: + dependencies: + esbuild: 0.18.20 + postcss: 8.4.31 + rollup: 3.29.4 + optionalDependencies: + fsevents: 2.3.3 + + vitepress-plugin-back-to-top@1.0.1: + dependencies: + vue: 3.3.7 + transitivePeerDependencies: + - typescript + + vitepress@1.0.0-rc.24(@algolia/client-search@4.20.0)(search-insights@2.9.0): + dependencies: + '@docsearch/css': 3.5.2 + '@docsearch/js': 3.5.2(@algolia/client-search@4.20.0)(search-insights@2.9.0) + '@types/markdown-it': 13.0.5 + '@vitejs/plugin-vue': 4.3.1(vite@4.5.0)(vue@3.3.7) + '@vue/devtools-api': 6.5.1 + '@vueuse/core': 10.5.0(vue@3.3.7) + '@vueuse/integrations': 10.5.0(focus-trap@7.5.4)(vue@3.3.7) + focus-trap: 7.5.4 + mark.js: 8.11.1 + minisearch: 6.2.0 + shiki: 0.14.5 + vite: 4.5.0 + vue: 3.3.7 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - '@vue/composition-api' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vscode-oniguruma@1.7.0: {} + + vscode-textmate@8.0.0: {} + + vue-demi@0.14.6(vue@3.3.7): + dependencies: + vue: 3.3.7 + + vue@3.3.7: + dependencies: + '@vue/compiler-dom': 3.3.7 + '@vue/compiler-sfc': 3.3.7 + '@vue/runtime-dom': 3.3.7 + '@vue/server-renderer': 3.3.7(vue@3.3.7) + '@vue/shared': 3.3.7 diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 00000000..f3c47d11 --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# empty workspace to prevent pnpm from installing docs deps unnecessarily +packages: + - . \ No newline at end of file diff --git a/docs/public/assets/mtproto_vs_botapi.svg b/docs/public/assets/mtproto_vs_botapi.svg new file mode 100644 index 00000000..d5dc791e --- /dev/null +++ b/docs/public/assets/mtproto_vs_botapi.svg @@ -0,0 +1,3 @@ + + +
HTTP, JSON
HTTP, JSON
Client
Client
TCP, MTProto
TCP, MTProto
Bot API
Bot API
Telegram
Telegram
TCP/WebSocket, MTProto
TCP/WebSocket, MTProto
mtcute
mtcute
Telegram
Telegram
C++
C++
TDLib
TDLib
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/public/mtcute-logo.png b/docs/public/mtcute-logo.png new file mode 100644 index 00000000..270eaa78 Binary files /dev/null and b/docs/public/mtcute-logo.png differ diff --git a/docs/public/mtcute-logo.svg b/docs/public/mtcute-logo.svg new file mode 100644 index 00000000..eaff0b1f --- /dev/null +++ b/docs/public/mtcute-logo.svg @@ -0,0 +1,5 @@ + + + + +