@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/controls.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session controls from a chat: model, reasoning effort, access mode and
|
|
3
|
+
* context use, picked from numbered lists.
|
|
4
|
+
*/
|
|
5
|
+
import { defaultModelOf, effortChoices, effortName, findModel, modelName, pickedSelection, sessionModelOf } from './models.js'
|
|
6
|
+
import { controllerOf, currentPermission, permissionOptions, setPermission } from './sessions.js'
|
|
7
|
+
|
|
8
|
+
/** @returns `n` as a 1-based list index into `items`, or undefined. */
|
|
9
|
+
export function pick(items, value) {
|
|
10
|
+
const number = Number(value)
|
|
11
|
+
return Number.isSafeInteger(number) && number >= 1 && number <= items.length ? items[number - 1] : undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The chat's session id, or undefined. */
|
|
15
|
+
export function sessionOf(bridge, chatId) {
|
|
16
|
+
const id = bridge.store().chat(chatId)?.sessionId
|
|
17
|
+
return typeof id === 'string' ? id : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The chat session's live agent when it is loaded, or undefined. */
|
|
21
|
+
export function liveAgent(bridge, chatId) {
|
|
22
|
+
const sessionId = sessionOf(bridge, chatId)
|
|
23
|
+
return sessionId === undefined ? undefined : bridge.ctx.get('agents')?.get(sessionId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @returns a compact token count, such as 1M or 12.3k. */
|
|
27
|
+
function formatTokens(value) {
|
|
28
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '?'
|
|
29
|
+
if (value >= 1_000_000) return `${Math.round(value / 100_000) / 10}M`
|
|
30
|
+
if (value >= 1000) return `${Math.round(value / 100) / 10}k`
|
|
31
|
+
return String(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @returns context use, such as "16.8k of 1M" or "16.8k used". */
|
|
35
|
+
function contextLabel({ used, windowSize }) {
|
|
36
|
+
if (used === undefined) return windowSize === undefined ? 'unknown' : `${formatTokens(windowSize)} window`
|
|
37
|
+
return windowSize === undefined ? `${formatTokens(used)} used` : `${formatTokens(used)} of ${formatTokens(windowSize)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Model, effort and access mode of a chat's session. */
|
|
41
|
+
async function controls(bridge, chatId) {
|
|
42
|
+
const catalog = await bridge.modelCatalog.load()
|
|
43
|
+
const sessionId = sessionOf(bridge, chatId)
|
|
44
|
+
const current = await sessionModelOf(bridge.ctx, sessionId) ?? defaultModelOf(bridge.ctx)
|
|
45
|
+
const agent = liveAgent(bridge, chatId)
|
|
46
|
+
const mode = currentPermission(bridge.ctx, agent)
|
|
47
|
+
const modeName = permissionOptions(bridge.ctx).find((option) => option.value === mode)?.name ?? mode ?? '?'
|
|
48
|
+
return { catalog, current, mode, modeName }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Context window and last request size of a chat's session. */
|
|
52
|
+
async function contextUse(bridge, chatId, current) {
|
|
53
|
+
let windowSize
|
|
54
|
+
try {
|
|
55
|
+
const llm = bridge.ctx.get('llm')
|
|
56
|
+
const resolve = llm?.resolveModelInfo ?? llm?.resolveModel
|
|
57
|
+
const info = current === undefined || resolve === undefined ? undefined : await resolve.call(llm, current.provider, current.model)
|
|
58
|
+
windowSize = info?.context?.contextWindow
|
|
59
|
+
} catch {
|
|
60
|
+
// Unknown.
|
|
61
|
+
}
|
|
62
|
+
if (windowSize === undefined) {
|
|
63
|
+
const catalog = await bridge.modelCatalog.load()
|
|
64
|
+
windowSize = findModel(catalog, current)?.model?.contextWindow
|
|
65
|
+
}
|
|
66
|
+
let used
|
|
67
|
+
const sessionId = sessionOf(bridge, chatId)
|
|
68
|
+
if (sessionId !== undefined) {
|
|
69
|
+
try {
|
|
70
|
+
const events = (await bridge.ctx.get('sessionQuery')?.readSurface(sessionId))?.events ?? []
|
|
71
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
72
|
+
const usage = events[index]?.type === 'assistant/message' ? (events[index].data?.usage ?? events[index].data?.message?.usage) : undefined
|
|
73
|
+
if (usage === undefined) continue
|
|
74
|
+
used = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Unknown.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { windowSize, used }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Apply a model selection the way the Web picker does, and remember it for /new. */
|
|
85
|
+
async function selectModel(bridge, chatId, selection) {
|
|
86
|
+
const store = bridge.store()
|
|
87
|
+
store.data.picks[String(chatId)] = selection
|
|
88
|
+
await store.save()
|
|
89
|
+
const agent = await bridge.ensureAgent(chatId, { claim: true })
|
|
90
|
+
const controller = controllerOf(bridge.ctx)
|
|
91
|
+
if (typeof controller?.selectModel === 'function') {
|
|
92
|
+
await controller.selectModel({ sessionId: agent.session?.id ?? agent.id, ...selection })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
agent.model.select(selection)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Handlers of the control commands, merged into the command table. */
|
|
99
|
+
export const CONTROL_HANDLERS = {
|
|
100
|
+
async menu(bridge, { chatId }) {
|
|
101
|
+
const { catalog, current, modeName } = await controls(bridge, chatId)
|
|
102
|
+
const context = await contextUse(bridge, chatId, current)
|
|
103
|
+
await bridge.notice(chatId, [
|
|
104
|
+
'Session controls:',
|
|
105
|
+
`🤖 Model: ${modelName(catalog, current)} — /model`,
|
|
106
|
+
`🧠 Effort: ${effortName(catalog, current, 'Default')} — /effort`,
|
|
107
|
+
`🛡 Access: ${modeName} — /mode`,
|
|
108
|
+
`📚 Context: ${contextLabel(context)} — /context`,
|
|
109
|
+
].join('\n'))
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async model(bridge, { chatId }, args) {
|
|
113
|
+
const { catalog, current } = await controls(bridge, chatId)
|
|
114
|
+
if (catalog === undefined || catalog.groups.length === 0) {
|
|
115
|
+
await bridge.notice(chatId, 'No models are available right now.')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const [first, second] = args.split(/\s+/u).filter(Boolean)
|
|
119
|
+
if (first === undefined) {
|
|
120
|
+
await bridge.notice(chatId, [
|
|
121
|
+
`Model now: ${modelName(catalog, current)}`,
|
|
122
|
+
'',
|
|
123
|
+
...catalog.groups.map((group, index) => `${index + 1}. ${group.name ?? group.id} (${group.models.length})${group.id === current?.provider ? ' ✅' : ''}`),
|
|
124
|
+
...(catalog.failures.length > 0 ? ['', `Unavailable: ${catalog.failures.map((failure) => failure.name ?? failure.id).join(', ')}`] : []),
|
|
125
|
+
'',
|
|
126
|
+
'/model <n> lists the models of provider n.',
|
|
127
|
+
].join('\n'))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const group = pick(catalog.groups, first)
|
|
131
|
+
if (group === undefined) {
|
|
132
|
+
await bridge.notice(chatId, `No provider ${first}; /model lists them.`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (second === undefined) {
|
|
136
|
+
await bridge.notice(chatId, [
|
|
137
|
+
`${group.name ?? group.id}:`,
|
|
138
|
+
...group.models.map((model, index) => `${index + 1}. ${model.name ?? model.id}${group.id === current?.provider && model.id === current?.model ? ' ✅' : ''}`),
|
|
139
|
+
'',
|
|
140
|
+
`/model ${first} <n> switches this chat's session to model n.`,
|
|
141
|
+
].join('\n'))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const model = pick(group.models, second)
|
|
145
|
+
if (model === undefined) {
|
|
146
|
+
await bridge.notice(chatId, `No model ${second}; /model ${first} lists them.`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
await selectModel(bridge, chatId, pickedSelection(current, group.id, model))
|
|
150
|
+
await bridge.notice(chatId, `🤖 Model: ${model.name ?? model.id}`)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async effort(bridge, { chatId }, args) {
|
|
154
|
+
const { catalog, current } = await controls(bridge, chatId)
|
|
155
|
+
const model = findModel(catalog, current)?.model
|
|
156
|
+
const choices = effortChoices(model, 'Default')
|
|
157
|
+
if (choices.length === 0) {
|
|
158
|
+
await bridge.notice(chatId, 'This model has no effort setting.')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const effective = current?.reasoningEffort ?? model?.reasoning?.defaultEffort
|
|
162
|
+
if (args.length === 0) {
|
|
163
|
+
await bridge.notice(chatId, [
|
|
164
|
+
'Reasoning effort:',
|
|
165
|
+
...choices.map((choice, index) => `${index + 1}. ${choice.name}${choice.id === effective ? ' ✅' : ''}`),
|
|
166
|
+
'',
|
|
167
|
+
'/effort <n> picks one.',
|
|
168
|
+
].join('\n'))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const choice = pick(choices, args.split(/\s+/u)[0])
|
|
172
|
+
if (choice === undefined || current === undefined) {
|
|
173
|
+
await bridge.notice(chatId, `No effort ${args}; /effort lists them.`)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
await selectModel(bridge, chatId, { provider: current.provider, model: current.model, ...(choice.id === undefined ? {} : { reasoningEffort: choice.id }) })
|
|
177
|
+
await bridge.notice(chatId, `🧠 Effort: ${choice.name}`)
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async mode(bridge, { chatId }, args) {
|
|
181
|
+
const options = permissionOptions(bridge.ctx)
|
|
182
|
+
const { mode } = await controls(bridge, chatId)
|
|
183
|
+
if (args.length === 0) {
|
|
184
|
+
await bridge.notice(chatId, [
|
|
185
|
+
'Access mode of this session:',
|
|
186
|
+
...options.map((option, index) => `${index + 1}. ${option.name}${option.value === mode ? ' ✅' : ''}`),
|
|
187
|
+
'',
|
|
188
|
+
'/mode <n> picks one.',
|
|
189
|
+
].join('\n'))
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const option = pick(options, args.split(/\s+/u)[0])
|
|
193
|
+
if (option === undefined) {
|
|
194
|
+
await bridge.notice(chatId, `No mode ${args}; /mode lists them.`)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
setPermission(bridge.ctx, await bridge.ensureAgent(chatId, { claim: true }), option.value)
|
|
198
|
+
await bridge.notice(chatId, `🛡 Access: ${option.name}`)
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async context(bridge, { chatId }) {
|
|
202
|
+
const { catalog, current, modeName } = await controls(bridge, chatId)
|
|
203
|
+
const context = await contextUse(bridge, chatId, current)
|
|
204
|
+
await bridge.notice(chatId, [
|
|
205
|
+
'Context:',
|
|
206
|
+
`Model: ${modelName(catalog, current)}${current === undefined ? '' : ` (${current.provider}/${current.model})`}`,
|
|
207
|
+
`Window: ${formatTokens(context.windowSize)}`,
|
|
208
|
+
`Last request: ${context.used === undefined ? 'unknown' : formatTokens(context.used)}`,
|
|
209
|
+
`Effort: ${effortName(catalog, current, 'Default')}`,
|
|
210
|
+
`Access: ${modeName}`,
|
|
211
|
+
].join('\n'))
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Delta Chat side: one chatmail profile driven through the official
|
|
3
|
+
* `deltachat-rpc-server` (JSON-RPC over stdio).
|
|
4
|
+
*
|
|
5
|
+
* The server is spawned here rather than with `startDeltaChat` from
|
|
6
|
+
* `@deltachat/stdio-rpc-server`, because that helper throws from the child's
|
|
7
|
+
* `exit` and `error` handlers, which would take the whole Host process down
|
|
8
|
+
* when the server quits. Here an unexpected exit resolves `exited`.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { extname, join } from 'node:path'
|
|
13
|
+
import { getRPCServerPath } from '@deltachat/stdio-rpc-server'
|
|
14
|
+
import { StdioDeltaChat } from '@deltachat/jsonrpc-client'
|
|
15
|
+
import { vcardFingerprint } from './identity.js'
|
|
16
|
+
|
|
17
|
+
/** Connectivity at or above this value means connected (DC_CONNECTIVITY_CONNECTED is 4000). */
|
|
18
|
+
const CONNECTED = 4000
|
|
19
|
+
/** Connectivity at or above this value is at least trying (connecting, working). */
|
|
20
|
+
const CONNECTING = 3000
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A complete `MessageData` object; the RPC server wants every field.
|
|
24
|
+
* @param data - fields to set.
|
|
25
|
+
* @returns the message data.
|
|
26
|
+
*/
|
|
27
|
+
export function messageData(data) {
|
|
28
|
+
return {
|
|
29
|
+
text: null,
|
|
30
|
+
html: null,
|
|
31
|
+
viewtype: null,
|
|
32
|
+
file: null,
|
|
33
|
+
filename: null,
|
|
34
|
+
location: null,
|
|
35
|
+
overrideSenderName: null,
|
|
36
|
+
quotedMessageId: null,
|
|
37
|
+
quotedText: null,
|
|
38
|
+
...data,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** File extensions and the Delta Chat view type they are sent as. */
|
|
43
|
+
const VIEW_TYPES = new Map([
|
|
44
|
+
['.jpg', 'Image'], ['.jpeg', 'Image'], ['.png', 'Image'], ['.webp', 'Image'], ['.heic', 'Image'],
|
|
45
|
+
['.gif', 'Gif'],
|
|
46
|
+
['.mp4', 'Video'], ['.mov', 'Video'], ['.webm', 'Video'], ['.mkv', 'Video'],
|
|
47
|
+
['.mp3', 'Audio'], ['.m4a', 'Audio'], ['.ogg', 'Audio'], ['.opus', 'Audio'], ['.wav', 'Audio'], ['.flac', 'Audio'],
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a file is shown in Delta Chat, by its extension.
|
|
52
|
+
* @param path - file path.
|
|
53
|
+
* @returns a Delta Chat view type.
|
|
54
|
+
*/
|
|
55
|
+
export function viewTypeOf(path) {
|
|
56
|
+
return VIEW_TYPES.get(extname(String(path)).toLowerCase()) ?? 'File'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start the RPC server on an accounts directory.
|
|
61
|
+
* @param options - `{ dir, log }`.
|
|
62
|
+
* @returns `{ dc, close, exited }`; `dc` is a `StdioDeltaChat` with its event
|
|
63
|
+
* loop running; `exited` resolves with an error when the server quits
|
|
64
|
+
* without `close` (requests in flight then never settle, so callers race
|
|
65
|
+
* their work against it).
|
|
66
|
+
*/
|
|
67
|
+
export async function startServer({ dir, log }) {
|
|
68
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
69
|
+
const binary = getRPCServerPath()
|
|
70
|
+
const child = spawn(binary, [], {
|
|
71
|
+
env: {
|
|
72
|
+
...(process.env.RUST_LOG === undefined ? {} : { RUST_LOG: process.env.RUST_LOG }),
|
|
73
|
+
DC_ACCOUNTS_PATH: dir,
|
|
74
|
+
},
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
|
+
})
|
|
77
|
+
let closing = false
|
|
78
|
+
let stderr = ''
|
|
79
|
+
let reportExit
|
|
80
|
+
const exited = new Promise((resolve) => { reportExit = resolve })
|
|
81
|
+
child.stderr.on('data', (chunk) => {
|
|
82
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-2000)
|
|
83
|
+
})
|
|
84
|
+
child.on('error', (error) => {
|
|
85
|
+
if (!closing) reportExit(new Error(`deltachat-rpc-server could not run: ${error.message}`))
|
|
86
|
+
})
|
|
87
|
+
child.on('exit', (code, signal) => {
|
|
88
|
+
if (closing) return
|
|
89
|
+
const tail = stderr.trim().split('\n').slice(-3).join(' | ')
|
|
90
|
+
reportExit(new Error(`deltachat-rpc-server quit (${signal ?? `code ${code}`})${tail ? `: ${tail}` : ''}`))
|
|
91
|
+
})
|
|
92
|
+
// Writes after the server died must not become uncaught errors.
|
|
93
|
+
child.stdin.on('error', (error) => log?.(`rpc stdin: ${error.message}`))
|
|
94
|
+
const dc = new StdioDeltaChat(child.stdin, child.stdout, true)
|
|
95
|
+
dc.eventTask?.catch?.((error) => {
|
|
96
|
+
if (!closing) log?.(`event loop stopped: ${error?.message ?? error}`)
|
|
97
|
+
})
|
|
98
|
+
return {
|
|
99
|
+
dc,
|
|
100
|
+
exited,
|
|
101
|
+
close() {
|
|
102
|
+
closing = true
|
|
103
|
+
child.kill()
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create the bot profile on the first start, or bring an existing one up to
|
|
110
|
+
* the configured relays and look.
|
|
111
|
+
* @param dc - client.
|
|
112
|
+
* @param options - `{ relays, displayName, avatar, log }`.
|
|
113
|
+
* @returns the account id.
|
|
114
|
+
*/
|
|
115
|
+
export async function ensureAccount(dc, { relays, displayName, avatar, log }) {
|
|
116
|
+
const ids = await dc.rpc.getAllAccountIds()
|
|
117
|
+
const accountId = ids[0] ?? await dc.rpc.addAccount()
|
|
118
|
+
await dc.rpc.batchSetConfig(accountId, { bot: '1', displayname: displayName || 'DSH' })
|
|
119
|
+
if (typeof avatar === 'string' && avatar.length > 0) {
|
|
120
|
+
try {
|
|
121
|
+
await dc.rpc.setConfig(accountId, 'selfavatar', avatar)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log?.(`avatar ${avatar} not set: ${error?.message ?? error}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const wanted = relays.map((relay) => String(relay).trim().toLowerCase()).filter((relay) => relay.length > 0)
|
|
127
|
+
const present = new Set((await dc.rpc.listTransports(accountId).catch(() => []))
|
|
128
|
+
.map((transport) => String(transport.addr).split('@')[1]?.toLowerCase())
|
|
129
|
+
.filter(Boolean))
|
|
130
|
+
const failures = []
|
|
131
|
+
for (const relay of wanted) {
|
|
132
|
+
if (present.has(relay)) continue
|
|
133
|
+
try {
|
|
134
|
+
await dc.rpc.addTransportFromQr(accountId, `dcaccount:${relay}`)
|
|
135
|
+
present.add(relay)
|
|
136
|
+
log?.(`added relay ${relay}`)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
failures.push(`${relay}: ${error?.message ?? error}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!await dc.rpc.isConfigured(accountId)) {
|
|
142
|
+
throw new Error(`no relay could create the Delta Chat profile (${failures.join('; ') || 'no relays configured'})`)
|
|
143
|
+
}
|
|
144
|
+
for (const failure of failures) log?.(`relay not added: ${failure}`)
|
|
145
|
+
return accountId
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write the bot's invite link and its QR code next to the profile.
|
|
150
|
+
* @param dc - client.
|
|
151
|
+
* @param accountId - account.
|
|
152
|
+
* @param dir - directory for `invite.txt` and `invite.svg`.
|
|
153
|
+
* @returns `{ link, svgPath }`.
|
|
154
|
+
*/
|
|
155
|
+
export async function writeInvite(dc, accountId, dir) {
|
|
156
|
+
const link = await dc.rpc.getChatSecurejoinQrCode(accountId, null)
|
|
157
|
+
const svg = await dc.rpc.createQrSvg(link)
|
|
158
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
159
|
+
const svgPath = join(dir, 'invite.svg')
|
|
160
|
+
await writeFile(join(dir, 'invite.txt'), `${link}\n`, { mode: 0o600 })
|
|
161
|
+
await writeFile(svgPath, svg, { mode: 0o600 })
|
|
162
|
+
return { link, svgPath }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fingerprints of contacts, cached by contact id. A contact's key never
|
|
167
|
+
* changes in Delta Chat (a new key is a new contact), so entries stay valid.
|
|
168
|
+
* @param dc - client.
|
|
169
|
+
* @param accountId - account.
|
|
170
|
+
* @returns `get(contactId)` resolving to the fingerprint or ''.
|
|
171
|
+
*/
|
|
172
|
+
export function createFingerprints(dc, accountId) {
|
|
173
|
+
const cache = new Map()
|
|
174
|
+
return {
|
|
175
|
+
async get(contactId) {
|
|
176
|
+
if (cache.has(contactId)) return cache.get(contactId)
|
|
177
|
+
let fingerprint = ''
|
|
178
|
+
try {
|
|
179
|
+
fingerprint = vcardFingerprint(await dc.rpc.makeVcard(accountId, [contactId]))
|
|
180
|
+
} catch {
|
|
181
|
+
fingerprint = ''
|
|
182
|
+
}
|
|
183
|
+
if (fingerprint !== '') cache.set(contactId, fingerprint)
|
|
184
|
+
return fingerprint
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Keep IO healthy: restart it when connectivity stays down or the inbox has
|
|
191
|
+
* been quiet for too long, and nudge the network now and then. Delta Chat
|
|
192
|
+
* recovers by itself in most cases; this covers long sleeps and dead sockets.
|
|
193
|
+
* @param dc - client.
|
|
194
|
+
* @param accountId - account.
|
|
195
|
+
* @param log - logger for restarts.
|
|
196
|
+
* @returns a stop function.
|
|
197
|
+
*/
|
|
198
|
+
export function startWatchdog(dc, accountId, log) {
|
|
199
|
+
let bad = 0
|
|
200
|
+
let lastIdle = Date.now()
|
|
201
|
+
const onIdle = (contextId) => {
|
|
202
|
+
if (contextId === accountId) lastIdle = Date.now()
|
|
203
|
+
}
|
|
204
|
+
dc.on('ImapInboxIdle', onIdle)
|
|
205
|
+
const restart = async (reason) => {
|
|
206
|
+
log(`restarting IO: ${reason}`)
|
|
207
|
+
try {
|
|
208
|
+
await dc.rpc.stopIo(accountId)
|
|
209
|
+
await dc.rpc.startIo(accountId)
|
|
210
|
+
lastIdle = Date.now()
|
|
211
|
+
bad = 0
|
|
212
|
+
} catch (error) {
|
|
213
|
+
log(`IO restart failed: ${error?.message ?? error}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const check = setInterval(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const connectivity = await dc.rpc.getConnectivity(accountId)
|
|
219
|
+
bad = connectivity < CONNECTING ? bad + 1 : 0
|
|
220
|
+
if (bad >= 2) await restart(`connectivity ${connectivity} on two checks`)
|
|
221
|
+
else if (Date.now() - lastIdle > 20 * 60_000 && connectivity < CONNECTED) await restart('no inbox idle for 20 minutes')
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log(`watchdog: ${error?.message ?? error}`)
|
|
224
|
+
}
|
|
225
|
+
}, 5 * 60_000)
|
|
226
|
+
const nudge = setInterval(() => {
|
|
227
|
+
dc.rpc.maybeNetwork().catch(() => {})
|
|
228
|
+
}, 10 * 60_000)
|
|
229
|
+
check.unref?.()
|
|
230
|
+
nudge.unref?.()
|
|
231
|
+
return () => {
|
|
232
|
+
clearInterval(check)
|
|
233
|
+
clearInterval(nudge)
|
|
234
|
+
dc.off?.('ImapInboxIdle', onIdle)
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/identity.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a Delta Chat contact is. Delta Chat contacts are identified by their
|
|
3
|
+
* OpenPGP key (addresses can change when someone moves or adds relays), so
|
|
4
|
+
* the allowlist and the sender label use the key fingerprint.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a fingerprint as typed or shown by Delta Chat ("3C1F 9E0A ...").
|
|
10
|
+
* @param value - fingerprint text.
|
|
11
|
+
* @returns 40 (v4) or 64 (v6) upper-case hex characters, or '' when invalid.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeFingerprint(value) {
|
|
14
|
+
const hex = String(value ?? '').replace(/[\s:]/gu, '').toUpperCase()
|
|
15
|
+
return /^(?:[0-9A-F]{40}|[0-9A-F]{64})$/u.test(hex) ? hex : ''
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read the first OpenPGP packet (the primary public key) of a key block.
|
|
20
|
+
* @param key - binary transferable public key.
|
|
21
|
+
* @returns `{ tag, body }`, or undefined when the data is not a packet.
|
|
22
|
+
*/
|
|
23
|
+
function firstPacket(key) {
|
|
24
|
+
if (key.length < 2 || (key[0] & 0x80) === 0) return undefined
|
|
25
|
+
const header = key[0]
|
|
26
|
+
let offset = 1
|
|
27
|
+
let length
|
|
28
|
+
let tag
|
|
29
|
+
if ((header & 0x40) !== 0) {
|
|
30
|
+
tag = header & 0x3f
|
|
31
|
+
const first = key[offset++]
|
|
32
|
+
if (first < 192) length = first
|
|
33
|
+
else if (first < 224) length = ((first - 192) << 8) + key[offset++] + 192
|
|
34
|
+
else if (first === 255) {
|
|
35
|
+
length = key.readUInt32BE(offset)
|
|
36
|
+
offset += 4
|
|
37
|
+
} else return undefined
|
|
38
|
+
} else {
|
|
39
|
+
tag = (header >> 2) & 0x0f
|
|
40
|
+
const type = header & 0x03
|
|
41
|
+
if (type === 0) length = key[offset++]
|
|
42
|
+
else if (type === 1) {
|
|
43
|
+
length = key.readUInt16BE(offset)
|
|
44
|
+
offset += 2
|
|
45
|
+
} else if (type === 2) {
|
|
46
|
+
length = key.readUInt32BE(offset)
|
|
47
|
+
offset += 4
|
|
48
|
+
} else return undefined
|
|
49
|
+
}
|
|
50
|
+
if (offset + length > key.length) return undefined
|
|
51
|
+
return { tag, body: key.subarray(offset, offset + length) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fingerprint of an OpenPGP public key (RFC 4880 v4: SHA-1 over 0x99, a
|
|
56
|
+
* two-byte length and the key packet; RFC 9580 v6: SHA-256 over 0x9B, a
|
|
57
|
+
* four-byte length and the packet).
|
|
58
|
+
* @param key - binary transferable public key.
|
|
59
|
+
* @returns the fingerprint in upper-case hex, or '' when it cannot be read.
|
|
60
|
+
*/
|
|
61
|
+
export function keyFingerprint(key) {
|
|
62
|
+
const packet = firstPacket(key)
|
|
63
|
+
if (packet === undefined || packet.tag !== 6 || packet.body.length === 0) return ''
|
|
64
|
+
const version = packet.body[0]
|
|
65
|
+
if (version === 4) {
|
|
66
|
+
const head = Buffer.from([0x99, (packet.body.length >> 8) & 0xff, packet.body.length & 0xff])
|
|
67
|
+
return createHash('sha1').update(head).update(packet.body).digest('hex').toUpperCase()
|
|
68
|
+
}
|
|
69
|
+
if (version === 6) {
|
|
70
|
+
const head = Buffer.alloc(5)
|
|
71
|
+
head[0] = 0x9b
|
|
72
|
+
head.writeUInt32BE(packet.body.length, 1)
|
|
73
|
+
return createHash('sha256').update(head).update(packet.body).digest('hex').toUpperCase()
|
|
74
|
+
}
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fingerprint of the key a vCard (from `makeVcard`) carries.
|
|
80
|
+
* @param vcard - vCard text.
|
|
81
|
+
* @returns the fingerprint, or '' when the card has no readable key.
|
|
82
|
+
*/
|
|
83
|
+
export function vcardFingerprint(vcard) {
|
|
84
|
+
const unfolded = String(vcard ?? '').replace(/\r?\n[ \t]/gu, '')
|
|
85
|
+
const match = unfolded.match(/^KEY[^:\n]*:data:application\/pgp-keys;base64\\?,([A-Za-z0-9+/=]+)\s*$/mu)
|
|
86
|
+
if (match === null) return ''
|
|
87
|
+
return keyFingerprint(Buffer.from(match[1], 'base64'))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Short form of a fingerprint for display: the first and last four groups.
|
|
92
|
+
* @param fingerprint - normalized fingerprint.
|
|
93
|
+
* @returns e.g. `3C1F 9E0A … 8ACE 1357`.
|
|
94
|
+
*/
|
|
95
|
+
export function shortFingerprint(fingerprint) {
|
|
96
|
+
const groups = String(fingerprint).match(/.{1,4}/gu) ?? []
|
|
97
|
+
if (groups.length <= 4) return groups.join(' ')
|
|
98
|
+
return `${groups.slice(0, 2).join(' ')} … ${groups.slice(-2).join(' ')}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find a fingerprint on the allowlist.
|
|
103
|
+
* @param allowed - configured `{ fingerprint, name }` entries.
|
|
104
|
+
* @param fingerprint - the contact's fingerprint.
|
|
105
|
+
* @returns the entry with its fingerprint normalized, or undefined.
|
|
106
|
+
*/
|
|
107
|
+
export function allowedEntry(allowed, fingerprint) {
|
|
108
|
+
const wanted = normalizeFingerprint(fingerprint)
|
|
109
|
+
if (wanted === '' || !Array.isArray(allowed)) return undefined
|
|
110
|
+
for (const entry of allowed) {
|
|
111
|
+
if (normalizeFingerprint(entry?.fingerprint) !== wanted) continue
|
|
112
|
+
const name = typeof entry.name === 'string' ? entry.name.trim() : ''
|
|
113
|
+
return { fingerprint: wanted, name }
|
|
114
|
+
}
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The line that tells the agent who wrote a message. The bot adds it from the
|
|
120
|
+
* sender's verified key, so text inside a message cannot fake it.
|
|
121
|
+
* @param sender - `{ name, fingerprint, displayName, chatId, messageId }`.
|
|
122
|
+
* @returns one bracketed line.
|
|
123
|
+
*/
|
|
124
|
+
export function senderLine({ name, fingerprint, displayName, chatId, messageId }) {
|
|
125
|
+
// The display name is chosen by the contact; keep it from shaping the line.
|
|
126
|
+
const shown = String(displayName ?? '').replace(/[\[\]"\r\n]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 64)
|
|
127
|
+
const who = name || shown || 'unknown'
|
|
128
|
+
const details = []
|
|
129
|
+
if (shown && shown !== name) details.push(`Delta Chat name "${shown}"`)
|
|
130
|
+
details.push(`key ${fingerprint}`)
|
|
131
|
+
if (Number.isSafeInteger(chatId)) details.push(`chat ${chatId}`)
|
|
132
|
+
if (Number.isSafeInteger(messageId)) details.push(`message ${messageId}`)
|
|
133
|
+
return `[Delta Chat message from ${who} (${details.join(', ')})]`
|
|
134
|
+
}
|
package/src/inbound.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn one incoming Delta Chat message into a DSH prompt: the sender line,
|
|
3
|
+
* what it quotes, its text, and its file as an attachment (pictures as
|
|
4
|
+
* images, recordings transcribed).
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { basename } from 'node:path'
|
|
8
|
+
import { MAX_FILE_BYTES } from './constants.js'
|
|
9
|
+
import { senderLine } from './identity.js'
|
|
10
|
+
import { clip } from './text.js'
|
|
11
|
+
import { transcribe, transcriptNote } from './voice.js'
|
|
12
|
+
|
|
13
|
+
/** Longest quoted text passed to the agent. */
|
|
14
|
+
const QUOTE_LIMIT = 2000
|
|
15
|
+
/** Longest text of a message sent on someone's behalf passed to the agent. */
|
|
16
|
+
const SENT_TEXT_LIMIT = 8000
|
|
17
|
+
/** Image types the DSH attachment store takes as pictures. */
|
|
18
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Describe what a message quotes.
|
|
22
|
+
* @param quote - `msg.quote`.
|
|
23
|
+
* @param context - `{ quotedOwn, sentRecord }`: whether the quoted message is
|
|
24
|
+
* the bot's own and the send record it belongs to.
|
|
25
|
+
* @returns a context block, or undefined.
|
|
26
|
+
*/
|
|
27
|
+
export function describeQuote(quote, { quotedOwn, sentRecord } = {}) {
|
|
28
|
+
if (quote === null || quote === undefined) return undefined
|
|
29
|
+
const text = clip(quote.text ?? '', QUOTE_LIMIT)
|
|
30
|
+
if (quote.kind !== 'WithMessage') return `[Quoting:]\n${text}`
|
|
31
|
+
if (quotedOwn) {
|
|
32
|
+
if (sentRecord !== undefined) {
|
|
33
|
+
const by = sentRecord.job ? `job "${sentRecord.job}"` : sentRecord.fromSession ? `session ${sentRecord.fromSession}` : sentRecord.label || 'another sender'
|
|
34
|
+
const full = typeof sentRecord.text === 'string' && sentRecord.text.length > 0 ? clip(sentRecord.text, SENT_TEXT_LIMIT) : text
|
|
35
|
+
return `[Replying to a message ${by} sent here:]\n${full}`
|
|
36
|
+
}
|
|
37
|
+
return `[Replying to your earlier message:]\n${text}`
|
|
38
|
+
}
|
|
39
|
+
const author = quote.overrideSenderName || quote.authorDisplayName || 'someone'
|
|
40
|
+
return `[Replying to ${author}:]\n${text}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Delta Chat view types that carry speech. */
|
|
44
|
+
const SPOKEN = { Voice: 'voice message', Audio: 'audio file' }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the prompt for one message.
|
|
48
|
+
* @param msg - Delta Chat message (`getMessage`).
|
|
49
|
+
* @param options - `{ sender, chatId, quotedOwn, sentRecord, attachments, voice, signal, warn }`:
|
|
50
|
+
* `sender` is `{ name, fingerprint, displayName }`; `attachments` the DSH
|
|
51
|
+
* attachment store; `voice` `{ mode, language, command, ffmpeg, speech }`.
|
|
52
|
+
* @returns `{ text, attachments }`.
|
|
53
|
+
*/
|
|
54
|
+
export async function composePrompt(msg, options) {
|
|
55
|
+
const { sender, chatId, attachments: store, voice, signal, warn } = options
|
|
56
|
+
const lines = [senderLine({ ...sender, chatId, messageId: msg.id })]
|
|
57
|
+
if (msg.isForwarded) lines.push('[Forwarded message]')
|
|
58
|
+
const quote = describeQuote(msg.quote, options)
|
|
59
|
+
if (quote !== undefined) lines.push(quote)
|
|
60
|
+
const blocks = []
|
|
61
|
+
if (typeof msg.file === 'string' && msg.file.length > 0) {
|
|
62
|
+
const name = msg.fileName || basename(msg.file)
|
|
63
|
+
const size = msg.fileBytes ?? (await stat(msg.file).catch(() => undefined))?.size ?? 0
|
|
64
|
+
const spoken = SPOKEN[msg.viewType]
|
|
65
|
+
if (spoken !== undefined && voice.mode !== 'off') {
|
|
66
|
+
const media = { kind: spoken, name, seconds: msg.duration > 0 ? Math.round(msg.duration / 1000) : undefined }
|
|
67
|
+
try {
|
|
68
|
+
const result = await transcribe({ ...voice, path: msg.file, signal })
|
|
69
|
+
lines.push(transcriptNote(media, { ...result, path: msg.file }))
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lines.push(transcriptNote(media, { path: msg.file, error: error?.message ?? String(error) }))
|
|
72
|
+
}
|
|
73
|
+
} else if (size > MAX_FILE_BYTES) {
|
|
74
|
+
lines.push(`[File "${name}" (${size} bytes) is too large to attach; it is saved at ${msg.file}]`)
|
|
75
|
+
} else {
|
|
76
|
+
lines.push(`[${msg.viewType === 'Image' || msg.viewType === 'Gif' || msg.viewType === 'Sticker' ? 'Picture' : 'File'} "${name}", saved at ${msg.file}]`)
|
|
77
|
+
}
|
|
78
|
+
if (size <= MAX_FILE_BYTES && store !== undefined) {
|
|
79
|
+
try {
|
|
80
|
+
const data = await readFile(msg.file)
|
|
81
|
+
const mediaType = String(msg.fileMime ?? '').toLowerCase()
|
|
82
|
+
let block
|
|
83
|
+
if (IMAGE_TYPES.has(mediaType)) {
|
|
84
|
+
try {
|
|
85
|
+
block = { type: 'image', attachment: await store.saveImage({ data, mediaType, name }) }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
warn?.(`${name} kept as a file, not an image: ${error?.message ?? error}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
block ??= { type: 'file', attachment: await store.saveFile({ data, name }) }
|
|
91
|
+
blocks.push(block)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
warn?.(`${name} not attached: ${error?.message ?? error}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const body = String(msg.text ?? '').trim()
|
|
98
|
+
if (body.length > 0) lines.push(body)
|
|
99
|
+
return { text: lines.join('\n'), attachments: blocks }
|
|
100
|
+
}
|