@soffits/dsh-deltachat-bot 0.0.0-stage → 0.1.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/src/text.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Text going between DSH and Delta Chat: the answer a turn leaves, Markdown
3
+ * turned into readable plain text (Delta Chat apps show messages as plain
4
+ * text), splitting, and the silent marker.
5
+ */
6
+ import { MAX_MESSAGE_TEXT, SILENT_REPLY } from './constants.js'
7
+
8
+ /**
9
+ * The answer one assistant message holds: the last text block of a message
10
+ * that called no tool. Text written between tool calls is working narration,
11
+ * not part of the answer; a message that called a tool is mid-chain.
12
+ * @param message - an assistant message (`{ content: [...] }`).
13
+ * @returns the answer, or '' when the message holds none.
14
+ */
15
+ export function messageAnswer(message) {
16
+ const content = Array.isArray(message?.content) ? message.content : []
17
+ if (content.some((block) => block?.type === 'tool-call')) return ''
18
+ const texts = content
19
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
20
+ .map((block) => block.text.trim())
21
+ .filter((text) => text.length > 0)
22
+ return texts.at(-1) ?? ''
23
+ }
24
+
25
+ /**
26
+ * Remove the silent marker from the end of an answer.
27
+ * @param text - final answer.
28
+ * @returns `{ text, silent }`: the rest of the text and whether the marker was there.
29
+ */
30
+ export function stripSilent(text) {
31
+ const trimmed = String(text ?? '').trim()
32
+ if (!trimmed.endsWith(SILENT_REPLY)) return { text: trimmed, silent: false }
33
+ return { text: trimmed.slice(0, -SILENT_REPLY.length).trim(), silent: true }
34
+ }
35
+
36
+ /**
37
+ * Local image paths a Markdown answer shows inline (`![alt](/abs/path)` or a
38
+ * `file://` URL), outside code. Delta Chat cannot show them inline, so they
39
+ * are sent as files after the text.
40
+ * @param markdown - answer text.
41
+ * @returns absolute paths, without duplicates.
42
+ */
43
+ export function localImagePaths(markdown) {
44
+ const found = []
45
+ const pattern = /!\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^)\s]+))(?:\s+"[^"\n]*")?\s*\)/gu
46
+ let fenced = false
47
+ for (const line of String(markdown ?? '').split('\n')) {
48
+ if (/^\s*```/u.test(line)) {
49
+ fenced = !fenced
50
+ continue
51
+ }
52
+ if (fenced) continue
53
+ for (const match of line.split(/`[^`]*`/gu).join(' ').matchAll(pattern)) {
54
+ let target = (match[1] ?? match[2]).trim()
55
+ if (/^file:\/\//iu.test(target)) {
56
+ try {
57
+ target = decodeURIComponent(new URL(target).pathname)
58
+ } catch {
59
+ continue
60
+ }
61
+ }
62
+ if (target.startsWith('/') && !found.includes(target)) found.push(target)
63
+ }
64
+ }
65
+ return found
66
+ }
67
+
68
+ /**
69
+ * Inline Markdown of one line as plain text.
70
+ * @param line - text outside code fences.
71
+ * @returns the line without emphasis markers, with links spelled out.
72
+ */
73
+ function plainInline(line) {
74
+ // Keep inline code as it is, without its backticks.
75
+ const parts = line.split(/(`[^`\n]+`)/gu)
76
+ return parts.map((part, index) => {
77
+ if (index % 2 === 1) return part.slice(1, -1)
78
+ let text = part
79
+ // Images: local ones are sent as files, remote ones keep their address.
80
+ text = text.replace(/!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/gu, (_all, alt, target) => {
81
+ if (target.startsWith('/') || /^file:\/\//iu.test(target)) return alt.trim().length > 0 ? `[${alt.trim()}]` : ''
82
+ return alt.trim().length > 0 ? `${alt.trim()} (${target})` : target
83
+ })
84
+ text = text.replace(/\[([^\]]+)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/gu, (_all, label, target) => {
85
+ const shown = label.trim()
86
+ return shown === target || shown === target.replace(/^mailto:/u, '') ? target : `${shown} (${target})`
87
+ })
88
+ text = text.replace(/<(https?:\/\/[^>\s]+)>/gu, '$1')
89
+ text = text.replace(/(\*\*|__)(?=\S)([^\n]*?\S)\1/gu, '$2')
90
+ text = text.replace(/(^|[\s(])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?=$|[\s).,!?:;])/gu, '$1$2')
91
+ text = text.replace(/(^|[\s(])_(?=[^\s_])([^_\n]*?[^\s_])_(?=$|[\s).,!?:;])/gu, '$1$2')
92
+ text = text.replace(/~~(?=\S)([^\n]*?\S)~~/gu, '$1')
93
+ return text
94
+ }).join('')
95
+ }
96
+
97
+ /**
98
+ * Turn a Markdown answer into plain text that reads well in Delta Chat:
99
+ * emphasis markers and fence lines go, headings become plain lines, bullets
100
+ * become dots, links show their address, tables become rows of cells. Text
101
+ * inside code fences is kept exactly.
102
+ * @param markdown - answer text.
103
+ * @returns plain text.
104
+ */
105
+ export function toPlainText(markdown) {
106
+ const out = []
107
+ let fenced = false
108
+ for (const raw of String(markdown ?? '').replace(/\r\n/gu, '\n').split('\n')) {
109
+ if (/^\s*(```|~~~)/u.test(raw)) {
110
+ fenced = !fenced
111
+ continue
112
+ }
113
+ if (fenced) {
114
+ out.push(raw)
115
+ continue
116
+ }
117
+ let line = raw
118
+ if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/u.test(line) && line.includes('|')) continue
119
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/u.test(line)) {
120
+ out.push('—')
121
+ continue
122
+ }
123
+ const heading = line.match(/^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/u)
124
+ if (heading !== null) {
125
+ out.push(plainInline(heading[1]))
126
+ continue
127
+ }
128
+ if (/^\s*\|.*\|\s*$/u.test(line)) {
129
+ const cells = line.trim().slice(1, -1).split('|').map((cell) => plainInline(cell.trim()))
130
+ out.push(cells.join(' · '))
131
+ continue
132
+ }
133
+ line = line.replace(/^(\s*)[-*+]\s+\[( |x|X)\]\s+/u, (_all, indent, mark) => `${indent}${mark === ' ' ? '☐' : '☑'} `)
134
+ line = line.replace(/^(\s*)[-*+]\s+/u, '$1• ')
135
+ out.push(plainInline(line))
136
+ }
137
+ return out.join('\n').replace(/\n{3,}/gu, '\n\n').trim()
138
+ }
139
+
140
+ /**
141
+ * Split text into messages no longer than the limit, preferring paragraph,
142
+ * then line, then word boundaries.
143
+ * @param text - plain text.
144
+ * @param limit - longest message.
145
+ * @returns non-empty parts.
146
+ */
147
+ export function splitText(text, limit = MAX_MESSAGE_TEXT) {
148
+ const parts = []
149
+ let rest = String(text ?? '').trim()
150
+ while (rest.length > limit) {
151
+ const window = rest.slice(0, limit)
152
+ let cut = window.lastIndexOf('\n\n')
153
+ if (cut < limit / 2) cut = window.lastIndexOf('\n')
154
+ if (cut < limit / 2) cut = window.lastIndexOf(' ')
155
+ if (cut < limit / 2) cut = limit
156
+ parts.push(rest.slice(0, cut).trim())
157
+ rest = rest.slice(cut).trim()
158
+ }
159
+ if (rest.length > 0) parts.push(rest)
160
+ return parts
161
+ }
162
+
163
+ /**
164
+ * Cut a preview to a length, on a word boundary when one is near.
165
+ * @param text - any text.
166
+ * @param max - longest result.
167
+ * @returns the preview, with an ellipsis when cut.
168
+ */
169
+ export function clip(text, max) {
170
+ const value = String(text ?? '').trim()
171
+ if (value.length <= max) return value
172
+ const cut = value.slice(0, max - 1)
173
+ const space = cut.lastIndexOf(' ')
174
+ return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`
175
+ }
package/src/titles.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Session titles of chat sessions: `DeltaChat-<person>-<task>`. `person` is
3
+ * who the chat is with (a group's name for a group), `task` the conversation
4
+ * title DSH generates, or the chat's name until there is one. DSH has no title
5
+ * hook, so a generated title is appended again with the prefix and the same
6
+ * source, which keeps automatic titling going.
7
+ */
8
+ import { readFileSync, statSync } from 'node:fs'
9
+ import { normalizeFingerprint } from './identity.js'
10
+
11
+ /** Title source marker shared with the other chat bridges' formats. */
12
+ export const TITLE_SOURCE = 'DeltaChat'
13
+
14
+ /** Words taken from the first message when DSH's fallback title is the sender line. */
15
+ const FALLBACK_WORDS = 8
16
+
17
+ /** @returns one line of text, without the separator dashes doubling up. */
18
+ function clean(value) {
19
+ return String(value ?? '').replace(/\s+/gu, ' ').trim()
20
+ }
21
+
22
+ /**
23
+ * The full title.
24
+ * @param person - who the chat is with.
25
+ * @param task - conversation title.
26
+ */
27
+ export function chatTitle(person, task) {
28
+ return `${TITLE_SOURCE}-${clean(person) || 'unknown'}-${clean(task)}`
29
+ }
30
+
31
+ /**
32
+ * The conversation part of a title, without a `DeltaChat-<person>-` prefix.
33
+ * @param title - current title.
34
+ * @param persons - names the prefix may carry (the current one and earlier ones).
35
+ */
36
+ export function taskOf(title, persons) {
37
+ const value = clean(title)
38
+ for (const person of persons) {
39
+ if (person === undefined || person === null) continue
40
+ const prefix = `${TITLE_SOURCE}-${clean(person) || 'unknown'}-`
41
+ if (value.startsWith(prefix)) return value.slice(prefix.length)
42
+ }
43
+ return value
44
+ }
45
+
46
+ /**
47
+ * A conversation title from the first message of a session, used when DSH's
48
+ * fallback title is the bot's sender line.
49
+ * @param events - session events.
50
+ * @returns a few words of the first message's own text, or ''.
51
+ */
52
+ export function firstMessageTask(events) {
53
+ for (const event of Array.isArray(events) ? events : []) {
54
+ if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user') continue
55
+ const text = (event.data.content ?? []).filter((block) => block?.type === 'text').map((block) => block.text).join('\n')
56
+ const body = text.split('\n').filter((line) => !/^\[[^\]]*\]$/u.test(line.trim())).join(' ')
57
+ const words = clean(body).split(' ').filter(Boolean)
58
+ if (words.length > 0) return words.slice(0, FALLBACK_WORDS).join(' ')
59
+ }
60
+ return ''
61
+ }
62
+
63
+ /**
64
+ * Names for fingerprints from a JSON file with a `deltachat_keys` object, read
65
+ * again when the file changes.
66
+ * @returns `nameOf(path, fingerprint)`.
67
+ */
68
+ export function createPeopleFile() {
69
+ let cached = { path: '', mtime: 0, keys: {} }
70
+ return {
71
+ nameOf(path, fingerprint) {
72
+ if (typeof path !== 'string' || path.length === 0) return undefined
73
+ try {
74
+ const mtime = statSync(path).mtimeMs
75
+ if (cached.path !== path || cached.mtime !== mtime) {
76
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
77
+ const keys = {}
78
+ for (const [key, name] of Object.entries(parsed?.deltachat_keys ?? {})) {
79
+ const normalized = normalizeFingerprint(key)
80
+ if (normalized && typeof name === 'string') keys[normalized] = name
81
+ }
82
+ cached = { path, mtime, keys }
83
+ }
84
+ } catch {
85
+ return undefined
86
+ }
87
+ return cached.keys[normalizeFingerprint(fingerprint)]
88
+ },
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Keep a bound session's title in the chat format.
94
+ * @param options - `{ service, session, person, earlier, placeholder, previous, warn }`:
95
+ * the title service, the live session, who the chat is with, names an older
96
+ * title may carry instead, the task shown before a conversation title
97
+ * exists, and the placeholder title the bot set earlier (so a renamed chat
98
+ * moves it).
99
+ * @returns the placeholder title now set by the bot, or `previous`.
100
+ */
101
+ export function syncTitle({ service, session, person, earlier = [], placeholder, previous, warn }) {
102
+ if (service === undefined || session === undefined) return previous
103
+ let current
104
+ try {
105
+ current = service.get(session)
106
+ } catch (error) {
107
+ warn?.(`title not read for ${session.id}: ${error?.message ?? error}`)
108
+ return previous
109
+ }
110
+ const placeholderTitle = chatTitle(person, placeholder)
111
+ // A title someone typed stays; only the bot's own placeholder follows the chat.
112
+ if (current === undefined || (current.source?.kind === 'user' && current.title === previous && previous !== placeholderTitle)) {
113
+ service.rename(session, placeholderTitle)
114
+ return placeholderTitle
115
+ }
116
+ if (current.source?.kind === 'user') return previous
117
+ let task = taskOf(current.title, [person, ...earlier])
118
+ if (current.source?.kind === 'fallback' && /^\[Delta Chat message from/u.test(task)) {
119
+ task = firstMessageTask(typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : []) || placeholder
120
+ }
121
+ const wanted = chatTitle(person, task)
122
+ if (wanted !== current.title) {
123
+ session.append('session/title', {
124
+ title: wanted,
125
+ messageSeqs: Array.isArray(current.messageSeqs) ? [...current.messageSeqs] : [],
126
+ source: current.source,
127
+ })
128
+ }
129
+ return previous
130
+ }
131
+
132
+ /**
133
+ * Whether a session still shows the bot's placeholder, so its first
134
+ * conversation title should be generated.
135
+ * @param service - title service.
136
+ * @param session - live session.
137
+ * @param previous - the placeholder title the bot set.
138
+ */
139
+ export function showsPlaceholder(service, session, previous) {
140
+ try {
141
+ const current = service.get(session)
142
+ return current?.source?.kind === 'user' && current.title === previous
143
+ } catch {
144
+ return false
145
+ }
146
+ }
package/src/turns.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Turn delivery: follow every session bound to a chat, collect each turn's
3
+ * answer, and send it when the turn ends.
4
+ */
5
+ import { basename } from 'node:path'
6
+ import { REQUEST_PREFIX } from './constants.js'
7
+ import { clip, localImagePaths, messageAnswer, stripSilent } from './text.js'
8
+
9
+ /**
10
+ * Create the turn tracker.
11
+ * @param deps - `{ ctx, getStore, isRunning, setting, outbound, chatState, publishStatus, fireAndForget, messageOf }`.
12
+ * @returns `{ onSessionEvent, currentTurn }`.
13
+ */
14
+ export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatState, publishStatus, fireAndForget, messageOf }) {
15
+ const { queueOut, notice, sendText, sendFiles, checkFiles } = outbound
16
+ /** Turns of bound sessions by session id: `{ current, turns: Map<turn, record>, pendingOurs }`. */
17
+ const sessionTurns = new Map()
18
+
19
+ /** @returns the turn bookkeeping of a session. */
20
+ function turnsOf(sessionId) {
21
+ let entry = sessionTurns.get(sessionId)
22
+ if (entry === undefined) {
23
+ entry = { current: undefined, turns: new Map(), pendingOurs: undefined }
24
+ sessionTurns.set(sessionId, entry)
25
+ }
26
+ return entry
27
+ }
28
+
29
+ /** @returns the record of a session's running turn, or undefined. */
30
+ function currentTurn(sessionId) {
31
+ const entry = sessionTurns.get(sessionId)
32
+ return entry?.current === undefined ? undefined : entry.turns.get(entry.current)
33
+ }
34
+
35
+ /**
36
+ * Follow every session bound to a chat: collect each turn's answer and send
37
+ * it when the turn ends. Turns started from Delta Chat are always delivered;
38
+ * turns started elsewhere (Web UI, schedules) when `mirrorTurns` is on.
39
+ */
40
+ function onSessionEvent(session, event) {
41
+ const store = getStore()
42
+ if (store === undefined || !isRunning()) return
43
+ const sessionId = session?.id
44
+ const chatId = store.chatOfSession(sessionId)
45
+ if (chatId === undefined) return
46
+ const entry = turnsOf(sessionId)
47
+ const mirror = setting('mirrorTurns', true) === true
48
+ if (event.type === 'turn/start') {
49
+ const record = { texts: [], ours: entry.pendingOurs !== undefined, lastPrompt: entry.pendingOurs, goal: false, mirrored: mirror, sent: false, stopped: false }
50
+ entry.pendingOurs = undefined
51
+ entry.current = event.data?.turn
52
+ entry.turns.set(event.data?.turn, record)
53
+ return
54
+ }
55
+ const record = entry.current === undefined ? undefined : entry.turns.get(entry.current)
56
+ if (event.type === 'user/message') {
57
+ const source = event.data?.source
58
+ const rpcId = typeof source?.rpcId === 'string' ? source.rpcId : ''
59
+ if (source?.kind === 'user' && rpcId.startsWith(REQUEST_PREFIX)) {
60
+ const messageId = Number(rpcId.slice(rpcId.lastIndexOf(':') + 1))
61
+ if (record === undefined) entry.pendingOurs = messageId
62
+ else {
63
+ record.ours = true
64
+ record.lastPrompt = messageId
65
+ }
66
+ return
67
+ }
68
+ if (rpcId.startsWith('deltachat-claim:')) return
69
+ if (source?.kind === 'goal' && record !== undefined) record.goal = true
70
+ if (!mirror) return
71
+ const label = source?.kind === 'schedule' ? '⏰ Scheduled task' : source?.kind !== 'user' ? undefined : rpcId.startsWith('telegram') ? '✈️ From Telegram' : '🖥 From Web'
72
+ if (label === undefined) return
73
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
74
+ const text = content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n').trim()
75
+ const files = content.filter((block) => block?.type === 'image' || block?.type === 'file').length
76
+ if (text.length === 0 && files === 0) return
77
+ notice(chatId, `${label}:\n${clip(text, 3000)}${files === 0 ? '' : `\n📎 ${files}`}`)
78
+ return
79
+ }
80
+ if (event.type === 'assistant/message') {
81
+ const turn = entry.turns.get(event.data?.turn) ?? record
82
+ const answer = messageAnswer(event.data?.message ?? event.data)
83
+ if (turn !== undefined && answer.length > 0) turn.texts.push(answer)
84
+ return
85
+ }
86
+ if (event.type === 'deliverables/presented') {
87
+ const turn = entry.turns.get(event.data?.turn) ?? record
88
+ if (turn !== undefined && !(turn.ours || turn.goal || turn.mirrored)) return
89
+ const paths = (Array.isArray(event.data?.files) ? event.data.files : []).map((file) => file?.path).filter((path) => typeof path === 'string')
90
+ if (paths.length === 0) return
91
+ if (turn !== undefined) turn.sent = true
92
+ void queueOut(chatId, () => forwardFiles(chatId, paths))
93
+ return
94
+ }
95
+ if (event.type === 'goal/change') {
96
+ fireAndForget('goal-change', () => announceGoal(chatId, sessionId))
97
+ return
98
+ }
99
+ if (event.type === 'turn/end') {
100
+ const turn = event.data?.turn
101
+ const finished = entry.turns.get(turn)
102
+ entry.turns.delete(turn)
103
+ if (entry.current === turn) entry.current = undefined
104
+ if (finished !== undefined) void queueOut(chatId, () => deliverTurn(chatId, finished, event.data?.reason))
105
+ }
106
+ }
107
+
108
+ /** Send files a session showed its user, with a note for those that cannot go. */
109
+ async function forwardFiles(chatId, paths, quiet = false) {
110
+ const failed = []
111
+ for (const path of paths) {
112
+ try {
113
+ await sendFiles(chatId, await checkFiles([path]))
114
+ } catch (error) {
115
+ if (!quiet) failed.push(`${basename(path)}: ${messageOf(error)}`)
116
+ }
117
+ }
118
+ if (failed.length > 0) await sendText(chatId, `⚠️ Could not send:\n${failed.join('\n')}`, { plain: true })
119
+ }
120
+
121
+ /** Send a finished turn's answer into its chat. */
122
+ async function deliverTurn(chatId, turn, reason) {
123
+ if (!(turn.ours || turn.goal || turn.mirrored)) return
124
+ if (turn.stopped || reason?.kind === 'aborted' || reason?.kind === 'blocked') return
125
+ const { text, silent } = stripSilent(turn.texts.join('\n\n'))
126
+ if (reason?.kind === 'error' && text.length === 0) {
127
+ await sendText(chatId, `❌ ${reason.error?.message ?? 'The turn failed.'}`, { plain: true })
128
+ return
129
+ }
130
+ if (text.length > 0) {
131
+ const chat = chatState(chatId)
132
+ // Quote the prompt when the chat moved on since it was sent.
133
+ const quote = turn.ours && Number.isSafeInteger(turn.lastPrompt) && chat.lastInbound !== turn.lastPrompt ? turn.lastPrompt : undefined
134
+ await sendText(chatId, text, { quote })
135
+ const images = localImagePaths(text)
136
+ if (images.length > 0) await forwardFiles(chatId, images, true)
137
+ await publishStatus({ lastReplyAt: Date.now() })
138
+ return
139
+ }
140
+ if (turn.ours && !silent && !turn.sent && reason?.kind !== 'error') await sendText(chatId, '✅ Done.', { plain: true })
141
+ }
142
+
143
+ /** Tell the chat when its session's goal changes phase. */
144
+ async function announceGoal(chatId, sessionId) {
145
+ const goals = ctx.get('goals')
146
+ const agent = ctx.get('agents')?.get(sessionId)
147
+ if (goals === undefined || agent === undefined) return
148
+ const view = goals.get(agent)
149
+ const chat = chatState(chatId)
150
+ const phase = view === undefined ? 'none' : view.phase
151
+ if (chat.goalPhase === phase) return
152
+ const previous = chat.goalPhase
153
+ chat.goalPhase = phase
154
+ if (previous === undefined) return
155
+ if (phase === 'complete') await notice(chatId, `🎯 Goal complete: ${clip(view.objective, 300)}`)
156
+ else if (phase === 'blocked') await notice(chatId, `🎯 Goal blocked: ${clip(view.objective, 300)}`)
157
+ else if (phase === 'paused') await notice(chatId, '🎯 Goal paused. /goal resume continues it.')
158
+ }
159
+
160
+ return { onSessionEvent, currentTurn }
161
+ }
package/src/voice.js ADDED
@@ -0,0 +1,126 @@
1
+ /** Voice messages and audio files: decoding and transcription. */
2
+ import { spawn } from 'node:child_process'
3
+
4
+ /**
5
+ * Wrap raw 16 kHz mono 16-bit PCM in the canonical 44-byte WAV header that
6
+ * DSH speech providers accept.
7
+ * @param pcm - little-endian samples.
8
+ * @returns WAV bytes.
9
+ */
10
+ export function pcmToWave(pcm) {
11
+ const data = pcm.length % 2 === 0 ? pcm : pcm.subarray(0, pcm.length - 1)
12
+ const header = Buffer.alloc(44)
13
+ header.write('RIFF', 0, 'ascii')
14
+ header.writeUInt32LE(36 + data.length, 4)
15
+ header.write('WAVE', 8, 'ascii')
16
+ header.write('fmt ', 12, 'ascii')
17
+ header.writeUInt32LE(16, 16)
18
+ header.writeUInt16LE(1, 20)
19
+ header.writeUInt16LE(1, 22)
20
+ header.writeUInt32LE(16000, 24)
21
+ header.writeUInt32LE(32000, 28)
22
+ header.writeUInt16LE(2, 32)
23
+ header.writeUInt16LE(16, 34)
24
+ header.write('data', 36, 'ascii')
25
+ header.writeUInt32LE(data.length, 40)
26
+ return Buffer.concat([header, data])
27
+ }
28
+
29
+ /**
30
+ * Run one program and collect its output.
31
+ * @param argv - program and arguments; no shell is involved.
32
+ * @param signal - cancellation.
33
+ * @param timeoutMs - upper bound for the run.
34
+ * @returns stdout bytes; rejects with stderr on a non-zero exit.
35
+ */
36
+ export function runProgram(argv, signal, timeoutMs) {
37
+ return new Promise((resolve, reject) => {
38
+ if (signal?.aborted) {
39
+ reject(signal.reason ?? new Error('aborted'))
40
+ return
41
+ }
42
+ const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
43
+ const out = []
44
+ const err = []
45
+ let settled = false
46
+ const finish = (error, value) => {
47
+ if (settled) return
48
+ settled = true
49
+ clearTimeout(timer)
50
+ signal?.removeEventListener('abort', abort)
51
+ if (error) reject(error)
52
+ else resolve(value)
53
+ }
54
+ const abort = () => {
55
+ child.kill('SIGKILL')
56
+ finish(new Error(`${argv[0]} was cancelled`))
57
+ }
58
+ const timer = setTimeout(() => {
59
+ child.kill('SIGKILL')
60
+ finish(new Error(`${argv[0]} took longer than ${Math.round(timeoutMs / 1000)} s`))
61
+ }, timeoutMs)
62
+ signal?.addEventListener('abort', abort, { once: true })
63
+ child.stdout.on('data', (chunk) => out.push(chunk))
64
+ child.stderr.on('data', (chunk) => err.push(chunk))
65
+ child.on('error', (error) => finish(new Error(`${argv[0]} could not start: ${error.message}`)))
66
+ child.on('close', (code) => {
67
+ if (code === 0) finish(undefined, Buffer.concat(out))
68
+ else finish(new Error(`${argv[0]} exited with ${code}: ${Buffer.concat(err).toString('utf8').trim().slice(-400)}`))
69
+ })
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Transcribe one recording.
75
+ * @param options - `{ mode, path, language, command, ffmpeg, speech, signal, timeoutMs }`:
76
+ * `mode` is `dsh` (the DSH speech-to-text service, `speech`), `command`
77
+ * (`command` argv, `{file}` replaced by `path`, transcript on stdout) or `off`.
78
+ * @returns `{ text, by }`; rejects when transcription fails.
79
+ */
80
+ export async function transcribe(options) {
81
+ const { mode, path, signal } = options
82
+ const timeoutMs = options.timeoutMs ?? 180_000
83
+ if (mode === 'command') {
84
+ const command = Array.isArray(options.command) ? options.command.filter((part) => typeof part === 'string') : []
85
+ if (command.length === 0) throw new Error('no transcription command is configured')
86
+ const argv = command.some((part) => part.includes('{file}'))
87
+ ? command.map((part) => part.replaceAll('{file}', path))
88
+ : [...command, path]
89
+ const text = (await runProgram(argv, signal, timeoutMs)).toString('utf8').trim()
90
+ return { text, by: 'the configured transcription command' }
91
+ }
92
+ if (mode !== 'dsh') throw new Error('transcription is turned off')
93
+ const speech = options.speech
94
+ if (speech === undefined || typeof speech.resolve !== 'function') throw new Error('the DSH speech-to-text service is not enabled')
95
+ const pcm = await runProgram(
96
+ [options.ffmpeg || 'ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-i', path, '-vn', '-ac', '1', '-ar', '16000', '-f', 's16le', 'pipe:1'],
97
+ signal,
98
+ timeoutMs,
99
+ )
100
+ if (pcm.length === 0) throw new Error('the recording has no audio')
101
+ const spec = speech.resolve({
102
+ audio: pcmToWave(pcm),
103
+ ...(typeof options.language === 'string' && options.language.length > 0 ? { language: options.language } : {}),
104
+ })
105
+ const result = await speech.transcribe(spec, signal ?? new AbortController().signal)
106
+ const provider = spec.provider?.info?.name ?? spec.provider?.info?.id
107
+ return { text: String(result?.text ?? '').trim(), by: `DSH speech-to-text${provider ? ` (${provider})` : ''}` }
108
+ }
109
+
110
+ /**
111
+ * The text the agent reads next to a spoken recording.
112
+ * @param media - `{ kind, name, seconds }`.
113
+ * @param outcome - `{ path, text, by, error }`.
114
+ * @returns prompt text.
115
+ */
116
+ export function transcriptNote(media, outcome) {
117
+ const length = media.seconds === undefined ? '' : `, ${Math.floor(media.seconds / 60)}:${String(media.seconds % 60).padStart(2, '0')}`
118
+ const where = typeof outcome.path === 'string' ? `the file is attached and saved at ${outcome.path}` : 'the file is attached'
119
+ if (outcome.error !== undefined) {
120
+ return `[Delta Chat ${media.kind} "${media.name}"${length}: not transcribed (${outcome.error}); ${where}]`
121
+ }
122
+ if (outcome.text.length === 0) {
123
+ return `[Delta Chat ${media.kind} "${media.name}"${length}: ${outcome.by} heard no speech; ${where}]`
124
+ }
125
+ return `[Delta Chat ${media.kind} "${media.name}"${length}, transcribed by ${outcome.by}; the transcript may contain mistakes, and ${where} to check it]\n${outcome.text}`
126
+ }
package/tools.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Model-facing tool for the Delta Chat bot. A separate plugin entry from the
3
+ * bot (index.js): the bot is looked up as the `deltachatChats` service at call
4
+ * time, so reloading or restarting the bot never unregisters the tool.
5
+ */
6
+ import z from '@deepseek-ai/schemastery'
7
+ import { SERVICE_NAME } from './src/constants.js'
8
+
9
+ export const name = 'deltachat-bot-tools'
10
+ export const inject = ['tools']
11
+ export const Config = z.object({})
12
+
13
+ /** JSON Schema of the send tool's arguments. */
14
+ export const SEND_SCHEMA = {
15
+ type: 'object',
16
+ properties: {
17
+ chat: { type: 'string', description: 'Numeric Delta Chat chat id; each message from a chat names it in its sender line, and /id in the chat shows it.' },
18
+ text: { type: 'string', description: 'Message text; Markdown is turned into plain text.' },
19
+ files: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of files to send.' },
20
+ reply_to: { type: 'string', description: 'Message id to quote (or to react to, with reaction).' },
21
+ reaction: { type: 'string', description: 'An emoji to react to reply_to with instead of sending a message; an empty string removes the reaction.' },
22
+ },
23
+ required: ['chat'],
24
+ additionalProperties: false,
25
+ }
26
+
27
+ /**
28
+ * The send tool.
29
+ * @param getService - returns the running bot's service, or undefined.
30
+ * @returns a tool definition.
31
+ */
32
+ export function createSendTool(getService) {
33
+ return {
34
+ name: 'deltachat_send',
35
+ description: 'Send a message, files or a reaction into a Delta Chat chat of the DSH Delta Chat bot, addressed by its numeric chat id. Not for answering a Delta Chat message in its own session: the final answer of the turn is delivered there by itself. Use it to reach a chat from another session or a job, to send files, or to react to a message. The message is also recorded in the session bound to that chat; no turn is started.',
36
+ parameters: SEND_SCHEMA,
37
+ output: { schema: { type: 'object' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
38
+ async execute(args, exec) {
39
+ const service = getService()
40
+ if (service === undefined) return { code: 'unavailable', message: 'The Delta Chat bot is not running.' }
41
+ const sessionId = exec?.agent?.session?.id ?? exec?.agent?.id
42
+ try {
43
+ return await service.send({
44
+ to: args?.chat,
45
+ text: typeof args?.text === 'string' ? args.text : '',
46
+ files: Array.isArray(args?.files) ? args.files : [],
47
+ ...(args?.reply_to === undefined ? {} : { replyTo: args.reply_to }),
48
+ ...(typeof args?.reaction === 'string' ? { reaction: args.reaction } : {}),
49
+ from: typeof sessionId === 'string' ? { sessionId, label: `session ${sessionId}` } : undefined,
50
+ })
51
+ } catch (error) {
52
+ return { code: 'failed', message: error?.message ?? String(error) }
53
+ }
54
+ },
55
+ }
56
+ }
57
+
58
+ export function apply(ctx) {
59
+ ctx.tools.register(createSendTool(() => ctx.get(SERVICE_NAME)))
60
+ }