telegraf
Version:
Modern Telegram Bot Framework
355 lines (307 loc) • 11.4 kB
text/typescript
import * as crypto from 'crypto'
import * as http from 'http'
import * as https from 'https'
import * as tg from './core/types/typegram'
import * as tt from './telegram-types'
import { Composer } from './composer'
import { MaybePromise } from './core/helpers/util'
import ApiClient from './core/network/client'
import { compactOptions } from './core/helpers/compact'
import Context from './context'
import d from 'debug'
import generateCallback from './core/network/webhook'
import { Polling } from './core/network/polling'
import pTimeout from 'p-timeout'
import Telegram from './telegram'
import { TlsOptions } from 'tls'
import { URL } from 'url'
import safeCompare = require('safe-compare')
const debug = d('telegraf:main')
const DEFAULT_OPTIONS: Telegraf.Options<Context> = {
telegram: {},
handlerTimeout: 90_000, // 90s in ms
contextType: Context,
}
function always<T>(x: T) {
return () => x
}
const anoop = always(Promise.resolve())
export namespace Telegraf {
export interface Options<TContext extends Context> {
contextType: new (
...args: ConstructorParameters<typeof Context>
) => TContext
handlerTimeout: number
telegram?: Partial<ApiClient.Options>
}
export interface LaunchOptions {
dropPendingUpdates?: boolean
/** List the types of updates you want your bot to receive */
allowedUpdates?: tt.UpdateType[]
/** Configuration options for when the bot is run via webhooks */
webhook?: {
/** Public domain for webhook. */
domain: string
/**
* Webhook url path; will be automatically generated if not specified
* @deprecated Pass `path` instead
* */
hookPath?: string
/** Webhook url path; will be automatically generated if not specified */
path?: string
host?: string
port?: number
/** The fixed IP address which will be used to send webhook requests instead of the IP address resolved through DNS */
ipAddress?: string
/**
* Maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery, 1-100. Defaults to 40.
* Use lower values to limit the load on your bot's server, and higher values to increase your bot's throughput.
*/
maxConnections?: number
/** TLS server options. Omit to use http. */
tlsOptions?: TlsOptions
/**
* A secret token to be sent in a header `“X-Telegram-Bot-Api-Secret-Token”` in every webhook request.
* 1-256 characters. Only characters `A-Z`, `a-z`, `0-9`, `_` and `-` are allowed.
* The header is useful to ensure that the request comes from a webhook set by you.
*/
secretToken?: string
/**
* Upload your public key certificate so that the root certificate in use can be checked.
* See [self-signed guide](https://core.telegram.org/bots/self-signed) for details.
*/
certificate?: tg.InputFile
cb?: http.RequestListener
}
}
}
const TOKEN_HEADER = 'x-telegram-bot-api-secret-token'
export class Telegraf<C extends Context = Context> extends Composer<C> {
private readonly options: Telegraf.Options<C>
private webhookServer?: http.Server | https.Server
private polling?: Polling
/** Set manually to avoid implicit `getMe` call in `launch` or `webhookCallback` */
public botInfo?: tg.UserFromGetMe
public telegram: Telegram
readonly context: Partial<C> = {}
/** Assign to this to customise the webhook filter middleware.
* `{ path, secretToken }` will be bound to this rather than the Telegraf instance.
* Remember to assign a regular function and not an arrow function so it's bindable.
*/
public webhookFilter = function (
// NOTE: this function is assigned to a variable instead of being a method to signify that it's assignable
// NOTE: the `this` binding is so custom impls don't need to double wrap
this: {
/** @deprecated Use path instead */
hookPath: string
path: string
secretToken?: string
},
req: http.IncomingMessage
) {
const debug = d('telegraf:webhook')
if (req.method === 'POST') {
if (safeCompare(this.path, req.url as string)) {
// no need to check if secret_token was not set
if (!this.secretToken) return true
else {
const token = req.headers[TOKEN_HEADER] as string
if (safeCompare(this.secretToken, token)) return true
else debug('Secret token does not match:', token, this.secretToken)
}
} else debug('Path does not match:', req.url, this.path)
} else debug('Unexpected request method, not POST. Received:', req.method)
return false
}
private handleError = (err: unknown, ctx: C): MaybePromise<void> => {
// set exit code to emulate `warn-with-error-code` behavior of
// https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode
// to prevent a clean exit despite an error being thrown
process.exitCode = 1
console.error('Unhandled error while processing', ctx.update)
throw err
}
constructor(token: string, options?: Partial<Telegraf.Options<C>>) {
super()
// @ts-expect-error Trust me, TS
this.options = {
...DEFAULT_OPTIONS,
...compactOptions(options),
}
this.telegram = new Telegram(token, this.options.telegram)
debug('Created a `Telegraf` instance')
}
private get token() {
return this.telegram.token
}
/** @deprecated use `ctx.telegram.webhookReply` */
set webhookReply(webhookReply: boolean) {
this.telegram.webhookReply = webhookReply
}
/** @deprecated use `ctx.telegram.webhookReply` */
get webhookReply() {
return this.telegram.webhookReply
}
/**
* _Override_ error handling
*/
catch(handler: (err: unknown, ctx: C) => MaybePromise<void>) {
this.handleError = handler
return this
}
/**
* You must call `bot.telegram.setWebhook` for this to work.
* You should probably use {@link Telegraf.createWebhook} instead.
*/
webhookCallback(path = '/', opts: { secretToken?: string } = {}) {
const { secretToken } = opts
return generateCallback(
this.webhookFilter.bind({ hookPath: path, path, secretToken }),
(update: tg.Update, res: http.ServerResponse) =>
this.handleUpdate(update, res)
)
}
private getDomainOpts(opts: { domain: string; path?: string }) {
const protocol =
opts.domain.startsWith('https://') || opts.domain.startsWith('http://')
if (protocol)
debug(
'Unexpected protocol in domain, telegraf will use https:',
opts.domain
)
const domain = protocol ? new URL(opts.domain).host : opts.domain
const path = opts.path ?? `/telegraf/${this.secretPathComponent()}`
const url = `https://${domain}${path}`
return { domain, path, url }
}
/**
* Specify a url to receive incoming updates via webhook.
* Returns an Express-style middleware you can pass to app.use()
*/
async createWebhook(
opts: { domain: string; path?: string } & tt.ExtraSetWebhook
) {
const { domain, path, ...extra } = opts
const domainOpts = this.getDomainOpts({ domain, path })
await this.telegram.setWebhook(domainOpts.url, extra)
debug(`Webhook set to ${domainOpts.url}`)
return this.webhookCallback(domainOpts.path, {
secretToken: extra.secret_token,
})
}
private startPolling(allowedUpdates: tt.UpdateType[] = []) {
this.polling = new Polling(this.telegram, allowedUpdates)
return this.polling.loop(async (update) => {
await this.handleUpdate(update)
})
}
private startWebhook(
path: string,
tlsOptions?: TlsOptions,
port?: number,
host?: string,
cb?: http.RequestListener,
secretToken?: string
) {
const webhookCb = this.webhookCallback(path, { secretToken })
const callback: http.RequestListener =
typeof cb === 'function'
? (req, res) => webhookCb(req, res, () => cb(req, res))
: webhookCb
this.webhookServer =
tlsOptions != null
? https.createServer(tlsOptions, callback)
: http.createServer(callback)
this.webhookServer.listen(port, host, () => {
debug('Webhook listening on port: %s', port)
})
return this
}
secretPathComponent() {
return crypto
.createHash('sha3-256')
.update(this.token)
.update(process.version) // salt
.digest('hex')
}
async launch(onLaunch?: () => void): Promise<void>
async launch(
config: Telegraf.LaunchOptions,
onLaunch?: () => void
): Promise<void>
/**
* @see https://github.com/telegraf/telegraf/discussions/1344#discussioncomment-335700
*/
async launch(
config: Telegraf.LaunchOptions | (() => void) = {},
/** @experimental */
onLaunch?: () => void
) {
const [cfg, onMe] =
typeof config === 'function' ? [{}, config] : [config, onLaunch]
const drop_pending_updates = cfg.dropPendingUpdates
const allowed_updates = cfg.allowedUpdates
const webhook = cfg.webhook
debug('Connecting to Telegram')
this.botInfo ??= await this.telegram.getMe()
onMe?.()
debug(`Launching @${this.botInfo.username}`)
if (webhook === undefined) {
await this.telegram.deleteWebhook({ drop_pending_updates })
debug('Bot started with long polling')
await this.startPolling(allowed_updates)
return
}
const domainOpts = this.getDomainOpts({
domain: webhook.domain,
path: webhook.path ?? webhook.hookPath,
})
const { tlsOptions, port, host, cb, secretToken } = webhook
this.startWebhook(domainOpts.path, tlsOptions, port, host, cb, secretToken)
await this.telegram.setWebhook(domainOpts.url, {
drop_pending_updates: drop_pending_updates,
allowed_updates: allowed_updates,
ip_address: webhook.ipAddress,
max_connections: webhook.maxConnections,
secret_token: webhook.secretToken,
certificate: webhook.certificate,
})
debug(`Bot started with webhook @ ${domainOpts.url}`)
}
stop(reason = 'unspecified') {
debug('Stopping bot... Reason:', reason)
// https://github.com/telegraf/telegraf/pull/1224#issuecomment-742693770
if (this.polling === undefined && this.webhookServer === undefined) {
throw new Error('Bot is not running!')
}
this.webhookServer?.close()
this.polling?.stop()
}
private botInfoCall?: Promise<tg.UserFromGetMe>
async handleUpdate(update: tg.Update, webhookResponse?: http.ServerResponse) {
this.botInfo ??=
(debug(
'Update %d is waiting for `botInfo` to be initialized',
update.update_id
),
await (this.botInfoCall ??= this.telegram.getMe()))
debug('Processing update', update.update_id)
const tg = new Telegram(this.token, this.telegram.options, webhookResponse)
const TelegrafContext = this.options.contextType
const ctx = new TelegrafContext(update, tg, this.botInfo)
Object.assign(ctx, this.context)
try {
await pTimeout(
Promise.resolve(this.middleware()(ctx, anoop)),
this.options.handlerTimeout
)
} catch (err) {
return await this.handleError(err, ctx)
} finally {
if (webhookResponse?.writableEnded === false) {
webhookResponse.end()
}
debug('Finished processing update', update.update_id)
}
}
}