UNPKG

10.8 kBPlain TextView Raw
1import * as crypto from 'crypto'
2import * as http from 'http'
3import * as https from 'https'
4import * as tg from './core/types/typegram'
5import * as tt from './telegram-types'
6import { Composer } from './composer'
7import { MaybePromise } from './core/helpers/util'
8import ApiClient from './core/network/client'
9import { compactOptions } from './core/helpers/compact'
10import Context from './context'
11import d from 'debug'
12import generateCallback from './core/network/webhook'
13import { Polling } from './core/network/polling'
14import pTimeout from 'p-timeout'
15import Telegram from './telegram'
16import { TlsOptions } from 'tls'
17import { URL } from 'url'
18import safeCompare = require('safe-compare')
19const debug = d('telegraf:main')
20
21const DEFAULT_OPTIONS: Telegraf.Options<Context> = {
22 telegram: {},
23 handlerTimeout: 90_000, // 90s in ms
24 contextType: Context,
25}
26
27function always<T>(x: T) {
28 return () => x
29}
30
31const anoop = always(Promise.resolve())
32
33export namespace Telegraf {
34 export interface Options<TContext extends Context> {
35 contextType: new (
36 ...args: ConstructorParameters<typeof Context>
37 ) => TContext
38 handlerTimeout: number
39 telegram?: Partial<ApiClient.Options>
40 }
41
42 export interface LaunchOptions {
43 dropPendingUpdates?: boolean
44 /** List the types of updates you want your bot to receive */
45 allowedUpdates?: tt.UpdateType[]
46 /** Configuration options for when the bot is run via webhooks */
47 webhook?: {
48 /** Public domain for webhook. */
49 domain: string
50
51 /** Webhook url path; will be automatically generated if not specified */
52 hookPath?: string
53
54 host?: string
55 port?: number
56
57 /** The fixed IP address which will be used to send webhook requests instead of the IP address resolved through DNS */
58 ipAddress?: string
59
60 /**
61 * Maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery, 1-100. Defaults to 40.
62 * Use lower values to limit the load on your bot's server, and higher values to increase your bot's throughput.
63 */
64 maxConnections?: number
65
66 /** TLS server options. Omit to use http. */
67 tlsOptions?: TlsOptions
68
69 /**
70 * A secret token to be sent in a header `“X-Telegram-Bot-Api-Secret-Token”` in every webhook request.
71 * 1-256 characters. Only characters `A-Z`, `a-z`, `0-9`, `_` and `-` are allowed.
72 * The header is useful to ensure that the request comes from a webhook set by you.
73 */
74 secretToken?: string
75
76 /**
77 * Upload your public key certificate so that the root certificate in use can be checked.
78 * See [self-signed guide](https://core.telegram.org/bots/self-signed) for details.
79 */
80 certificate?: tg.InputFile
81
82 cb?: http.RequestListener
83 }
84 }
85}
86
87const TOKEN_HEADER = 'x-telegram-bot-api-secret-token'
88
89export class Telegraf<C extends Context = Context> extends Composer<C> {
90 private readonly options: Telegraf.Options<C>
91 private webhookServer?: http.Server | https.Server
92 private polling?: Polling
93 /** Set manually to avoid implicit `getMe` call in `launch` or `webhookCallback` */
94 public botInfo?: tg.UserFromGetMe
95 public telegram: Telegram
96 readonly context: Partial<C> = {}
97
98 /** Assign to this to customise the webhook filter middleware.
99 * `{ hookPath, secretToken }` will be bound to this rather than the Telegraf instance.
100 * Remember to assign a regular function and not an arrow function so it's bindable.
101 */
102 public webhookFilter = function (
103 // NOTE: this function is assigned to a variable instead of being a method to signify that it's assignable
104 // NOTE: the `this` binding is so custom impls don't need to double wrap
105 this: { hookPath: string; secretToken?: string },
106 req: http.IncomingMessage
107 ) {
108 const debug = d('telegraf:webhook')
109
110 if (req.method === 'POST') {
111 if (safeCompare(this.hookPath, req.url as string)) {
112 // no need to check if secret_token was not set
113 if (!this.secretToken) return true
114 else {
115 const token = req.headers[TOKEN_HEADER] as string
116 if (safeCompare(this.secretToken, token)) return true
117 else debug('Secret token does not match:', token, this.secretToken)
118 }
119 } else debug('Path does not match:', req.url, this.hookPath)
120 } else debug('Unexpected request method, not POST. Received:', req.method)
121
122 return false
123 }
124
125 private handleError = (err: unknown, ctx: C): MaybePromise<void> => {
126 // set exit code to emulate `warn-with-error-code` behavior of
127 // https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode
128 // to prevent a clean exit despite an error being thrown
129 process.exitCode = 1
130 console.error('Unhandled error while processing', ctx.update)
131 throw err
132 }
133
134 constructor(token: string, options?: Partial<Telegraf.Options<C>>) {
135 super()
136 // @ts-expect-error Trust me, TS
137 this.options = {
138 ...DEFAULT_OPTIONS,
139 ...compactOptions(options),
140 }
141 this.telegram = new Telegram(token, this.options.telegram)
142 debug('Created a `Telegraf` instance')
143 }
144
145 private get token() {
146 return this.telegram.token
147 }
148
149 /** @deprecated use `ctx.telegram.webhookReply` */
150 set webhookReply(webhookReply: boolean) {
151 this.telegram.webhookReply = webhookReply
152 }
153
154 /** @deprecated use `ctx.telegram.webhookReply` */
155 get webhookReply() {
156 return this.telegram.webhookReply
157 }
158
159 /**
160 * _Override_ error handling
161 */
162 catch(handler: (err: unknown, ctx: C) => MaybePromise<void>) {
163 this.handleError = handler
164 return this
165 }
166
167 /**
168 * You must call `bot.telegram.setWebhook` for this to work.
169 * You should probably use {@link Telegraf.createWebhook} instead.
170 */
171 webhookCallback(hookPath = '/', opts: { secretToken?: string } = {}) {
172 const { secretToken } = opts
173 return generateCallback(
174 this.webhookFilter.bind({ hookPath, secretToken }),
175 (update: tg.Update, res: http.ServerResponse) =>
176 this.handleUpdate(update, res)
177 )
178 }
179
180 private getDomainOpts(opts: { domain: string; path?: string }) {
181 const protocol =
182 opts.domain.startsWith('https://') || opts.domain.startsWith('http://')
183
184 if (protocol)
185 debug(
186 'Unexpected protocol in domain, telegraf will use https:',
187 opts.domain
188 )
189
190 const domain = protocol ? new URL(opts.domain).host : opts.domain
191 const path = opts.path ?? `/telegraf/${this.secretPathComponent()}`
192 const url = `https://${domain}${path}`
193
194 return { domain, path, url }
195 }
196
197 /**
198 * Specify a url to receive incoming updates via webhook.
199 * Returns an Express-style middleware you can pass to app.use()
200 */
201 async createWebhook(
202 opts: { domain: string; path?: string } & tt.ExtraSetWebhook
203 ) {
204 const { domain, path, ...extra } = opts
205
206 const domainOpts = this.getDomainOpts({ domain, path })
207
208 await this.telegram.setWebhook(domainOpts.url, extra)
209 debug(`Webhook set to ${domainOpts.url}`)
210
211 return this.webhookCallback(domainOpts.path, {
212 secretToken: extra.secret_token,
213 })
214 }
215
216 private startPolling(allowedUpdates: tt.UpdateType[] = []) {
217 this.polling = new Polling(this.telegram, allowedUpdates)
218 return this.polling.loop(async (update) => {
219 await this.handleUpdate(update)
220 })
221 }
222
223 private startWebhook(
224 hookPath: string,
225 tlsOptions?: TlsOptions,
226 port?: number,
227 host?: string,
228 cb?: http.RequestListener,
229 secretToken?: string
230 ) {
231 const webhookCb = this.webhookCallback(hookPath, { secretToken })
232 const callback: http.RequestListener =
233 typeof cb === 'function'
234 ? (req, res) => webhookCb(req, res, () => cb(req, res))
235 : webhookCb
236 this.webhookServer =
237 tlsOptions != null
238 ? https.createServer(tlsOptions, callback)
239 : http.createServer(callback)
240 this.webhookServer.listen(port, host, () => {
241 debug('Webhook listening on port: %s', port)
242 })
243 return this
244 }
245
246 secretPathComponent() {
247 return crypto
248 .createHash('sha3-256')
249 .update(this.token)
250 .update(process.version) // salt
251 .digest('hex')
252 }
253
254 /**
255 * @see https://github.com/telegraf/telegraf/discussions/1344#discussioncomment-335700
256 */
257 async launch(config: Telegraf.LaunchOptions = {}) {
258 debug('Connecting to Telegram')
259 this.botInfo ??= await this.telegram.getMe()
260 debug(`Launching @${this.botInfo.username}`)
261
262 if (config.webhook === undefined) {
263 await this.telegram.deleteWebhook({
264 drop_pending_updates: config.dropPendingUpdates,
265 })
266 debug('Bot started with long polling')
267 await this.startPolling(config.allowedUpdates)
268 return
269 }
270
271 const domainOpts = this.getDomainOpts({
272 domain: config.webhook.domain,
273 path: config.webhook.hookPath,
274 })
275
276 const { tlsOptions, port, host, cb, secretToken } = config.webhook
277
278 this.startWebhook(domainOpts.path, tlsOptions, port, host, cb, secretToken)
279
280 await this.telegram.setWebhook(domainOpts.url, {
281 drop_pending_updates: config.dropPendingUpdates,
282 allowed_updates: config.allowedUpdates,
283 ip_address: config.webhook.ipAddress,
284 max_connections: config.webhook.maxConnections,
285 secret_token: config.webhook.secretToken,
286 certificate: config.webhook.certificate,
287 })
288
289 debug(`Bot started with webhook @ ${domainOpts.url}`)
290 }
291
292 stop(reason = 'unspecified') {
293 debug('Stopping bot... Reason:', reason)
294 // https://github.com/telegraf/telegraf/pull/1224#issuecomment-742693770
295 if (this.polling === undefined && this.webhookServer === undefined) {
296 throw new Error('Bot is not running!')
297 }
298 this.webhookServer?.close()
299 this.polling?.stop()
300 }
301
302 private botInfoCall?: Promise<tg.UserFromGetMe>
303 async handleUpdate(update: tg.Update, webhookResponse?: http.ServerResponse) {
304 this.botInfo ??=
305 (debug(
306 'Update %d is waiting for `botInfo` to be initialized',
307 update.update_id
308 ),
309 await (this.botInfoCall ??= this.telegram.getMe()))
310 debug('Processing update', update.update_id)
311 const tg = new Telegram(this.token, this.telegram.options, webhookResponse)
312 const TelegrafContext = this.options.contextType
313 const ctx = new TelegrafContext(update, tg, this.botInfo)
314 Object.assign(ctx, this.context)
315 try {
316 await pTimeout(
317 Promise.resolve(this.middleware()(ctx, anoop)),
318 this.options.handlerTimeout
319 )
320 } catch (err) {
321 return await this.handleError(err, ctx)
322 } finally {
323 if (webhookResponse?.writableEnded === false) {
324 webhookResponse.end()
325 }
326 debug('Finished processing update', update.update_id)
327 }
328 }
329}