1 | import * as crypto from 'crypto'
|
2 | import * as http from 'http'
|
3 | import * as https from 'https'
|
4 | import * as tg from './core/types/typegram'
|
5 | import * as tt from './telegram-types'
|
6 | import { Composer } from './composer'
|
7 | import { MaybePromise } from './core/helpers/util'
|
8 | import ApiClient from './core/network/client'
|
9 | import { compactOptions } from './core/helpers/compact'
|
10 | import Context from './context'
|
11 | import d from 'debug'
|
12 | import generateCallback from './core/network/webhook'
|
13 | import { Polling } from './core/network/polling'
|
14 | import pTimeout from 'p-timeout'
|
15 | import Telegram from './telegram'
|
16 | import { TlsOptions } from 'tls'
|
17 | import { URL } from 'url'
|
18 | import safeCompare = require('safe-compare')
|
19 | const debug = d('telegraf:main')
|
20 |
|
21 | const DEFAULT_OPTIONS: Telegraf.Options<Context> = {
|
22 | telegram: {},
|
23 | handlerTimeout: 90_000,
|
24 | contextType: Context,
|
25 | }
|
26 |
|
27 | function always<T>(x: T) {
|
28 | return () => x
|
29 | }
|
30 |
|
31 | const anoop = always(Promise.resolve())
|
32 |
|
33 | export 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 |
|
45 | allowedUpdates?: tt.UpdateType[]
|
46 |
|
47 | webhook?: {
|
48 |
|
49 | domain: string
|
50 |
|
51 |
|
52 | hookPath?: string
|
53 |
|
54 | host?: string
|
55 | port?: number
|
56 |
|
57 |
|
58 | ipAddress?: string
|
59 |
|
60 | |
61 |
|
62 |
|
63 |
|
64 | maxConnections?: number
|
65 |
|
66 |
|
67 | tlsOptions?: TlsOptions
|
68 |
|
69 | |
70 |
|
71 |
|
72 |
|
73 |
|
74 | secretToken?: string
|
75 |
|
76 | |
77 |
|
78 |
|
79 |
|
80 | certificate?: tg.InputFile
|
81 |
|
82 | cb?: http.RequestListener
|
83 | }
|
84 | }
|
85 | }
|
86 |
|
87 | const TOKEN_HEADER = 'x-telegram-bot-api-secret-token'
|
88 |
|
89 | export 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 |
|
94 | public botInfo?: tg.UserFromGetMe
|
95 | public telegram: Telegram
|
96 | readonly context: Partial<C> = {}
|
97 |
|
98 | |
99 |
|
100 |
|
101 |
|
102 | public webhookFilter = function (
|
103 |
|
104 |
|
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 |
|
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 |
|
127 |
|
128 |
|
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 |
|
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 |
|
150 | set webhookReply(webhookReply: boolean) {
|
151 | this.telegram.webhookReply = webhookReply
|
152 | }
|
153 |
|
154 |
|
155 | get webhookReply() {
|
156 | return this.telegram.webhookReply
|
157 | }
|
158 |
|
159 | |
160 |
|
161 |
|
162 | catch(handler: (err: unknown, ctx: C) => MaybePromise<void>) {
|
163 | this.handleError = handler
|
164 | return this
|
165 | }
|
166 |
|
167 | |
168 |
|
169 |
|
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 |
|
199 |
|
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)
|
251 | .digest('hex')
|
252 | }
|
253 |
|
254 | |
255 |
|
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 |
|
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 | }
|