@nitra/telegram 1.6.0 → 1.8.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.
Files changed (3) hide show
  1. package/README.md +99 -0
  2. package/package.json +12 -12
  3. package/src/index.js +61 -53
package/README.md CHANGED
@@ -1 +1,100 @@
1
1
  # @nitra/telegram
2
+
3
+ Мінімальний хелпер для надсилання повідомлень і документів у Telegram.
4
+
5
+ ## Встановлення
6
+
7
+ ```sh
8
+ bun add @nitra/telegram
9
+ ```
10
+
11
+ ## Налаштування
12
+
13
+ Потрібні змінні середовища (перевіряються при імпорті через `@nitra/check-env`):
14
+
15
+ | Змінна | Опис |
16
+ | -------------------- | ------------------------------------------------- |
17
+ | `TELEGRAM_BOT_TOKEN` | токен бота |
18
+ | `TELEGRAM_CHAT_ID` | id чату/каналу для відправки |
19
+ | `TELEGRAM_THREAD_ID` | id топіка в супергрупі (опційно, дефолтний топік) |
20
+
21
+ ## Формат за замовчуванням
22
+
23
+ Дефолтний `parse_mode` — **MarkdownV2**. Telegram вимагає екранувати спецсимволи
24
+ `_ * [ ] ( ) ~ \` > # + - = | { } . !`— для динамічного контенту (тексти помилок,
25
+ змінні) використовуйте`escapeMarkdownV2()`:
26
+
27
+ ```js
28
+ import { sendMessage, escapeMarkdownV2 } from '@nitra/telegram'
29
+
30
+ await sendMessage(`*Помилка:* ${escapeMarkdownV2(err.message)}`)
31
+ ```
32
+
33
+ Якщо розмітка все одно невалідна, повідомлення **не губиться** — бібліотека один раз
34
+ повторює запит без розмітки (plain text).
35
+
36
+ ## API
37
+
38
+ ### `sendMessage(text, params?)`
39
+
40
+ ```js
41
+ // MarkdownV2 (дефолт)
42
+ await sendMessage('*жирний* текст')
43
+
44
+ // HTML
45
+ await sendMessage('<b>жирний</b>', { parse_mode: 'HTML' })
46
+
47
+ // без розмітки (plain text)
48
+ await sendMessage('будь-який текст', { parse_mode: '' })
49
+
50
+ // без звуку
51
+ await sendMessage('тихо', { disable_notification: true })
52
+
53
+ // у топік супергрупи
54
+ await sendMessage('повідомлення в топік', { message_thread_id: 123 })
55
+ ```
56
+
57
+ `params`:
58
+
59
+ | Поле | Тип | За замовчуванням | Опис |
60
+ | ---------------------- | -------------------------------- | ---------------- | --------------------------------------- |
61
+ | `parse_mode` | `'MarkdownV2' \| 'HTML' \| ''` | `'MarkdownV2'` | формат розмітки; `''`/`null` — вимкнути |
62
+ | `message_thread_id` | `number` | `TELEGRAM_THREAD_ID` | id топіка; override для env змінної |
63
+ | `disable_notification` | `boolean` | — | надіслати без звуку |
64
+
65
+ > У робочі години (08:00–18:00) сповіщення зі звуком; поза ними — автоматично тихо.
66
+ > Повідомлення довші за 4096 символів обрізаються.
67
+
68
+ ### `sendDocument(document, params?)`
69
+
70
+ ```js
71
+ await sendDocument(Buffer.from(csv), {
72
+ filename: 'report.csv',
73
+ contentType: 'text/csv',
74
+ caption: `*Звіт:* ${escapeMarkdownV2('users_2026.csv')}`
75
+ })
76
+
77
+ // у топік супергрупи
78
+ await sendDocument(Buffer.from(csv), {
79
+ filename: 'report.csv',
80
+ contentType: 'text/csv',
81
+ message_thread_id: 123
82
+ })
83
+ ```
84
+
85
+ `params`: `filename`, `contentType`, `caption`, `parse_mode` (дефолт MarkdownV2, лише
86
+ для `caption`), `message_thread_id`, `disable_notification`. Як і в `sendMessage`,
87
+ невалідна розмітка caption не блокує відправку — повтор без розмітки.
88
+
89
+ ### `escapeMarkdownV2(text)`
90
+
91
+ Екранує всі зарезервовані символи MarkdownV2. Застосовуйте до **динамічних** частин
92
+ (не до всього повідомлення — інакше зникне навмисна розмітка).
93
+
94
+ ```js
95
+ escapeMarkdownV2('a_b.c!') // → 'a\\_b\\.c\\!'
96
+ ```
97
+
98
+ ### `DEFAULT_PARSE_MODE`
99
+
100
+ Константа з дефолтним форматом (`'MarkdownV2'`).
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@nitra/telegram",
3
+ "version": "1.8.0",
3
4
  "description": "telegram helper",
4
- "version": "1.6.0",
5
- "type": "module",
6
- "main": "src/index.js",
7
- "files": [
8
- "src"
9
- ],
10
- "exports": {
11
- ".": "./src/index.js"
12
- },
13
5
  "keywords": [
14
6
  "nitra",
15
7
  "telegram"
16
8
  ],
9
+ "homepage": "https://github.com/nitra/telegram",
10
+ "bugs": "https://github.com/nitra/telegram/issues",
11
+ "license": "MIT",
17
12
  "repository": {
18
13
  "type": "git",
19
14
  "url": "https://github.com/nitra/telegram.git"
20
15
  },
21
- "bugs": "https://github.com/nitra/telegram/issues",
22
- "homepage": "https://github.com/nitra/telegram",
23
- "license": "MIT",
16
+ "files": [
17
+ "src/index.js"
18
+ ],
19
+ "type": "module",
20
+ "main": "src/index.js",
21
+ "exports": {
22
+ ".": "./src/index.js"
23
+ },
24
24
  "dependencies": {
25
25
  "@nitra/check-env": "^4.1.0",
26
26
  "@nitra/pino": "^2.8.1"
package/src/index.js CHANGED
@@ -6,30 +6,26 @@ checkEnv(['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'])
6
6
 
7
7
  export const MAX_TELEGRAM_MSG_LENGTH = 4096
8
8
 
9
- export const sendMessage = async (text, params) => {
10
- const currentHour = new Date().getHours()
11
- // if (process.env.TELEGRAM_ROUND_CLOCK || (currentHour >= 8 && currentHour <= 18)) {
12
- // Max length of a Telegram message is 4096 characters
13
- if (text.length >= MAX_TELEGRAM_MSG_LENGTH) {
14
- text = text.slice(0, MAX_TELEGRAM_MSG_LENGTH)
15
- }
16
-
17
- let url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage?chat_id=${
18
- env.TELEGRAM_CHAT_ID
19
- }&text=${encodeURIComponent(text)}`
9
+ export const DEFAULT_PARSE_MODE = 'MarkdownV2'
10
+
11
+ // Екранує спецсимволи MarkdownV2. Застосовувати до ДИНАМІЧНОГО контенту (тексти
12
+ // помилок, змінні) інакше Telegram падає з "can't parse entities".
13
+ export const escapeMarkdownV2 = text => String(text).replaceAll(/[_*[\]()~`>#+\-=|{}.!\\]/g, String.raw`\$&`)
14
+
15
+ // Дефолт — MarkdownV2; явні '' / null / undefined → без розмітки (plain text).
16
+ const resolveParseMode = params => {
17
+ const raw = params && 'parse_mode' in params ? params.parse_mode : DEFAULT_PARSE_MODE
18
+ if (!raw) return ''
19
+ const known = { html: 'HTML', markdownv2: 'MarkdownV2' }
20
+ return known[String(raw).toLowerCase()] ?? raw
21
+ }
20
22
 
21
- if (params?.parse_mode?.toLowerCase() === 'html') {
22
- url += '&parse_mode=HTML'
23
- }
24
-
25
- // Якщо в неробочий час або відключено сповіщення, то додаємо параметр disable_notification
26
- if (!(currentHour >= 8 && currentHour <= 18) || params?.disable_notification === true) {
27
- url += '&disable_notification=true'
28
- }
23
+ const isWorkingHour = () => { const h = new Date().getHours(); return h >= 8 && h <= 18 }
29
24
 
25
+ const telegramRequest = async (url, init, { params, parseMode, onParseError }) => {
30
26
  let res
31
27
  try {
32
- res = await fetch(url)
28
+ res = await fetch(url, init)
33
29
  } catch (error) {
34
30
  log.error(error)
35
31
  return false
@@ -37,52 +33,64 @@ export const sendMessage = async (text, params) => {
37
33
 
38
34
  if (res.status >= 400) {
39
35
  const data = await res.json()
40
-
41
- log.error(data.description, text)
36
+ if (parseMode && /can't parse entities/i.test(data.description ?? '')) {
37
+ return onParseError()
38
+ }
39
+ log.error(data.description)
42
40
  return false
43
41
  }
44
42
  }
45
43
 
44
+ const resolveCommonParams = params => ({
45
+ threadId: params?.message_thread_id ?? env.TELEGRAM_THREAD_ID,
46
+ silent: !isWorkingHour() || params?.disable_notification === true,
47
+ })
48
+
49
+ export const sendMessage = async (text, params) => {
50
+ if (text.length >= MAX_TELEGRAM_MSG_LENGTH) {
51
+ text = text.slice(0, MAX_TELEGRAM_MSG_LENGTH)
52
+ }
53
+
54
+ const parseMode = resolveParseMode(params)
55
+ const { threadId, silent } = resolveCommonParams(params)
56
+
57
+ // Telegram HTML не підтримує <br> — конвертуємо у перенос рядка.
58
+ if (parseMode === 'HTML') {
59
+ text = text.replaceAll(/<\/?br\s*\/?>/gi, '\n')
60
+ }
61
+
62
+ let url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage?chat_id=${env.TELEGRAM_CHAT_ID}&text=${encodeURIComponent(text)}`
63
+ if (parseMode) url += `&parse_mode=${parseMode}`
64
+ if (threadId) url += `&message_thread_id=${threadId}`
65
+ if (silent) url += '&disable_notification=true'
66
+
67
+ return telegramRequest(url, undefined, {
68
+ params,
69
+ parseMode,
70
+ onParseError: () => sendMessage(text, { ...params, parse_mode: '' }),
71
+ })
72
+ }
73
+
46
74
  export const sendDocument = async (document, params = {}) => {
47
- const currentHour = new Date().getHours()
75
+ const parseMode = resolveParseMode(params)
76
+ const { threadId, silent } = resolveCommonParams(params)
48
77
 
49
78
  const formData = new FormData()
50
79
  formData.append('chat_id', env.TELEGRAM_CHAT_ID)
80
+ formData.append('document', new Blob([document], { type: params.contentType || 'application/octet-stream' }), params.filename || 'document.txt')
51
81
 
52
- // Додаємо документ як Blob з параметрами
53
- const blob = new Blob([document], { type: params.contentType || 'application/octet-stream' })
54
- formData.append('document', blob, params.filename || 'document.txt')
55
-
56
- // Додаємо опціональні параметри
57
82
  if (params.caption) {
58
83
  formData.append('caption', params.caption)
84
+ if (parseMode) formData.append('parse_mode', parseMode)
59
85
  }
60
-
61
- if (params.parse_mode) {
62
- formData.append('parse_mode', params.parse_mode)
63
- }
64
-
65
- // Якщо в неробочий час або відключено сповіщення, то додаємо параметр disable_notification
66
- if (!(currentHour >= 8 && currentHour <= 18) || params?.disable_notification === true) {
67
- formData.append('disable_notification', 'true')
68
- }
86
+ if (threadId) formData.append('message_thread_id', String(threadId))
87
+ if (silent) formData.append('disable_notification', 'true')
69
88
 
70
89
  const url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendDocument`
71
90
 
72
- let res
73
- try {
74
- res = await fetch(url, {
75
- method: 'POST',
76
- body: formData
77
- })
78
- } catch (error) {
79
- log.error(error)
80
- return false
81
- }
82
-
83
- if (res.status >= 400) {
84
- const data = await res.json()
85
- log.error(data.description)
86
- return false
87
- }
91
+ return telegramRequest(url, { method: 'POST', body: formData }, {
92
+ params,
93
+ parseMode,
94
+ onParseError: () => sendDocument(document, { ...params, parse_mode: '' }),
95
+ })
88
96
  }