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 -[![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. @@ -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 = () => ( + +) + +// 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 +
+ +) + +// Create summary text +const summary = Text.tgx( + +) + +// Publish a new post +const publish = Send.message(Content.text(summary)).pipe( + Send.to(Dialog.channel(3011378744)), +) +``` + +
+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`](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. + +
+ +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" + } + } + ``` 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) => {