UNPKG

7.7 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 * as util from 'util'
7import { Composer, MaybePromise } from './composer'
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'
18const debug = d('telegraf:main')
19
20const DEFAULT_OPTIONS: Telegraf.Options<Context> = {
21 telegram: {},
22 handlerTimeout: 90_000, // 90s in ms
23 contextType: Context,
24}
25
26function always<T>(x: T) {
27 return () => x
28}
29const anoop = always(Promise.resolve())
30
31// eslint-disable-next-line
32export namespace Telegraf {
33 export interface Options<TContext extends Context> {
34 contextType: new (
35 ...args: ConstructorParameters<typeof Context>
36 ) => TContext
37 handlerTimeout: number
38 telegram?: Partial<ApiClient.Options>
39 }
40
41 export interface LaunchOptions {
42 dropPendingUpdates?: boolean
43 /** List the types of updates you want your bot to receive */
44 allowedUpdates?: tt.UpdateType[]
45 /** Configuration options for when the bot is run via webhooks */
46 webhook?: {
47 /** Public domain for webhook. If domain is not specified, hookPath should contain a domain name as well (not only path component). */
48 domain?: string
49
50 /** Webhook url path; will be automatically generated if not specified */
51 hookPath?: string
52
53 host?: string
54 port?: number
55
56 /** TLS server options. Omit to use http. */
57 tlsOptions?: TlsOptions
58
59 cb?: http.RequestListener
60 }
61 }
62}
63
64// eslint-disable-next-line import/export
65export class Telegraf<C extends Context = Context> extends Composer<C> {
66 private readonly options: Telegraf.Options<C>
67 private webhookServer?: http.Server | https.Server
68 private polling?: Polling
69 /** Set manually to avoid implicit `getMe` call in `launch` or `webhookCallback` */
70 public botInfo?: tg.UserFromGetMe
71 public telegram: Telegram
72 readonly context: Partial<C> = {}
73
74 private handleError = (err: unknown, ctx: C): MaybePromise<void> => {
75 // set exit code to emulate `warn-with-error-code` behavior of
76 // https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode
77 // to prevent a clean exit despite an error being thrown
78 process.exitCode = 1
79 console.error('Unhandled error while processing', ctx.update)
80 throw err
81 }
82
83 constructor(token: string, options?: Partial<Telegraf.Options<C>>) {
84 super()
85 // @ts-expect-error
86 this.options = {
87 ...DEFAULT_OPTIONS,
88 ...compactOptions(options),
89 }
90 this.telegram = new Telegram(token, this.options.telegram)
91 debug('Created a `Telegraf` instance')
92 }
93
94 private get token() {
95 return this.telegram.token
96 }
97
98 /** @deprecated use `ctx.telegram.webhookReply` */
99 set webhookReply(webhookReply: boolean) {
100 this.telegram.webhookReply = webhookReply
101 }
102
103 get webhookReply() {
104 return this.telegram.webhookReply
105 }
106
107 /**
108 * _Override_ error handling
109 */
110 catch(handler: (err: unknown, ctx: C) => MaybePromise<void>) {
111 this.handleError = handler
112 return this
113 }
114
115 webhookCallback(path = '/') {
116 return generateCallback(
117 path,
118 (update: tg.Update, res: http.ServerResponse) =>
119 this.handleUpdate(update, res)
120 )
121 }
122
123 private startPolling(allowedUpdates: tt.UpdateType[] = []) {
124 this.polling = new Polling(this.telegram, allowedUpdates)
125 // eslint-disable-next-line @typescript-eslint/no-floating-promises
126 this.polling.loop(async (updates) => {
127 await this.handleUpdates(updates)
128 })
129 }
130
131 private startWebhook(
132 hookPath: string,
133 tlsOptions?: TlsOptions,
134 port?: number,
135 host?: string,
136 cb?: http.RequestListener
137 ) {
138 const webhookCb = this.webhookCallback(hookPath)
139 const callback: http.RequestListener =
140 typeof cb === 'function'
141 ? (req, res) => webhookCb(req, res, () => cb(req, res))
142 : webhookCb
143 this.webhookServer =
144 tlsOptions != null
145 ? https.createServer(tlsOptions, callback)
146 : http.createServer(callback)
147 this.webhookServer.listen(port, host, () => {
148 debug('Webhook listening on port: %s', port)
149 })
150 return this
151 }
152
153 secretPathComponent() {
154 return crypto
155 .createHash('sha3-256')
156 .update(this.token)
157 .update(process.version) // salt
158 .digest('hex')
159 }
160
161 /**
162 * @see https://github.com/telegraf/telegraf/discussions/1344#discussioncomment-335700
163 */
164 async launch(config: Telegraf.LaunchOptions = {}) {
165 debug('Connecting to Telegram')
166 this.botInfo ??= await this.telegram.getMe()
167 debug(`Launching @${this.botInfo.username}`)
168 if (config.webhook === undefined) {
169 await this.telegram.deleteWebhook({
170 drop_pending_updates: config.dropPendingUpdates,
171 })
172 this.startPolling(config.allowedUpdates)
173 debug('Bot started with long polling')
174 return
175 }
176 if (
177 typeof config.webhook.domain !== 'string' &&
178 typeof config.webhook.hookPath !== 'string'
179 ) {
180 throw new Error('Webhook domain or webhook path is required')
181 }
182 let domain = config.webhook.domain ?? ''
183 if (domain.startsWith('https://') || domain.startsWith('http://')) {
184 domain = new URL(domain).host
185 }
186 const hookPath =
187 config.webhook.hookPath ?? `/telegraf/${this.secretPathComponent()}`
188 const { port, host, tlsOptions, cb } = config.webhook
189 this.startWebhook(hookPath, tlsOptions, port, host, cb)
190 if (!domain) {
191 debug('Bot started with webhook')
192 return
193 }
194 await this.telegram.setWebhook(`https://${domain}${hookPath}`, {
195 drop_pending_updates: config.dropPendingUpdates,
196 allowed_updates: config.allowedUpdates,
197 })
198 debug(`Bot started with webhook @ https://${domain}`)
199 }
200
201 stop(reason = 'unspecified') {
202 debug('Stopping bot... Reason:', reason)
203 // https://github.com/telegraf/telegraf/pull/1224#issuecomment-742693770
204 if (this.polling === undefined && this.webhookServer === undefined) {
205 throw new Error('Bot is not running!')
206 }
207 this.webhookServer?.close()
208 this.polling?.stop()
209 }
210
211 private handleUpdates(updates: readonly tg.Update[]) {
212 if (!Array.isArray(updates)) {
213 throw new TypeError(util.format('Updates must be an array, got', updates))
214 }
215 return Promise.all(updates.map((update) => this.handleUpdate(update)))
216 }
217
218 private botInfoCall?: Promise<tg.UserFromGetMe>
219 async handleUpdate(update: tg.Update, webhookResponse?: http.ServerResponse) {
220 this.botInfo ??=
221 (debug(
222 'Update %d is waiting for `botInfo` to be initialized',
223 update.update_id
224 ),
225 await (this.botInfoCall ??= this.telegram.getMe()))
226 debug('Processing update', update.update_id)
227 const tg = new Telegram(this.token, this.telegram.options, webhookResponse)
228 const TelegrafContext = this.options.contextType
229 const ctx = new TelegrafContext(update, tg, this.botInfo)
230 Object.assign(ctx, this.context)
231 try {
232 await pTimeout(
233 Promise.resolve(this.middleware()(ctx, anoop)),
234 this.options.handlerTimeout
235 )
236 } catch (err) {
237 return await this.handleError(err, ctx)
238 } finally {
239 if (webhookResponse?.writableEnded === false) {
240 webhookResponse.end()
241 }
242 debug('Finished processing update', update.update_id)
243 }
244 }
245}