From d56a7a803964e227b86e5c6248583c669e909e7f Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 14:24:28 +0500 Subject: [PATCH 01/12] docs: add rules to AGENTS.md to use Node and pnpm --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) 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. From 3292c7e3fa8ab6046b56d1e01cf8f5e7b932b917 Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 14:46:56 +0500 Subject: [PATCH 02/12] refactor!: reverse channel<->supergroup IDs aliasing --- src/BotApiError.ts | 2 +- src/Dialog.ts | 61 +++++++++++++++++++++--------------------- src/internal/dialog.ts | 33 +++++++++++------------ src/internal/send.ts | 6 ++--- test/Dialog.test.ts | 26 +++++++++--------- 5 files changed, 64 insertions(+), 64 deletions(-) diff --git a/src/BotApiError.ts b/src/BotApiError.ts index 2776153..d5757ea 100644 --- a/src/BotApiError.ts +++ b/src/BotApiError.ts @@ -99,7 +99,7 @@ export const fromResponse = (response: FailureResponse): BotApiError => { response, supergroup: Dialog.supergroup( Option.getOrThrow( - Dialog.decodePeerId('supergroup', response.parameters.migrate_to_chat_id), + Dialog.decodePeerId('channel', response.parameters.migrate_to_chat_id), ), ), }) diff --git a/src/Dialog.ts b/src/Dialog.ts index e11ab1c..0ab4846 100644 --- a/src/Dialog.ts +++ b/src/Dialog.ts @@ -36,8 +36,8 @@ export class ChannelDm extends Data.TaggedClass('ChannelDm')<{ export type Peer = | User | Group - | Supergroup | Channel + | Supergroup export class User extends Data.TaggedClass('User')<{ id: UserId @@ -59,27 +59,27 @@ export class Group extends Data.TaggedClass('Group')<{ } } -export class Supergroup extends Data.TaggedClass('Supergroup')<{ - id: SupergroupId +export class Channel extends Data.TaggedClass('Channel')<{ + id: ChannelId }> { public dialogId(): DialogId { - return Option.getOrThrow(internal.encodePeerId('supergroup', this.id)) + return Option.getOrThrow(internal.encodePeerId('channel', this.id)) } - public topic(topicId: number): ForumTopic { - return new ForumTopic({ supergroup: this, topicId }) + public directMessages(topicId: number): ChannelDm { + return new ChannelDm({ channel: this, topicId }) } } -export class Channel extends Data.TaggedClass('Channel')<{ - id: ChannelId +export class Supergroup extends Data.TaggedClass('Supergroup')<{ + id: SupergroupId }> { public dialogId(): DialogId { - return Option.getOrThrow(internal.encodePeerId('supergroup', this.id)) + return Option.getOrThrow(internal.encodePeerId('channel', this.id)) } - public directMessages(topicId: number): ChannelDm { - return new ChannelDm({ channel: this, topicId }) + public topic(topicId: number): ForumTopic { + return new ForumTopic({ supergroup: this, topicId }) } } @@ -110,21 +110,22 @@ export const GroupId = Brand.refined( n => Brand.error(`Invalid group ID: ${n}`), ) -export type SupergroupId = number & Brand.Brand<'@grom.js/effect-tg/SupergroupId'> -export const SupergroupId = Brand.refined( - n => Option.isSome(internal.encodePeerId('supergroup', n)), - n => Brand.error(`Invalid supergroup or channel ID: ${n}`), -) - /** - * @alias SupergroupId + * ID for channels (including supergroups). + * + * @see {@link https://core.telegram.org/api/bots/ids Telegram API • Bot API dialog IDs} */ -export type ChannelId = SupergroupId +export type ChannelId = number & Brand.Brand<'@grom.js/effect-tg/ChannelId'> +export const ChannelId = Brand.refined( + n => Option.isSome(internal.encodePeerId('channel', n)), + n => Brand.error(`Invalid channel or supergroup ID: ${n}`), +) -/** - * @alias SupergroupId - */ -export const ChannelId = SupergroupId +/** @alias ChannelId */ +export type SupergroupId = ChannelId + +/** @alias ChannelId */ +export const SupergroupId = ChannelId // ============================================================================= // Dialog ID <-> Peer ID @@ -133,7 +134,7 @@ export const ChannelId = SupergroupId export const decodeDialogId: (dialogId: number) => Option.Option< | { peer: 'user', id: UserId } | { peer: 'group', id: GroupId } - | { peer: 'supergroup', id: SupergroupId } + | { peer: 'channel', id: ChannelId } | { peer: 'monoforum', id: number } | { peer: 'secret-chat', id: number } > = internal.decodeDialogId @@ -141,13 +142,13 @@ export const decodeDialogId: (dialogId: number) => Option.Option< export const decodePeerId: { (peer: 'user', dialogId: number): Option.Option (peer: 'group', dialogId: number): Option.Option - (peer: 'supergroup', dialogId: number): Option.Option + (peer: 'channel', dialogId: number): Option.Option (peer: 'monoforum', dialogId: number): Option.Option (peer: 'secret-chat', dialogId: number): Option.Option } = internal.decodePeerId export const encodePeerId: ( - peer: 'user' | 'group' | 'supergroup' | 'monoforum' | 'secret-chat', + peer: 'user' | 'group' | 'channel' | 'monoforum' | 'secret-chat', id: number, ) => Option.Option = internal.encodePeerId @@ -163,12 +164,12 @@ export const group: (id: number) => Group = (id) => { return new Group({ id: GroupId(id) }) } -export const supergroup: (id: number) => Supergroup = (id) => { - return new Supergroup({ id: SupergroupId(id) }) +export const channel: (id: number) => Channel = (id) => { + return new Channel({ id: ChannelId(id) }) } -export const channel: (id: number) => Channel = (id) => { - return new Channel({ id: SupergroupId(id) }) +export const supergroup: (id: number) => Supergroup = (id) => { + return new Supergroup({ id: ChannelId(id) }) } export const ofMessage: ( diff --git a/src/internal/dialog.ts b/src/internal/dialog.ts index b946087..10fdfcc 100644 --- a/src/internal/dialog.ts +++ b/src/internal/dialog.ts @@ -9,7 +9,7 @@ import * as Dialog from '../Dialog.ts' export const decodeDialogId = (dialogId: number): Option.Option< | { peer: 'user', id: Dialog.UserId } | { peer: 'group', id: Dialog.GroupId } - | { peer: 'supergroup', id: Dialog.SupergroupId } + | { peer: 'channel', id: Dialog.ChannelId } | { peer: 'monoforum', id: number } | { peer: 'secret-chat', id: number } > => { @@ -21,7 +21,7 @@ export const decodeDialogId = (dialogId: number): Option.Option< return Option.some({ peer: 'group', id: -dialogId as Dialog.GroupId }) } if (-1997852516352 <= dialogId && dialogId <= -1000000000001) { - return Option.some({ peer: 'supergroup', id: -(dialogId + 1000000000000) as Dialog.SupergroupId }) + return Option.some({ peer: 'channel', id: -(dialogId + 1000000000000) as Dialog.ChannelId }) } if (-4000000000000 <= dialogId && dialogId <= -2002147483649) { return Option.some({ peer: 'monoforum', id: -(dialogId + 1000000000000) }) @@ -36,7 +36,7 @@ export const decodeDialogId = (dialogId: number): Option.Option< export const decodePeerId: { (peer: 'user', dialogId: number): Option.Option (peer: 'group', dialogId: number): Option.Option - (peer: 'supergroup', dialogId: number): Option.Option + (peer: 'channel', dialogId: number): Option.Option (peer: 'monoforum', dialogId: number): Option.Option (peer: 'secret-chat', dialogId: number): Option.Option } = (peer, dialogId) => { @@ -48,7 +48,7 @@ export const decodePeerId: { } export const encodePeerId = ( - peer: 'user' | 'group' | 'supergroup' | 'monoforum' | 'secret-chat', + peer: 'user' | 'group' | 'channel' | 'monoforum' | 'secret-chat', id: number, ): Option.Option => { if (Number.isSafeInteger(id)) { @@ -58,7 +58,7 @@ export const encodePeerId = ( if (peer === 'group' && 1 <= id && id <= 999999999999) { return Option.some(-id as Dialog.DialogId) } - if (peer === 'supergroup' && 1 <= id && id <= 997852516352) { + if (peer === 'channel' && 1 <= id && id <= 997852516352) { return Option.some(-(id + 1000000000000) as Dialog.DialogId) } if (peer === 'monoforum' && 1002147483649 <= id && id <= 3000000000000) { @@ -78,8 +78,7 @@ export const encodePeerId = ( export const ofMessage: ( message: BotApi.Types.Message, ) => Dialog.Dialog = (m) => { - // TODO: Remove type assertion when bot-api-spec updates types. - switch (m.chat.type as 'private' | 'group' | 'supergroup' | 'channel') { + switch (m.chat.type) { case 'private': { const user = new Dialog.User({ id: Option.getOrThrow(decodePeerId('user', m.chat.id)), @@ -94,23 +93,23 @@ export const ofMessage: ( id: Option.getOrThrow(decodePeerId('group', m.chat.id)), }) } - case 'supergroup': { - const supergroup = new Dialog.Supergroup({ - id: Option.getOrThrow(decodePeerId('supergroup', m.chat.id)), - }) - if (m.message_thread_id != null) { - return supergroup.topic(m.message_thread_id) - } - return supergroup - } case 'channel': { const channel = new Dialog.Channel({ - id: Option.getOrThrow(decodePeerId('supergroup', m.chat.id)), + id: Option.getOrThrow(decodePeerId('channel', m.chat.id)), }) if (m.direct_messages_topic != null) { return channel.directMessages(m.direct_messages_topic.topic_id) } return channel } + case 'supergroup': { + const supergroup = new Dialog.Supergroup({ + id: Option.getOrThrow(decodePeerId('channel', m.chat.id)), + }) + if (m.message_thread_id != null) { + return supergroup.topic(m.message_thread_id) + } + return supergroup + } } } diff --git a/src/internal/send.ts b/src/internal/send.ts index fc34e84..dc9c36b 100644 --- a/src/internal/send.ts +++ b/src/internal/send.ts @@ -250,12 +250,12 @@ const paramsDialog: ( Group: group => ({ chat_id: group.dialogId(), }), - Supergroup: supergroup => ({ - chat_id: supergroup.dialogId(), - }), Channel: channel => ({ chat_id: channel.dialogId(), }), + Supergroup: supergroup => ({ + chat_id: supergroup.dialogId(), + }), PrivateTopic: topic => ({ chat_id: topic.user.dialogId(), message_thread_id: topic.topicId, diff --git a/test/Dialog.test.ts b/test/Dialog.test.ts index 77b74f3..d4ef3ef 100644 --- a/test/Dialog.test.ts +++ b/test/Dialog.test.ts @@ -9,8 +9,8 @@ describe('Dialog', () => { expect(Option.getOrThrow(Dialog.decodeDialogId(-2002147483649))).toEqual({ peer: 'monoforum', id: 1002147483649 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-2002147483648))).toEqual({ peer: 'secret-chat', id: -2147483648 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-1997852516353))).toEqual({ peer: 'secret-chat', id: 2147483647 }) - expect(Option.getOrThrow(Dialog.decodeDialogId(-1997852516352))).toEqual({ peer: 'supergroup', id: 997852516352 }) - expect(Option.getOrThrow(Dialog.decodeDialogId(-1000000000001))).toEqual({ peer: 'supergroup', id: 1 }) + expect(Option.getOrThrow(Dialog.decodeDialogId(-1997852516352))).toEqual({ peer: 'channel', id: 997852516352 }) + expect(Option.getOrThrow(Dialog.decodeDialogId(-1000000000001))).toEqual({ peer: 'channel', id: 1 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-999999999999))).toEqual({ peer: 'group', id: 999999999999 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-1))).toEqual({ peer: 'group', id: 1 }) expect(Option.getOrThrow(Dialog.decodeDialogId(1))).toEqual({ peer: 'user', id: 1 }) @@ -20,7 +20,7 @@ describe('Dialog', () => { it('should decode valid IDs correctly', () => { expect(Option.getOrThrow(Dialog.decodeDialogId(500000000000))).toEqual({ peer: 'user', id: 500000000000 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-500000000000))).toEqual({ peer: 'group', id: 500000000000 }) - expect(Option.getOrThrow(Dialog.decodeDialogId(-1500000000000))).toEqual({ peer: 'supergroup', id: 500000000000 }) + expect(Option.getOrThrow(Dialog.decodeDialogId(-1500000000000))).toEqual({ peer: 'channel', id: 500000000000 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-3500000000000))).toEqual({ peer: 'monoforum', id: 2500000000000 }) expect(Option.getOrThrow(Dialog.decodeDialogId(-2000000000000))).toEqual({ peer: 'secret-chat', id: 0 }) }) @@ -46,8 +46,8 @@ describe('Dialog', () => { expect(Option.getOrThrow(Dialog.decodePeerId('monoforum', -2002147483649))).toBe(1002147483649) expect(Option.getOrThrow(Dialog.decodePeerId('secret-chat', -2002147483648))).toBe(-2147483648) expect(Option.getOrThrow(Dialog.decodePeerId('secret-chat', -1997852516353))).toBe(2147483647) - expect(Option.getOrThrow(Dialog.decodePeerId('supergroup', -1997852516352))).toBe(997852516352) - expect(Option.getOrThrow(Dialog.decodePeerId('supergroup', -1000000000001))).toBe(1) + expect(Option.getOrThrow(Dialog.decodePeerId('channel', -1997852516352))).toBe(997852516352) + expect(Option.getOrThrow(Dialog.decodePeerId('channel', -1000000000001))).toBe(1) expect(Option.getOrThrow(Dialog.decodePeerId('group', -999999999999))).toBe(999999999999) expect(Option.getOrThrow(Dialog.decodePeerId('group', -1))).toBe(1) expect(Option.getOrThrow(Dialog.decodePeerId('user', 1))).toBe(1) @@ -57,7 +57,7 @@ describe('Dialog', () => { it('should decode valid IDs correctly', () => { expect(Option.getOrThrow(Dialog.decodePeerId('monoforum', -3500000000000))).toBe(2500000000000) expect(Option.getOrThrow(Dialog.decodePeerId('secret-chat', -2000000000000))).toBe(0) - expect(Option.getOrThrow(Dialog.decodePeerId('supergroup', -1500000000000))).toBe(500000000000) + expect(Option.getOrThrow(Dialog.decodePeerId('channel', -1500000000000))).toBe(500000000000) expect(Option.getOrThrow(Dialog.decodePeerId('group', -500000000000))).toBe(500000000000) expect(Option.getOrThrow(Dialog.decodePeerId('user', 500000000000))).toBe(500000000000) }) @@ -65,7 +65,7 @@ describe('Dialog', () => { it('should return None for mismatched peer type', () => { expect(Option.isNone(Dialog.decodePeerId('user', -1))).toBe(true) expect(Option.isNone(Dialog.decodePeerId('group', 1))).toBe(true) - expect(Option.isNone(Dialog.decodePeerId('supergroup', -1))).toBe(true) + expect(Option.isNone(Dialog.decodePeerId('channel', -1))).toBe(true) expect(Option.isNone(Dialog.decodePeerId('monoforum', -1))).toBe(true) expect(Option.isNone(Dialog.decodePeerId('secret-chat', -1))).toBe(true) }) @@ -77,8 +77,8 @@ describe('Dialog', () => { expect(Option.getOrThrow(Dialog.encodePeerId('user', 0xFFFFFFFFFF))).toBe(1099511627775) expect(Option.getOrThrow(Dialog.encodePeerId('group', 1))).toBe(-1) expect(Option.getOrThrow(Dialog.encodePeerId('group', 999999999999))).toBe(-999999999999) - expect(Option.getOrThrow(Dialog.encodePeerId('supergroup', 1))).toBe(-1000000000001) - expect(Option.getOrThrow(Dialog.encodePeerId('supergroup', 997852516352))).toBe(-1997852516352) + expect(Option.getOrThrow(Dialog.encodePeerId('channel', 1))).toBe(-1000000000001) + expect(Option.getOrThrow(Dialog.encodePeerId('channel', 997852516352))).toBe(-1997852516352) expect(Option.getOrThrow(Dialog.encodePeerId('monoforum', 1002147483649))).toBe(-2002147483649) expect(Option.getOrThrow(Dialog.encodePeerId('monoforum', 3000000000000))).toBe(-4000000000000) expect(Option.getOrThrow(Dialog.encodePeerId('secret-chat', -2147483648))).toBe(-2002147483648) @@ -88,7 +88,7 @@ describe('Dialog', () => { it('should encode valid IDs correctly', () => { expect(Option.getOrThrow(Dialog.encodePeerId('user', 500000000000))).toBe(500000000000) expect(Option.getOrThrow(Dialog.encodePeerId('group', 500000000000))).toBe(-500000000000) - expect(Option.getOrThrow(Dialog.encodePeerId('supergroup', 500000000000))).toBe(-1500000000000) + expect(Option.getOrThrow(Dialog.encodePeerId('channel', 500000000000))).toBe(-1500000000000) expect(Option.getOrThrow(Dialog.encodePeerId('monoforum', 2500000000000))).toBe(-3500000000000) expect(Option.getOrThrow(Dialog.encodePeerId('secret-chat', 0))).toBe(-2000000000000) }) @@ -98,8 +98,8 @@ describe('Dialog', () => { expect(Option.isNone(Dialog.encodePeerId('user', 1099511627776))).toBe(true) expect(Option.isNone(Dialog.encodePeerId('group', 0))).toBe(true) expect(Option.isNone(Dialog.encodePeerId('group', 1000000000000))).toBe(true) - expect(Option.isNone(Dialog.encodePeerId('supergroup', 0))).toBe(true) - expect(Option.isNone(Dialog.encodePeerId('supergroup', 997852516353))).toBe(true) + expect(Option.isNone(Dialog.encodePeerId('channel', 0))).toBe(true) + expect(Option.isNone(Dialog.encodePeerId('channel', 997852516353))).toBe(true) expect(Option.isNone(Dialog.encodePeerId('monoforum', 1002147483648))).toBe(true) expect(Option.isNone(Dialog.encodePeerId('monoforum', 3000000000001))).toBe(true) expect(Option.isNone(Dialog.encodePeerId('secret-chat', -2147483649))).toBe(true) @@ -116,7 +116,7 @@ describe('Dialog', () => { it.each([ ['user', 9091348234], ['group', 43138491], - ['supergroup', 12729042939], + ['channel', 12729042939], ['monoforum', 2987658076159], ['secret-chat', 2140000000], ] as const)('should roundtrip %s ID', (peer, peerId) => { From 9d2280f55b7dba3bdb80f2a02c1b4cf1c3db722b Mon Sep 17 00:00:00 2001 From: evermake Date: Sun, 8 Feb 2026 17:22:38 +0500 Subject: [PATCH 03/12] wip --- README.md | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/README.md b/README.md index b6e1dc4..e953578 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,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 +21,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 @@ -172,3 +177,150 @@ 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 thread where the message will be sent. +- **Reply** — (optional) information about the message being replied to. +- **Markup** — (optional) markup for replying to the message. +- **Options** — (optional) additional options for sending the message. + +_TODO: add subsections explaining each of these parts_ + +`Send.sendMessage` function accepts mentioned parameters and returns an `Effect` that sends a message, automatically choosing the appropriate method based on the content type. + +**Example:** Sending messages using `Send.sendMessage`. + +```ts +// TODO: come up with a couple of short examples using different feautes; +``` + +### Prepared messages + +_TODO: explain that it contains content, markup, and options, but dialog should be provided; explain benefits_ +_TODO: explain that it's pipeable_ + +Create reusable message templates with `Send.message`: + +```ts +// TODO: more interesting and useful examples + +import { Content, Send, Text } from '@grom.js/effect-tg' +import { Effect } from 'effect' + +// Create a prepared message +const welcomeMessage = Send.message( + Content.text(Text.html('Welcome! Thanks for joining.')), +) + +// Send to a specific dialog +const program = welcomeMessage.pipe( + Send.to(Dialog.user(123456789)), +) +``` + +### Composing options + +_TODO: refine this section_ + +Chain modifiers to customize message behavior: + +```ts +import { Content, Send, Text } from '@grom.js/effect-tg' +import { pipe } from 'effect' + +const content = Content.text(Text.plain('Secret message! 🤫')) + +const silentProtectedMessage = pipe( + Send.message(), + Send.withoutNotification, + Send.withContentProtection, +) +``` + +Available modifiers: + +- `withMarkup` / `withoutMarkup` — reply keyboard or inline buttons +- `withNotification` / `withoutNotification` — enable/disable notification +- `withContentProtection` / `withoutContentProtection` — prevent forwarding/saving +- `withPaidBroadcast` / `withoutPaidBroadcast` — paid broadcast mode + +### Text formatting + +`Text` module provides all [formatting options](https://core.telegram.org/bots/api#formatting-options) supported by the Bot API. + +**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 using JSX syntax, with JSX runtime implemented by [`@grom.js/tgx`](https://github.com/grom-dev/tgx). + +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 \B@d\ \_iNpUtS\_. +- **Type safety**: Free 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:** Using JSX to created formatted text. + +```tsx +// TODO: come up with some interesting example to showcase JSX benefits +``` + +
+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 `TgxElement`s to the parameters for a `send*` method. + +
+ +To enable JSX support: + +1. Install `@grom.js/tgx` package: + ```sh + npm install @grom.js/tgx + ``` +2. Update your `tsconfig.json`: + ```json + { + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@grom.js/tgx" + } + } + ``` From d9de70ebd9c986cd5cf5bd4d77feccec94caaac8 Mon Sep 17 00:00:00 2001 From: evermake Date: Mon, 9 Feb 2026 22:40:12 +0500 Subject: [PATCH 04/12] wip --- README.md | 185 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 150 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e953578..5cb6172 100644 --- a/README.md +++ b/README.md @@ -197,69 +197,136 @@ To send a message, you need: - **Content** — content of the message to be sent. - **Dialog** — target chat and thread where the message will be sent. -- **Reply** — (optional) information about the message being replied to. - **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. -_TODO: add subsections explaining each of these parts_ - `Send.sendMessage` function accepts mentioned parameters and returns an `Effect` that sends a message, automatically choosing the appropriate method based on the content type. **Example:** Sending messages using `Send.sendMessage`. ```ts -// TODO: come up with a couple of short examples using different feautes; +import { Content, Dialog, File, Reply, Send, Text } from '@grom.js/effect-tg' +import { Effect } from 'effect' + +const program = Effect.gen(function* () { + // Send a text message + yield* Send.sendMessage({ + content: Content.text(Text.plain('Hello!')), + dialog: Dialog.user(123456789), + }) + + // Send a photo with caption to a forum topic + yield* Send.sendMessage({ + content: Content.photo(File.External(new URL('https://example.com/image.jpg')), { + caption: Text.plain('Check this out!'), + }), + dialog: Dialog.supergroup(987654321).topic(42), + }) + + // Reply to a message + const sent = yield* Send.sendMessage({ + content: Content.dice('🎲'), + dialog: Dialog.user(123456789), + }) + yield* Send.sendMessage({ + content: Content.text(Text.plain('Good luck!')), + dialog: Dialog.user(123456789), + reply: Reply.toMessage(sent), + }) +}) ``` -### Prepared messages +#### Content -_TODO: explain that it contains content, markup, and options, but dialog should be provided; explain benefits_ -_TODO: explain that it's pipeable_ +`Content` module provides constructors for every supported content type: -Create reusable message templates with `Send.message`: +| Constructor | Bot API method | Description | +| ---------------------- | --------------- | --------------------------------------------------- | +| `Content.text` | `sendMessage` | Text with optional link preview | +| `Content.photo` | `sendPhoto` | Photo with optional caption and spoiler | +| `Content.video` | `sendVideo` | Video with optional caption, spoiler, and streaming | +| `Content.animation` | `sendAnimation` | GIF or video without sound | +| `Content.audio` | `sendAudio` | Audio file with optional metadata | +| `Content.voice` | `sendVoice` | Voice note | +| `Content.videoNote` | `sendVideoNote` | Round video | +| `Content.document` | `sendDocument` | File of any type | +| `Content.sticker` | `sendSticker` | Sticker | +| `Content.location` | `sendLocation` | Static location | +| `Content.liveLocation` | `sendLocation` | Live location with updates | +| `Content.venue` | `sendVenue` | Venue with address | +| `Content.contact` | `sendContact` | Phone contact | +| `Content.dice` | `sendDice` | Animated emoji | -```ts -// TODO: more interesting and useful examples +#### Dialog -import { Content, Send, Text } from '@grom.js/effect-tg' -import { Effect } from 'effect' +`Dialog` module provides constructors for all target types: -// Create a prepared message -const welcomeMessage = Send.message( - Content.text(Text.html('Welcome! Thanks for joining.')), -) +- `Dialog.user(id)` — private chat with a user. +- `Dialog.group(id)` — group chat. +- `Dialog.supergroup(id)` — supergroup chat. +- `Dialog.channel(id)` — channel. -// Send to a specific dialog -const program = welcomeMessage.pipe( - Send.to(Dialog.user(123456789)), -) -``` +To target a specific thread or topic, chain a method on the peer: -### Composing options +- `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` extracts the dialog from an incoming `Message` object. -_TODO: refine this section_ +#### Reply -Chain modifiers to customize message behavior: +`Reply` module provides two ways to create a reply reference: + +- `Reply.make({ dialog, messageId })` — reply to a message by ID in a specific dialog. +- `Reply.toMessage(message)` — reply to a `Message` object, extracting the dialog and ID automatically. + +Both accept an optional `optional` flag — when `true`, the message will be sent even if the referenced message is not found. + +### Prepared messages + +`Send.message` creates a reusable `MessageToSend` that bundles content, markup, reply, and options — everything except the target dialog. This lets you define a message template once and send it to different dialogs later. + +`MessageToSend` is pipeable: you can chain modifiers on it and provide the target dialog with `Send.to`. + +**Example:** Creating and sending prepared messages. ```ts -import { Content, Send, Text } from '@grom.js/effect-tg' +import { Content, Dialog, Send, Text } from '@grom.js/effect-tg' import { pipe } from 'effect' -const content = Content.text(Text.plain('Secret message! 🤫')) +// Define a reusable message +const welcomeMessage = Send.message( + Content.text(Text.html('Welcome! Thanks for joining.')), +) + +// Send to a specific user +const program = welcomeMessage.pipe( + Send.to(Dialog.user(123456789)), +) -const silentProtectedMessage = pipe( - Send.message(), +// Send a silent, protected message +const secretMessage = pipe( + Send.message(Content.text(Text.plain('Secret message!'))), Send.withoutNotification, Send.withContentProtection, + Send.to(Dialog.user(123456789)), ) ``` -Available modifiers: +### Composing options + +Chain modifiers on a `MessageToSend` to customize its behavior: -- `withMarkup` / `withoutMarkup` — reply keyboard or inline buttons -- `withNotification` / `withoutNotification` — enable/disable notification -- `withContentProtection` / `withoutContentProtection` — prevent forwarding/saving -- `withPaidBroadcast` / `withoutPaidBroadcast` — paid broadcast mode +- `Send.withMarkup` / `Send.withoutMarkup` — set or remove reply keyboard / inline buttons. +- `Send.withReply` / `Send.withoutReply` — set or remove the message being replied to. +- `Send.withNotification` / `Send.withoutNotification` — enable or disable notification sound. +- `Send.withContentProtection` / `Send.withoutContentProtection` — prevent or allow forwarding and saving. +- `Send.withPaidBroadcast` / `Send.withoutPaidBroadcast` — enable or disable paid broadcast mode. +- `Send.withOptions` — merge arbitrary options at once. + +All dual modifiers (`withMarkup`, `withReply`, `withOptions`) support both data-first and data-last calling conventions. ### Text formatting @@ -293,10 +360,58 @@ Benefits of using JSX: `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:** Using JSX to created formatted text. +**Example:** Composing reusable messages with JSX. ```tsx -// TODO: come up with some interesting example to showcase JSX benefits +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'} +) + +// Component that renders a deploy summary +const DeploySummary = (props: { + service: string + version: string + env: string + author: string + url: string +}) => ( + <> + Deploy to {props.env} + {'\n\n'} + {props.service} + {props.version} + {props.author} + {'\n'} + View in dashboard + {'\n\n'} +
+ Changelog:{'\n'} + - Fix rate limiting on /api/submit{'\n'} + - Add retry logic for webhook delivery{'\n'} + - Update dependencies +
+ +) + +// Compose the final message +const deployNotification = pipe( + Send.message(Content.text(Text.tgx( + , + ))), + Send.withoutNotification, + Send.to(Dialog.channel(123456789)), +) ```
From e441cc297b5c37c83b77704df18af54ab7dadbbb Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 12:28:48 +0500 Subject: [PATCH 05/12] badges --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5cb6172..857b4c4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # effect-tg -[![Effectful](https://img.shields.io/badge/Effectful-%23fff?style=flat&logo=effect&logoColor=%23fff&logoSize=auto&color=%23000)](https://effect.website/) +[![Effectful](https://img.shields.io/badge/Yes.-%23fff?style=flat&logo=effect&logoColor=%23000&logoSize=auto&label=Effect%3F&labelColor=%23fff&color=%23000)](https://effect.website/) +[![Bot API](https://img.shields.io/badge/v9.4-%23fff?style=flat&logo=telegram&logoColor=%2325A3E1&logoSize=auto&label=Bot%20API&labelColor=%23fff&color=%2325A3E1)](https://core.telegram.org/bots/api) [![npm](https://img.shields.io/npm/v/%40grom.js%2Feffect-tg?style=flat&logo=npm&logoColor=%23BB443E&logoSize=auto&label=Latest&labelColor=%23fff&color=%23BB443E)](https://www.npmjs.com/package/@grom.js/effect-tg) -[![codecov](https://img.shields.io/codecov/c/github/grom-dev/effect-tg?style=flat&logo=codecov&logoColor=%23fff&logoSize=auto&label=Coverage&labelColor=%23f07&color=%23fff)](https://codecov.io/gh/grom-dev/effect-tg) +[![codecov](https://img.shields.io/codecov/c/github/grom-dev/effect-tg?style=flat&logo=codecov&logoColor=%23f07&label=Coverage&labelColor=%23fff&color=%23f07)](https://codecov.io/gh/grom-dev/effect-tg) Effectful library for crafting Telegram bots. From 7aa32751b6b7583b9fc01861acfb2a5bad658c3d Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 12:34:54 +0500 Subject: [PATCH 06/12] refine --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 857b4c4..ad396ec 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,7 @@ Text.markdown('*Bold* and _italic_.') #### JSX syntax -`Text` module also allows to compose formatted text using JSX syntax, with JSX runtime implemented by [`@grom.js/tgx`](https://github.com/grom-dev/tgx). +`Text` module also allows to compose formatted text with JSX. Benefits of using JSX: @@ -420,7 +420,7 @@ const deployNotification = pipe( 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. +`effect-tg` relies on JSX runtime from [`@grom.js/tgx`](https://github.com/grom-dev/tgx), which transforms JSX elements to `TgxElement` instances. When `Send.sendMessage` encounters an instance of `Text.Tgx`, it converts inner `TgxElement`s to the parameters for a `send*` method.
@@ -428,10 +428,13 @@ When `Send.sendMessage` encounters an instance of `Text.Tgx`, it converts inner To enable JSX support: 1. Install `@grom.js/tgx` package: + ```sh npm install @grom.js/tgx ``` + 2. Update your `tsconfig.json`: + ```json { "compilerOptions": { From 0d8e3d5daa701a81f2931325289d0254ffb0f3a8 Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 12:41:41 +0500 Subject: [PATCH 07/12] refine --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ad396ec..b27dda4 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,16 @@ Failed `BotApi` method calls result in `BotApiError`, which is a union of tagged - `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. All errors except `TransportError` also have `response` property that contains the original response from Bot API. From dda4bf0ae44c011c50e0cab9089f26aed704f2be Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 12:45:13 +0500 Subject: [PATCH 08/12] refine --- README.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b27dda4..8493515 100644 --- a/README.md +++ b/README.md @@ -111,19 +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. From aa6618d4ab82abea5a0ade2b60bd6aede956289c Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 14:49:52 +0500 Subject: [PATCH 09/12] wip --- README.md | 94 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 8493515..e543b9c 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Bot API exposes multiple methods for sending a message, each corresponding to a To send a message, you need: - **Content** — content of the message to be sent. -- **Dialog** — target chat and thread where the message will 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. @@ -203,35 +203,7 @@ To send a message, you need: **Example:** Sending messages using `Send.sendMessage`. ```ts -import { Content, Dialog, File, Reply, Send, Text } from '@grom.js/effect-tg' -import { Effect } from 'effect' - -const program = Effect.gen(function* () { - // Send a text message - yield* Send.sendMessage({ - content: Content.text(Text.plain('Hello!')), - dialog: Dialog.user(123456789), - }) - - // Send a photo with caption to a forum topic - yield* Send.sendMessage({ - content: Content.photo(File.External(new URL('https://example.com/image.jpg')), { - caption: Text.plain('Check this out!'), - }), - dialog: Dialog.supergroup(987654321).topic(42), - }) - - // Reply to a message - const sent = yield* Send.sendMessage({ - content: Content.dice('🎲'), - dialog: Dialog.user(123456789), - }) - yield* Send.sendMessage({ - content: Content.text(Text.plain('Good luck!')), - dialog: Dialog.user(123456789), - reply: Reply.toMessage(sent), - }) -}) +// TODO: variety of examples ``` #### Content @@ -257,14 +229,14 @@ const program = Effect.gen(function* () { #### Dialog -`Dialog` module provides constructors for all target types: +`Dialog` module provides constructors for all target chats: - `Dialog.user(id)` — private chat with a user. - `Dialog.group(id)` — group chat. - `Dialog.supergroup(id)` — supergroup chat. - `Dialog.channel(id)` — channel. -To target a specific thread or topic, chain a method on the peer: +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. @@ -272,6 +244,14 @@ To target a specific thread or topic, chain a method on the peer: `Dialog.ofMessage` extracts the dialog from an incoming `Message` object. +**Dialog ID vs peer ID** + +Bot API uses a single integer (`chat_id`) that [encodes both peer type and ID](https://core.telegram.org/api/bots/ids): user 1:1; group = `-id`; supergroup/channel = `-(id + 1000000000000)`. Some responses return dialog IDs, others peer IDs — wrong format causes errors. + +**Branded types** + +`UserId`, `GroupId`, `SupergroupId`, `ChannelId`, `DialogId` prevent mixing. `SupergroupId` and `ChannelId` are the same type (supergroups are a special kind of channel; both share the same ID space). Use `Dialog.decodePeerId`, `Dialog.encodePeerId`, `Dialog.decodeDialogId` to convert. + #### Reply `Reply` module provides two ways to create a reply reference: @@ -281,35 +261,67 @@ To target a specific thread or topic, chain a method on the peer: Both accept an optional `optional` flag — when `true`, the message will be sent even if the referenced message is not found. +#### Markup + +`Markup` module provides reply markup types and constructors: + +- `inlineKeyboard(rows)` — buttons attached to the message (callback, URL, web app, etc.). +- `replyKeyboard(rows, options?)` — custom keyboard replacing the default; options include `oneTime`, `resizable`, `selective`, `inputPlaceholder`. +- `replyKeyboardRemove` — hide a reply keyboard. +- `forceReply` — show a reply input field. + +Use `InlineButton` and `ReplyButton` builders to create button rows. Example: + +```ts +import { InlineButton, inlineKeyboard, ReplyButton, replyKeyboard } from '@grom.js/effect-tg' + +// Inline keyboard: URL and callback buttons +const inline = inlineKeyboard([ + [InlineButton.url('Open', 'https://example.com'), InlineButton.callback('Tap me', 'action_1')], +]) + +// Reply keyboard: simple buttons +const reply = replyKeyboard([ + ['Option A', 'Option B'], + [ReplyButton.requestContact('Share phone')], +], { oneTime: true }) +``` + ### Prepared messages -`Send.message` creates a reusable `MessageToSend` that bundles content, markup, reply, and options — everything except the target dialog. This lets you define a message template once and send it to different dialogs later. +`Send.message` creates a `MessageToSend` — a reusable Effect that bundles content, markup, reply, and options. It does not send until you run it. + +Flow: -`MessageToSend` is pipeable: you can chain modifiers on it and provide the target dialog with `Send.to`. +1. `Send.message(content)` creates a `MessageToSend` (an Effect that requires `TargetDialog`). +2. Chain modifiers (`Send.withMarkup`, `Send.withoutNotification`, etc.) to customize. +3. `Send.to(dialog)` provides the target and returns a plain Effect; the message sends when that Effect runs (e.g. `yield*` in a generator, `Effect.runPromise`, or as part of a larger program). **Example:** Creating and sending prepared messages. ```ts import { Content, Dialog, Send, Text } from '@grom.js/effect-tg' -import { pipe } from 'effect' +import { Effect, pipe } from 'effect' -// Define a reusable message +// Reusable template const welcomeMessage = Send.message( Content.text(Text.html('Welcome! Thanks for joining.')), ) -// Send to a specific user -const program = welcomeMessage.pipe( - Send.to(Dialog.user(123456789)), -) +// Send to different dialogs — runs the Effect to perform the API call +const program = Effect.gen(function* () { + yield* welcomeMessage.pipe(Send.to(Dialog.user(123456789))) + yield* welcomeMessage.pipe(Send.to(Dialog.user(987654321))) +}) -// Send a silent, protected message +// With modifiers: silent, protected const secretMessage = pipe( Send.message(Content.text(Text.plain('Secret message!'))), Send.withoutNotification, Send.withContentProtection, Send.to(Dialog.user(123456789)), ) +// Use yield* secretMessage or Effect.runPromise(secretMessage) to send ``` ### Composing options From c9fff7897d055aed7c58237816e6d5f427572b84 Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 22:48:27 +0500 Subject: [PATCH 10/12] refine --- README.md | 227 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 146 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index e543b9c..2dc0fac 100644 --- a/README.md +++ b/README.md @@ -135,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', () => @@ -198,41 +198,89 @@ To send a message, you need: - **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 method based on the content type. +`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 -// TODO: variety of examples +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')], + ]), + }) + + // Silently reply with a dice + const roll = yield* Send.sendMessage({ + content: Content.dice('🎲'), + dialog: Dialog.user(382713), + reply: Reply.toMessage(greeting), + options: Send.options({ disableNotification: true }) + }) + + // React to the roll — reply to the dice message + const rolled = roll.dice!.value + yield* Send.sendMessage({ + content: Content.text( + rolled === 6 + ? Text.html('JACKPOT! You are officially the luckiest person alive.') + : Text.plain(`You rolled ${rolled}. Disappointing. I expected more from you.`), + ), + dialog: Dialog.user(382713), + reply: Reply.toMessage(roll), + }) + + // Log the result to a channel direct messages topic + yield* Send.sendMessage({ + content: Content.text(Text.plain(`User 382713 rolled ${rolled}.`)), + dialog: Dialog.channel(100200).directMessages(42), + }) +}) ``` #### Content -`Content` module provides constructors for every supported content type: - -| Constructor | Bot API method | Description | -| ---------------------- | --------------- | --------------------------------------------------- | -| `Content.text` | `sendMessage` | Text with optional link preview | -| `Content.photo` | `sendPhoto` | Photo with optional caption and spoiler | -| `Content.video` | `sendVideo` | Video with optional caption, spoiler, and streaming | -| `Content.animation` | `sendAnimation` | GIF or video without sound | -| `Content.audio` | `sendAudio` | Audio file with optional metadata | -| `Content.voice` | `sendVoice` | Voice note | -| `Content.videoNote` | `sendVideoNote` | Round video | -| `Content.document` | `sendDocument` | File of any type | -| `Content.sticker` | `sendSticker` | Sticker | -| `Content.location` | `sendLocation` | Static location | -| `Content.liveLocation` | `sendLocation` | Live location with updates | -| `Content.venue` | `sendVenue` | Venue with address | -| `Content.contact` | `sendContact` | Phone contact | -| `Content.dice` | `sendDice` | Animated emoji | +`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 constructors for all target chats: +`Dialog` module provides utilities for creating target chats: - `Dialog.user(id)` — private chat with a user. -- `Dialog.group(id)` — group chat. +- `Dialog.group(id)` — chat of a (basic) group. - `Dialog.supergroup(id)` — supergroup chat. - `Dialog.channel(id)` — channel. @@ -242,60 +290,58 @@ To target a specific topic, chain a method on the peer: - `Dialog.supergroup(id).topic(topicId)` — topic in a forum supergroup. - `Dialog.channel(id).directMessages(topicId)` — channel direct messages. -`Dialog.ofMessage` extracts the dialog from an incoming `Message` object. +`Dialog.ofMessage` helper extracts the dialog from an incoming `Message` object. -**Dialog ID vs peer ID** +##### Dialog and peer IDs -Bot API uses a single integer (`chat_id`) that [encodes both peer type and ID](https://core.telegram.org/api/bots/ids): user 1:1; group = `-id`; supergroup/channel = `-(id + 1000000000000)`. Some responses return dialog IDs, others peer IDs — wrong format causes errors. +Bot API uses a single integer to encode peer type with its ID — [dialog ID](https://core.telegram.org/api/bots/ids). -**Branded types** +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`. -`UserId`, `GroupId`, `SupergroupId`, `ChannelId`, `DialogId` prevent mixing. `SupergroupId` and `ChannelId` are the same type (supergroups are a special kind of channel; both share the same ID space). Use `Dialog.decodePeerId`, `Dialog.encodePeerId`, `Dialog.decodeDialogId` to convert. +To prevent this confusion, `Dialog` module defines **branded types** that distinguish peer IDs from dialog IDs at the type level: -#### Reply +- `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. -`Reply` module provides two ways to create a reply reference: +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: -- `Reply.make({ dialog, messageId })` — reply to a message by ID in a specific dialog. -- `Reply.toMessage(message)` — reply to a `Message` object, extracting the dialog and ID automatically. - -Both accept an optional `optional` flag — when `true`, the message will be sent even if the referenced message is not found. +- `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: -- `inlineKeyboard(rows)` — buttons attached to the message (callback, URL, web app, etc.). -- `replyKeyboard(rows, options?)` — custom keyboard replacing the default; options include `oneTime`, `resizable`, `selective`, `inputPlaceholder`. -- `replyKeyboardRemove` — hide a reply keyboard. -- `forceReply` — show a reply input field. +- `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. -Use `InlineButton` and `ReplyButton` builders to create button rows. Example: +**Example:** Creating inline and reply keyboards. ```ts -import { InlineButton, inlineKeyboard, ReplyButton, replyKeyboard } from '@grom.js/effect-tg' +import { Markup } from '@grom.js/effect-tg' -// Inline keyboard: URL and callback buttons -const inline = inlineKeyboard([ - [InlineButton.url('Open', 'https://example.com'), InlineButton.callback('Tap me', 'action_1')], +const inline = Markup.inlineKeyboard([ + [Markup.InlineButton.callback('Like', 'liked')], + [Markup.InlineButton.url('Source code', 'https://github.com/grom-dev/effect-tg')], ]) -// Reply keyboard: simple buttons -const reply = replyKeyboard([ +const reply = Markup.replyKeyboard([ ['Option A', 'Option B'], - [ReplyButton.requestContact('Share phone')], + [Markup.ReplyButton.requestContact('Share phone')], ], { oneTime: true }) ``` ### Prepared messages -`Send.message` creates a `MessageToSend` — a reusable Effect that bundles content, markup, reply, and options. It does not send until you run it. - -Flow: - -1. `Send.message(content)` creates a `MessageToSend` (an Effect that requires `TargetDialog`). -2. Chain modifiers (`Send.withMarkup`, `Send.withoutNotification`, etc.) to customize. -3. `Send.to(dialog)` provides the target and returns a plain Effect; the message sends when that Effect runs (e.g. `yield*` in a generator, `Effect.runPromise`, or as part of a larger program). +`Send.message` creates a `MessageToSend` — a reusable Effect that bundles content, markup, reply, and options. Chain modifiers to customize it, then call `Send.to(dialog)` to provide the target. The message is sent when the resulting Effect runs. **Example:** Creating and sending prepared messages. @@ -308,10 +354,10 @@ const welcomeMessage = Send.message( Content.text(Text.html('Welcome! Thanks for joining.')), ) -// Send to different dialogs — runs the Effect to perform the API call +// Send to different dialogs const program = Effect.gen(function* () { - yield* welcomeMessage.pipe(Send.to(Dialog.user(123456789))) - yield* welcomeMessage.pipe(Send.to(Dialog.user(987654321))) + yield* welcomeMessage.pipe(Send.to(Dialog.user(123))) + yield* welcomeMessage.pipe(Send.to(Dialog.channel(321))) }) // With modifiers: silent, protected @@ -321,25 +367,38 @@ const secretMessage = pipe( Send.withContentProtection, Send.to(Dialog.user(123456789)), ) -// Use yield* secretMessage or Effect.runPromise(secretMessage) to send ``` ### Composing options Chain modifiers on a `MessageToSend` to customize its behavior: -- `Send.withMarkup` / `Send.withoutMarkup` — set or remove reply keyboard / inline buttons. -- `Send.withReply` / `Send.withoutReply` — set or remove the message being replied to. -- `Send.withNotification` / `Send.withoutNotification` — enable or disable notification sound. -- `Send.withContentProtection` / `Send.withoutContentProtection` — prevent or allow forwarding and saving. -- `Send.withPaidBroadcast` / `Send.withoutPaidBroadcast` — enable or disable paid broadcast mode. -- `Send.withOptions` — merge arbitrary options at once. +- `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. -All dual modifiers (`withMarkup`, `withReply`, `withOptions`) support both data-first and data-last calling conventions. +```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 all [formatting options](https://core.telegram.org/bots/api#formatting-options) supported by the Bot API. +`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. @@ -364,8 +423,8 @@ 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 \B@d\ \_iNpUtS\_. -- **Type safety**: Free LSP hints and type checking for text entities and 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. @@ -381,6 +440,11 @@ const Field = (props: PropsWithChildren<{ label: string }>) => ( <>{props.label}: {props.children}{'\n'} ) +// Simple component for convenience +const RocketEmoji = () => ( + +) + // Component that renders a deploy summary const DeploySummary = (props: { service: string @@ -390,7 +454,7 @@ const DeploySummary = (props: { url: string }) => ( <> - Deploy to {props.env} + Deploy to {props.env} {'\n\n'} {props.service} {props.version} @@ -407,19 +471,20 @@ const DeploySummary = (props: { ) -// Compose the final message -const deployNotification = pipe( - Send.message(Content.text(Text.tgx( - , - ))), - Send.withoutNotification, - Send.to(Dialog.channel(123456789)), +// Create summary text +const summary = Text.tgx( + +) + +// Publish a new post +const publish = Send.message(Content.text(summary)).pipe( + Send.to(Dialog.channel(3011378744)), ) ``` From d52c90510146a6178add5f0cb26171e6c45d8fc5 Mon Sep 17 00:00:00 2001 From: evermake Date: Wed, 11 Feb 2026 23:08:19 +0500 Subject: [PATCH 11/12] wip --- README.md | 72 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2dc0fac..10b6d02 100644 --- a/README.md +++ b/README.md @@ -226,31 +226,29 @@ const program = Effect.gen(function* () { ]), }) - // Silently reply with a dice + // Reply with a dice const roll = yield* Send.sendMessage({ content: Content.dice('🎲'), dialog: Dialog.user(382713), reply: Reply.toMessage(greeting), - options: Send.options({ disableNotification: true }) }) - // React to the roll — reply to the dice message const rolled = roll.dice!.value - yield* Send.sendMessage({ - content: Content.text( - rolled === 6 - ? Text.html('JACKPOT! You are officially the luckiest person alive.') - : Text.plain(`You rolled ${rolled}. Disappointing. I expected more from you.`), - ), - dialog: Dialog.user(382713), - reply: Reply.toMessage(roll), - }) - - // Log the result to a channel direct messages topic - yield* Send.sendMessage({ - content: Content.text(Text.plain(`User 382713 rolled ${rolled}.`)), - dialog: Dialog.channel(100200).directMessages(42), - }) + 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 }) + }) + } }) ``` @@ -323,7 +321,7 @@ Constructors like `Dialog.user`, `Dialog.channel`, etc. validate and encode IDs - `Markup.replyKeyboardRemove()` — hide a previously shown reply keyboard. - `Markup.forceReply()` — forces Telegram client to reply to the message. -**Example:** Creating inline and reply keyboards. +**Example:** Creating reply markups. ```ts import { Markup } from '@grom.js/effect-tg' @@ -333,10 +331,13 @@ const inline = Markup.inlineKeyboard([ [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 }) +const reply = Markup.replyKeyboard( + [ + ['Option A', 'Option B'], + [Markup.ReplyButton.requestContact('Share phone')], + ], + { oneTime: true, resizable: true } +) ``` ### Prepared messages @@ -347,25 +348,32 @@ const reply = Markup.replyKeyboard([ ```ts import { Content, Dialog, Send, Text } from '@grom.js/effect-tg' -import { Effect, pipe } from 'effect' +import { Effect } from 'effect' // Reusable template const welcomeMessage = Send.message( - Content.text(Text.html('Welcome! Thanks for joining.')), + 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 program = Effect.gen(function* () { +const greet1 = Effect.gen(function* () { yield* welcomeMessage.pipe(Send.to(Dialog.user(123))) yield* welcomeMessage.pipe(Send.to(Dialog.channel(321))) }) -// With modifiers: silent, protected -const secretMessage = pipe( - Send.message(Content.text(Text.plain('Secret message!'))), - Send.withoutNotification, - Send.withContentProtection, - Send.to(Dialog.user(123456789)), +// 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)), ) ``` From 8567991820f2c6371a80326e00c9e1b2725d09d1 Mon Sep 17 00:00:00 2001 From: evermake Date: Thu, 12 Feb 2026 00:34:25 +0500 Subject: [PATCH 12/12] wip --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 10b6d02..5886e55 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,12 @@ const reply = Markup.replyKeyboard( ### Prepared messages -`Send.message` creates a `MessageToSend` — a reusable Effect that bundles content, markup, reply, and options. Chain modifiers to customize it, then call `Send.to(dialog)` to provide the target. The message is sent when the resulting Effect runs. +`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.