@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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +182 -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 +628 -0
- package/src/commands.js +429 -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/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +174 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/sessions.js +380 -0
- package/src/spool.js +172 -0
- package/src/state.js +147 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +161 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
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 (`` 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
|
+
}
|