@soffits/dsh-deltachat-bot 0.0.0-stage → 0.2.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/bin/dsh-deltachat.js +140 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +14 -0
- package/index.js +4 -0
- package/locale/en.json +6 -0
- package/package.json +67 -4
- package/src/app.js +653 -0
- package/src/commands.js +454 -0
- package/src/config.js +63 -0
- package/src/constants.js +50 -0
- package/src/controls.js +214 -0
- package/src/core.js +236 -0
- package/src/helpers.js +180 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +183 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/reactions.js +125 -0
- package/src/sessions.js +388 -0
- package/src/spool.js +172 -0
- package/src/state.js +162 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +197 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
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 in the chat this session is bound to: the final answer of the turn is delivered there by itself, so text-only sends into that chat are refused. Use it to reach another chat, 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
|
+
}
|