Skip to content

Modern Telegram client framework for TypeScript/JavaScript (Node.js).

License

Notifications You must be signed in to change notification settings

breitsmiley/airgram

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Airgram

Modern Telegram client framework for TypeScript/JavaScript.

  • Strictly typed;
  • Described all the Telegram API methods;
  • Max flexible;
  • Built on middleware;
  • Authorization helper;
  • Getting updates;
  • Encrypts secret keys

Documentation

API reference

Installation

# npm
npm install airgram
# yarn
yarn add airgram

Getting started

Note: all the following examples created with TypeScript. Please check out JavaScript example.

Basic usage (source code):

import 'reflect-metadata' // Do not forget to import
import { Airgram, AuthDialog } from 'airgram'
import { prompt } from 'airgram/helpers'

// Obtain app id and hash here: https://my.telegram.org/apps
const app = { 
  id: Number(process.env.APP_ID!), 
  hash: process.env.APP_HASH! 
}

const airgram = new Airgram(app)

// Authorization
const { auth } = airgram

airgram.use(auth)

auth.use(new AuthDialog({
  phoneNumber: process.env.PHONE_NUMBER, // your phone number
  code: () => prompt(`Please enter the secret code:\n`)
}))

// Updates
const { updates } = airgram

airgram.use(updates)

// Get all updates
updates.use((ctx, next) => {
  console.log(ctx.update)
  return next()
})

// Get only new message updates
updates.on('updateNewMessage', (ctx, next) => {
  console.log('New message:', ctx.update)
  return next()
})

updates.startPolling().then(() => {
  console.log('Long polling started')
}).catch((error) => {
  console.error(error)
})

// Get dialogs list
airgram.client.messages.getDialogs({
  flags: 0,
  limit: 30,
  offset_date: 0,
  offset_id: 0,
  offset_peer: {_: 'inputPeerEmpty'},
}).then((dialogs) => {
  console.info(dialogs)
}).catch((error) => {
  console.error(error)
})

Authorization

Airgram provides component Auth, which implements basic logic for authorization or registration a new Telegram account. User just needs to specify the phone number, secret code and some other necessary information.

import Airgram, { AuthDialog } from 'airgram'

const airgram = new Airgram({ id: process.env.APP_ID, hash: process.env.APP_HASH })

const { auth } = airgram

auth.use((ctx: { _: string, state: { [key: string]: string } }, next: () => Promise<any>) => {
  // Set default values
  Object.assign(ctx.state, {
    firstName: 'John',
    lastName: 'Smith',
    phoneNumber: '649965043265'
  })
  return next()
})

// You can use method `on` to call a callback only for given type of the requests
auth.on('code', async ({ state }, next) => {
  state.code = await getSecretCodeFromSomewhere()
  return next()
})

auth.login()

In the example above you have to implement getSecretCodeFromSomewhere() function by yourself. That function returns Promise<string>, where string is a secret code received from Telegram.

You can use helper prompt to communicate with user by the command line:

import { prompt } from 'airgram/helpers'

// ...

auth.on('code', async ({ state }, next) => {
  state.code = await prompt('Please input the secret code:')
  return next()
})

Component AuthDialog implements authorization middleware and provides more intuitive interface:

import { Airgram,  AuthDialog } from 'airgram'
import { prompt } from 'airgram/helpers'

const airgram = new Airgram({ id: process.env.APP_ID, hash: process.env.APP_HASH })

const { auth } = airgram

// Register authorization middleware instead of calling `airgram.auth.login()` manually
airgram.use(auth)

// Register middleware to receive data from user
auth.use(new AuthDialog({
  firstName: 'John',
  lastName: 'Smith',
  phoneNumber: () => process.env.PHONE_NUMBER || prompt('Please input your phone number:'),
  code: async () => prompt('Please input the secret code:'),
  samePhoneNumber: ({ phoneNumber }) => prompt(`Do you want to sign in with the "${phoneNumber}" phone number? Y/n`)
    .then((answer) => !['N', 'n'].includes(answer.charAt(0))),
  continue: ({ phoneNumber }) => prompt(`Last authorization with the "${phoneNumber}" phone number has broken. If you have the secret code and wish to continue, input "Yes". Y/n`)
    .then((answer) => !['N', 'n'].includes(answer.charAt(0)))
}))

Config that passed to AuthDialog constructor has the following properties:

Key Note
firstName Only for registration
lastName Only for registration
phoneNumber Only digits
code The secret code received from Telegram
continue Do not start login with zero if the secret code has already been sent
samePhoneNumber Whether to use previous phone number or input the new one

Each property value has a type: ((state: Partial<Answers>) => Promise<string>) | string | undefined. Default value: undefined.

Requests

All Telegram API methods are described and have suitable methods in Airgram.

Getting updates

For getting updates use the Updates component as shown below:

import { Airgram, api } from 'airgram'

const airgram = new Airgram(/* config */)

airgram.updates.getDifference().then((difference: api.UpdatesDifferenceUnion) => {
  console.log('difference:', difference)
})

Note: method airgram.updates.getDifference() is the wrapper for the method airgram.client.updates.getDifference(), which has more complicated interface:

interface GetDifferenceParams {
  date: number,
  flags: number,
  pts: number,
  qts: number,
  pts_total_limit?: number
}

// `getUpdatesState()` – it's your own function that returns the current updates state
const { pts, date, qts } = getUpdatesState()

const params: GetDifferenceParams = { pts, date, qts, flags: 0 }

airgram.client.updates.getDifference(params).then((difference) => {
  console.log('difference:', difference)
})

Long polling

In most cases you do not need to call getDifference() directly. Airgram supports long polling connection to deliver updates as soon as they come.

Use component Updates as middleware to handle incoming updates:

const { updates } = airgram

// Use the `Updates` component as a middleware
airgram.use(updates)

// Start to listen new updates
updates.startPolling()

If you need to break long polling connection use method stop():

updates.stop()

Use methods use() and on() to add your own handlers for updates. Do not forget to call next() if you want to run next handlers.

  // Get all updates
  updates.use((ctx, next) => {
    console.log(`Update type: ${ctx._}`)
    return next()
  })
  
  // Get only new message updates
  updates.on('updateNewMessage', (ctx, next) => {
    console.log(`New message: ${ctx.update}`)
    return next()
  })

Argument ctx

Argument ctx is an object with the following structure:

Key Type Note
_ string Updates type
client Client The same as in airgram.client
state {[key: string]: any} Just a plain object. You should use it to pass some data between different middleware
update {[key: string]: any} An object with update data

Containers

Some of the incoming updates have nested structure. Updates of this type are containers for other updates.

Container usually consists of a list of the chats and users, which are in the children updates. You should save them to your own store.

  updates.use(({ update }, next) => {
    if ('chats' in update && update.chats.length) {
      // saveChats(update.chats)
    }
    if ('users' in update && update.users.length) {
      // saveUsers(update.users)
    }
    return next()
  })

Data store

Airgram keeps 4 types of data:

  • updates state
  • chat's updates state
  • last login information
  • Telegram access keys

By default all information written into a memory store. If you do not want to lose data between sessions you have to use persistent store.

In the next example we will show how to use PouchDB as a persistent store. Of course, you may easily create a provider for any other database.

Important: do not forget to encrypt all secret information. We will skip this part of the code to simplify it.

Airgram store has the following simple interface:

interface Store<DocT extends { [key: string]: any }> {
  delete (key: string): Promise<void>

  get (key: string): Promise<DocT | null>

  get (key: string, field: string): Promise<any>

  set (key: string, value: Partial<DocT>): Promise<Partial<DocT>>
}

Ok, lets implement it for PouchDB:

import PouchDB from 'pouchdb'
import UpsertPlugin from 'pouchdb-upsert'

PouchDB.plugin(UpsertPlugin)

// Here is using pouchdb-server
const db = new PouchDB(`http://127.0.0.1:5984/airgram`)


export default class PouchDBStore {
  public async delete (id: string): Promise<void> {
    try {
      await db.remove(id)
    } catch (e) {
      throw e
    }
  }
    
  public async get (key: string, field?: string): Promise<any> {
    try {
      const value = await db.get<DocT>(key)
      return field ? value[field] : value
    } catch (e) {
      return null
    }
  }
    
  public async set (id: string, doc: Partial<DocT>): Promise<Partial<DocT>> {
    let nextDoc
    return db.upsert(id, (currentDoc: DocT) => {
      nextDoc = Object.assign({}, currentDoc, doc)
      return nextDoc
    }).then(() => nextDoc)
  }
}

When the store component is created we can bind it to Airgram:

import { Airgram, TYPES } from 'airgram'

const airgram = new Airgram(/* config */)

airgram.bind(TYPES.AuthStore).to(PouchDBStore)
airgram.bind(TYPES.MtpStateStore).to(PouchDBStore)

Please follow to the example page to see the source code.

Encryption

By default, encryption of the secret keys is switched of. Follow this instruction to encrypt dangerous data:

// Set secret keys
airgram.client.crypto.setSecretKeys({
  key: process.env.SECRET_KEY,
  iv: process.env.SECRET_IV
})

// Set what have to be encrypted
airgram.client.mtpState.encryptedFields = ['authKey', 'serverSalt'] // or true | (field: string) => boolean

Use helper generateSecretKeys() to generate random key and iv:

import { generateSecretKeys } from 'airgram/helpers'

const secret = generateSecretKeys()

console.log(secret)

// Output like: 
// { 
//   iv: '0639aca8feb0d2b20da5c561a25c0c25',
//   key: '49ad5e7b838924c316eedc83b2b7906f4b4058b577a26746895249ebae9d6764' 
// }

Middleware

Middleware is a chain of callback functions, which are called before the request is send to Telegram. Middleware allows you modify requests and responses to add some additional handlers.

Inside of the box Airgram provides 2 components you can use as middleware. There are Auth and Updates.

This is a scaffolding for middleware function:

airgram.use((ctx, next) => {
  
  // Add some code here
  
  return next()
})

Function takes 2 arguments: ctx and next.

ctx

Argument ctx contains an object with the following structure:

Key Type Note
_ string Updates type
client Client The same as in airgram.client
state { [key: string]: any } Just a plain object. You should use it to pass some data between different middleware
handled boolean Whether the request has already been handled or not
request { [key: string]: any } Telegram request
deferred { resolve: (response: any) => any, reject: (error: Error) => any } You may immediately resolve or reject the Telegram request

ctx.request

Key Type Note
method string Request method name
params {[key: string]: any} Optional. Request parameters according to the Telegram schema
options {[key: string]: any}} Optional. Additional options passed to the request

next

The second argument next is a callback function which runs the next handler.

Function next() returns a promise Promise<any>, so we can use the next handlers result inside of our middleware:

airgram.use(async (ctx, next) => {
  const start = new Date()
  const { request } = ctx
  const result = await next()
  console.log(`Method: ${request.method}, params: ${JSON.stringify(request.params)}, result: ${JSON.stringify(result)}, ${new Date() - start}ms`)
})

use() vs on()

When we set middleware by use() method, callback will be called for every request. Method on() allows us to mount callback only to some type of requests.

airgram.on('messages.getDialogs', async (ctx, next) => {
  const dialogs = await next()
  updateMyDialogList(dialogs)
})

Class as a middleware

Sometimes middleware may be pretty complicated or you want to reuse the code. In this case the most suitable way is to create new class (or use existing) and pass it to use() or on() methods.

Your class must have a middleware() method which returns a factory:

class MyMiddlewareClass {
  handle(ctx) {
    // do some work
  }
  
  middleware() {
    return (ctx, next) => {
      this.handle(ctx)
      return next()
    }
  }
}

const myMiddleware = new MyMiddlewareClass()

airgram.use(myMiddleware)

Logging

By default, all messages with level info and above fall into terminal via default console utility. You can change this behavior.

Lets extend the Logger component. We will add timestamp label and replace console utility to debug library:

import { Airgram, getCalleeName, Logger, TYPES } from 'airgram'
import * as createLogger from 'debug'
import moment from 'moment'
import { injectable } from 'inversify'

const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS'
const writeLog = createLogger('airgram')

@injectable()
class DebugLogger extends Logger {
  protected formatMessage (message, level) {
    const parts = [
      `${name}${' '.repeat(7 - level.name.length)} `,
      `${moment().format(DATETIME_FORMAT)}: `,
      this.namespace.map((v) => `[${v}] `)
    ]
    return `${parts.join('')}${message}`
  }

  protected log (level, message) {
    writeLog(this.formatMessage(message, level))
  }
}

airgram.bind(TYPES.Logger).to(DebugLogger).onActivation((context, logger) => {
  logger.namespace = [getCalleeName(context)] // log will show current component name
  logger.level = 'verbose' 
  return logger
})

Sure, you may create your own logger class from scratch. You just need to implement the following interface:

export interface Logger {
  namespace: string[]
  debug (...args: any[]): void
  verbose (...args: any[]): void
  info (...args: any[]): void
  warn (...args: any[]): void
  error (...args: any[]): void
}

Components

Under the hood Airgram uses inversion of control (IoC) container. It gives max flexibility. You may change or replace any part of framework.

The most popular cases when you need to inject your own component:

  • custom logger (debug, winston and etc)
  • persistent store

Please check out IoC container documentation.

import { Airgram, ag, DevLogger, TYPES } from 'airgram'

const airgram = new Airgram(/* config */)

airgram.bind<ag.Logger>(TYPES.Logger).to(DevLogger)

Airgram container is accessible via airgram.container property.

Since all components are defined during initialization you should use method airgram.container.rebind to avoid an error. There is a shortcut: airgram.bind.

All the injectable components are contained in the variable TYPES.

The most popular of them:

Type Name
AuthStore Keeps the condition of whether the user is logged in or not
ChatStore Store that keeps chat's state
Client Value of the airgram.client property
MtpStateStore Store for telegram secret keys
Logger By default the logger use console
UpdatesStateStore Updates state

License

The source code is licensed under GPL v3. License is available here.

About

Modern Telegram client framework for TypeScript/JavaScript (Node.js).

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • JavaScript 100.0%