diff --git a/AGENTS.md b/AGENTS.md
index db91cc8..75d3d44 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,3 +7,9 @@
Use **"topic"** in favor of "thread" when referring to message containers in forums, private chats, or channel direct messages.
**Why:** (1) The Bot API consistently uses "topic" in type names (`ForumTopic`, `DirectMessagesTopic`), method names (`createForumTopic`, `editForumTopic`), and Telegram's own docs. (2) Conceptually, a **topic** is a named container for a conversation (forum-style: Discourse, Reddit), while a **thread** typically denotes reply chains in messengers (Slack, Discord). Telegram's forum feature is topic-based—named containers with icons—so "topic" fits the design. The parameter `message_thread_id` is a legacy name; map it to `topicId` at API boundaries.
+
+## Package Manager & Runtime
+
+- Use **pnpm** in favor of other package managers (npm, yarn, deno, bun).
+- Run installed executables and scripts with `pnpm exec`.
+- Use **Node.js** for running JS and TS in favor of other runtimes (deno, bun). Node supports TypeScript via its built-in loader.
diff --git a/README.md b/README.md
index b6e1dc4..5886e55 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
# effect-tg
-[](https://effect.website/)
+[](https://effect.website/)
+[](https://core.telegram.org/bots/api)
[](https://www.npmjs.com/package/@grom.js/effect-tg)
-[](https://codecov.io/gh/grom-dev/effect-tg)
+[](https://codecov.io/gh/grom-dev/effect-tg)
Effectful library for crafting Telegram bots.
@@ -10,6 +11,8 @@ Effectful library for crafting Telegram bots.
- Modular design to build with [Effect](https://effect.website).
- Complete type definitions for [Bot API](https://core.telegram.org/bots/api) methods and types.
+- Composable API for [sending messages](#sending-messages).
+- [JSX syntax](#jsx-syntax) support for creating formatted text.
## Installation
@@ -19,6 +22,9 @@ 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
@@ -105,15 +111,11 @@ const BotApiLive = Layer.provide(
Failed `BotApi` method calls result in `BotApiError`, which is a union of tagged errors with additional information:
-- `TransportError` — HTTP or network failure.
- - `cause` — original error from `HttpClient`.
-- `RateLimited` — bot has exceeded the flood limit.
- - `retryAfter` — duration to wait before the next attempt.
-- `GroupUpgraded` — group has been migrated to a supergroup.
- - `supergroup` — object containing the ID of the new supergroup.
-- `MethodFailed` — response was unsuccessful, but the exact reason could not be determined.
- - `possibleReason` — 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.
+- **`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.
@@ -133,7 +135,7 @@ const program = BotApi.callMethod('doSomething').pipe(
RateLimited: ({ retryAfter }) =>
Effect.logError(`Try again in ${Duration.format(retryAfter)}`),
GroupUpgraded: ({ supergroup }) =>
- Effect.logError(`Group now has a new ID: ${supergroup.id}`),
+ Effect.logError(`Group is now a supergroup with ID: ${supergroup.id}`),
MethodFailed: ({ possibleReason, response }) =>
Match.value(possibleReason).pipe(
Match.when('BotBlockedByUser', () =>
@@ -172,3 +174,358 @@ type GiftsCollector = (
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`.
+
+```ts
+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('Cat of the day\nRate this cat:') },
+ ),
+ 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](https://core.telegram.org/api/bots/ids).
+
+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.
+
+```ts
+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.
+
+```ts
+import { Content, Dialog, Send, Text } from '@grom.js/effect-tg'
+import { Effect } from 'effect'
+
+// Reusable template
+const welcomeMessage = Send.message(
+ Content.text(Text.html('Welcome! 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.
+
+```ts
+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](https://core.telegram.org/bots/api#formatting-options) to be used in text messages and captions.
+
+**Example:** Formatting text with `Text` module.
+
+```tsx
+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('Bold and italic.')
+
+// 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 \bAd\ \_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.
+
+**Example:** Composing reusable messages with JSX.
+
+```tsx
+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 }>) => (
+ <>{props.label}: {props.children}{'\n'}>
+)
+
+// Simple component for convenience
+const RocketEmoji = () => (
+ {props.service}{props.version}
+ Changelog:{'\n'} + - Fix rate limiting on /api/submit{'\n'} + - Add retry logic for webhook delivery{'\n'} + - Update dependencies ++ > +) + +// Create summary text +const summary = Text.tgx( +