@effect-ak/tg-bot-client 0.6.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,75 +1,41 @@
1
1
  {
2
2
  "name": "@effect-ak/tg-bot-client",
3
- "version": "0.6.4",
4
- "homepage": "https://effect-ak.github.io/telegram-bot-playground/",
3
+ "type": "module",
4
+ "description": "Type-safe HTTP client for Telegram Bot API",
5
+ "version": "1.0.0",
6
+ "license": "MIT",
5
7
  "author": {
6
8
  "name": "Aleksandr Kondaurov",
7
9
  "email": "kondaurov.dev@gmail.com"
8
10
  },
9
- "publishConfig": {
10
- "access": "public"
11
- },
12
- "description": "TypeScript types for Telegram Bot Api and Telegram.Webapp",
13
- "files": [
14
- "dist/*.js",
15
- "dist/*.mjs",
16
- "dist/*.d.ts",
17
- "!dist/bot-bundle.d.ts",
18
- "!dist/bot-bundle.d.mts"
19
- ],
20
- "keywords": [
21
- "telegram typescript types",
22
- "telegram bot api client",
23
- "telegram webapp"
24
- ],
25
11
  "repository": {
26
12
  "type": "git",
27
- "url": "https://github.com/effect-ak/tg-bot-client"
13
+ "url": "https://github.com/effect-ak/tg-bot-client",
14
+ "directory": "packages/client"
28
15
  },
29
16
  "bugs": {
30
17
  "url": "https://github.com/effect-ak/tg-bot-client/issues"
31
18
  },
32
- "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
33
22
  "types": "./dist/index.d.ts",
34
- "main": "./dist/index.js",
35
- "module": "./dist/index.mjs",
23
+ "main": "./dist/index.cjs",
24
+ "module": "./dist/index.js",
36
25
  "exports": {
37
26
  ".": {
38
27
  "types": "./dist/index.d.ts",
39
- "require": "./dist/index.js",
40
- "import": "./dist/index.mjs"
41
- },
42
- "./bot": {
43
- "types": "./dist/bot.d.ts",
44
- "require": "./dist/bot.js",
45
- "import": "./dist/bot.mjs"
46
- },
47
- "./bot-bundle": {
48
- "types": "./dist/bot.d.ts",
49
- "require": "./dist/bot-bundle.js",
50
- "import": "./dist/bot-bundle.mjs"
51
- },
52
- "./webapp": {
53
- "types": "./dist/webapp.d.ts",
54
- "require": "./dist/webapp.js",
55
- "import": "./dist/webapp.mjs"
28
+ "import": "./dist/index.js",
29
+ "require": "./dist/index.cjs"
56
30
  }
57
31
  },
58
- "devDependencies": {
59
- "@types/node": "^22.10.1",
60
- "@types/js-yaml": "^4.0.9",
61
- "js-yaml": "^4.1.0",
62
- "node-html-parser": "^6.1.13",
63
- "openapi-types": "^12.1.3",
64
- "ts-morph": "^24.0.0",
65
- "tsx": "^4.19.3",
66
- "typescript": "^5.8.3",
67
- "vite-tsconfig-paths": "^5.1.4",
68
- "vitest": "^3.1.1",
69
- "effect": "3.12.0",
70
- "tsup": "^8.5.0"
32
+ "dependencies": {
33
+ "@effect-ak/tg-bot-api": "0.9.2"
71
34
  },
72
35
  "peerDependencies": {
73
36
  "effect": "^3.12.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup"
74
40
  }
75
41
  }
@@ -0,0 +1,70 @@
1
+ import * as Micro from "effect/Micro"
2
+ import * as Context from "effect/Context"
3
+
4
+ import { TgBotClientError } from "./errors"
5
+ import { TgBotApiBaseUrl, TgBotApiToken } from "./config"
6
+ import { executeTgBotMethod } from "./execute"
7
+
8
+ export class ClientFileService extends Context.Tag("ClientFileService")<
9
+ ClientFileService,
10
+ {
11
+ getFile: (input: GetFile) => ReturnType<typeof getFile>
12
+ }
13
+ >() {
14
+ static live = () => {
15
+ return ClientFileService.context({
16
+ getFile
17
+ })
18
+ }
19
+ }
20
+
21
+ export interface GetFile {
22
+ fileId: string
23
+ type?: string
24
+ }
25
+
26
+ const getFile = ({ fileId, type }: GetFile) =>
27
+ getFileBytes(fileId).pipe(
28
+ Micro.andThen(
29
+ ({ content, file_name }) =>
30
+ new File([content], file_name, {
31
+ ...(type ? { type } : undefined)
32
+ })
33
+ )
34
+ )
35
+
36
+ const getFileBytes = (fileId: string) =>
37
+ Micro.gen(function* () {
38
+ const response = yield* executeTgBotMethod("get_file", { file_id: fileId })
39
+ const file_path = response.file_path
40
+
41
+ if (!file_path || file_path.length == 0) {
42
+ return yield* Micro.fail(
43
+ new TgBotClientError({
44
+ cause: {
45
+ _tag: "UnableToGetFile",
46
+ cause: "File path not defined"
47
+ }
48
+ })
49
+ )
50
+ }
51
+
52
+ const file_name = file_path.replaceAll("/", "-")
53
+ const baseUrl = yield* Micro.service(TgBotApiBaseUrl)
54
+ const botToken = yield* Micro.service(TgBotApiToken)
55
+
56
+ const url = `${baseUrl}/file/bot${botToken}/${file_path}`
57
+
58
+ const content = yield* Micro.tryPromise({
59
+ try: () => fetch(url).then((_) => _.arrayBuffer()),
60
+ catch: (cause) =>
61
+ new TgBotClientError({
62
+ cause: { _tag: "UnableToGetFile", cause }
63
+ })
64
+ })
65
+
66
+ return {
67
+ content,
68
+ file_name
69
+ }
70
+ })
package/src/client.ts ADDED
@@ -0,0 +1,46 @@
1
+ import * as Micro from "effect/Micro"
2
+ import * as Context from "effect/Context"
3
+
4
+ import type { Api } from "@effect-ak/tg-bot-api"
5
+ import { executeTgBotMethod } from "./execute"
6
+ import { TgBotApiToken } from "./config"
7
+ import { GetFile, ClientFileService } from "./client-file"
8
+
9
+ export interface TgBotClient {
10
+ readonly execute: <M extends keyof Api>(
11
+ method: M,
12
+ input: Parameters<Api[M]>[0]
13
+ ) => Promise<ReturnType<Api[M]>>
14
+ readonly getFile: (input: GetFile) => Promise<File>
15
+ }
16
+
17
+ interface MakeTgClient {
18
+ bot_token: string
19
+ }
20
+
21
+ export function makeTgBotClient(config: MakeTgClient): TgBotClient {
22
+ return createEffect(config).pipe(Micro.runSync)
23
+ }
24
+
25
+ const createEffect = ({ bot_token }: MakeTgClient) =>
26
+ Micro.gen(function* () {
27
+ const file = yield* Micro.service(ClientFileService)
28
+ const context = Context.make(TgBotApiToken, bot_token)
29
+
30
+ const execute = <M extends keyof Api>(
31
+ method: M,
32
+ input: Parameters<Api[M]>[0]
33
+ ) =>
34
+ executeTgBotMethod(method, input).pipe(
35
+ Micro.provideContext(context),
36
+ Micro.runPromise
37
+ )
38
+
39
+ const getFile = (input: GetFile) =>
40
+ file.getFile(input).pipe(Micro.provideContext(context), Micro.runPromise)
41
+
42
+ return {
43
+ execute,
44
+ getFile
45
+ }
46
+ }).pipe(Micro.provideContext(ClientFileService.live()))
package/src/config.ts ADDED
@@ -0,0 +1,13 @@
1
+ import * as Context from "effect/Context"
2
+
3
+ import { TG_BOT_API_URL } from "./const"
4
+
5
+ export class TgBotApiBaseUrl extends Context.Reference<TgBotApiBaseUrl>()(
6
+ "TgBotApiBaseUrl",
7
+ { defaultValue: () => TG_BOT_API_URL }
8
+ ) {}
9
+
10
+ export class TgBotApiToken extends Context.Tag("TgBotApiToken")<
11
+ TgBotApiToken,
12
+ string
13
+ >() {}
package/src/const.ts ADDED
@@ -0,0 +1,20 @@
1
+ export const TG_BOT_API_URL = "https://api.telegram.org"
2
+
3
+ export const MESSAGE_EFFECTS = {
4
+ "🔥": "5104841245755180586",
5
+ "👍": "5107584321108051014",
6
+ "👎": "5104858069142078462",
7
+ "❤️": "5159385139981059251",
8
+ "🎉": "5046509860389126442",
9
+ "💩": "5046589136895476101"
10
+ } as const
11
+
12
+ export type MessageEffect = keyof typeof MESSAGE_EFFECTS
13
+
14
+ export const messageEffectIdCodes = Object.keys(
15
+ MESSAGE_EFFECTS
16
+ ) as MessageEffect[]
17
+
18
+ export const isMessageEffect = (input: unknown): input is MessageEffect => {
19
+ return typeof input === "string" && input in MESSAGE_EFFECTS
20
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,14 @@
1
+ import * as Data from "effect/Data"
2
+
3
+ type ErrorReason = Data.TaggedEnum<{
4
+ NotOkResponse: { errorCode?: number; details?: string }
5
+ UnexpectedResponse: { response: unknown }
6
+ ClientInternalError: { cause: unknown }
7
+ UnableToGetFile: { cause: unknown }
8
+ BotHandlerError: { cause: unknown }
9
+ NotJsonResponse: { response: unknown }
10
+ }>
11
+
12
+ export class TgBotClientError extends Data.TaggedError("TgBotClientError")<{
13
+ cause: ErrorReason
14
+ }> {}
package/src/execute.ts ADDED
@@ -0,0 +1,84 @@
1
+ import * as String from "effect/String"
2
+ import * as Micro from "effect/Micro"
3
+ import type { Api } from "@effect-ak/tg-bot-api"
4
+
5
+ import { TgBotClientError } from "./errors"
6
+ import { isFileContent, isTgBotApiResponse } from "./guards"
7
+ import { TgBotApiBaseUrl, TgBotApiToken } from "./config"
8
+
9
+ export const executeTgBotMethod = <M extends keyof Api>(
10
+ method: M,
11
+ input: Parameters<Api[M]>[0]
12
+ ): Micro.Micro<ReturnType<Api[M]>, TgBotClientError, TgBotApiToken> =>
13
+ Micro.gen(function* () {
14
+ const botToken = yield* Micro.service(TgBotApiToken)
15
+ const baseUrl = yield* Micro.service(TgBotApiBaseUrl)
16
+
17
+ const httpResponse = yield* Micro.tryPromise({
18
+ try: () =>
19
+ fetch(`${baseUrl}/bot${botToken}/${String.snakeToCamel(method)}`, {
20
+ body: makePayload(input) ?? null,
21
+ method: "POST"
22
+ }),
23
+ catch: (cause) =>
24
+ new TgBotClientError({
25
+ cause: { _tag: "ClientInternalError", cause }
26
+ })
27
+ })
28
+
29
+ const response = yield* Micro.tryPromise({
30
+ try: () => httpResponse.json(),
31
+ catch: () =>
32
+ new TgBotClientError({
33
+ cause: { _tag: "NotJsonResponse", response: httpResponse }
34
+ })
35
+ })
36
+
37
+ if (!isTgBotApiResponse(response)) {
38
+ return yield* Micro.fail(
39
+ new TgBotClientError({
40
+ cause: { _tag: "UnexpectedResponse", response }
41
+ })
42
+ )
43
+ }
44
+
45
+ if (!httpResponse.ok) {
46
+ return yield* Micro.fail(
47
+ new TgBotClientError({
48
+ cause: {
49
+ _tag: "NotOkResponse",
50
+ ...(response.error_code
51
+ ? { errorCode: response.error_code }
52
+ : undefined),
53
+ ...(response.description
54
+ ? { details: response.description }
55
+ : undefined)
56
+ }
57
+ })
58
+ )
59
+ }
60
+
61
+ return response.result as ReturnType<Api[M]>
62
+ })
63
+
64
+ export const makePayload = (body: object): FormData | undefined => {
65
+ const entries = Object.entries(body)
66
+
67
+ if (entries.length == 0) return undefined
68
+
69
+ const result = new FormData()
70
+
71
+ for (const [key, value] of entries) {
72
+ if (!value) continue
73
+
74
+ if (typeof value != "object") {
75
+ result.append(key, `${value}`)
76
+ } else if (isFileContent(value)) {
77
+ result.append(key, new Blob([value.file_content]), value.file_name)
78
+ } else {
79
+ result.append(key, JSON.stringify(value))
80
+ }
81
+ }
82
+
83
+ return result
84
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface FileContent {
2
+ file_content: Uint8Array<ArrayBuffer>
3
+ file_name: string
4
+ }
5
+
6
+ export const isFileContent = (input: unknown): input is FileContent =>
7
+ typeof input == "object" &&
8
+ input != null &&
9
+ "file_content" in input &&
10
+ input.file_content instanceof Uint8Array &&
11
+ "file_name" in input &&
12
+ typeof input.file_name == "string"
13
+
14
+ export interface TgBotApiResponseSchema {
15
+ ok: boolean
16
+ error_code?: number
17
+ description?: string
18
+ result?: unknown
19
+ }
20
+
21
+ export const isTgBotApiResponse = (
22
+ input: unknown
23
+ ): input is TgBotApiResponseSchema =>
24
+ typeof input == "object" &&
25
+ input != null &&
26
+ "ok" in input &&
27
+ typeof input.ok == "boolean"
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./execute"
2
+ export * from "./client-file"
3
+ export * from "./client"
4
+ export * from "./config"
5
+ export * from "./errors"
6
+ export * from "./guards"
7
+ export * from "./const"
@@ -0,0 +1,102 @@
1
+ import { describe, expect, assert, vi } from "vitest"
2
+
3
+ import { fixture } from "./fixture"
4
+ import { MESSAGE_EFFECTS } from "../src/const"
5
+ import { Micro } from "effect"
6
+ import { executeTgBotMethod } from "../src/execute"
7
+
8
+ const fetchSpy = vi.spyOn(global, "fetch")
9
+
10
+ describe("telegram bot client, execute method", () => {
11
+ fixture("send dice", async ({ chat_id, context }) => {
12
+ // skip();
13
+
14
+ const response = await executeTgBotMethod("send_dice", {
15
+ chat_id,
16
+ emoji: "🎲",
17
+ message_effect_id: MESSAGE_EFFECTS["🔥"]
18
+ }).pipe(Micro.provideContext(context), Micro.runPromiseExit)
19
+
20
+ assert(response._tag == "Success")
21
+
22
+ const url = fetchSpy.mock.calls[0][0] as string
23
+ const lastPath = url.split("/").pop()
24
+
25
+ expect(lastPath).toEqual("sendDice")
26
+
27
+ assert(response != null)
28
+
29
+ expect(response.value.chat.id).toBeDefined()
30
+ })
31
+
32
+ fixture("send message", async ({ chat_id, client, skip }) => {
33
+ skip()
34
+
35
+ const response = await client.execute("send_message", {
36
+ chat_id,
37
+ text: "hey again",
38
+ message_effect_id: MESSAGE_EFFECTS["🔥"]
39
+ })
40
+
41
+ expect(response.chat.id).toBeDefined()
42
+ })
43
+
44
+ fixture("send message with keyboard", async ({ chat_id, client, skip }) => {
45
+ skip()
46
+
47
+ const response = await client.execute("send_message", {
48
+ chat_id,
49
+ text: "hey again!",
50
+ message_effect_id: MESSAGE_EFFECTS["🎉"],
51
+ reply_markup: {
52
+ inline_keyboard: [
53
+ [
54
+ {
55
+ text: "api documentation",
56
+ web_app: {
57
+ url: "https://core.telegram.org/api"
58
+ }
59
+ }
60
+ ]
61
+ ]
62
+ }
63
+ })
64
+
65
+ expect(response.chat.id).toBeDefined()
66
+ })
67
+
68
+ fixture("send document", async ({ chat_id, client, skip }) => {
69
+ skip()
70
+
71
+ const response = await client.execute("send_document", {
72
+ chat_id,
73
+ message_effect_id: MESSAGE_EFFECTS["🎉"],
74
+ document: {
75
+ file_content: Buffer.from("Hello!"),
76
+ file_name: "hello.txt"
77
+ },
78
+ caption: "simple text file"
79
+ })
80
+
81
+ expect(response.document?.file_id).toBeDefined()
82
+
83
+ expect(response.chat.id).toBeDefined()
84
+ })
85
+
86
+ fixture("send message with action", async ({ chat_id, client }) => {
87
+ await client.execute("send_chat_action", {
88
+ chat_id,
89
+ action: "upload_voice"
90
+ })
91
+
92
+ await new Promise((res) => setTimeout(res, 5000))
93
+
94
+ const response = await client.execute("send_message", {
95
+ chat_id,
96
+ text: "hey again with typings",
97
+ message_effect_id: MESSAGE_EFFECTS["🔥"]
98
+ })
99
+
100
+ expect(response.chat.id).toBeDefined()
101
+ })
102
+ })
@@ -0,0 +1,23 @@
1
+ import { assert, describe, expect } from "vitest"
2
+
3
+ import { fixture } from "./fixture"
4
+
5
+ describe("telegram bot client, download file", () => {
6
+ fixture("get file content", async ({ client, chat_id }) => {
7
+ const document = await client.execute("send_document", {
8
+ chat_id,
9
+ document: {
10
+ file_content: Buffer.from("Hello!"),
11
+ file_name: "hello.txt"
12
+ }
13
+ })
14
+
15
+ const fileId = document.document?.file_id
16
+
17
+ assert(fileId, "file id is null")
18
+
19
+ const response = await client.getFile({ fileId })
20
+
21
+ expect(response).toBeDefined()
22
+ })
23
+ })
@@ -0,0 +1,35 @@
1
+ import { test } from "vitest"
2
+ import { Context } from "effect"
3
+
4
+ import { makeTgBotClient, TgBotClient } from "../src/client"
5
+ import { TgBotApiToken } from "../src/config"
6
+
7
+ interface Fixture {
8
+ readonly token: string
9
+ readonly client: TgBotClient
10
+ readonly chat_id: string
11
+ readonly context: Context.Context<TgBotApiToken>
12
+ }
13
+
14
+ export const fixture = test.extend<Fixture>({
15
+ token: async (_, use) => {
16
+ const token = process.env["bot_token"]
17
+ if (!token) throw Error("bot_token not defined in config.json")
18
+ use(token)
19
+ },
20
+
21
+ client: async ({ token }, use) => {
22
+ const client = makeTgBotClient({
23
+ bot_token: token
24
+ })
25
+ use(client)
26
+ },
27
+ chat_id: async (_, use) => {
28
+ const chatId = process.env["chat_id"]
29
+ if (!chatId) throw Error("chat_id not defined in config.json")
30
+ use(chatId)
31
+ },
32
+ context: async ({ token }, use) => {
33
+ use(Context.make(TgBotApiToken, token))
34
+ }
35
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src", "test"],
4
+ "compilerOptions": {
5
+ "lib": ["DOM", "ESNext"],
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "~/*": ["./src/*"],
9
+ "~test/*": ["./test/*"]
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://cdn.jsdelivr.net/npm/tsup/schema.json",
3
+ "entry": ["src/index.ts"],
4
+ "outDir": "dist",
5
+ "format": ["esm", "cjs"],
6
+ "splitting": false,
7
+ "sourcemap": false,
8
+ "clean": true,
9
+ "dts": true
10
+ }