@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.
@@ -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
+ }
@@ -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
+ }