diff --git a/eslint.config.ts b/eslint.config.ts index e53d491..f388b70 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -27,5 +27,6 @@ export default antfu({ 'test/prefer-lowercase-title': ['error', { ignore: ['describe'], }], + 'unicorn/throw-new-error': 'off', }, }) diff --git a/src/Bot.ts b/src/Bot.ts index 2029db5..e8f214e 100644 --- a/src/Bot.ts +++ b/src/Bot.ts @@ -4,10 +4,9 @@ import * as Context from 'effect/Context' export type Bot = Effect.Effect -export class Update extends Context.Tag('@grom.js/effect-tg/Bot/Update')< - Update, - BotApi.Types.Update ->() {} +export interface Update extends Readonly {} + +export const Update: Context.Tag = Context.GenericTag('@grom.js/effect-tg/Bot/Update') export interface Middleware { (self: Bot): Bot diff --git a/src/BotApi.ts b/src/BotApi.ts index 4addbe3..12e2fe1 100644 --- a/src/BotApi.ts +++ b/src/BotApi.ts @@ -17,12 +17,11 @@ import * as BotApiTransport from './BotApiTransport.ts' import * as BotApiUrl from './BotApiUrl.ts' import * as internal from './internal/botApi.ts' -export type { MethodParams, MethodResults, Service, Types } +export type { MethodParams, MethodResults, Types } -export class BotApi extends Context.Tag('@grom.js/effect-tg/BotApi')< - BotApi, - Service ->() {} +export interface BotApi extends Readonly {} + +export const BotApi: Context.Tag = Context.GenericTag('@grom.js/effect-tg/BotApi') export interface BotApiMethod { (...args: MethodArgs): Effect.Effect< @@ -47,8 +46,8 @@ export const callMethod: ( ) as any export const make: (args: { - transport: BotApiTransport.Service -}) => Service = internal.make + transport: BotApiTransport.BotApiTransport +}) => BotApi = internal.make export const layer: Layer.Layer< BotApi, @@ -95,7 +94,7 @@ export const layerConfig = (options: { * - Adding custom retry logic or error handling * - Integrating with monitoring or debugging tools */ - readonly transformTransport?: (transport: BotApiTransport.Service) => BotApiTransport.Service + readonly transformTransport?: (transport: BotApiTransport.BotApiTransport) => BotApiTransport.BotApiTransport }): Layer.Layer => { const { token, diff --git a/src/BotApiError.ts b/src/BotApiError.ts index d5757ea..d73a51f 100644 --- a/src/BotApiError.ts +++ b/src/BotApiError.ts @@ -1,7 +1,7 @@ import type * as HttpBody from '@effect/platform/HttpBody' import type * as HttpClientError from '@effect/platform/HttpClientError' import type * as BotApiTransport from './BotApiTransport.ts' -import * as Data from 'effect/Data' +import { TypeIdError } from '@effect/platform/Error' import * as Duration from 'effect/Duration' import * as Match from 'effect/Match' import * as Option from 'effect/Option' @@ -9,7 +9,7 @@ import * as Predicate from 'effect/Predicate' import * as Dialog from './Dialog.ts' import * as internal from './internal/botApiError.ts' -export const TypeId = '@grom.js/effect-tg/BotApiError' +export const TypeId: unique symbol = Symbol.for('@grom.js/effect-tg/BotApiError') export type TypeId = typeof TypeId @@ -25,14 +25,12 @@ export type BotApiError = /** * Error caused by the transport when accessing Bot API. */ -export class TransportError extends Data.TaggedError('TransportError')<{ - cause: +export class TransportError extends TypeIdError(TypeId, 'TransportError')<{ + readonly cause: | HttpClientError.HttpClientError | HttpBody.HttpBodyError }> { - readonly [TypeId]: TypeId = TypeId - - override get message() { + override get message(): string { return Match.value(this.cause).pipe( Match.tagsExhaustive({ RequestError: e => e.message, @@ -48,43 +46,39 @@ export class TransportError extends Data.TaggedError('TransportError')<{ } } -export class MethodFailed extends Data.TaggedError('MethodFailed')<{ - response: FailureResponse - possibleReason: MethodFailureReason +export class MethodFailed extends TypeIdError(TypeId, 'MethodFailed')<{ + readonly response: FailureResponse + readonly possibleReason: MethodFailureReason }> { - readonly [TypeId]: TypeId = TypeId - override get message() { return `(${this.response.error_code}) ${this.response.description}` } } -export class GroupUpgraded extends Data.TaggedError('GroupUpgraded')<{ - response: FailureResponse - supergroup: Dialog.Supergroup +export class GroupUpgraded extends TypeIdError(TypeId, 'GroupUpgraded')<{ + readonly response: FailureResponse + readonly supergroup: Dialog.Supergroup }> { - readonly [TypeId]: TypeId = TypeId - override get message() { return `Group has been upgraded to a supergroup with ID ${this.supergroup.id}.` } } -export class RateLimited extends Data.TaggedError('RateLimited')<{ - response: FailureResponse - retryAfter: Duration.Duration +export class RateLimited extends TypeIdError(TypeId, 'RateLimited')<{ + readonly response: FailureResponse + readonly retryAfter: Duration.Duration }> { - readonly [TypeId]: TypeId = TypeId - override get message() { return `Flood limit exceeded. Should wait for ${Duration.format(this.retryAfter)} before retrying.` } } -export class InternalServerError extends Data.TaggedError('InternalServerError')<{ - response: FailureResponse +export class InternalServerError extends TypeIdError(TypeId, 'InternalServerError')<{ + readonly response: FailureResponse }> { - readonly [TypeId]: TypeId = TypeId + override get message() { + return `Internal error (${this.response.error_code}): ${this.response.description}` + } } export const fromResponse = (response: FailureResponse): BotApiError => { diff --git a/src/BotApiTransport.ts b/src/BotApiTransport.ts index a1cfdae..44f288e 100644 --- a/src/BotApiTransport.ts +++ b/src/BotApiTransport.ts @@ -7,18 +7,15 @@ import * as Layer from 'effect/Layer' import * as BotApiUrl from './BotApiUrl.ts' import * as internal from './internal/botApiTransport.ts' -export class BotApiTransport extends Context.Tag('@grom.js/effect-tg/BotApiTransport')< - BotApiTransport, - Service ->() {} - -export interface Service { - sendRequest: ( +export interface BotApiTransport { + readonly sendRequest: ( method: string, params: unknown, ) => Effect.Effect } +export const BotApiTransport: Context.Tag = Context.GenericTag('@grom.js/effect-tg/BotApiTransport') + /** * @see https://core.telegram.org/bots/api#making-requests */ @@ -36,8 +33,8 @@ export type BotApiResponse = export const make: (options: { httpClient: HttpClient.HttpClient - botApiUrl: BotApiUrl.Service -}) => Service = internal.make + botApiUrl: BotApiUrl.BotApiUrl +}) => BotApiTransport = internal.make export const layer: Layer.Layer< BotApiTransport, diff --git a/src/BotApiUrl.ts b/src/BotApiUrl.ts index b70297b..3c3338c 100644 --- a/src/BotApiUrl.ts +++ b/src/BotApiUrl.ts @@ -1,23 +1,20 @@ import * as Context from 'effect/Context' -export class BotApiUrl extends Context.Tag('@grom.js/effect-tg/BotApiUrl')< - BotApiUrl, - Service ->() {} - -export interface Service { - toMethod: (method: string) => URL - toFile: (filePath: string) => URL +export interface BotApiUrl { + readonly toMethod: (method: string) => URL + readonly toFile: (filePath: string) => URL } -export const makeProd = (token: string): Service => ( +export const BotApiUrl: Context.Tag = Context.GenericTag('@grom.js/effect-tg/BotApiUrl') + +export const makeProd = (token: string): BotApiUrl => ( { toMethod: (method: string) => new URL(`https://api.telegram.org/bot${token}/${method}`), toFile: (filePath: string) => new URL(`https://api.telegram.org/file/bot${token}/${filePath}`), } ) -export const makeTest = (token: string): Service => ( +export const makeTest = (token: string): BotApiUrl => ( { toMethod: (method: string) => new URL(`https://api.telegram.org/bot${token}/test/${method}`), // TODO: make sure this works in test environment diff --git a/src/Content.ts b/src/Content.ts index 5c941ef..161d72f 100644 --- a/src/Content.ts +++ b/src/Content.ts @@ -2,7 +2,6 @@ import type * as Duration from 'effect/Duration' import type * as File from './File.ts' import type * as LinkPreview from './LinkPreview.ts' import type * as Text_ from './Text.ts' -import * as Data from 'effect/Data' import * as Option from 'effect/Option' /** @@ -28,184 +27,171 @@ export type Content = | Sticker /** - * Content of a text message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_text.html TDLib • td_api.inputMessageText} * @see {@link https://core.telegram.org/bots/api#sendmessage Bot API • sendMessage} */ -export class Text extends Data.TaggedClass('Text')<{ - text: Text_.Text - linkPreview: Option.Option -}> {} +export interface Text { + readonly _tag: 'Text' + readonly text: Text_.Text + readonly linkPreview: Option.Option +} /** - * Content of a photo message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_photo.html TDLib • td_api.inputMessagePhoto} * @see {@link https://core.telegram.org/bots/api#sendphoto Bot API • sendPhoto} */ -export class Photo extends Data.TaggedClass('Photo')<{ - file: File.FileId | File.External | File.InputFile - caption: Option.Option - layout: 'caption-above' | 'caption-below' - spoiler: boolean -}> {} +export interface Photo { + readonly _tag: 'Photo' + readonly file: File.FileId | File.External | File.InputFile + readonly caption: Option.Option + readonly layout: 'caption-above' | 'caption-below' + readonly spoiler: boolean +} /** - * Content of an audio message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_audio.html TDLib • td_api.inputMessageAudio} * @see {@link https://core.telegram.org/bots/api#sendaudio Bot API • sendAudio} */ -export class Audio extends Data.TaggedClass('Audio')<{ - file: File.FileId | File.External | File.InputFile - caption: Option.Option - duration: Option.Option - performer: Option.Option - title: Option.Option - thumbnail: Option.Option -}> {} +export interface Audio { + readonly _tag: 'Audio' + readonly file: File.FileId | File.External | File.InputFile + readonly caption: Option.Option + readonly duration: Option.Option + readonly performer: Option.Option + readonly title: Option.Option + readonly thumbnail: Option.Option +} /** - * Content of a document message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_document.html TDLib • td_api.inputMessageDocument} * @see {@link https://core.telegram.org/bots/api#senddocument Bot API • sendDocument} */ -export class Document extends Data.TaggedClass('Document')<{ - file: File.FileId | File.External | File.InputFile - caption: Option.Option - thumbnail: Option.Option - contentTypeDetection: boolean -}> {} +export interface Document { + readonly _tag: 'Document' + readonly file: File.FileId | File.External | File.InputFile + readonly caption: Option.Option + readonly thumbnail: Option.Option + readonly contentTypeDetection: boolean +} /** - * Content of a video message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_video.html TDLib • td_api.inputMessageVideo} * @see {@link https://core.telegram.org/bots/api#sendvideo Bot API • sendVideo} */ -export class Video extends Data.TaggedClass('Video')<{ - file: File.FileId | File.External | File.InputFile - caption: Option.Option - layout: 'caption-above' | 'caption-below' - spoiler: boolean - duration: Option.Option - width: Option.Option - height: Option.Option - thumbnail: Option.Option - cover: Option.Option - startAt: Option.Option - supportsStreaming: boolean -}> {} +export interface Video { + readonly _tag: 'Video' + readonly file: File.FileId | File.External | File.InputFile + readonly caption: Option.Option + readonly layout: 'caption-above' | 'caption-below' + readonly spoiler: boolean + readonly duration: Option.Option + readonly width: Option.Option + readonly height: Option.Option + readonly thumbnail: Option.Option + readonly cover: Option.Option + readonly startAt: Option.Option + readonly supportsStreaming: boolean +} /** - * Content of an animation message (GIF or video without sound). - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_animation.html TDLib • td_api.inputMessageAnimation} * @see {@link https://core.telegram.org/bots/api#sendanimation Bot API • sendAnimation} */ -export class Animation extends Data.TaggedClass('Animation')<{ - file: File.FileId | File.External | File.InputFile - caption: Option.Option - layout: 'caption-above' | 'caption-below' - spoiler: boolean - duration: Option.Option - width: Option.Option - height: Option.Option - thumbnail: Option.Option -}> {} +export interface Animation { + readonly _tag: 'Animation' + readonly file: File.FileId | File.External | File.InputFile + readonly caption: Option.Option + readonly layout: 'caption-above' | 'caption-below' + readonly spoiler: boolean + readonly duration: Option.Option + readonly width: Option.Option + readonly height: Option.Option + readonly thumbnail: Option.Option +} /** - * Content of a voice note message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_voice_note.html TDLib • td_api.inputMessageVoiceNote} * @see {@link https://core.telegram.org/bots/api#sendvoice Bot API • sendVoice} */ -export class Voice extends Data.TaggedClass('Voice')<{ - file: File.FileId | File.External | File.InputFile - caption: Option.Option - duration: Option.Option -}> {} +export interface Voice { + readonly _tag: 'Voice' + readonly file: File.FileId | File.External | File.InputFile + readonly caption: Option.Option + readonly duration: Option.Option +} /** - * Content of a video note message (round video). - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_video_note.html TDLib • td_api.inputMessageVideoNote} * @see {@link https://core.telegram.org/bots/api#sendvideonote Bot API • sendVideoNote} */ -export class VideoNote extends Data.TaggedClass('VideoNote')<{ - file: File.FileId | File.InputFile - duration: Option.Option - diameter: Option.Option - thumbnail: Option.Option -}> {} +export interface VideoNote { + readonly _tag: 'VideoNote' + readonly file: File.FileId | File.InputFile + readonly duration: Option.Option + readonly diameter: Option.Option + readonly thumbnail: Option.Option +} /** - * Content of a location message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_location.html TDLib • td_api.inputMessageLocation} * @see {@link https://core.telegram.org/bots/api#sendlocation Bot API • sendLocation} */ -export class Location extends Data.TaggedClass('Location')<{ - latitude: number - longitude: number - uncertaintyRadius: Option.Option - livePeriod: Option.Option - heading: Option.Option - proximityAlertRadius: Option.Option -}> {} +export interface Location { + readonly _tag: 'Location' + readonly latitude: number + readonly longitude: number + readonly uncertaintyRadius: Option.Option + readonly livePeriod: Option.Option + readonly heading: Option.Option + readonly proximityAlertRadius: Option.Option +} /** - * Content of a venue message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_venue.html TDLib • td_api.inputMessageVenue} * @see {@link https://core.telegram.org/bots/api#sendvenue Bot API • sendVenue} */ -export class Venue extends Data.TaggedClass('Venue')<{ - latitude: number - longitude: number - title: string - address: string - foursquareId: Option.Option - foursquareType: Option.Option - googlePlaceId: Option.Option - googlePlaceType: Option.Option -}> {} +export interface Venue { + readonly _tag: 'Venue' + readonly latitude: number + readonly longitude: number + readonly title: string + readonly address: string + readonly foursquareId: Option.Option + readonly foursquareType: Option.Option + readonly googlePlaceId: Option.Option + readonly googlePlaceType: Option.Option +} /** - * Content of a contact message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_contact.html TDLib • td_api.inputMessageContact} * @see {@link https://core.telegram.org/bots/api#sendcontact Bot API • sendContact} */ -export class Contact extends Data.TaggedClass('Contact')<{ - phoneNumber: string - firstName: string - lastName: Option.Option - vcard: Option.Option -}> {} +export interface Contact { + readonly _tag: 'Contact' + readonly phoneNumber: string + readonly firstName: string + readonly lastName: Option.Option + readonly vcard: Option.Option +} /** - * Content of a dice message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_dice.html TDLib • td_api.inputMessageDice} * @see {@link https://core.telegram.org/bots/api#senddice Bot API • sendDice} */ -export class Dice extends Data.TaggedClass('Dice')<{ - emoji: '🎲' | '🎯' | '🏀' | '⚽' | '🎳' | '🎰' -}> {} +export interface Dice { + readonly _tag: 'Dice' + readonly emoji: '🎲' | '🎯' | '🏀' | '⚽' | '🎳' | '🎰' +} /** - * Content of a sticker message. - * * @see {@link https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_sticker.html TDLib • td_api.inputMessageSticker} * @see {@link https://core.telegram.org/bots/api#sendsticker Bot API • sendSticker} */ -export class Sticker extends Data.TaggedClass('Sticker')<{ - file: File.FileId | File.External | File.InputFile - emoji: Option.Option -}> {} +export interface Sticker { + readonly _tag: 'Sticker' + readonly file: File.FileId | File.External | File.InputFile + readonly emoji: Option.Option +} // ——— Constructors —————————————————————————————————————————————————————————— @@ -214,7 +200,8 @@ export const text = ( options?: { linkPreview?: LinkPreview.LinkPreview }, -) => new Text({ +): Text => ({ + _tag: 'Text', text, linkPreview: Option.fromNullable(options?.linkPreview), }) @@ -226,7 +213,8 @@ export const photo = ( layout?: 'caption-above' | 'caption-below' spoiler?: boolean }, -): Photo => new Photo({ +): Photo => ({ + _tag: 'Photo', file, caption: Option.fromNullable(options.caption), layout: options.layout ?? 'caption-below', @@ -242,7 +230,8 @@ export const audio = ( title?: string thumbnail?: File.InputFile }, -): Audio => new Audio({ +): Audio => ({ + _tag: 'Audio', file, caption: Option.fromNullable(options.caption), duration: Option.fromNullable(options.duration), @@ -258,7 +247,8 @@ export const document = ( thumbnail?: File.InputFile contentTypeDetection?: boolean }, -): Document => new Document({ +): Document => ({ + _tag: 'Document', file, caption: Option.fromNullable(options.caption), thumbnail: Option.fromNullable(options.thumbnail), @@ -279,7 +269,8 @@ export const video = ( startAt?: Duration.Duration supportsStreaming?: boolean }, -): Video => new Video({ +): Video => ({ + _tag: 'Video', file, caption: Option.fromNullable(options.caption), layout: options.layout ?? 'caption-below', @@ -304,7 +295,8 @@ export const animation = ( height?: number thumbnail?: File.InputFile }, -): Animation => new Animation({ +): Animation => ({ + _tag: 'Animation', file, caption: Option.fromNullable(options.caption), layout: options.layout ?? 'caption-below', @@ -321,7 +313,8 @@ export const voice = ( caption?: Text_.Text duration?: Duration.Duration }, -): Voice => new Voice({ +): Voice => ({ + _tag: 'Voice', file, caption: Option.fromNullable(options.caption), duration: Option.fromNullable(options.duration), @@ -334,7 +327,8 @@ export const videoNote = ( diameter?: number thumbnail?: File.InputFile }, -): VideoNote => new VideoNote({ +): VideoNote => ({ + _tag: 'VideoNote', file, duration: Option.fromNullable(options.duration), diameter: Option.fromNullable(options.diameter), @@ -345,7 +339,8 @@ export const location = (options: { latitude: number longitude: number uncertaintyRadius?: number -}): Location => new Location({ +}): Location => ({ + _tag: 'Location', latitude: options.latitude, longitude: options.longitude, uncertaintyRadius: Option.fromNullable(options.uncertaintyRadius), @@ -361,7 +356,8 @@ export const liveLocation = (options: { livePeriod: Duration.Duration heading?: number proximityAlertRadius?: number -}): Location => new Location({ +}): Location => ({ + _tag: 'Location', latitude: options.latitude, longitude: options.longitude, uncertaintyRadius: Option.fromNullable(options.uncertaintyRadius), @@ -379,7 +375,8 @@ export const venue = (options: { foursquareType?: string googlePlaceId?: string googlePlaceType?: string -}): Venue => new Venue({ +}): Venue => ({ + _tag: 'Venue', latitude: options.latitude, longitude: options.longitude, title: options.title, @@ -395,16 +392,17 @@ export const contact = (options: { firstName: string lastName?: string vcard?: string -}): Contact => new Contact({ +}): Contact => ({ + _tag: 'Contact', phoneNumber: options.phoneNumber, firstName: options.firstName, lastName: Option.fromNullable(options.lastName), vcard: Option.fromNullable(options.vcard), }) -export const dice = (emoji: Dice['emoji']): Dice => new Dice({ emoji }) +export const dice = (emoji: Dice['emoji']): Dice => ({ _tag: 'Dice', emoji }) export const sticker = ( file: File.FileId | File.External | File.InputFile, emoji?: string, -): Sticker => new Sticker({ file, emoji: Option.fromNullable(emoji) }) +): Sticker => ({ _tag: 'Sticker', file, emoji: Option.fromNullable(emoji) }) diff --git a/src/Dialog.ts b/src/Dialog.ts index 0ab4846..401e97b 100644 --- a/src/Dialog.ts +++ b/src/Dialog.ts @@ -1,6 +1,6 @@ import type * as BotApi from './BotApi.ts' import * as Brand from 'effect/Brand' -import * as Data from 'effect/Data' +import * as Match from 'effect/Match' import * as Option from 'effect/Option' import * as internal from './internal/dialog.ts' @@ -14,20 +14,23 @@ export type Dialog = | ForumTopic | ChannelDm -export class PrivateTopic extends Data.TaggedClass('PrivateTopic')<{ - user: User - topicId: number -}> {} +export interface PrivateTopic { + readonly _tag: 'PrivateTopic' + readonly user: User + readonly topicId: number +} -export class ForumTopic extends Data.TaggedClass('ForumTopic')<{ - supergroup: Supergroup - topicId: number -}> {} +export interface ForumTopic { + readonly _tag: 'ForumTopic' + readonly supergroup: Supergroup + readonly topicId: number +} -export class ChannelDm extends Data.TaggedClass('ChannelDm')<{ - channel: Channel - topicId: number -}> {} +export interface ChannelDm { + readonly _tag: 'ChannelDm' + readonly channel: Channel + readonly topicId: number +} // ============================================================================= // Peer @@ -39,49 +42,38 @@ export type Peer = | Channel | Supergroup -export class User extends Data.TaggedClass('User')<{ - id: UserId -}> { - public dialogId(): DialogId { - return Option.getOrThrow(internal.encodePeerId('user', this.id)) - } - - public topic(topicId: number): PrivateTopic { - return new PrivateTopic({ user: this, topicId }) - } +export interface User { + readonly _tag: 'User' + readonly id: UserId } -export class Group extends Data.TaggedClass('Group')<{ - id: GroupId -}> { - public dialogId(): DialogId { - return Option.getOrThrow(internal.encodePeerId('group', this.id)) - } +export interface Group { + readonly _tag: 'Group' + readonly id: GroupId } -export class Channel extends Data.TaggedClass('Channel')<{ - id: ChannelId -}> { - public dialogId(): DialogId { - return Option.getOrThrow(internal.encodePeerId('channel', this.id)) - } +export interface Channel { + readonly _tag: 'Channel' + readonly id: ChannelId +} - public directMessages(topicId: number): ChannelDm { - return new ChannelDm({ channel: this, topicId }) - } +export interface Supergroup { + readonly _tag: 'Supergroup' + readonly id: SupergroupId } -export class Supergroup extends Data.TaggedClass('Supergroup')<{ - id: SupergroupId -}> { - public dialogId(): DialogId { - return Option.getOrThrow(internal.encodePeerId('channel', this.id)) - } +// ============================================================================= +// Peer Functions +// ============================================================================= - public topic(topicId: number): ForumTopic { - return new ForumTopic({ supergroup: this, topicId }) - } -} +export const dialogId: (peer: Peer) => DialogId = Match.type().pipe( + Match.tagsExhaustive({ + User: u => Option.getOrThrow(internal.encodePeerId('user', u.id)), + Group: g => Option.getOrThrow(internal.encodePeerId('group', g.id)), + Channel: c => Option.getOrThrow(internal.encodePeerId('channel', c.id)), + Supergroup: s => Option.getOrThrow(internal.encodePeerId('channel', s.id)), + }), +) // ============================================================================= // Brands @@ -93,30 +85,25 @@ export class Supergroup extends Data.TaggedClass('Supergroup')<{ * @see {@link https://core.telegram.org/api/bots/ids Telegram API • Bot API dialog IDs} */ export type DialogId = number & Brand.Brand<'@grom.js/effect-tg/DialogId'> -export const DialogId = Brand.refined( +export const DialogId: Brand.Brand.Constructor = Brand.refined( n => Option.isSome(internal.decodeDialogId(n)), n => Brand.error(`Invalid dialog ID: ${n}`), ) export type UserId = number & Brand.Brand<'@grom.js/effect-tg/UserId'> -export const UserId = Brand.refined( +export const UserId: Brand.Brand.Constructor = Brand.refined( n => Option.isSome(internal.encodePeerId('user', n)), n => Brand.error(`Invalid user ID: ${n}`), ) export type GroupId = number & Brand.Brand<'@grom.js/effect-tg/GroupId'> -export const GroupId = Brand.refined( +export const GroupId: Brand.Brand.Constructor = Brand.refined( n => Option.isSome(internal.encodePeerId('group', n)), n => Brand.error(`Invalid group ID: ${n}`), ) -/** - * ID for channels (including supergroups). - * - * @see {@link https://core.telegram.org/api/bots/ids Telegram API • Bot API dialog IDs} - */ export type ChannelId = number & Brand.Brand<'@grom.js/effect-tg/ChannelId'> -export const ChannelId = Brand.refined( +export const ChannelId: Brand.Brand.Constructor = Brand.refined( n => Option.isSome(internal.encodePeerId('channel', n)), n => Brand.error(`Invalid channel or supergroup ID: ${n}`), ) @@ -125,7 +112,7 @@ export const ChannelId = Brand.refined( export type SupergroupId = ChannelId /** @alias ChannelId */ -export const SupergroupId = ChannelId +export const SupergroupId: Brand.Brand.Constructor = ChannelId // ============================================================================= // Dialog ID <-> Peer ID @@ -156,21 +143,40 @@ export const encodePeerId: ( // Constructors // ============================================================================= -export const user: (id: number) => User = (id) => { - return new User({ id: UserId(id) }) -} - -export const group: (id: number) => Group = (id) => { - return new Group({ id: GroupId(id) }) -} - -export const channel: (id: number) => Channel = (id) => { - return new Channel({ id: ChannelId(id) }) -} - -export const supergroup: (id: number) => Supergroup = (id) => { - return new Supergroup({ id: ChannelId(id) }) -} +export const user: (id: number) => User = id => ({ _tag: 'User', id: UserId(id) }) + +export const group: (id: number) => Group = id => ({ _tag: 'Group', id: GroupId(id) }) + +export const channel: (id: number) => Channel = id => ({ _tag: 'Channel', id: ChannelId(id) }) + +export const supergroup: (id: number) => Supergroup = id => ({ _tag: 'Supergroup', id: ChannelId(id) }) + +export const privateTopic: ( + user: User, + topicId: number, +) => PrivateTopic = (user, topicId) => ({ + _tag: 'PrivateTopic', + user, + topicId, +}) + +export const forumTopic: ( + supergroup: Supergroup, + topicId: number, +) => ForumTopic = (supergroup, topicId) => ({ + _tag: 'ForumTopic', + supergroup, + topicId, +}) + +export const channelDm: ( + channel: Channel, + topicId: number, +) => ChannelDm = (channel, topicId) => ({ + _tag: 'ChannelDm', + channel, + topicId, +}) export const ofMessage: ( message: BotApi.Types.Message, diff --git a/src/File.ts b/src/File.ts index 8cf39d6..74c163d 100644 --- a/src/File.ts +++ b/src/File.ts @@ -7,20 +7,51 @@ import type * as BotApi from './BotApi.ts' import type * as BotApiError from './BotApiError.ts' import type * as BotApiUrl from './BotApiUrl.ts' import * as Brand from 'effect/Brand' -import * as Data from 'effect/Data' +import * as Predicate from 'effect/Predicate' import * as internal from './internal/file.ts' export type FileId = string & Brand.Brand<'FileId'> -export const FileId = Brand.nominal() +export const FileId: Brand.Brand.Constructor = Brand.nominal() export type External = URL & Brand.Brand<'External'> -export const External = Brand.nominal() +export const External: Brand.Brand.Constructor = Brand.nominal() -export class InputFile extends Data.TaggedClass('InputFile')<{ +// ============================================================================= +// InputFile +// ============================================================================= + +const InputFileTypeId: unique symbol = Symbol.for('effect-tg/InputFile') + +export type InputFileTypeId = typeof InputFileTypeId + +export interface InputFile { + readonly [InputFileTypeId]: InputFileTypeId + readonly stream: Stream.Stream + readonly filename: string + readonly mimeType?: string +} + +const InputFileProto = { + [InputFileTypeId]: InputFileTypeId, +} + +export const make: (args: { stream: Stream.Stream filename: string mimeType?: string -}> {} +}) => InputFile = ({ stream, filename, mimeType }) => { + const file = Object.create(InputFileProto) + file.stream = stream + file.filename = filename + file.mimeType = mimeType + return file +} + +export const isInputFile = (u: unknown): u is InputFile => Predicate.hasProperty(u, InputFileTypeId) + +// ============================================================================= +// Utilities +// ============================================================================= /** * Downloads a file from the Bot API server. diff --git a/src/Markup.ts b/src/Markup.ts index dc04d9a..cc25296 100644 --- a/src/Markup.ts +++ b/src/Markup.ts @@ -1,4 +1,3 @@ -import * as Data from 'effect/Data' import * as Option from 'effect/Option' // ============================================================================= @@ -14,27 +13,31 @@ export type Markup = | ReplyKeyboardRemove | ForceReply -export class InlineKeyboard extends Data.TaggedClass('InlineKeyboard')<{ +export interface InlineKeyboard { + readonly _tag: 'InlineKeyboard' readonly rows: ReadonlyArray> -}> {} +} -export class ReplyKeyboard extends Data.TaggedClass('ReplyKeyboard')<{ +export interface ReplyKeyboard { + readonly _tag: 'ReplyKeyboard' readonly rows: ReadonlyArray> readonly persistent: boolean readonly resizable: boolean readonly oneTime: boolean readonly selective: boolean readonly inputPlaceholder: Option.Option -}> {} +} -export class ReplyKeyboardRemove extends Data.TaggedClass('ReplyKeyboardRemove')<{ +export interface ReplyKeyboardRemove { + readonly _tag: 'ReplyKeyboardRemove' readonly selective: boolean -}> {} +} -export class ForceReply extends Data.TaggedClass('ForceReply')<{ +export interface ForceReply { + readonly _tag: 'ForceReply' readonly selective: boolean readonly inputPlaceholder: Option.Option -}> {} +} // ============================================================================= // Constructors @@ -42,7 +45,10 @@ export class ForceReply extends Data.TaggedClass('ForceReply')<{ export const inlineKeyboard = ( rows: ReadonlyArray>, -): InlineKeyboard => new InlineKeyboard({ rows }) +): InlineKeyboard => ({ + _tag: 'InlineKeyboard', + rows, +}) export const replyKeyboard = ( rows: ReadonlyArray>, @@ -53,27 +59,31 @@ export const replyKeyboard = ( readonly selective?: boolean readonly inputPlaceholder?: string }, -): ReplyKeyboard => - new ReplyKeyboard({ - rows, - persistent: options?.persistent ?? false, - resizable: options?.resizable ?? false, - oneTime: options?.oneTime ?? false, - selective: options?.selective ?? false, - inputPlaceholder: Option.fromNullable(options?.inputPlaceholder), - }) - -export const replyKeyboardRemove = (options?: { readonly selective?: boolean }): ReplyKeyboardRemove => - new ReplyKeyboardRemove({ selective: options?.selective ?? false }) +): ReplyKeyboard => ({ + _tag: 'ReplyKeyboard', + rows, + persistent: options?.persistent ?? false, + resizable: options?.resizable ?? false, + oneTime: options?.oneTime ?? false, + selective: options?.selective ?? false, + inputPlaceholder: Option.fromNullable(options?.inputPlaceholder), +}) + +export const replyKeyboardRemove = (options?: { + readonly selective?: boolean +}): ReplyKeyboardRemove => ({ + _tag: 'ReplyKeyboardRemove', + selective: options?.selective ?? false, +}) export const forceReply = (options?: { readonly selective?: boolean readonly inputPlaceholder?: string -}): ForceReply => - new ForceReply({ - selective: options?.selective ?? false, - inputPlaceholder: Option.fromNullable(options?.inputPlaceholder), - }) +}): ForceReply => ({ + _tag: 'ForceReply', + selective: options?.selective ?? false, + inputPlaceholder: Option.fromNullable(options?.inputPlaceholder), +}) // ============================================================================= // Inline button diff --git a/src/Runner.ts b/src/Runner.ts index 7d1b84e..0399f1b 100644 --- a/src/Runner.ts +++ b/src/Runner.ts @@ -1,5 +1,7 @@ import type * as Effect from 'effect/Effect' import type * as Bot from './Bot.ts' +import type * as BotApi from './BotApi.ts' +import type * as BotApiError from './BotApiError.ts' import * as internal from './internal/runner.ts' /** @@ -19,4 +21,6 @@ export interface Runner { * Creates a simple runner that fetches updates by calling `BotApi.getUpdates` * method and handles them one by one. */ -export const makeSimple = internal.makeSimple +export const makeSimple: (options?: { + allowedUpdates?: string[] +}) => Runner = internal.makeSimple diff --git a/src/Send.ts b/src/Send.ts index 0a5f04a..e6aa6cb 100644 --- a/src/Send.ts +++ b/src/Send.ts @@ -36,6 +36,32 @@ export const sendMessage: (params: { BotApi.BotApi > = internal.sendMessage +// ============================================================================= +// TargetDialog +// ============================================================================= + +/** + * Target dialog for sending messages. + */ +export interface TargetDialog { + readonly dialog: Dialog.Dialog | Dialog.DialogId +} + +export const TargetDialog: Context.Tag = Context.GenericTag('@grom.js/effect-tg/Send/TargetDialog') + +/** + * Provides the target dialog for sending messages. + */ +export const to: { + (dialog: Dialog.Dialog | Dialog.DialogId): (effect: Effect.Effect) => Effect.Effect> + (effect: Effect.Effect, dialog: Dialog.Dialog | Dialog.DialogId): Effect.Effect> +} = Function.dual(2, ( + effect: Effect.Effect, + dialog: Dialog.Dialog | Dialog.DialogId, +): Effect.Effect> => ( + Effect.provideService(effect, TargetDialog, { dialog }) +)) + // ============================================================================= // MessageToSend // ============================================================================= @@ -71,7 +97,7 @@ const MessageToSendProto = { commit(this: MessageToSend) { return Effect.flatMap( TargetDialog, - dialog => sendMessage({ + ({ dialog }) => sendMessage({ dialog, content: this.content, markup: this.markup, @@ -109,31 +135,6 @@ export const message = (content: Content.Content, params?: { return self } -// ============================================================================= -// TargetDialog -// ============================================================================= - -/** - * Target dialog for sending messages. - */ -export class TargetDialog extends Context.Tag('@grom.js/effect-tg/Send/TargetDialog')< - TargetDialog, - Dialog.Dialog | Dialog.DialogId ->() {} - -/** - * Provides the target dialog for sending messages. - */ -export const to: { - (dialog: Dialog.Dialog): (effect: Effect.Effect) => Effect.Effect> - (effect: Effect.Effect, dialog: Dialog.Dialog): Effect.Effect> -} = Function.dual(2, ( - effect: Effect.Effect, - dialog: Dialog.Dialog, -): Effect.Effect> => ( - Effect.provideService(effect, TargetDialog, dialog) -)) - // ============================================================================= // Reply Markup // ============================================================================= diff --git a/src/Text.ts b/src/Text.ts index 02a5dd3..3dfe141 100644 --- a/src/Text.ts +++ b/src/Text.ts @@ -1,6 +1,5 @@ import type { TgxElement } from '@grom.js/tgx' import type { Types } from './BotApi.ts' -import * as Data from 'effect/Data' /** * Formatted text. @@ -11,32 +10,36 @@ export type Text = | Markdown | Tgx -export class Plain extends Data.TaggedClass('Plain')<{ - text: string - entities?: Array -}> {} +export interface Plain { + readonly _tag: 'Plain' + readonly text: string + readonly entities?: Array +} -export class Html extends Data.TaggedClass('Html')<{ - html: string -}> {} +export interface Html { + readonly _tag: 'Html' + readonly html: string +} -export class Markdown extends Data.TaggedClass('Markdown')<{ - markdown: string -}> {} +export interface Markdown { + readonly _tag: 'Markdown' + readonly markdown: string +} -export class Tgx extends Data.TaggedClass('Tgx')<{ - tgx: TgxElement -}> {} +export interface Tgx { + readonly _tag: 'Tgx' + readonly tgx: TgxElement +} // ———— Constructors ——————————————————————————————————————————————————————————— export const plain = ( text: string, entities?: Array, -): Plain => new Plain({ text, entities }) +): Plain => ({ _tag: 'Plain', text, entities }) -export const html = (html: string): Html => new Html({ html }) +export const html = (html: string): Html => ({ _tag: 'Html', html }) -export const markdown = (markdown: string): Markdown => new Markdown({ markdown }) +export const markdown = (markdown: string): Markdown => ({ _tag: 'Markdown', markdown }) -export const tgx = (tgx: TgxElement): Tgx => new Tgx({ tgx }) +export const tgx = (tgx: TgxElement): Tgx => ({ _tag: 'Tgx', tgx }) diff --git a/src/internal/botApi.ts b/src/internal/botApi.ts index 2de0d3a..96cd4c6 100644 --- a/src/internal/botApi.ts +++ b/src/internal/botApi.ts @@ -3,17 +3,16 @@ import type * as BotApiTransport from '../BotApiTransport.ts' import * as Effect from 'effect/Effect' import * as BotApiError from '../BotApiError.ts' -export const make = ({ - transport, -}: { - transport: BotApiTransport.Service -}): BotApi.Service => ( +export const make = ({ transport }: { + transport: BotApiTransport.BotApiTransport +}): BotApi.BotApi => ( new Proxy({}, { get: (_target, prop) => { if (typeof prop !== 'string') { return } const method = prop + // TODO: Shouldn't we cache effects not to create them on each call? return Effect.fnUntraced( function* (params: void | Record = {}) { const response = yield* transport.sendRequest(method, params) @@ -24,5 +23,5 @@ export const make = ({ }, ) }, - }) as BotApi.Service + }) as BotApi.BotApi ) diff --git a/src/internal/botApiTransport.ts b/src/internal/botApiTransport.ts index c504c91..cce60e2 100644 --- a/src/internal/botApiTransport.ts +++ b/src/internal/botApiTransport.ts @@ -18,7 +18,7 @@ interface ExtractedFile { * {@linkcode File.InputFile InputFile} instances. */ const hasInputFile = (value: unknown): boolean => { - if (value instanceof File.InputFile) { + if (File.isInputFile(value)) { return true } if (Array.isArray(value)) { @@ -34,7 +34,7 @@ const cloneAndExtract = ( value: unknown, files: ExtractedFile[], ): unknown => { - if (value instanceof File.InputFile) { + if (File.isInputFile(value)) { const attachId = String(files.length + 1) files.push({ attachId, file: value }) return `attach://${attachId}` @@ -108,8 +108,8 @@ export const make = ({ botApiUrl, }: { httpClient: HttpClient.HttpClient - botApiUrl: BotApiUrl.Service -}): BotApiTransport.Service => ({ + botApiUrl: BotApiUrl.BotApiUrl +}): BotApiTransport.BotApiTransport => ({ sendRequest: (method, params) => ( Effect.gen(function* () { const body = yield* makeHttpBody(params) diff --git a/src/internal/dialog.ts b/src/internal/dialog.ts index 7c55e4c..0a6f83c 100644 --- a/src/internal/dialog.ts +++ b/src/internal/dialog.ts @@ -80,34 +80,26 @@ export const ofMessage: ( ) => Dialog.Dialog = (m) => { switch (m.chat.type) { case 'private': { - const user = new Dialog.User({ - id: Option.getOrThrow(decodePeerId('user', m.chat.id)), - }) + const user = Dialog.user(Option.getOrThrow(decodePeerId('user', m.chat.id))) if (m.message_thread_id != null) { - return user.topic(m.message_thread_id) + return Dialog.privateTopic(user, m.message_thread_id) } return user } case 'group': { - return new Dialog.Group({ - id: Option.getOrThrow(decodePeerId('group', m.chat.id)), - }) + return Dialog.group(Option.getOrThrow(decodePeerId('group', m.chat.id))) } case 'channel': { - const channel = new Dialog.Channel({ - id: Option.getOrThrow(decodePeerId('channel', m.chat.id)), - }) + const channel = Dialog.channel(Option.getOrThrow(decodePeerId('channel', m.chat.id))) if (m.direct_messages_topic != null) { - return channel.directMessages(m.direct_messages_topic.topic_id) + return Dialog.channelDm(channel, m.direct_messages_topic.topic_id) } return channel } case 'supergroup': { - const supergroup = new Dialog.Supergroup({ - id: Option.getOrThrow(decodePeerId('channel', m.chat.id)), - }) + const supergroup = Dialog.supergroup(Option.getOrThrow(decodePeerId('channel', m.chat.id))) if (m.message_thread_id != null) { - return supergroup.topic(m.message_thread_id) + return Dialog.forumTopic(supergroup, m.message_thread_id) } return supergroup } diff --git a/src/internal/file.ts b/src/internal/file.ts index 96f6b8b..234cc5a 100644 --- a/src/internal/file.ts +++ b/src/internal/file.ts @@ -1,11 +1,14 @@ -import type { FileId } from '../File.ts' +import type * as File from '../File.ts' import * as HttpClient from '@effect/platform/HttpClient' import * as Effect from 'effect/Effect' import * as BotApi from '../BotApi.ts' import * as BotApiUrl from '../BotApiUrl.ts' +/** + * @internal + */ export const download = Effect.fnUntraced( - function* (fileId: FileId) { + function* (fileId: File.FileId) { const file = yield* BotApi.callMethod('getFile', { file_id: fileId }) if (file.file_path == null) { return yield* Effect.die(new Error(`Bot API returned no file path for file "${fileId}".`)) diff --git a/src/internal/send.ts b/src/internal/send.ts index e1986a3..5d814d2 100644 --- a/src/internal/send.ts +++ b/src/internal/send.ts @@ -1,5 +1,4 @@ import type * as Content from '../Content.ts' -import type * as Dialog from '../Dialog.ts' import type * as Markup from '../Markup.ts' import type * as Reply from '../Reply.ts' import type * as Send from '../Send.ts' @@ -7,10 +6,10 @@ import type * as Text from '../Text.ts' import type { Types } from './botApi.gen.ts' import * as Tgx from '@grom.js/tgx' import * as Duration from 'effect/Duration' -import * as Effect from 'effect/Effect' import * as Match from 'effect/Match' import * as Option from 'effect/Option' import * as BotApi from '../BotApi.ts' +import * as Dialog from '../Dialog.ts' import * as LinkPreview from '../LinkPreview.ts' // ============================================================================= @@ -245,27 +244,27 @@ const paramsDialog: ( })), Match.tagsExhaustive({ User: user => ({ - chat_id: user.dialogId(), + chat_id: Dialog.dialogId(user), }), Group: group => ({ - chat_id: group.dialogId(), + chat_id: Dialog.dialogId(group), }), Channel: channel => ({ - chat_id: channel.dialogId(), + chat_id: Dialog.dialogId(channel), }), Supergroup: supergroup => ({ - chat_id: supergroup.dialogId(), + chat_id: Dialog.dialogId(supergroup), }), PrivateTopic: topic => ({ - chat_id: topic.user.dialogId(), + chat_id: Dialog.dialogId(topic.user), message_thread_id: topic.topicId, }), ForumTopic: topic => ({ - chat_id: topic.supergroup.dialogId(), + chat_id: Dialog.dialogId(topic.supergroup), message_thread_id: topic.topicId, }), ChannelDm: dm => ({ - chat_id: dm.channel.dialogId(), + chat_id: Dialog.dialogId(dm.channel), direct_messages_topic_id: dm.topicId, }), }), @@ -465,7 +464,7 @@ const paramsReply = ( reply_parameters: { chat_id: Match.value(reply.dialog).pipe( Match.when(Match.number, id => id), - Match.orElse(peer => peer.dialogId()), + Match.orElse(peer => Dialog.dialogId(peer)), ), message_id: reply.messageId, checklist_task_id: Option.getOrUndefined(reply.taskId), @@ -491,21 +490,22 @@ const paramsOptions = (options: Send.Options): ParamsOptions => { // Send Methods // ============================================================================= -export const sendMessage = Effect.fnUntraced(function* (params: { +/** + * @internal + */ +export const sendMessage = ({ content, dialog, markup, reply, options }: { content: Content.Content dialog: Dialog.Dialog | Dialog.DialogId markup?: Markup.Markup reply?: Reply.Reply options?: Send.Options -}) { - return yield* BotApi.callMethod( - methodByContent[params.content._tag], - { - ...paramsContent(params.content), - ...paramsDialog(params.dialog), - ...(params.markup ? paramsMarkup(params.markup) : {}), - ...(params.reply ? paramsReply(params.reply) : {}), - ...(params.options ? paramsOptions(params.options) : {}), - }, - ) -}) +}) => BotApi.callMethod( + methodByContent[content._tag], + { + ...paramsContent(content), + ...paramsDialog(dialog), + ...(markup ? paramsMarkup(markup) : {}), + ...(reply ? paramsReply(reply) : {}), + ...(options ? paramsOptions(options) : {}), + }, +)