Skip to content

Repository files navigation

effect-tg

EffectfulBot APInpmcodecov

Effectful library for crafting Telegram bots.

Features

Installation

# Install the library
npm install @grom.js/effect-tg
# Install Effect dependencies
npm install effect @effect/platform
# Install JSX runtime for formatted text
npm install @grom.js/tgx

Working with Bot API

Calling methods

BotApi service provides access to Telegram's Bot API. Each method on BotApi corresponds to the Bot API method with typed parameters and results. Methods return an Effect that succeeds with the method result or fails with BotApiError (see "Error handling").

Example: Calling Bot API methods using BotApi.

import{BotApi}from'@grom.js/effect-tg'import{Effect}from'effect'constprogram=Effect.gen(function*(){constapi=yield*BotApi.BotApiconstme=yield*api.getMe()yield*api.sendMessage({chat_id: 123456789,text: `Hello from ${me.username}!`,})})

Alternatively, you can use BotApi.callMethod function to call any method by name.

Example: Calling Bot API methods using BotApi.callMethod.

import{BotApi}from'@grom.js/effect-tg'import{Effect}from'effect'constprogram=Effect.gen(function*(){constme=yield*BotApi.callMethod('getMe')yield*BotApi.callMethod('sendMessage',{chat_id: 123456789,text: `Hello from ${me.username}!`,})})

Configuration

BotApi has a layered architecture:

β”Œβ€’ BotApi β€” typed interface that delegates calls to BotApiTransport.
└─┬‒ BotApiTransport β€” serializes parameters, sends HTTP requests, parses responses.
β”œβ”€β”€β€’ BotApiUrl β€” constructs endpoint URLs to methods and files.
└──‒ HttpClient β€” performs HTTP requests.

This design enables:

  • Extensibility: Extend BotApiTransport to implement logging, retrying, etc.
  • Testability: Mock implementation of BotApiTransport or HttpClient to test your bot.
  • Portability: Provide different BotApiUrl to run a bot on test environment or with local Bot API server.

Example: Constructing BotApi layer with method call tracing.

import{FetchHttpClient}from'@effect/platform'import{BotApi}from'@grom.js/effect-tg'import{Config,Effect,Layer}from'effect'constBotApiLive=Layer.provide(BotApi.layerConfig({token: Config.redacted('BOT_TOKEN'),environment: 'prod',transformTransport: transport=>({sendRequest: (method,params)=>transport.sendRequest(method,params).pipe(Effect.withSpan(method),),}),}),FetchHttpClient.layer)

Error handling

Failed BotApi method calls result in BotApiError, which is a union of tagged errors with additional information:

  • TransportError β€” HTTP or network failure. The cause property contains the original error from HttpClient.
  • RateLimited β€” bot has exceeded the flood limit. The retryAfter property contains the duration to wait before the next attempt.
  • GroupUpgraded β€” group has been migrated to a supergroup. The supergroup property contains an object with the ID of the new supergroup.
  • MethodFailed β€” response was unsuccessful, but the exact reason could not be determined. The possibleReason property contains a string literal representing one of the common failure reasons. It is determined by the error code and description of the Bot API response, which are subject to change.
  • InternalServerError β€” Bot API server failed with a 5xx error code.

All errors except TransportError also have response property that contains the original response from Bot API.

Example: Handling Bot API failures.

import{BotApi}from'@grom.js/effect-tg'import{Duration,Effect,Match}from'effect'constprogram=BotApi.callMethod('doSomething').pipe(Effect.matchEffect({onSuccess: result=>Effect.logInfo('Got result:',result),onFailure: e=>Match.value(e).pipe(Match.tagsExhaustive({TransportError: ({ message })=>Effect.logError(`Probably network issue: ${message}`),RateLimited: ({ retryAfter })=>Effect.logError(`Try again in ${Duration.format(retryAfter)}`),GroupUpgraded: ({ supergroup })=>Effect.logError(`Group is now a supergroup with ID: ${supergroup.id}`),MethodFailed: ({ possibleReason, response })=>Match.value(possibleReason).pipe(Match.when('BotBlockedByUser',()=>Effect.logError('I was blocked...')),Match.orElse(()=>Effect.logError(`Unsuccessful response: ${response.description}`)),),InternalServerError: ()=>Effect.logError('Not much we can do about it.'),}),),}),)

Types

BotApi module exports type definitions for all Bot API types, method parameters and results.

Example: Creating custom types from Bot API types.

import{BotApi,BotApiError}from'@grom.js/effect-tg'import{Effect}from'effect'// Union of all possible updatestypeUpdateType=Exclude<keyofBotApi.Types.Update,'update_id'>// Function to get gifts of multiple chatstypeGiftsCollector=(chatIds: Array<BotApi.MethodParams['getChatGifts']['chat_id']>,params: Omit<BotApi.MethodParams['getChatGifts'],'chat_id'>,)=>Effect.Effect<Array<BotApi.MethodResults['getChatGifts']>,BotApiError.BotApiError,BotApi.BotApi>

Sending messages

One of the most common tasks for a messenger bot is sending messages.

Bot API exposes multiple methods for sending a message, each corresponding to a different content type:

  • sendMessage for text;
  • sendPhoto for photos;
  • sendVideo for videos;
  • and so on.

Send module provides a unified, more composable way to send messages of all kinds.

Basic usage

To send a message, you need:

  • Content β€” content of the message to be sent.
  • Dialog β€” target chat and topic where the message will be sent.
  • Markup β€” (optional) markup for replying to the message.
  • Reply β€” (optional) information about the message being replied to.
  • Options β€” (optional) additional options for sending the message.

Send.sendMessage function accepts mentioned parameters and returns an Effect that sends a message, automatically choosing the appropriate Bot API method based on the content type.

Example: Sending messages using Send.sendMessage.

import{Content,Dialog,File,Markup,Reply,Send,Text}from'@grom.js/effect-tg'import{Effect}from'effect'constprogram=Effect.gen(function*(){// Plain text to a userconstgreeting=yield*Send.sendMessage({content: Content.text(Text.plain('Hey! Wanna roll a dice?')),dialog: Dialog.user(382713),})// Photo with formatted caption and inline keyboardyield*Send.sendMessage({content: Content.photo(File.External(newURL('https://cataas.com/cat')),{caption: Text.html('<b>Cat of the day</b>\n<i>Rate this cat:</i>')},),dialog: Dialog.user(382713),markup: Markup.inlineKeyboard([[Markup.InlineButton.callback('❀️','rate_love')],[Markup.InlineButton.callback('πŸ‘Ž','rate_nope')],]),})// Reply with a diceconstroll=yield*Send.sendMessage({content: Content.dice('🎲'),dialog: Dialog.user(382713),reply: Reply.toMessage(greeting),})constrolled=roll.dice!.valueif(rolled===6){// DM channelyield*Send.sendMessage({content: Content.text(Text.plain(`User 382713 rolled ${rolled}.`)),dialog: Dialog.channelDm(Dialog.channel(100200),42),})}else{// Send silentlyyield*Send.sendMessage({content: Content.text(Text.plain(`You rolled ${rolled}. Disappointing.`)),dialog: Dialog.user(382713),options: Send.options({disableNotification: true})})}})

Content

Content module provides constructors for creating objects that represent the content of a message. Send.sendMessage uses the content type to choose the appropriate Bot API method automatically.

ConstructorBot API methodDescription
Content.textsendMessageText
Content.photosendPhotoPhoto
Content.videosendVideoVideo
Content.animationsendAnimationGIF or video w/o sound
Content.audiosendAudioAudio file
Content.voicesendVoiceVoice note
Content.videoNotesendVideoNoteRound video note
Content.documentsendDocumentFile of any type
Content.stickersendStickerSticker
Content.locationsendLocationStatic location
Content.liveLocationsendLocationLive location
Content.venuesendVenueVenue with address
Content.contactsendContactPhone contact
Content.dicesendDiceRandom dice

Dialog

Dialog module provides utilities for creating target chats:

  • Dialog.user(id) β€” private chat with a user.
  • Dialog.group(id) β€” chat of a (basic) group.
  • Dialog.supergroup(id) β€” supergroup chat.
  • Dialog.channel(id) β€” channel.

Targeting a specific topic:

  • Dialog.privateTopic(user, topicId) β€” topic in a private chat.
  • Dialog.forumTopic(supergroup, topicId) β€” topic in a forum supergroup.
  • Dialog.channelDm(channel, topicId) β€” channel direct messages.

Dialog.ofMessage helper extracts the dialog from an incoming Message object.

Dialog and peer IDs

Bot API uses a single integer to encode peer type with its ID β€” dialog ID.

This may not be a problem for user IDs, since user IDs map to the same dialog IDs. However, this may cause some defects when working with other peers. For example, to send a message to a channel with ID 3011378744, you need to set chat_id parameter to -1003011378744.

To prevent this confusion, Dialog module defines branded types that distinguish peer IDs from dialog IDs at the type level:

  • UserId β€” number representing a user ID.
  • GroupId β€” number representing a group ID.
  • ChannelId β€” number representing a channel ID.
  • SupergroupId β€” alias to ChannelId, since supergroups share ID space with channels.
  • DialogId β€” number encoding peer type and peer ID.

Constructors like Dialog.user, Dialog.channel, etc. validate and encode IDs internally, so you rarely need to convert manually. When you do, Dialog module exports conversion utilities:

  • Dialog.decodeDialogId(dialogId) β€” decodes a dialog ID into peer type and peer ID.
  • Dialog.decodePeerId(peer, dialogId) β€” extracts a typed peer ID from a dialog ID.
  • Dialog.encodePeerId(peer, id) β€” encodes a peer type and ID into a dialog ID.

Markup

Markup module provides reply markup types and constructors:

  • Markup.inlineKeyboard(rows) β€” inline keyboard attached to the message.
  • Markup.replyKeyboard(rows, options?) β€” custom keyboard for quick reply or other action.
  • Markup.replyKeyboardRemove() β€” hide a previously shown reply keyboard.
  • Markup.forceReply() β€” forces Telegram client to reply to the message.

Example: Creating reply markups.

import{Markup}from'@grom.js/effect-tg'constinline=Markup.inlineKeyboard([[Markup.InlineButton.callback('Like','liked')],[Markup.InlineButton.url('Source code','https://github.com/grom-dev/effect-tg')],])constreply=Markup.replyKeyboard([['Option A','Option B'],[Markup.ReplyButton.requestContact('Share phone')],],{oneTime: true,resizable: true})

Prepared messages

Send.message creates a MessageToSend β€” prepared message that bundles content, markup, reply, and options.

MessageToSend is also an Effect, which means:

  • It can be piped to chain modifiers that customize markup, reply, and options.
  • It can be executed to send the message. To be sent, Send.TargetDialog service should be provided.

Example: Creating and sending prepared messages.

import{Content,Dialog,Markup,Send,Text}from'@grom.js/effect-tg'import{Effect}from'effect'// Reusable templateconstwelcomeMessage=Send.message(Content.text(Text.html('<b>Welcome!</b> Thanks for joining.'))).pipe(Send.withMarkup(Markup.replyKeyboard([[Markup.ReplyButton.text('Effect?')],[Markup.ReplyButton.text('Die.')],]),),)// Send to different dialogsconstgreet1=Effect.gen(function*(){yield*welcomeMessage.pipe(Send.to(Dialog.user(123)))yield*welcomeMessage.pipe(Send.to(Dialog.channel(321)))})// Send to the same dialog with different optionsconstgreet2=Effect.gen(function*(){yield*welcomeMessage.pipe(Send.withoutNotification)yield*welcomeMessage.pipe(Send.withContentProtection)}).pipe(Send.to(Dialog.forumTopic(Dialog.supergroup(4),2)),)

Composing options

Chain modifiers on a MessageToSend to customize its behavior:

  • withMarkup/withoutMarkup β€” set/remove reply markup.
  • withReply/withoutReply β€” set/remove reply options.
  • withNotification/withoutNotification β€” enable/disable notification sound.
  • withContentProtection/withoutContentProtection β€” prevent/allow forwarding and saving.
  • withPaidBroadcast/withoutPaidBroadcast β€” enable/disable paid broadcast.
  • withOptions β€” merge with the new send options.

Example: Chaining modifiers on a prepared message.

import{Content,Markup,Send,Text}from'@grom.js/effect-tg'constsecretPromo=Send.message(Content.text(Text.plain('Shh!'))).pipe(Send.withMarkup(Markup.inlineKeyboard([[Markup.InlineButton.copyText('Copy promo','EFFECT_TG')],]),),Send.withoutNotification,Send.withContentProtection,)

Text formatting

Text module provides utilities for creating formatted text to be used in text messages and captions.

Example: Formatting text with Text module.

import{Text}from'@grom.js/effect-tg'// Plain text β€” sent as isText.plain('*Not bold*. _Not italic_.')// Markdown β€” sent with 'MarkdownV2' parse modeText.markdown('*Bold* and _italic_.')// HTML β€” sent with 'HTML' parse modeText.html('<b>Bold</b> and <i>italic</i>.')

JSX syntax

Text module also allows to compose formatted text with JSX.

Benefits of using JSX:

  • Validation: JSX is validated during compilation, so you can't specify invalid HTML or Markdown.
  • Composability: JSX allows composing formatted text with custom components.
  • Auto-escaping: JSX escapes special characters, saving you from <s>bAd</s> _iNpUtS_.
  • Type safety: LSP hints and type checking for text entities and custom components.

Text.tgx function accepts a JSX element and returns an instance of Text.Tgx, which can then be used as a content of a message.

How to enable?
  1. Install @grom.js/tgx package:

    npm install @grom.js/tgx
  2. Update your tsconfig.json:

    {
    "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@grom.js/tgx"
    }
    }
How it works?

JSX is just syntactic sugar transformed by the compiler. Result of transformation depends on the JSX runtime. effect-tg relies on JSX runtime from @grom.js/tgx, which transforms JSX elements to TgxElement instances. When Send.sendMessage encounters an instance of Text.Tgx, it converts inner TgxElements to the parameters for a send* method.

Example: Composing reusable messages with JSX.

importtype{PropsWithChildren}from'@grom.js/tgx'import{Content,Dialog,Send,Text}from'@grom.js/effect-tg'// Reusable component for a key-value fieldconstField=(props: PropsWithChildren<{label: string}>)=>(<><b>{props.label}:</b>{props.children}{'\n'}</>)// Simple component for convenienceconstRocketEmoji=()=>(<emojiid="5445284980978621387"alt="πŸš€"/>)// Component that renders a deploy summaryconstDeploySummary=(props: {service: stringversion: stringenv: stringauthor: stringurl: string})=>(<><RocketEmoji/><b>Deploy to <i>{props.env}</i></b>{'\n\n'}<Fieldlabel="Service"><code>{props.service}</code></Field><Fieldlabel="Version"><code>{props.version}</code></Field><Fieldlabel="Author">{props.author}</Field>{'\n'}<ahref={props.url}>View in dashboard</a>{'\n\n'}<blockquoteexpandable>
Changelog:{'\n'}
- Fix rate limiting on /api/submit{'\n'}
- Add retry logic for webhook delivery{'\n'}
- Update dependencies
</blockquote></>)// Create summary textconstsummary=Text.tgx(<DeploySummaryservice="billing-api"version="2.14.0"env="production"author="Alice"url="https://deploy.example.com/runs/4821"/>)// Publish a new postconstpublish=Send.message(Content.text(summary)).pipe(Send.to(Dialog.channel(3011378744)),)

About

πŸ› οΈ Effectful library for crafting Telegram bots.

Topics

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages