@soffits/dsh-telegram-bot 0.0.0-stage → 0.4.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/CREDITS.md +24 -0
- package/LICENSE +22 -0
- package/MAINTAINING.md +39 -0
- package/README.md +293 -2
- package/bin/dsh-telegram-send.js +110 -0
- package/client.js +809 -0
- package/cordis.patch.yml +8 -0
- package/docs/bot-setting.gif +0 -0
- package/icon.svg +12 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/package.json +72 -4
- package/src/admission.js +21 -0
- package/src/app.js +6286 -0
- package/src/client.js +809 -0
- package/src/config.js +73 -0
- package/src/constants.js +80 -0
- package/src/errors.js +16 -0
- package/src/final-text.js +45 -0
- package/src/formatting.js +390 -0
- package/src/index.js +24 -0
- package/src/message-context.js +339 -0
- package/src/models.js +257 -0
- package/src/outbox.js +160 -0
- package/src/sent-log.js +112 -0
- package/src/state.js +49 -0
- package/src/status.js +43 -0
- package/src/steer.js +104 -0
- package/src/stickers.js +185 -0
- package/src/topics.js +211 -0
- package/src/update-ledger.js +58 -0
- package/src/utils.js +139 -0
- package/src/voice.js +151 -0
package/src/topics.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/** Topic names known to the bot, send targets and topic errors. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pick the topic name a Telegram message announces, if any.
|
|
5
|
+
* @param message - Telegram message, including service messages.
|
|
6
|
+
* @returns `{ name, authoritative }`, or undefined.
|
|
7
|
+
*/
|
|
8
|
+
export function topicNameOf(message) {
|
|
9
|
+
const created = message?.forum_topic_created?.name
|
|
10
|
+
if (typeof created === 'string' && created.trim().length > 0) return { name: created.trim(), authoritative: true }
|
|
11
|
+
const edited = message?.forum_topic_edited?.name
|
|
12
|
+
if (typeof edited === 'string' && edited.trim().length > 0) return { name: edited.trim(), authoritative: true }
|
|
13
|
+
// Replies in a topic often carry its creation message; use it only to fill
|
|
14
|
+
// a gap, because it shows the name the topic was created with.
|
|
15
|
+
const original = message?.reply_to_message?.forum_topic_created?.name
|
|
16
|
+
if (typeof original === 'string' && original.trim().length > 0) return { name: original.trim(), authoritative: false }
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Joins a topic name and the task part of a topic session's title. */
|
|
21
|
+
export const TITLE_SEPARATOR = ' · '
|
|
22
|
+
|
|
23
|
+
/** The title form earlier versions gave topic sessions: "TG · <topic name>". */
|
|
24
|
+
const LEGACY_PREFIX = 'TG · '
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The DSH title of a topic session: the topic name, then what the
|
|
28
|
+
* conversation is about once DSH has titled it.
|
|
29
|
+
* @param prefix - the topic's name.
|
|
30
|
+
* @param task - the conversation's own title, or empty before one exists.
|
|
31
|
+
* @returns the session title.
|
|
32
|
+
*/
|
|
33
|
+
export function topicTitle(prefix, task) {
|
|
34
|
+
const rest = typeof task === 'string' ? task.trim() : ''
|
|
35
|
+
return rest.length === 0 ? prefix : `${prefix}${TITLE_SEPARATOR}${rest}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a title is one the bot set before any conversation title existed:
|
|
40
|
+
* a bare topic name (current or earlier) or the old "TG · <name>" form.
|
|
41
|
+
* @param title - current session title.
|
|
42
|
+
* @param names - topic names the title may carry.
|
|
43
|
+
* @returns true for a bot placeholder.
|
|
44
|
+
*/
|
|
45
|
+
export function isPlaceholderTitle(title, names) {
|
|
46
|
+
return typeof title === 'string' && (title.startsWith(LEGACY_PREFIX) || names.includes(title))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The conversation part of a title, without a topic-name prefix the bot added.
|
|
51
|
+
* @param title - current session title.
|
|
52
|
+
* @param names - topic names a prefix may carry, current name first.
|
|
53
|
+
* @returns the task part; empty when the title is only a prefix.
|
|
54
|
+
*/
|
|
55
|
+
export function topicTask(title, names) {
|
|
56
|
+
if (isPlaceholderTitle(title, names)) return ''
|
|
57
|
+
// Longest name first, so "Chat · 2" wins over "Chat" for "Chat · 2 · Fix".
|
|
58
|
+
for (const name of [...names].sort((a, b) => b.length - a.length)) {
|
|
59
|
+
if (title.startsWith(`${name}${TITLE_SEPARATOR}`)) return title.slice(name.length + TITLE_SEPARATOR.length).trim()
|
|
60
|
+
}
|
|
61
|
+
return title
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Commands about the chat as a whole rather than one topic. When one of them
|
|
66
|
+
* is sent in All, Telegram turns it into a topic of its own; the bot answers
|
|
67
|
+
* in All instead and removes that topic.
|
|
68
|
+
*/
|
|
69
|
+
export const CHAT_COMMANDS = new Set(['/start', '/help', '/sessions', '/status', '/menu', '/id', '/ping', '/restart'])
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a message is a chat-wide command that Telegram moved into a topic it
|
|
73
|
+
* created for that very message: users who may create topics get one for any
|
|
74
|
+
* message sent in All, named after the message.
|
|
75
|
+
* @param command - normalized command token, such as `/start`.
|
|
76
|
+
* @param text - full message text.
|
|
77
|
+
* @param topicName - known name of the message's topic, if any.
|
|
78
|
+
* @param fresh - whether the topic has no session and no earlier activity.
|
|
79
|
+
* @returns true when the command should be answered in All.
|
|
80
|
+
*/
|
|
81
|
+
export function isAutoCommandTopic(command, text, topicName, fresh) {
|
|
82
|
+
if (!fresh || !CHAT_COMMANDS.has(command)) return false
|
|
83
|
+
if (typeof topicName !== 'string' || typeof text !== 'string') return false
|
|
84
|
+
const name = topicName.trim()
|
|
85
|
+
// Telegram names the topic after the message, cut to 128 characters.
|
|
86
|
+
return name.startsWith('/') && text.trim().startsWith(name)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether a Bot API error means the topic itself no longer exists, so its
|
|
91
|
+
* binding can never work again. A closed topic is not gone: it can be reopened.
|
|
92
|
+
* @param description - Telegram's error description.
|
|
93
|
+
* @returns true for a deleted or invalid topic.
|
|
94
|
+
*/
|
|
95
|
+
export function isTopicGone(description) {
|
|
96
|
+
return /message thread not found|(?:topic|thread)[^.]{0,40}(?:not found|deleted)|TOPIC_ID_INVALID|TOPIC_DELETED/iu.test(String(description ?? ''))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when a thread id denotes the chat-wide All topic. */
|
|
100
|
+
function isAllTopic(threadId) {
|
|
101
|
+
return threadId === undefined || threadId === null || threadId === 0 || threadId === 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Outbound topic fields for send methods. Private-chat and forum topics use
|
|
106
|
+
* `message_thread_id`; a cached direct id is used only for chats without a
|
|
107
|
+
* forum thread (for example channel direct-messages chats).
|
|
108
|
+
* @param directChats - `{ "<chatId>": true }` for private chats with topics.
|
|
109
|
+
* @param directTopics - `{ "<chatId>:<threadId>": directTopicId }`.
|
|
110
|
+
* @param chatId - chat holding the topic.
|
|
111
|
+
* @param threadId - stored topic thread id.
|
|
112
|
+
* @returns `{}`, `{ message_thread_id }` or `{ direct_messages_topic_id }`.
|
|
113
|
+
*/
|
|
114
|
+
export function topicTargetFor(directChats, directTopics, chatId, threadId) {
|
|
115
|
+
if (isAllTopic(threadId)) return {}
|
|
116
|
+
// Private-chat topics use message_thread_id. If Telegram rejects it with
|
|
117
|
+
// "message thread not found", call() retries with direct_messages_topic_id.
|
|
118
|
+
if (directChats?.[String(chatId)] === true) return { message_thread_id: threadId }
|
|
119
|
+
const direct = directTopics?.[`${chatId}:${threadId}`]
|
|
120
|
+
if (Number.isSafeInteger(direct)) return { direct_messages_topic_id: direct }
|
|
121
|
+
return { message_thread_id: threadId }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a Bot API message proves it reached the expected topic. A response
|
|
126
|
+
* without any topic field is not proof: Telegram may have silently sent the
|
|
127
|
+
* message into All after the topic was deleted.
|
|
128
|
+
* @param directChats - `{ "<chatId>": true }` for private chats with topics.
|
|
129
|
+
* @param directTopics - `{ "<chatId>:<threadId>": directTopicId }`.
|
|
130
|
+
* @param chatId - chat holding the topic.
|
|
131
|
+
* @param threadId - stored topic thread id.
|
|
132
|
+
* @param delivered - Bot API result for the sent message.
|
|
133
|
+
* @returns whether the message belongs to the expected topic.
|
|
134
|
+
*/
|
|
135
|
+
export function deliveredToTopic(directChats, directTopics, chatId, threadId, delivered) {
|
|
136
|
+
if (delivered === null || typeof delivered !== 'object') return false
|
|
137
|
+
if (typeof delivered.message_thread_id === 'number') return delivered.message_thread_id === threadId
|
|
138
|
+
const direct = delivered.direct_messages_topic?.topic_id
|
|
139
|
+
if (typeof direct === 'number') {
|
|
140
|
+
if (directChats?.[String(chatId)] === true) return direct === threadId
|
|
141
|
+
const expected = directTopics?.[`${chatId}:${threadId}`]
|
|
142
|
+
return typeof expected !== 'number' || direct === expected
|
|
143
|
+
}
|
|
144
|
+
// No topic fields at all: Telegram silently drops a message into All when
|
|
145
|
+
// the topic is gone, so this is not proof of delivery.
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a programmatic send target to the chat that owns the thread.
|
|
151
|
+
* Thread ids belong to one chat (each private chat has its own topics), so
|
|
152
|
+
* the chat comes from where the bot saw the thread, never from whichever chat
|
|
153
|
+
* spoke last. Only numeric thread ids are accepted; topic names are display
|
|
154
|
+
* text, not addresses.
|
|
155
|
+
* @param keys - known `"<chatId>:<threadId>"` topic keys.
|
|
156
|
+
* @param chat - chat id the caller names, or undefined to use the owner.
|
|
157
|
+
* @param target - thread id, as a number or a string of digits.
|
|
158
|
+
* @returns `{ chatId, threadId }`.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveSendTarget(keys, chat, target) {
|
|
161
|
+
const raw = typeof target === 'number' ? String(target) : String(target ?? '').trim()
|
|
162
|
+
if (raw.length === 0) throw new Error('A Telegram thread id is required.')
|
|
163
|
+
if (!/^\d+$/u.test(raw)) throw new Error(`"${raw}" is not a Telegram thread id. Topics are addressed by their numeric thread id; /id in a topic shows it.`)
|
|
164
|
+
const threadId = Number(raw)
|
|
165
|
+
const owners = [...new Set([...keys]
|
|
166
|
+
.map((key) => String(key).match(/^(-?\d+):(\d+)$/u))
|
|
167
|
+
.filter((match) => match !== null && Number(match[2]) === threadId)
|
|
168
|
+
.map((match) => match[1]))]
|
|
169
|
+
const wanted = typeof chat === 'number' ? String(chat) : String(chat ?? '').trim()
|
|
170
|
+
if (wanted.length > 0) {
|
|
171
|
+
if (!/^-?\d+$/u.test(wanted)) throw new Error(`"${wanted}" is not a Telegram chat id.`)
|
|
172
|
+
if (!owners.includes(wanted)) throw new Error(`Unknown thread id ${threadId} in chat ${wanted}: the bot knows no topic with that id there. /id in a topic shows its thread id.`)
|
|
173
|
+
return { chatId: Number(wanted), threadId }
|
|
174
|
+
}
|
|
175
|
+
if (owners.length === 0) throw new Error(`Unknown thread id ${threadId}: the bot knows no topic with that id in any chat. /id in a topic shows its thread id.`)
|
|
176
|
+
if (owners.length > 1) throw new Error(`Thread id ${threadId} exists in several chats (${owners.join(', ')}); name the chat to send to.`)
|
|
177
|
+
return { chatId: Number(owners[0]), threadId }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve a `/topic delete` target typed by a person: a topic thread id, or a
|
|
182
|
+
* known topic name compared without regard to case. Programmatic sends use
|
|
183
|
+
* `resolveSendTarget`, since names can change.
|
|
184
|
+
* @param names - `{ "<chatId>:<threadId>": name }`.
|
|
185
|
+
* @param chatId - chat the topics live in.
|
|
186
|
+
* @param target - topic name or thread id.
|
|
187
|
+
* @returns `{ threadId, name }`.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveTopicTarget(names, chatId, target) {
|
|
190
|
+
const raw = typeof target === 'number' ? String(target) : String(target ?? '').trim()
|
|
191
|
+
if (raw.length === 0) throw new Error('A topic name or thread id is required.')
|
|
192
|
+
const known = Object.entries(names ?? {})
|
|
193
|
+
.filter(([key]) => key.startsWith(`${chatId}:`))
|
|
194
|
+
.map(([key, name]) => ({ threadId: Number(key.slice(String(chatId).length + 1)), name: String(name) }))
|
|
195
|
+
if (/^\d+$/u.test(raw)) {
|
|
196
|
+
const threadId = Number(raw)
|
|
197
|
+
if (!Number.isSafeInteger(threadId) || threadId <= 0 || threadId > 2_147_483_647) {
|
|
198
|
+
const list = known.map((entry) => `"${entry.name}" (${entry.threadId})`).join(', ')
|
|
199
|
+
throw new Error(`"${raw}" is not a Telegram topic id. Topic ids are message-thread ids (for example 55221).${list.length > 0 ? ` Known topics: ${list}.` : ''} Send /id inside the topic to see its id.`)
|
|
200
|
+
}
|
|
201
|
+
return { threadId, name: known.find((entry) => entry.threadId === threadId)?.name }
|
|
202
|
+
}
|
|
203
|
+
const wanted = raw.toLocaleLowerCase()
|
|
204
|
+
const matches = known.filter((entry) => entry.name.trim().toLocaleLowerCase() === wanted)
|
|
205
|
+
if (matches.length === 1) return matches[0]
|
|
206
|
+
if (matches.length > 1) {
|
|
207
|
+
throw new Error(`Several topics are named "${raw}" (thread ids ${matches.map((entry) => entry.threadId).join(', ')}). Send to a thread id instead.`)
|
|
208
|
+
}
|
|
209
|
+
const list = known.map((entry) => `"${entry.name}" (${entry.threadId})`).join(', ')
|
|
210
|
+
throw new Error(`Unknown Telegram topic "${raw}". ${list.length > 0 ? `Known topics: ${list}.` : 'The bot knows no topic names yet.'} Rename the topic in Telegram or with /rename so the bot learns its name, or send to its thread id (/id in the topic).`)
|
|
211
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { messageOf } from './utils.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serialize one Telegram update stream and persist its cursor before side effects.
|
|
5
|
+
* A pending entry left by a crash is marked uncertain and never replayed.
|
|
6
|
+
*/
|
|
7
|
+
export function createUpdateAdmission({ getState, persist, handle, onError = () => {} }) {
|
|
8
|
+
let tail = Promise.resolve()
|
|
9
|
+
|
|
10
|
+
async function process(update, signal) {
|
|
11
|
+
if (!Number.isSafeInteger(update?.update_id)) return
|
|
12
|
+
const state = getState()
|
|
13
|
+
state.updateLedger ??= {}
|
|
14
|
+
state.updateOffset ??= 0
|
|
15
|
+
const key = String(update.update_id)
|
|
16
|
+
const previous = state.updateLedger[key]
|
|
17
|
+
if (previous !== undefined) {
|
|
18
|
+
if (previous === 'pending') {
|
|
19
|
+
state.updateLedger[key] = 'uncertain'
|
|
20
|
+
state.updateOffset = Math.max(state.updateOffset, update.update_id + 1)
|
|
21
|
+
await persist()
|
|
22
|
+
try {
|
|
23
|
+
await onError(new Error(`Update ${update.update_id} has an uncertain outcome; refusing to replay`), update, signal)
|
|
24
|
+
} catch {
|
|
25
|
+
// Error reporting must never replay the update.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
if (update.update_id < state.updateOffset) return
|
|
31
|
+
state.updateLedger[key] = 'pending'
|
|
32
|
+
await persist()
|
|
33
|
+
try {
|
|
34
|
+
await handle(update, signal)
|
|
35
|
+
state.updateLedger[key] = 'done'
|
|
36
|
+
state.updateOffset = Math.max(state.updateOffset, update.update_id + 1)
|
|
37
|
+
const keys = Object.keys(state.updateLedger).sort((a, b) => Number(b) - Number(a))
|
|
38
|
+
for (const old of keys.slice(256)) delete state.updateLedger[old]
|
|
39
|
+
await persist()
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// Keep `pending`: after restart it becomes `uncertain`, not replayed.
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
accept(update, signal) {
|
|
48
|
+
const result = tail.then(() => process(update, signal))
|
|
49
|
+
tail = result.catch(() => {})
|
|
50
|
+
return result
|
|
51
|
+
},
|
|
52
|
+
idle() {
|
|
53
|
+
return tail.then(() => undefined)
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { messageOf }
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Small dependency-free helpers. */
|
|
2
|
+
import { DEFAULT_TOKEN_REF } from './constants.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read one error without ever touching the token.
|
|
6
|
+
* @param error - a thrown value.
|
|
7
|
+
* @returns a short diagnostic.
|
|
8
|
+
*/
|
|
9
|
+
function messageOf(error) {
|
|
10
|
+
return error instanceof Error ? error.message : String(error)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sleep that settles early when the runtime is stopped.
|
|
15
|
+
* @param ms - delay in milliseconds.
|
|
16
|
+
* @param signal - runtime lifetime.
|
|
17
|
+
* @returns resolves after the delay or the abort.
|
|
18
|
+
*/
|
|
19
|
+
function sleep(ms, signal) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const timer = setTimeout(resolve, ms)
|
|
22
|
+
signal.addEventListener(
|
|
23
|
+
'abort',
|
|
24
|
+
() => {
|
|
25
|
+
clearTimeout(timer)
|
|
26
|
+
resolve()
|
|
27
|
+
},
|
|
28
|
+
{ once: true },
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize one allowlist entry for case-insensitive comparison.
|
|
35
|
+
* @param value - id or @username.
|
|
36
|
+
* @returns normalized value.
|
|
37
|
+
*/
|
|
38
|
+
function normalizeEntry(value) {
|
|
39
|
+
return String(value).trim().toLowerCase()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read a Config field that may be a plain value or a volatile live reference.
|
|
44
|
+
* @param value - Config field or Volatile reference.
|
|
45
|
+
* @param fallback - value used when the field is absent.
|
|
46
|
+
* @returns the current value.
|
|
47
|
+
*/
|
|
48
|
+
function readLive(value, fallback) {
|
|
49
|
+
if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
|
|
50
|
+
try {
|
|
51
|
+
const current = value.get()
|
|
52
|
+
return current === undefined ? fallback : current
|
|
53
|
+
} catch {
|
|
54
|
+
return fallback
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return value === undefined || value === null ? fallback : value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @returns the live credential reference for the bot token. */
|
|
61
|
+
function tokenRefOf(config) {
|
|
62
|
+
const value = readLive(config.tokenRef, DEFAULT_TOKEN_REF)
|
|
63
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : DEFAULT_TOKEN_REF
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @returns whether the runtime is enabled in the live Config. */
|
|
67
|
+
function isEnabled(config) {
|
|
68
|
+
return readLive(config.enabled, true) !== false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns the live allowed-user list. */
|
|
72
|
+
function allowedUsersOf(config) {
|
|
73
|
+
const value = readLive(config.allowedUsers, [])
|
|
74
|
+
return Array.isArray(value) ? value : []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @returns the live allowed-chat list. */
|
|
78
|
+
function allowedChatsOf(config) {
|
|
79
|
+
const value = readLive(config.allowedChats, [])
|
|
80
|
+
return Array.isArray(value) ? value : []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The chat that gets lifecycle notices (bot enabled, Threaded mode warnings):
|
|
85
|
+
* `controlChat` when set, otherwise the private chat of the first allowed
|
|
86
|
+
* user, whose chat id is their user id. Both come from config, so a message
|
|
87
|
+
* from another allowed user never moves it.
|
|
88
|
+
* @returns a chat id or @channelname, or undefined when there is none (the
|
|
89
|
+
* first allowed user is a @username and `controlChat` is empty).
|
|
90
|
+
*/
|
|
91
|
+
function controlChatOf(config) {
|
|
92
|
+
const configured = readLive(config.controlChat, '')
|
|
93
|
+
const chat = typeof configured === 'string' ? configured.trim() : ''
|
|
94
|
+
if (chat.length > 0) return /^-?\d+$/u.test(chat) ? Number(chat) : chat
|
|
95
|
+
const owner = String(allowedUsersOf(config)[0] ?? '').trim()
|
|
96
|
+
return /^\d+$/u.test(owner) ? Number(owner) : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether one user is on the allowlist.
|
|
101
|
+
* @param from - Telegram user.
|
|
102
|
+
* @param allowedUsers - configured entries.
|
|
103
|
+
* @returns whether the user may talk to the bot.
|
|
104
|
+
*/
|
|
105
|
+
function userAllowed(from, allowedUsers) {
|
|
106
|
+
if (from === undefined || !Array.isArray(allowedUsers)) return false
|
|
107
|
+
const id = String(from.id)
|
|
108
|
+
const username = typeof from.username === 'string' ? `@${from.username.toLowerCase()}` : undefined
|
|
109
|
+
return allowedUsers.some((entry) => entry === id || (username !== undefined && normalizeEntry(entry) === username))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether one chat is on the allowlist. An empty chat list allows private
|
|
114
|
+
* chats only; a non-empty list requires the chat itself to match.
|
|
115
|
+
* @param chat - Telegram chat.
|
|
116
|
+
* @param allowedChats - configured entries.
|
|
117
|
+
* @returns whether the bot may answer in this chat.
|
|
118
|
+
*/
|
|
119
|
+
function chatAllowed(chat, allowedChats) {
|
|
120
|
+
if (!Array.isArray(allowedChats) || allowedChats.length === 0) return chat.type === 'private'
|
|
121
|
+
const id = String(chat.id)
|
|
122
|
+
const username = typeof chat.username === 'string' ? `@${chat.username.toLowerCase()}` : undefined
|
|
123
|
+
return allowedChats.some((entry) => entry === id || (username !== undefined && normalizeEntry(entry) === username))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
export {
|
|
128
|
+
messageOf,
|
|
129
|
+
sleep,
|
|
130
|
+
normalizeEntry,
|
|
131
|
+
readLive,
|
|
132
|
+
tokenRefOf,
|
|
133
|
+
isEnabled,
|
|
134
|
+
allowedUsersOf,
|
|
135
|
+
allowedChatsOf,
|
|
136
|
+
controlChatOf,
|
|
137
|
+
userAllowed,
|
|
138
|
+
chatAllowed,
|
|
139
|
+
}
|
package/src/voice.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Voice messages, audio files and round video messages: decoding and transcription. */
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
/** Telegram message fields that carry speech, with a default file extension. */
|
|
5
|
+
const SPOKEN = [
|
|
6
|
+
{ field: 'voice', kind: 'voice message', extension: '.ogg' },
|
|
7
|
+
{ field: 'video_note', kind: 'video message', extension: '.mp4' },
|
|
8
|
+
{ field: 'audio', kind: 'audio file', extension: '.mp3' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The spoken media a message carries, if any.
|
|
13
|
+
* @param message - Telegram message.
|
|
14
|
+
* @returns `{ field, kind, file, name, seconds }`, or undefined.
|
|
15
|
+
*/
|
|
16
|
+
export function spokenMediaOf(message) {
|
|
17
|
+
for (const entry of SPOKEN) {
|
|
18
|
+
const file = message?.[entry.field]
|
|
19
|
+
if (typeof file?.file_id !== 'string') continue
|
|
20
|
+
const id = message.message_id ?? Date.now()
|
|
21
|
+
const name = typeof file.file_name === 'string' && file.file_name.length > 0
|
|
22
|
+
? file.file_name
|
|
23
|
+
: `telegram-${entry.field.replace('_', '-')}-${id}${entry.extension}`
|
|
24
|
+
return { field: entry.field, kind: entry.kind, file, name, seconds: typeof file.duration === 'number' ? file.duration : undefined }
|
|
25
|
+
}
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap raw 16 kHz mono 16-bit PCM in the canonical 44-byte WAV header that
|
|
31
|
+
* DSH speech providers accept.
|
|
32
|
+
* @param pcm - little-endian samples.
|
|
33
|
+
* @returns WAV bytes.
|
|
34
|
+
*/
|
|
35
|
+
export function pcmToWave(pcm) {
|
|
36
|
+
const data = pcm.length % 2 === 0 ? pcm : pcm.subarray(0, pcm.length - 1)
|
|
37
|
+
const header = Buffer.alloc(44)
|
|
38
|
+
header.write('RIFF', 0, 'ascii')
|
|
39
|
+
header.writeUInt32LE(36 + data.length, 4)
|
|
40
|
+
header.write('WAVE', 8, 'ascii')
|
|
41
|
+
header.write('fmt ', 12, 'ascii')
|
|
42
|
+
header.writeUInt32LE(16, 16)
|
|
43
|
+
header.writeUInt16LE(1, 20)
|
|
44
|
+
header.writeUInt16LE(1, 22)
|
|
45
|
+
header.writeUInt32LE(16000, 24)
|
|
46
|
+
header.writeUInt32LE(32000, 28)
|
|
47
|
+
header.writeUInt16LE(2, 32)
|
|
48
|
+
header.writeUInt16LE(16, 34)
|
|
49
|
+
header.write('data', 36, 'ascii')
|
|
50
|
+
header.writeUInt32LE(data.length, 40)
|
|
51
|
+
return Buffer.concat([header, data])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run one program and collect its output.
|
|
56
|
+
* @param argv - program and arguments; no shell is involved.
|
|
57
|
+
* @param signal - cancellation.
|
|
58
|
+
* @param timeoutMs - upper bound for the run.
|
|
59
|
+
* @returns stdout bytes; rejects with stderr on a non-zero exit.
|
|
60
|
+
*/
|
|
61
|
+
export function runProgram(argv, signal, timeoutMs) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
if (signal?.aborted) {
|
|
64
|
+
reject(signal.reason ?? new Error('aborted'))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
68
|
+
const out = []
|
|
69
|
+
const err = []
|
|
70
|
+
let settled = false
|
|
71
|
+
const finish = (error, value) => {
|
|
72
|
+
if (settled) return
|
|
73
|
+
settled = true
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
signal?.removeEventListener('abort', abort)
|
|
76
|
+
if (error) reject(error)
|
|
77
|
+
else resolve(value)
|
|
78
|
+
}
|
|
79
|
+
const abort = () => {
|
|
80
|
+
child.kill('SIGKILL')
|
|
81
|
+
finish(new Error(`${argv[0]} was cancelled`))
|
|
82
|
+
}
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
child.kill('SIGKILL')
|
|
85
|
+
finish(new Error(`${argv[0]} took longer than ${Math.round(timeoutMs / 1000)} s`))
|
|
86
|
+
}, timeoutMs)
|
|
87
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
88
|
+
child.stdout.on('data', (chunk) => out.push(chunk))
|
|
89
|
+
child.stderr.on('data', (chunk) => err.push(chunk))
|
|
90
|
+
child.on('error', (error) => finish(new Error(`${argv[0]} could not start: ${error.message}`)))
|
|
91
|
+
child.on('close', (code) => {
|
|
92
|
+
if (code === 0) finish(undefined, Buffer.concat(out))
|
|
93
|
+
else finish(new Error(`${argv[0]} exited with ${code}: ${Buffer.concat(err).toString('utf8').trim().slice(-400)}`))
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Transcribe one recording.
|
|
100
|
+
* @param options - `{ mode, path, language, command, ffmpeg, speech, signal, timeoutMs }`:
|
|
101
|
+
* `mode` is `dsh` (the DSH speech-to-text service, `speech`), `command`
|
|
102
|
+
* (`command` argv, `{file}` replaced by `path`, transcript on stdout) or `off`.
|
|
103
|
+
* @returns `{ text, by }`; rejects when transcription fails.
|
|
104
|
+
*/
|
|
105
|
+
export async function transcribe(options) {
|
|
106
|
+
const { mode, path, signal } = options
|
|
107
|
+
const timeoutMs = options.timeoutMs ?? 180_000
|
|
108
|
+
if (mode === 'command') {
|
|
109
|
+
const command = Array.isArray(options.command) ? options.command.filter((part) => typeof part === 'string') : []
|
|
110
|
+
if (command.length === 0) throw new Error('no transcription command is configured')
|
|
111
|
+
const argv = command.some((part) => part.includes('{file}'))
|
|
112
|
+
? command.map((part) => part.replaceAll('{file}', path))
|
|
113
|
+
: [...command, path]
|
|
114
|
+
const text = (await runProgram(argv, signal, timeoutMs)).toString('utf8').trim()
|
|
115
|
+
return { text, by: 'the configured transcription command' }
|
|
116
|
+
}
|
|
117
|
+
if (mode !== 'dsh') throw new Error('transcription is turned off')
|
|
118
|
+
const speech = options.speech
|
|
119
|
+
if (speech === undefined || typeof speech.resolve !== 'function') throw new Error('the DSH speech-to-text service is not enabled')
|
|
120
|
+
const pcm = await runProgram(
|
|
121
|
+
[options.ffmpeg || 'ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-i', path, '-vn', '-ac', '1', '-ar', '16000', '-f', 's16le', 'pipe:1'],
|
|
122
|
+
signal,
|
|
123
|
+
timeoutMs,
|
|
124
|
+
)
|
|
125
|
+
if (pcm.length === 0) throw new Error('the recording has no audio')
|
|
126
|
+
const spec = speech.resolve({
|
|
127
|
+
audio: pcmToWave(pcm),
|
|
128
|
+
...(typeof options.language === 'string' && options.language.length > 0 ? { language: options.language } : {}),
|
|
129
|
+
})
|
|
130
|
+
const result = await speech.transcribe(spec, signal ?? new AbortController().signal)
|
|
131
|
+
const provider = spec.provider?.info?.name ?? spec.provider?.info?.id
|
|
132
|
+
return { text: String(result?.text ?? '').trim(), by: `DSH speech-to-text${provider ? ` (${provider})` : ''}` }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The text the agent reads next to a spoken recording.
|
|
137
|
+
* @param media - result of {@link spokenMediaOf}.
|
|
138
|
+
* @param outcome - `{ path, text, by, error }`.
|
|
139
|
+
* @returns prompt text.
|
|
140
|
+
*/
|
|
141
|
+
export function transcriptNote(media, outcome) {
|
|
142
|
+
const length = media.seconds === undefined ? '' : `, ${Math.floor(media.seconds / 60)}:${String(media.seconds % 60).padStart(2, '0')}`
|
|
143
|
+
const where = typeof outcome.path === 'string' ? `the file is attached and saved at ${outcome.path}` : 'the file is attached'
|
|
144
|
+
if (outcome.error !== undefined) {
|
|
145
|
+
return `[Telegram ${media.kind} "${media.name}"${length}: not transcribed (${outcome.error}); ${where}]`
|
|
146
|
+
}
|
|
147
|
+
if (outcome.text.length === 0) {
|
|
148
|
+
return `[Telegram ${media.kind} "${media.name}"${length}: ${outcome.by} heard no speech; ${where}]`
|
|
149
|
+
}
|
|
150
|
+
return `[Telegram ${media.kind} "${media.name}"${length}, transcribed by ${outcome.by}; the transcript may contain mistakes, and ${where} to check it]\n${outcome.text}`
|
|
151
|
+
}
|