telegraf
Version:
📡 Modern Telegram bot framework
240 lines (219 loc) • 6.87 kB
JavaScript
const debug = require('debug')('telegraf:client')
const crypto = require('crypto')
const fetch = require('node-fetch')
const fs = require('fs')
const https = require('https')
const path = require('path')
const TelegramError = require('./error')
const MultipartStream = require('./multipart-stream')
const { isStream } = MultipartStream
const WebhookBlacklist = [
'getChat',
'getChatAdministrators',
'getChatMember',
'getChatMembersCount',
'getFile',
'getFileLink',
'getGameHighScores',
'getMe',
'getUserProfilePhotos',
'getWebhookInfo'
]
const DefaultExtensions = {
audio: 'mp3',
photo: 'jpg',
sticker: 'webp',
video: 'mp4',
video_note: 'mp4',
voice: 'ogg'
}
const DefaultOptions = {
apiRoot: 'https://api.telegram.org',
webhookReply: true,
agent: new https.Agent({
keepAlive: true,
keepAliveMsecs: 10000
})
}
const WebhookReplyStub = {
webhook: true,
details: 'https://core.telegram.org/bots/api#making-requests-when-getting-updates'
}
function safeJSONParse (text) {
try {
return JSON.parse(text)
} catch (err) {
debug('JSON parse failed', err)
}
}
function includesMedia (payload) {
return Object.keys(payload).some(
(key) => Array.isArray(payload[key])
? payload[key].some(({ media }) => media && typeof media === 'object' && (media.source || media.url))
: payload[key] && typeof payload[key] === 'object' && (payload[key].source || payload[key].url)
)
}
function buildJSONConfig (options) {
return Promise.resolve({
method: 'POST',
compress: true,
headers: { 'content-type': 'application/json', 'connection': 'keep-alive' },
body: JSON.stringify(options)
})
}
function buildFormDataConfig (options) {
if (options.reply_markup && typeof options.reply_markup !== 'string') {
options.reply_markup = JSON.stringify(options.reply_markup)
}
const boundary = crypto.randomBytes(32).toString('hex')
const formData = new MultipartStream(boundary)
const tasks = Object.keys(options).map((key) => attachFormValue(formData, options[key], key))
return Promise.all(tasks).then(() => {
return {
method: 'POST',
compress: true,
headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, 'connection': 'keep-alive' },
body: formData
}
})
}
function attachFormValue (form, value, id) {
if (!value) {
return Promise.resolve()
}
const valueType = typeof value
if (valueType === 'string' || valueType === 'boolean' || valueType === 'number') {
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"` },
body: `${value}`
})
return Promise.resolve()
}
if (Array.isArray(value)) {
return Promise.all(
value.map((item) => {
if (typeof item.media !== 'object') {
return Promise.resolve(item)
}
const attachmentId = crypto.randomBytes(16).toString('hex')
return attachFormMedia(form, item.media, attachmentId)
.then(() => Object.assign({}, item, { media: `attach://${attachmentId}` }))
})
).then((items) => form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"` },
body: JSON.stringify(items)
}))
}
return attachFormMedia(form, value, id)
}
function attachFormMedia (form, media, id) {
let fileName = media.filename || `${id}.${DefaultExtensions[id] || 'dat'}`
if (media.url) {
return fetch(media.url).then((res) =>
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"` },
body: res.body
})
)
}
if (media.source) {
if (fs.existsSync(media.source)) {
fileName = media.filename || path.basename(media.source)
media.source = fs.createReadStream(media.source)
}
if (isStream(media.source) || Buffer.isBuffer(media.source)) {
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"` },
body: media.source
})
}
}
return Promise.resolve()
}
function isKoaResponse (response) {
return typeof response.set === 'function' && typeof response.header === 'object'
}
function answerToWebhook (response, payload = {}) {
if (!includesMedia(payload)) {
if (isKoaResponse(response)) {
response.body = payload
return Promise.resolve(WebhookReplyStub)
}
if (!response.headersSent) {
response.setHeader('content-type', 'application/json')
}
return new Promise((resolve) =>
response.end(JSON.stringify(payload), 'utf-8', () => resolve(WebhookReplyStub))
)
}
return buildFormDataConfig(payload)
.then(({ headers, body }) => {
if (isKoaResponse(response)) {
Object.keys(headers).forEach(key => response.set(key, headers[key]))
response.body = body
return Promise.resolve(WebhookReplyStub)
}
if (!response.headersSent) {
Object.keys(headers).forEach(key => response.setHeader(key, headers[key]))
}
return new Promise((resolve) => {
response.on('finish', () => resolve(WebhookReplyStub))
body.pipe(response)
})
})
}
class ApiClient {
constructor (token, options, webhookResponse) {
this.token = token
this.options = Object.assign({}, DefaultOptions, options)
if (this.options.apiRoot.startsWith('http://')) {
this.options.agent = null
}
this.response = webhookResponse
}
set webhookReply (enable) {
this.options.webhookReply = enable
}
get webhookReply () {
return this.options.webhookReply
}
callApi (method, payload = {}) {
const { token, options, response, responseEnd } = this
if (options.webhookReply && response && !responseEnd && !WebhookBlacklist.includes(method)) {
debug('â–· webhook call:', method)
this.responseEnd = true
return answerToWebhook(response, Object.assign({ method }, payload))
}
if (!token) {
throw new TelegramError({
error_code: 401,
description: 'Bot Token is required'
})
}
debug('▶︎ http call:', method)
const buildConfig = includesMedia(payload)
? buildFormDataConfig(Object.assign({ method }, payload))
: buildJSONConfig(payload)
return buildConfig
.then((config) => {
const apiUrl = `${options.apiRoot}/bot${token}/${method}`
config.agent = options.agent
return fetch(apiUrl, config)
})
.then((res) => res.text())
.then((text) => {
return safeJSONParse(text) || {
error_code: 500,
description: 'Unsupported http response from Telegram',
response: text
}
})
.then((data) => {
if (!data.ok) {
throw new TelegramError(data, { method, payload })
}
return data.result
})
}
}
module.exports = ApiClient