Skip to content

Commit

Permalink
Merge pull request #1705 from telegraf/feat/client
Browse files Browse the repository at this point in the history
  • Loading branch information
wojpawlik committed Sep 28, 2022
2 parents ff4f70c + 546aebd commit 77fc227
Show file tree
Hide file tree
Showing 12 changed files with 887 additions and 411 deletions.
709 changes: 362 additions & 347 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
},
"types": "./typings/index.d.ts",
"dependencies": {
"@telegraf/client": "^0.7.1",
"debug": "^4.3.3",
"mri": "^1.2.0",
"p-timeout": "^4.1.0",
Expand All @@ -68,21 +67,21 @@
},
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/node": "^18.7.20",
"@types/node": "^18.7.23",
"@types/safe-compare": "^1.1.0",
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
"@typescript-eslint/eslint-plugin": "^5.38.1",
"@typescript-eslint/parser": "^5.38.1",
"ava": "^4.0.1",
"eslint": "^8.8.0",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-ava": "^13.2.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-promise": "^6.0.0",
"prettier": "^2.5.1",
"typedoc": "^0.23.10",
"typescript": "^4.7.4"
"typedoc": "^0.23.15",
"typescript": "^4.8.4"
},
"keywords": [
"telegraf",
Expand Down
133 changes: 133 additions & 0 deletions src/core/network/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// for https://gist.github.com/2b1b226d52d675ec246c6f8abdab81ef
export type { Update, UserFromGetMe } from 'typegram'
import type { ApiResponse, File, Typegram } from 'typegram'
import createDebug from 'debug'
import { fetch, FormData, type RequestInit } from '../../vendor/fetch'

const debug = createDebug('telegraf:client')

export const defaultOptions = {
api: {
mode: 'bot' as 'bot' | 'user',
root: new URL('https://api.telegram.org'),
},
}

export type ClientOptions = typeof defaultOptions
export type TelegrafTypegram = Typegram<InputFile>
export type InputFile = Blob | StreamFile
export type TelegramP = TelegrafTypegram['TelegramP']
export type Opts = TelegrafTypegram['Opts']

type Telegram = TelegrafTypegram['Telegram']

export type Ret = {
[M in keyof Opts]: ReturnType<Telegram[M]>
}

export class StreamFile {
readonly size = NaN
constructor(
readonly stream: () => AsyncIterable<Uint8Array>,
readonly name: string
) {}
}

Object.defineProperty(StreamFile.prototype, Symbol.toStringTag, {
value: 'File',
})

// based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License)
export function isInputFile(value: any): value is InputFile {
return (
value !== null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.stream === 'function' &&
typeof value.constructor === 'function' &&
/^(?:Blob|File)$/.test(value[Symbol.toStringTag])
)
}

function stringify(value: unknown) {
if (typeof value === 'string') return value
if (isInputFile(value)) return value
return JSON.stringify(value)
}

function serialize(payload: Record<string, any>) {
const formData = new FormData()
const attach = (entry: any, index: number) => {
const result = { ...entry }
if (isInputFile(entry.media)) {
const id = entry.type + index
result.media = `attach://${id}`
formData.append(id, entry.media)
}
if (isInputFile(entry.thumb)) {
const id = 'thumb' + index
result.thumb = `attach://${id}`
formData.append(id, entry.thumb)
}
return result
}

// eslint-disable-next-line prefer-const
for (let [key, value] of Object.entries(payload)) {
if (key === 'media') value = value.map(attach)
if (value != null) formData.append(key, stringify(value) as any)
}
return formData
}

function redactToken(error: Error): never {
error.message = error.message.replace(
/\/(bot|user)(\d+):[^/]+\//,
'/$1$2:[REDACTED]/'
)
throw error
}

export interface Invocation<M extends keyof Opts> {
method: M
payload: Opts[M]
signal?: AbortSignal
}

export function createClient(token: string, { api } = defaultOptions) {
const call = async <M extends keyof Telegram>({
method,
payload,
signal,
}: Invocation<M>): Promise<ApiResponse<Ret[M]>> => {
debug('HTTP call', method, payload)
const body = serialize(payload)
const url = new URL(`./${api.mode}${token}/${method}`, api.root)
const init: RequestInit = { body, signal, method: 'post' }
const res = await fetch(url.href, init).catch(redactToken)
if (res.status >= 500) {
res.body?.cancel()
return {
ok: false,
error_code: res.status,
description: res.statusText,
}
}

return (await res.json()) as ApiResponse<Ret[M]>
}

const download = async (file: File) => {
const url = new URL(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`./file/${api.mode}${token}/${file.file_path!}`,
api.root
)
return await fetch(url)
}

return { call, download }
}

export type Client = ReturnType<typeof createClient>
9 changes: 8 additions & 1 deletion src/core/network/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ResponseParameters } from 'typegram'
import type { Response } from '../../vendor/fetch'

interface ErrorPayload {
error_code: number
Expand All @@ -23,4 +24,10 @@ export class TelegramError extends Error {
}
}

export default TelegramError
export class URLStreamError extends Error {
constructor(readonly res: Response, msg?: string) {
super(
msg || `Error ${res.status} while streaming file from URL: ${res.url}`
)
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export { Telegraf } from './telegraf'
export { Context } from './context'
export { Composer, NarrowedContext } from './composer'
export { Middleware, MiddlewareFn, MiddlewareObj } from './middleware'
export { TelegramError } from './core/network/error'
export * as errors from './core/network/error'
export { Telegram } from './telegram'
export * as Types from './telegram-types'

Expand Down
54 changes: 49 additions & 5 deletions src/input.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,59 @@
// import { InputFile, StreamFile } from '@telegraf/client'
import { createReadStream } from 'node:fs'
import { basename } from 'node:path'
import { Readable } from 'node:stream'
import { ReadableStream } from 'node:stream/web'
import { StreamFile } from './core/network/client'
import { URLStreamError } from './core/network/error'
import { fetch } from './vendor/fetch'

export * from '@telegraf/client/input'
/**
* The local file specified by path will be uploaded to Telegram using multipart/form-data.
*
* 10 MB max size for photos, 50 MB for other files.
*/
export const fromLocalFile = (path: string, filename = basename(path)) =>
new StreamFile(() => createReadStream(path), filename)

/**
* The buffer will be uploaded as file to Telegram using multipart/form-data.
*
* 10 MB max size for photos, 50 MB for other files.
*/
export const fromBuffer = (buffer: Uint8Array, name: string) =>
new StreamFile(() => Readable.from(buffer), name)

/**
* Contents of the stream will be uploaded as file to Telegram using multipart/form-data.
*
* 10 MB max size for photos, 50 MB for other files.
*/
export const fromReadableStream = (
stream: AsyncIterable<Uint8Array>,
filename: string
) => new StreamFile(() => stream, filename)

/**
* Contents of the URL will be streamed to Telegram.
*
* 10 MB max size for photos, 50 MB for other files.
* TODO(mkr): Maybe @telegraf/client needs to accept Promise<ReadableStream>
*/
// prettier-ignore
// export const fromURLStream = (url: string | URL, filename?: string): InputFile => new StreamFile(() => fetch(url).then(res => res.body), filename)
export const fromURLStream = (url: string | URL, filename: string) =>
new StreamFile(() => {
return {
// create AsyncIterable from Promise<ReadableStream>
async *[Symbol.asyncIterator]() {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) throw new URLStreamError(res)
const body: ReadableStream<Uint8Array> | null = res.body
if (!body)
throw new URLStreamError(
res,
'Unexpected empty body while streaming file from URL: ' + res.url
)
yield* body
},
}
}, filename)

/**
* Provide Telegram with an HTTP URL for the file to be sent.
Expand Down
9 changes: 4 additions & 5 deletions src/telegraf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ import Telegram from './telegram'
import { TlsOptions } from 'tls'
import { URL } from 'url'
import safeCompare = require('safe-compare')
import { Client, type ClientOptions } from '@telegraf/client'
import { createClient, type ClientOptions } from './core/network/client'
const debug = d('telegraf:main')

const DEFAULT_OPTIONS: Telegraf.Options<Context> = {
api: {},
handlerTimeout: 90_000, // 90s in ms
contextType: Context,
}
Expand All @@ -36,7 +35,7 @@ export namespace Telegraf {
...args: ConstructorParameters<typeof Context>
) => TContext
handlerTimeout: number
api?: Partial<ClientOptions>
client?: ClientOptions
}

export interface LaunchOptions {
Expand Down Expand Up @@ -140,7 +139,7 @@ export class Telegraf<C extends Context = Context> extends Composer<C> {
...compactOptions(options),
}
this.#token = token
this.telegram = new Telegram(new Client(token, this.options.api))
this.telegram = new Telegram(createClient(token, this.options.client))
debug('Created a `Telegraf` instance')
}

Expand Down Expand Up @@ -297,7 +296,7 @@ export class Telegraf<C extends Context = Context> extends Composer<C> {
await (this.botInfoCall ??= this.telegram.getMe()))
debug('Processing update', update.update_id)
// webhookResponse // TODO(mkr): remove webhookResponse entirely or re-introduce?
const tg = this.telegram.clone()
const tg = new Telegram(this.telegram)
const TelegrafContext = this.options.contextType
const ctx = new TelegrafContext(update, tg, this.botInfo)
Object.assign(ctx, this.context)
Expand Down
7 changes: 6 additions & 1 deletion src/telegram-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import { Message, Update } from 'typegram'
import { UnionKeys } from './deunionize'
import { TelegrafTypegram, TelegramP, InputFile, Opts } from '@telegraf/client'
import {
TelegrafTypegram,
TelegramP,
InputFile,
Opts,
} from './core/network/client'

export { Markup } from './markup'

Expand Down
50 changes: 6 additions & 44 deletions src/telegram.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,25 @@
import * as tg from 'typegram'
import * as tt from './telegram-types'
import { TT } from './telegram-types'
import { Client, type Opts } from '@telegraf/client'
import { isAbsolute } from 'path'
import { URL } from 'url'
import { TelegramError } from './index'
import type { Client, Opts } from './core/network/client'
import { TelegramError } from './core/network/error'

export type EndoFunction<T> = (t: T) => T
export type Transformer = EndoFunction<Client['call']>

export class Telegram {
#client: Client
private call: Client['call']
call: Client['call']
readonly download: Client['download']

constructor(client: Client) {
this.#client = client
this.call = this.#client.call.bind(this.#client)
this.call = client.call
this.download = client.download
}

use(transform: Transformer) {
this.call = transform(this.call)
}

clone() {
const telegram = new Telegram(this.#client)
telegram.call = this.call
return telegram
}

async callApi<M extends keyof Opts>(
method: M,
payload: Opts[M],
Expand All @@ -53,36 +45,6 @@ export class Telegram {
return this.callApi('getFile', { file_id: fileId })
}

/**
* Get download link to a file.
*/
async getFileLink(fileId: string | tg.File) {
if (typeof fileId === 'string') {
fileId = await this.getFile(fileId)
} else if (fileId.file_path === undefined) {
fileId = await this.getFile(fileId.file_id)
}

const root = this.#client.options.api.root.toString()

// Local bot API instances return the absolute path to the file
if (fileId.file_path !== undefined && isAbsolute(fileId.file_path)) {
const url = new URL(root)
url.port = ''
url.pathname = fileId.file_path
url.protocol = 'file:'
return url
}

return new URL(
`./file/${this.#client.options.api.mode}${
this.#client.token
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
}/${fileId.file_path!}`,
root
)
}

/**
* Directly request incoming updates.
* You should probably use `Telegraf::launch` instead.
Expand Down

0 comments on commit 77fc227

Please sign in to comment.