serverless-telegram
v0.8.3
Published
A simple library to remove some of the repetitive work in creating servlerless telegram bots
Downloads
20
Readme
serverless-telegram
An extremely lightweight library to remove some of the repetitive work in creating servlerless telegram bots. Built to have minimal dependencies and bundle size, designed from the ground up for use in a serverless function.
Your job is to write handler functions that receive a message, inline query, or callback query and optionally return a response. The rest will be taken care of.
The most support is provided for AWS Lambda and Azure Function Apps but other platforms can also be used with a little extra work to convert the HTTP requests/responses accordingly
Table of Contents
- Getting Started
- Documentation
- Developing serverless-telegram (via TSDX)
Getting Started
Guidance is provided for AWS and Azure, however other cloud providers can be used as well as long as you write your own HTTP wrapper.
The choice of provider is up to you, however it has been our experience that Azure provides a much nicer developer experience whereas AWS provides significantly better performance and more sensible billing. As such if you are brand new to both platforms it is probably worth starting on Azure and then moving to AWS once you have more experience. The same code will work on both platforms.
On Azure
Use the official quickstart to create a new Azure function using JavaScript or TypeScript. I recommend calling the function something like "telegram-webhook" or just "webhook" but it really doesn't matter.
Install
serverless-telegram
as a dependency:npm install serverless-telegram
Replace the function's
index.js
orindex.ts
with the following:JavaScript:
const { createAzureTelegramWebhook } = require('serverless-telegram'); module.exports = createAzureTelegramWebhook( ({ text }) => text && `You said: ${text}`, );
TypeScript:
import { createAzureTelegramWebhook } from 'serverless-telegram'; export default createAzureTelegramWebhook( ({ text }) => text && `You said: ${text}`, );
Edit the function's
function.json
and setauthLevel
tofunction
andmethods
to["post"]
, for example:{ "bindings": [ { "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", "methods": ["post"] }, { "type": "http", "direction": "out", "name": "res" } ] }
Use the VSCode Azure extension to add a new Application Setting to your app:
NODE_ENV
=production
Re-deploy the app (replace existing deployment)
Copy the URL of your deployed function using the VS code extension
Create a new telegram bot and set its webhook to point to this URL. A CLI tool is provided for convenience:
BOT_API_TOKEN=<your-bot-token> npx set-webhook <your-function-url>
Start a private chat with the bot and say "/start". It should reply with "You said: /start"
On AWS
You will need two things:
- A NodeJS Lambda Function
- An API Gateway connected to that function (either the RestApi and HttpApi interface)
You can set these up any number of ways, such as manually through the AWS console or using the AWS CLI, but one of the easier options is to use AWS SAM to create a project and deploy it to AWS. You will need the AWS CLI and to configure your credentials, as well as the SAM CLI v1.31 or newer. Then you can run the below to create a new app with some example rest API handlers:
sam init --runtime nodejs20.x --app-template quick-start-web
Then in the newly created project you will need to add serverless-telegram as a runtime dependency
# or pnpm, or yarn, or ...
npm i
npm i serverless-telegram
To create a telegram webhook handler, create a file src/handlers/webhook.mjs
with the following content:
import { createAwsTelegramWebhook } from 'serverless-telegram';
export const webhook = createAwsTelegramWebhook(
({ text }) => text && `You said: ${text}`,
);
If you like you can create a new file for tests at __tests__/unit/handlers/webhook.test.mjs
, with the following:
import { jest } from '@jest/globals';
import { webhook } from '../../../src/handlers/webhook.mjs';
// ignore debug output during tests
console.debug = jest.fn();
const testUpdate = async (botUpdate, expectedResponse) => {
const res = await webhook({ body: JSON.stringify(botUpdate) });
expect(res.statusCode).toEqual(200);
expect(res.body && JSON.parse(res.body)).toEqual(expectedResponse);
};
describe('webhook', function () {
it('responds to a simple text message', () => {
return testUpdate(
{
update_id: 1,
message: { chat: { id: 1 }, text: 'hi' },
},
{
method: 'sendMessage',
chat_id: 1,
text: 'You said: hi',
},
);
});
it('ignores a message without text', () => {
return testUpdate({ update_id: 1, message: { chat: { id: 1 } } }, '');
});
});
[!TIP] run your tests with
npm test
/pnpm test
/etc
To deploy this new webhook to a lambda and hook it up to an API, add a WebhookFunction
entry to the Resources
section in template.yaml
in the project root, like so:
Resources:
# ...
WebhookFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/webhook.webhook
Runtime: nodejs20.x
Description: Webhook to receive updates from the Telegram bot API
# Increase the RAM to also increase CPU quota. 1769 MB equals 1 full vCPU
MemorySize: 256
# HttpApi maximum timeout is 30 sec so the lambda timeout must be < 30
Timeout: 29
Environment:
Variables:
NODE_ENV: production
Events:
Webhook:
Type: HttpApi # Api also works, but HttpApi is simpler & faster
Properties:
Path: /webhook
Method: POST
And add a WebhookApi
entry to the Output
section to print out your webhook's URL:
Output:
# ...
WebhookApi:
Description: 'HTTP API endpoint URL for Telegram webhook'
# Below assumes you are using the HttpApi event type, adjust accordingly if using Api
Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/webhook'
At this point you can remove the source code, tests, fixtures (events folder), and resources (from template.yml) for the other endpoints that came with the quick start template, and you can uninstall the aws-sdk dependency. Or you can keep them all and use them for reference as you develop.
Next, deploy your new stack to AWS by running sam deploy --guided
. If you want to use a specific AWS creds/config profile, pass that with --profile
- Choose a stack name matching your project name, making sure it is unique to your AWS account & region.
- When asked if it's ok that authorization is not defined, choose Y
- All other options can be left as default
If everything worked ok you should see the new Webhook URL in the output section at the end. You will need this URL in the next steps
From now on whenever you want to deploy changes you can do so by running sam build && sam deploy
.
[!TIP] You can edit the new
samconfig.toml
in your project root and move theregion
andprofile
(if set) keys from the[default.deploy.parameters]
section to[default.global.parameters]
so that they apply to other commands besides deploy (e.g. logs).
Test your endpoint by sending a JSON POST request to it containing {"update_id":1,"message":{"chat":{"id":1},"text":"hi"}}
. For example using curl:
curl -H "Content-Type: application/json" -d '{"update_id":1,"message":{"chat":{"id":1},"text":"hi"}}' <your-webhook-url>
# Expected output: {"method":"sendMessage","chat_id":1,"text":"You said: hi"}%
Tail your deployed function's logs by running:
sam logs -t
Once your webhook is deployed, create a new telegram bot and copy the API token. Then set it up to use your new webhook with the provided CLI command:
BOT_API_TOKEN=<your-bot-token> npx set-webhook <your-webhook-url>
Start a private chat with the bot and say "/start". It should reply with "You said: /start". If you're watching your functions logs you should see the update & response there as well.
Next steps
Before you go any further, we suggest you set up a local dev server so that you can test your changes without deploying. It's not mandatory but it makes the development cycle a lot shorter.
Once you're ready to write your handler, the example below gives a quick overview of some of the concepts that are documented later in this readme. This bot greets users on any text message, echos sticker messages, and ignores all other messages. It also has logging and error reporting enabled.
/// @ts-check
// or `createAwsTelegramWebhook`
const { createAzureTelegramWebhook } = require('serverless-telegram');
const MY_CHAT_ID = 0; // TODO: Set your chat ID for error reports
// on AWS:
// exports.lambdaHandler = createAwsTelegramWebhook(async (msg, env) => {
// on Azure:
module.exports = createAzureTelegramWebhook(async (msg, env) => {
env.info('Got message:', msg);
const {
text,
sticker,
from: { first_name },
chat: { id },
} = msg;
if (text) return `Hello ${first_name}! Your chat ID is: ${id}`;
if (sticker) return { sticker: sticker.file_id };
}, MY_CHAT_ID);
Documentation
Creating a webhook
This library has a functional-style API in order to facilitate easier testing. You can write your telegram update handlers as pure functions and then pass them to createAzureTelegramWebhook
or createAwsTelegramWebhook
, which will turn them into an azure http function or aws lambda handler ready for deployment to your chosen cloud.
createAzureTelegramWebhook
and createAwsTelegramWebhook
take 2 arguments:
handler
- either a HandlerMap or just a MessageHandlererrorChatId
(optional) - see Receiving error reports
The return value should then be exported by your function's main script in the case of Azure, or as a named export matching your handler path (usually lambdaHandler) in the case of AWS.
Once deployed to the cloud, you'll need to get the Azure function URL (you can do this via the VS code extension) or AWS API Gateway URL (printed to the console after deployment) and set it as your bot's webhook. A CLI command is provided for this step:
BOT_API_TOKEN=<your-bot-token> npx set-webhook <your-function-url>
Example Setup
// handler.js
exports.message = ({ text }, env) => text && `You said: ${text}`;
exports.inline = ({ query }, env) =>
query && [{ photo_url: `https://i.imgur.com/${query}.jpeg` }];
exports.callback = ({ data }, env) => data && `You pressed: ${data}`;
// index.js
const { createAzureTelegramWebhook } = require('serverless-telegram');
const handler = require('./handler');
const errorChatId = parseInt(process.env.BOT_ERROR_CHAT_ID);
module.exports = createAzureTelegramWebhook(handler, errorChatId);
Types
HandlerMap
A simple object allowing any combination of message handler, inline handler and/or callback handler to be specified.
When a telegram update arrives, the appropriate handler will be called. If the update does not contain a message, inline query, or callback query then it will be ignored.
If no inline or callback handlers are needed, you can also just pass the message handler directly to createAzureTelegramWebhook
or createAwsTelegramWebhook
MessageHandler
InlineHandler
CallbackHandler
MessageEnv
, InlineEnv
, and CallbackEnv
The second argument passed to message, inline, and callback handlers is a MessageEnv
, InlineEnv
, or CallbackEnv
respectively.
This is mainly needed on Azure where logging has to be done via the context object, whereas on AWS you can simply log to the console, but it can still be useful on AWS for the send
method. The logging functions will still work in AWS (they simply redirect to the console) to make it easier for you to re-use code across cloud providers.
This env object has the following properties:
context
: the Azure context object or the AWS context object depending on the deployed platformmessage
(only onMessageEnv
): the incomingMessage
inlineQuery
(only onInlineEnv
): the incomingInlineQuery
callbackQuery
(only onCallbackEnv
): the incomingCallbackQuery
chatId
: the id of the chat where the update came from. Always present onMessageEnv
, never onInlineEnv
, and only onCallbackEnv
if the callback came from one of the bot's own messages and not from a message sent via inline query.debug(...data)
: function which logs to the debug log level (pointer to the incorrectly namedcontext.log.verbose
on Azure orconsole.debug
on AWS)info(...data)
: function to logs at info level (➡context.log.info
on Azure /console.info
on AWS)warn(...data)
: function to logs at warn level (➡context.log.warn
on Azure /console.warn
on AWS)error(...data)
: function to logs at error level (➡context.log.error
on Azure /console.error
on AWS)async send(res)
: Call the Telegram Bot API asynchronously during handler execution, see Using the Telegram API mid-execution
MessageResponse
A message handler can return any of the following data types:
string
- will send a text reply back to the same chat the message came fromResponseObject
- an object representing a richer response type. Any of the telegram bot APIsend*
methods are supported (sendPhoto
,sendMessage
, etc.), but for convenience thechat_id
andmethod
can be omitted and will be filled in automatically. Some examples:{ photo: 'https://example.com/image.png' }
- send a photo from a URL{ text: 'hello there' }
- send a message (equivalent to returning 'hello there'){ video: '<video file ID>' }
- resend a video for which you know the file ID
ResponseMethod
- Any of the telegram bot API methods. This must be an object with themethod
key set to the method name (e.g. 'sendMessage'), along with any other desired parameters. Ifchat_id
is not specified, it will automatically be set to be the same as that of the incoming messageNoResponse
- any falsy value (includingvoid
) will signify that no reply should be sent.
InlineResponse
An inline handler can return any of the following data types:
- Array of
InlineResult
objects, which are just like InlineQueryResults but for convenience theid
andtype
fields are optional - when not specified the ID will the array index and the type will be inferred automatically from the other parameters. - AnswerInlineQuery object, in case you want to specify additional options for example
cache_time
. For convenience theinline_query_id
can be left out and will be copied from the incoming query. The results array may also containInlineResult
objects (i.e. theid
andtype
fields are optional). ResponseMethod
- Instead of answering the inline query, you can send any of the telegram bot API methods. This must be an object with themethod
key set to the method name (e.g. 'sendMessage'), along with any other desired parameters. Note that since inline queries do not come from a chat,chat_id
cannot be automatically set and must be provided by you if requiredNoResponse
- any falsy value (includingvoid
) will signify that no reply should be sent.
CallbackResponse
Since callback queries should always be answered (even if the answer is empty) to stop the progress indicator on the telegram client, the return value from a callback handler must be a CallbackResponse
as it is always used to answer the callback query. Anything else you wish to do (such as send a message) should be done by using the Telegram API mid-execution, before returning.
A CallbackResponse
can be any of the following data types:
- A string, the text of which will be displayed to the user as a notification at the top of the chat screen or as an alert
- AnswerCallbackQuery object, in case you want to specify additional options for example
cache_time
. Nocallback_query_id
should be specificed however as it will be copied from the incoming query. NoResponse
- any falsy value (includingvoid
) will not show any notification to the user, but will still stop the progress indicator
Uploading Files
There are 3 ways to send files (e.g. a photo or video), depending on where it's coming from. They are listed in order of preference:
By HTTP/s URL - if the file is already online somewhere, simply provide the web URL and the telegram server will download it automatically.
E.g.:
{ photo: 'https://example.com/bird.jpg' }
As a
FileBuffer
- if you are generating the file during your handler's execution, then rather than saving it to disk it is better to keep it in memory as a Buffer skip the file I/O. For this to work, a filename must be provided to the API, by using theFileBuffer
interface. Simply return a plain object with the following 2 properties:buffer
- the Buffer objectfilename
- a name for the file (including file extension!) as a string. Do not include a path, this does not refer to a real file on disk.
E.g.:
{ photo: { buffer: <Buffer>, filename: 'this-can-be-anything.png' } }
As a local file path - if the file exists somewhere on the local file system where your function is executing, simply pass the file path (either absolute or relative to the nodejs process).
E.g.:
{ photo: '/tmp/chart.png' }
It will be automatically detected as a file path rather than a file ID as long as it contains any non-alphanumeric characters, otherwise you can guarantee that it's treated as a file by sending a
file: URL
. For convinence a utility functiontoFileUrl
is provided:const { utils } = require('serverless-telegram'); return { photo: utils.toFileUrl('local-file.png') }; // equivalent to: return { photo: new URL(`file://${path.resolve('local-file.png')}`) };
To save the resulting file ID, see Using the Telegram API mid-execution
Using the Telegram API mid-execution
For most use cases it is enough to simply return the bot's desired response, however sometimes you might want to manually call the telegram API, for example:
- Sending a chat action before you start processing
- Sending more than one response
- Using the Telegram API return value
To do so, first set the environment variable BOT_API_TOKEN
to your bot's API token (obtainable from the BotFather). You can do this by adding it as an Application Setting to your Azure function app.
Then, use the send
method on the env
object passed to your handler. It takes a single argument which can be any of the MessageResponse types (passing a NoResponse
will of course do nothing).
It returns a promise which resolves to the response data (if any).
Example usage:
// let the user know that something is happening since it might take a while
env.send({ action: 'upload_video' });
// Note: intentially *not* await-ing in this case so that work continues in parallel
// send the video
const result = await env.send({
video: '/tmp/video.mp4',
width: 640,
height: 480,
caption: 'Cute cat video',
});
// save the file ID
const fileId = result?.video?.file_id;
env.debug('fileId:', fileId);
// the file ID can be used to send the same video again without re-uploading:
if (fileId) return { video: fileId };
When on AWS, you can also import the lower level callTgApi
function from this library which can be used without an env
object. It supports any of the telegram bot API methods, taking an object with the method
key set to the method name (e.g. 'sendMessage') along with any other parameters.
Logging
When running on Azure, your async handler functions may be executed multiple times in parallel in the same node process. In order to make sure that logging is separated per function execution, Azure requires that you use special logging methods and not console.log. These logging methods are included as properties of the env object passed to your handler functions on each execution, see that section for details.
When running on AWS, you can simply log to the console as normally.
Receiving error reports
Any errors thrown by your functions are automatically caught and logged to the Azure/AWS log streams. If you wish to receive error reports in real time via telegram, pass the telegram chat ID that you want them sent to as a second argument to createAzureTelegramWebhook
or createAwsTelegramWebhook
.
An easy way to find out your chat ID is to send your bot a message and check the debug logs.
Running a local bot server during development
TL;DR
Install
env-cmd
andnodemon
either globally or as devDepenencies:npm i -g env-cmd nodemon
, ornpm i -D env-cmd nodemon
Add a
dev
script to yourpackage.json
:"scripts": { ... "dev": "nodemon -x env-cmd start-dev-server", ... }
echo 'BOT_API_TOKEN=<your dev bot's API token>' >> .env
npm run dev
If you get an error due to a webhook being set, see Deleting an existing webhook
Long version
Deploying to the cloud every time you want to test your bot would be a pain, which is why serverless-telegram comes with a built in dev server. It will use telegram's getUpdates
method to listen for bot updates, run them through your function code, and send the response back to the bot API.
The dev server can be run by importing startDevServer
from serverless-telegram
, or directly from the command line by calling npx start-dev-server
, but if you try this straight away, it will complain that the BOT_API_TOKEN
environment variable is not set. You will need to first create a new development bot (you can't use your production bot even if you wanted to, since that bot has a webhook set which means you can't manually pull updates), and then provide its api token to the dev server via an environment variable. For example:
BOT_API_TOKEN=<your bot API token> npx start-dev-server
To make this easier, you can use the env-cmd package. Install env-cmd
either as a dev dependency or globally, then create a .env
file at the root of your project (and add it to your .gitignore so you don't check it in!) with the following:
BOT_API_TOKEN=<your bot API token>
Now you can just run: npx env-cmd start-dev-server
If you want to automatically restart the server when your code changes, you can use nodemon like so: npx nodemon -x env-cmd start-dev-server
Now try sending a message to your dev bot in telegram and you should see it working!
By default, start-dev-server
will search your current directory for functions and run a dev server for any that it finds, but you can change this by passing a specific function directory to run only that function, or a path to your project root if you're running from elsewhere.
An optional second argument can be passed to change the logging level. Possible values are debug
(the default), info
, warn
, error
, and silent
An optional third argument can be passed to change the long poll timeout
Deleting an existing webhook
If you try to run a dev server for a bot which has a webhook set, it will fail since you cannot manually pull updates for a bot that also sends its updates to a webhook. If you are repurposing an existing bot and no longer need its webhook set, there is a CLI command provided to delete the webhook:
npx env-cmd delete-webhook
# or if you want to run for another bot whose token is not in your .env file
BOT_API_TOKEN=<bot api token> npx delete-webhook
Using with other cloud providers (GCP, etc.)
createAzureTelegramWebhook
and createAwsTelegramWebhook
are internally made of two parts: wrapTelegram
and wrapAzure
/wrapAws
. To use this library for other platforms besides Azure, you can use wrapTelegram
directly and write your own http wrapper. wrapTelegram
takes the same arguments as create[Azure|Aws]TelegramWebhook
, and will return a function that takes the JSON-parsed webhook body (i.e. a telegram update object) and returns the desired response body as a JS object (not stringified).
For example, wrapAws looks like this:
export const wrapAws =
(handler: BodyHandler<AwsContext>): AwsHttpFunction =>
async ({ body }, ctx) =>
(body && (await handler(JSON.parse(body), ctx))) || '';
A few things are happening here:
- The body property is extracted from the incoming event object
- AWS does not parse JSON bodies automatically so we must do this ourselves
- AWS will accept a JS object as a response and automatically stringify it, but it will not accept falsy values like undefined or null, instead we must convert this to an empty string
Developing serverless-telegram (via TSDX)
Initial Setup
Prerequisites:
- A version of NodeJS supported by your chosen cloud provider (see here for AWS)
- Pnpm (to support package resolutions overrides)
Clone the repo and then run pnpm install
Commands
TSDX scaffolds the library inside /src
.
To run TSDX, use:
pnpm start
This builds to /dist
and runs the project in watch mode so any edits you save inside src
causes a rebuild to /dist
.
To do a one-off build, use pnpm build
.
To run tests, use pnpm test
.
To run tests in watch mode, use pnpm test:watch
.
Configuration
Code quality is set up for you with prettier
, husky
(v3, since v4 does not support VS Code), and lint-staged
. Adjust the respective fields in package.json
accordingly.
Jest
Jest tests are set up to run with pnpm test
.
Bundle Analysis
size-limit
is set up to calculate the real cost of your library with npm run size
and visualize the bundle with npm run analyze
.
Rollup
TSDX uses Rollup as a bundler and generates multiple rollup configs for various module formats and build settings. See Optimizations for details.
TypeScript
tsconfig.json
is set up to interpret dom
and esnext
types, as well as react
for jsx
. Adjust according to your needs.
Continuous Integration
GitHub Actions
Two actions are added by default:
main
which installs deps w/ cache, lints, tests, and builds on all pushes against a Node and OS matrixsize
which comments cost comparison of your library on every pull request usingsize-limit
Optimizations
Please see the main tsdx
optimizations docs. In particular, know that you can take advantage of development-only optimizations:
// ./types/index.d.ts
declare var __DEV__: boolean;
// inside your code...
if (__DEV__) {
console.log('foo');
}
You can also choose to install and use invariant and warning functions.
Module Formats
CJS, ESModules, and UMD module formats are supported.
The appropriate paths are configured in package.json
and dist/index.js
accordingly. Please report if any issues are found.
Publishing to NPM
Run pnpm release