Skip to content

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

License

Notifications You must be signed in to change notification settings

grom-dev/effect-tg

Repository files navigation

effect-tg

Effectful Bot API npm codecov

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'

const program = Effect.gen(function* () {
  const api = yield* BotApi.BotApi
  const me = 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'

const program = Effect.gen(function* () {
  const me = 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'

const BotApiLive = 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'

const program = 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 updates
type UpdateType = Exclude<keyof BotApi.Types.Update, 'update_id'>

// Function to get gifts of multiple chats
type GiftsCollector = (
  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'

const program = Effect.gen(function* () {
  // Plain text to a user
  const greeting = yield* Send.sendMessage({
    content: Content.text(Text.plain('Hey! Wanna roll a dice?')),
    dialog: Dialog.user(382713),
  })

  // Photo with formatted caption and inline keyboard
  yield* Send.sendMessage({
    content: Content.photo(
      File.External(new URL('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 dice
  const roll = yield* Send.sendMessage({
    content: Content.dice('🎲'),
    dialog: Dialog.user(382713),
    reply: Reply.toMessage(greeting),
  })

  const rolled = roll.dice!.value
  if (rolled === 6) {
    // DM channel
    yield* Send.sendMessage({
      content: Content.text(Text.plain(`User 382713 rolled ${rolled}.`)),
      dialog: Dialog.channel(100200).directMessages(42),
    })
  }
  else {
    // Send silently
    yield* 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.

Constructor Bot API method Description
Content.text sendMessage Text
Content.photo sendPhoto Photo
Content.video sendVideo Video
Content.animation sendAnimation GIF or video w/o sound
Content.audio sendAudio Audio file
Content.voice sendVoice Voice note
Content.videoNote sendVideoNote Round video note
Content.document sendDocument File of any type
Content.sticker sendSticker Sticker
Content.location sendLocation Static location
Content.liveLocation sendLocation Live location
Content.venue sendVenue Venue with address
Content.contact sendContact Phone contact
Content.dice sendDice Random 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.

To target a specific topic, chain a method on the peer:

  • Dialog.user(id).topic(topicId) β€” topic in a private chat.
  • Dialog.supergroup(id).topic(topicId) β€” topic in a forum supergroup.
  • Dialog.channel(id).directMessages(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'

const inline = Markup.inlineKeyboard([
  [Markup.InlineButton.callback('Like', 'liked')],
  [Markup.InlineButton.url('Source code', 'https://github.com/grom-dev/effect-tg')],
])

const reply = 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, Send, Text } from '@grom.js/effect-tg'
import { Effect } from 'effect'

// Reusable template
const welcomeMessage = 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 dialogs
const greet1 = 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 options
const greet2 = Effect.gen(function* () {
  yield* welcomeMessage.pipe(Send.withoutNotification)
  yield* welcomeMessage.pipe(Send.withContentProtection)
}).pipe(
  Send.to(Dialog.supergroup(4).topic(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'

const secretPromo = 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 is
Text.plain('*Not bold*. _Not italic_.')

// HTML β€” sent with 'HTML' parse mode
Text.html('<b>Bold</b> and <i>italic</i>.')

// Markdown β€” sent with 'MarkdownV2' parse mode
Text.markdown('*Bold* and _italic_.')

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.

import type { PropsWithChildren } from '@grom.js/tgx/types'
import { Content, Dialog, Send, Text } from '@grom.js/effect-tg'
import { pipe } from 'effect'

// Reusable component for a key-value field
const Field = (props: PropsWithChildren<{ label: string }>) => (
  <><b>{props.label}:</b> {props.children}{'\n'}</>
)

// Simple component for convenience
const RocketEmoji = () => (
  <emoji id="5445284980978621387" alt="πŸš€" />
)

// Component that renders a deploy summary
const DeploySummary = (props: {
  service: string
  version: string
  env: string
  author: string
  url: string
}) => (
  <>
    <RocketEmoji /> <b>Deploy to <i>{props.env}</i></b>
    {'\n\n'}
    <Field label="Service"><code>{props.service}</code></Field>
    <Field label="Version"><code>{props.version}</code></Field>
    <Field label="Author">{props.author}</Field>
    {'\n'}
    <a href={props.url}>View in dashboard</a>
    {'\n\n'}
    <blockquote expandable>
      Changelog:{'\n'}
      - Fix rate limiting on /api/submit{'\n'}
      - Add retry logic for webhook delivery{'\n'}
      - Update dependencies
    </blockquote>
  </>
)

// Create summary text
const summary = Text.tgx(
  <DeploySummary
    service="billing-api"
    version="2.14.0"
    env="production"
    author="Alice"
    url="https://deploy.example.com/runs/4821"
  />
)

// Publish a new post
const publish = Send.message(Content.text(summary)).pipe(
  Send.to(Dialog.channel(3011378744)),
)

About

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

Topics

Resources

License

Stars

Watchers

Forks

Contributors 2

  •  
  •