@soffits/dsh-telegram-bot 0.0.0-stage → 0.4.1

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/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
+ }