@soffits/dsh-deltachat-bot 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/bin/dsh-deltachat.js +140 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +14 -0
- package/index.js +4 -0
- package/locale/en.json +6 -0
- package/package.json +67 -4
- package/src/app.js +653 -0
- package/src/commands.js +454 -0
- package/src/config.js +63 -0
- package/src/constants.js +50 -0
- package/src/controls.js +214 -0
- package/src/core.js +236 -0
- package/src/helpers.js +180 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +183 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/reactions.js +125 -0
- package/src/sessions.js +388 -0
- package/src/spool.js +172 -0
- package/src/state.js +162 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +197 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
package/src/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
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model selection for chats. The session's live agent owns its model
|
|
3
|
+
* (`agent.model.current()`); the bot keeps no copy of it. Lists come from the
|
|
4
|
+
* same catalog as the Web model picker (`sessionController.modelCatalog()`),
|
|
5
|
+
* so every configured provider and model shows with its Web display name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** How long one catalog read is reused for labels and menus. */
|
|
9
|
+
const CATALOG_TTL_MS = 60_000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Copy a selection, dropping an absent effort.
|
|
13
|
+
* @param selection - provider, model and optional effort.
|
|
14
|
+
* @returns a detached selection.
|
|
15
|
+
*/
|
|
16
|
+
function toSelection({ provider, model, reasoningEffort }) {
|
|
17
|
+
return { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a session's current model: the live agent's own choice, or, for a
|
|
22
|
+
* session that is not running, its recorded selection.
|
|
23
|
+
* @param ctx - Host context.
|
|
24
|
+
* @param sessionId - DSH session id.
|
|
25
|
+
* @returns the selection, or undefined when the session has none recorded.
|
|
26
|
+
*/
|
|
27
|
+
export async function sessionModelOf(ctx, sessionId) {
|
|
28
|
+
if (typeof sessionId !== 'string') return undefined
|
|
29
|
+
const agent = ctx.get('agents')?.get(sessionId)
|
|
30
|
+
if (typeof agent?.model?.current === 'function') {
|
|
31
|
+
const current = agent.model.current()
|
|
32
|
+
return current === undefined ? undefined : toSelection(current)
|
|
33
|
+
}
|
|
34
|
+
const query = ctx.get('sessionQuery')
|
|
35
|
+
if (typeof query?.observeSession !== 'function') return undefined
|
|
36
|
+
let observation
|
|
37
|
+
try {
|
|
38
|
+
observation = await query.observeSession(sessionId)
|
|
39
|
+
// The modelSelection projection's view: `next` is the pending choice or
|
|
40
|
+
// the route of the last request.
|
|
41
|
+
const view = observation?.projections?.values?.modelSelection
|
|
42
|
+
const recorded = view?.next ?? view?.pending ?? view?.lastUsed
|
|
43
|
+
return typeof recorded?.provider === 'string' && typeof recorded?.model === 'string' ? toSelection(recorded) : undefined
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined
|
|
46
|
+
} finally {
|
|
47
|
+
observation?.[Symbol.dispose]?.()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the deployment default model (the one the Web UI saves).
|
|
53
|
+
* @param ctx - Host context.
|
|
54
|
+
* @returns the selection, or undefined without a default-model service.
|
|
55
|
+
*/
|
|
56
|
+
export function defaultModelOf(ctx) {
|
|
57
|
+
try {
|
|
58
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection()
|
|
59
|
+
if (typeof selection?.provider === 'string' && typeof selection?.model === 'string') return toSelection(selection)
|
|
60
|
+
} catch {
|
|
61
|
+
// No default configured.
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the catalog no longer offers a selection's provider or model, for
|
|
68
|
+
* example after a provider was removed from the profile. A provider the
|
|
69
|
+
* catalog reports as failing is not counted: its models are unknown now, not
|
|
70
|
+
* gone. Without a catalog nothing is called gone.
|
|
71
|
+
* @param catalog - loaded catalog, or undefined.
|
|
72
|
+
* @param selection - provider and model, or undefined.
|
|
73
|
+
* @returns true when the selection names something the catalog dropped.
|
|
74
|
+
*/
|
|
75
|
+
export function isGoneModel(catalog, selection) {
|
|
76
|
+
if (catalog === undefined || typeof selection?.provider !== 'string' || typeof selection?.model !== 'string') return false
|
|
77
|
+
if (catalog.failures.some((failure) => failure?.id === selection.provider)) return false
|
|
78
|
+
return findModel(catalog, selection) === undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether two selections name the same provider, model and effort.
|
|
83
|
+
* @param left - first selection.
|
|
84
|
+
* @param right - second selection.
|
|
85
|
+
* @returns true when they agree.
|
|
86
|
+
*/
|
|
87
|
+
export function sameSelection(left, right) {
|
|
88
|
+
return left?.provider === right?.provider && left?.model === right?.model && left?.reasoningEffort === right?.reasoningEffort
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cached reader for the Web model catalog.
|
|
93
|
+
* @param ctx - Host context.
|
|
94
|
+
* @returns `load()` resolving the catalog, or undefined when it is unavailable.
|
|
95
|
+
*/
|
|
96
|
+
export function createModelCatalog(ctx) {
|
|
97
|
+
let cached
|
|
98
|
+
let loadedAt = 0
|
|
99
|
+
let pending
|
|
100
|
+
return {
|
|
101
|
+
/**
|
|
102
|
+
* @param fresh - skip the short-lived cache (used before applying a pick).
|
|
103
|
+
* @returns `{ groups, failures }` as the Web picker gets it, or undefined.
|
|
104
|
+
*/
|
|
105
|
+
async load(fresh = false) {
|
|
106
|
+
if (!fresh && cached !== undefined && Date.now() - loadedAt < CATALOG_TTL_MS) return cached
|
|
107
|
+
pending ??= (async () => {
|
|
108
|
+
const controller = ctx.get('sessionController')
|
|
109
|
+
if (typeof controller?.modelCatalog !== 'function') return undefined
|
|
110
|
+
const catalog = await controller.modelCatalog()
|
|
111
|
+
cached = { groups: Array.isArray(catalog?.groups) ? catalog.groups : [], failures: Array.isArray(catalog?.failures) ? catalog.failures : [] }
|
|
112
|
+
loadedAt = Date.now()
|
|
113
|
+
return cached
|
|
114
|
+
})().finally(() => { pending = undefined })
|
|
115
|
+
try {
|
|
116
|
+
return await pending
|
|
117
|
+
} catch {
|
|
118
|
+
return cached
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find a catalog entry for a selection.
|
|
126
|
+
* @param catalog - loaded catalog.
|
|
127
|
+
* @param selection - provider and model.
|
|
128
|
+
* @returns `{ group, model }`, or undefined.
|
|
129
|
+
*/
|
|
130
|
+
export function findModel(catalog, selection) {
|
|
131
|
+
const group = catalog?.groups.find((entry) => entry.id === selection?.provider)
|
|
132
|
+
const model = group?.models.find((entry) => entry.id === selection?.model)
|
|
133
|
+
return model === undefined ? undefined : { group, model }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Display name of a selection's model, as the Web picker shows it.
|
|
138
|
+
* @param catalog - loaded catalog, or undefined.
|
|
139
|
+
* @param selection - provider and model.
|
|
140
|
+
* @returns the name, or the raw model id.
|
|
141
|
+
*/
|
|
142
|
+
export function modelName(catalog, selection) {
|
|
143
|
+
if (selection === undefined) return '?'
|
|
144
|
+
return findModel(catalog, selection)?.model.name ?? selection.model
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Effort choices for one catalog model, the same list the Web picker builds:
|
|
149
|
+
* a provider-default entry when the model has no default effort, then its
|
|
150
|
+
* efforts. A model without reasoning has none.
|
|
151
|
+
* @param model - catalog model entry.
|
|
152
|
+
* @param defaultLabel - label for the provider default.
|
|
153
|
+
* @returns `{ id, name }` entries; `id` undefined means the provider default.
|
|
154
|
+
*/
|
|
155
|
+
export function effortChoices(model, defaultLabel) {
|
|
156
|
+
const reasoning = model?.reasoning
|
|
157
|
+
if (reasoning === undefined) return []
|
|
158
|
+
return [
|
|
159
|
+
...(reasoning.defaultEffort === undefined ? [{ id: undefined, name: defaultLabel }] : []),
|
|
160
|
+
...reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name })),
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Display name of a selection's effective effort.
|
|
166
|
+
* @param catalog - loaded catalog, or undefined.
|
|
167
|
+
* @param selection - current selection.
|
|
168
|
+
* @param defaultLabel - label for the provider default.
|
|
169
|
+
* @returns the name.
|
|
170
|
+
*/
|
|
171
|
+
export function effortName(catalog, selection, defaultLabel) {
|
|
172
|
+
const reasoning = findModel(catalog, selection)?.model.reasoning
|
|
173
|
+
const effective = selection?.reasoningEffort ?? reasoning?.defaultEffort
|
|
174
|
+
if (effective === undefined) return defaultLabel
|
|
175
|
+
return reasoning?.efforts.find((effort) => effort.id === effective)?.name ?? effective
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The selection a model pick makes, by the Web picker's rule: the same model
|
|
180
|
+
* keeps its effort, another model starts at its default effort.
|
|
181
|
+
* @param current - the session's current selection.
|
|
182
|
+
* @param provider - picked provider id.
|
|
183
|
+
* @param model - picked catalog model entry.
|
|
184
|
+
* @returns the selection to apply.
|
|
185
|
+
*/
|
|
186
|
+
export function pickedSelection(current, provider, model) {
|
|
187
|
+
const same = current?.provider === provider && current?.model === model.id
|
|
188
|
+
const reasoningEffort = same ? current.reasoningEffort ?? model.reasoning?.defaultEffort : model.reasoning?.defaultEffort
|
|
189
|
+
return toSelection({ provider, model: model.id, reasoningEffort })
|
|
190
|
+
}
|
|
191
|
+
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bot offers outside chats: the send service, the chat list, the
|
|
3
|
+
* invite, joining someone's invite, allowing a key, and its status.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { dataDir, POST_SOURCE_KIND } from './constants.js'
|
|
7
|
+
import { allowedEntry, normalizeFingerprint } from './identity.js'
|
|
8
|
+
import { finalError } from './outbound.js'
|
|
9
|
+
import { recordMessage, resolveAgent } from './sessions.js'
|
|
10
|
+
import { clip } from './text.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The address an invite link names as the profile's primary relay address.
|
|
14
|
+
* @param link - invite link.
|
|
15
|
+
* @returns the address, or undefined.
|
|
16
|
+
*/
|
|
17
|
+
export function primaryAddress(link) {
|
|
18
|
+
const match = String(link ?? '').match(/[#&]a=([^&]+)/u)
|
|
19
|
+
return match === null ? undefined : decodeURIComponent(match[1].replace(/\+/gu, ' '))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the operations.
|
|
24
|
+
* @param deps - `{ ctx, getStore, storeReady, requireRuntime, getRuntime, getStatus, setting, outbound, currentTurn, writeInvite, info, reportError, messageOf }`.
|
|
25
|
+
*/
|
|
26
|
+
export function createOperations({ ctx, getStore, storeReady, requireRuntime, getRuntime, getStatus, setting, outbound, currentTurn, writeInvite, info, reportError, messageOf }) {
|
|
27
|
+
const { queueOut, sendText, sendFiles, checkFiles } = outbound
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send text, files or a reaction into a chat on behalf of a job, a session
|
|
31
|
+
* or a command, and record it in the session bound to that chat.
|
|
32
|
+
* @param request - `{ to, text, files, from, replyTo, reaction }`: `to` is
|
|
33
|
+
* the chat id; `from` `{ job, sessionId, label }` names the sender;
|
|
34
|
+
* `replyTo` quotes a message; `reaction` reacts to `replyTo` instead of
|
|
35
|
+
* sending text.
|
|
36
|
+
* @returns where it went and whether the session recorded it.
|
|
37
|
+
*/
|
|
38
|
+
async function sendToChat(request) {
|
|
39
|
+
await storeReady
|
|
40
|
+
const store = getStore()
|
|
41
|
+
const { dc, accountId } = requireRuntime()
|
|
42
|
+
const chatId = Number(request?.to)
|
|
43
|
+
if (!Number.isSafeInteger(chatId) || chatId <= 9) throw finalError(`Unknown chat ${request?.to}; /id in a chat shows its id.`)
|
|
44
|
+
const chatInfo = await dc.rpc.getBasicChatInfo(accountId, chatId).catch(() => undefined)
|
|
45
|
+
if (chatInfo === undefined || chatInfo.isDeviceChat) throw finalError(`Unknown chat ${chatId}; /id in a chat shows its id.`)
|
|
46
|
+
const replyTo = request?.replyTo === undefined || request?.replyTo === null ? undefined : Number(request.replyTo)
|
|
47
|
+
if (typeof request?.reaction === 'string') {
|
|
48
|
+
if (!Number.isSafeInteger(replyTo)) throw finalError('A reaction needs replyTo: the message id to react to.')
|
|
49
|
+
await dc.rpc.sendReaction(accountId, replyTo, request.reaction.length === 0 ? [] : [request.reaction])
|
|
50
|
+
return { chatId, reacted: replyTo }
|
|
51
|
+
}
|
|
52
|
+
const text = typeof request?.text === 'string' ? request.text.trim() : ''
|
|
53
|
+
const paths = Array.isArray(request?.files) ? request.files.map(String) : []
|
|
54
|
+
if (text.length === 0 && paths.length === 0) throw finalError('Nothing to send: give text or files.')
|
|
55
|
+
const from = request?.from ?? {}
|
|
56
|
+
const sessionId = store.chat(chatId)?.sessionId
|
|
57
|
+
const ownTurn = typeof from.sessionId === 'string' && from.sessionId === sessionId ? currentTurn(sessionId) : undefined
|
|
58
|
+
// The answer of a turn this chat receives is delivered by itself, so text
|
|
59
|
+
// a session sends into its own chat would arrive twice.
|
|
60
|
+
const delivered = ownTurn !== undefined && (ownTurn.ours || ownTurn.goal || ownTurn.mirrored)
|
|
61
|
+
if (delivered && paths.length === 0) {
|
|
62
|
+
throw finalError('This session is bound to that chat and its answer is delivered there when the turn ends: write the text in your answer instead of sending it.')
|
|
63
|
+
}
|
|
64
|
+
const files = await checkFiles(paths)
|
|
65
|
+
const messageIds = await queueOut(chatId, async () => {
|
|
66
|
+
const ids = text.length > 0 ? await sendText(chatId, text, { quote: replyTo }) : []
|
|
67
|
+
ids.push(...await sendFiles(chatId, files))
|
|
68
|
+
return ids
|
|
69
|
+
})
|
|
70
|
+
if (ownTurn !== undefined) {
|
|
71
|
+
ownTurn.sent = true
|
|
72
|
+
if (text.length > 0) ownTurn.sentTexts.push(text)
|
|
73
|
+
}
|
|
74
|
+
await store.noteSent({
|
|
75
|
+
chatId,
|
|
76
|
+
messageIds,
|
|
77
|
+
text,
|
|
78
|
+
files: files.map((file) => file.name),
|
|
79
|
+
job: typeof from.job === 'string' && from.job.trim() ? from.job.trim() : null,
|
|
80
|
+
fromSession: typeof from.sessionId === 'string' ? from.sessionId : null,
|
|
81
|
+
label: typeof from.label === 'string' ? from.label : null,
|
|
82
|
+
})
|
|
83
|
+
let recorded = false
|
|
84
|
+
let recordError
|
|
85
|
+
if (typeof sessionId === 'string') {
|
|
86
|
+
try {
|
|
87
|
+
const agent = await resolveAgent(ctx, sessionId)
|
|
88
|
+
const self = sessionId === from.sessionId
|
|
89
|
+
const by = self ? ' by this session' : from.job ? ` by job "${from.job}"` : from.sessionId ? ` by session ${from.sessionId}` : from.label ? ` by ${from.label}` : ''
|
|
90
|
+
const header = `Sent to Delta Chat chat "${chatInfo.name}"${by}`
|
|
91
|
+
const content = [{ type: 'text', text: `[${header}]${text ? `\n${text}` : ''}${files.length > 0 ? `\n[Files: ${files.map((file) => file.name).join(', ')}]` : ''}` }]
|
|
92
|
+
recordMessage(agent, { id: randomUUID(), role: 'user', content, source: { kind: POST_SOURCE_KIND, form: 'notice', summary: clip(header, 120), chatId, self } })
|
|
93
|
+
recorded = true
|
|
94
|
+
} catch (error) {
|
|
95
|
+
recordError = messageOf(error)
|
|
96
|
+
reportError('send-record', error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { chatId, chat: chatInfo.name, messageIds, sessionId: sessionId ?? null, recorded, ...(recordError ? { recordError } : {}) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Known chats: every chat bound to a session, with its name. */
|
|
103
|
+
async function listChats() {
|
|
104
|
+
await storeReady
|
|
105
|
+
const store = getStore()
|
|
106
|
+
const { dc, accountId } = requireRuntime()
|
|
107
|
+
const result = []
|
|
108
|
+
for (const [chatId, record] of Object.entries(store.data.chats)) {
|
|
109
|
+
const chatInfo = await dc.rpc.getBasicChatInfo(accountId, Number(chatId)).catch(() => undefined)
|
|
110
|
+
if (chatInfo === undefined) continue
|
|
111
|
+
result.push({ chatId: Number(chatId), name: chatInfo.name, type: chatInfo.chatType, sessionId: record.sessionId ?? null })
|
|
112
|
+
}
|
|
113
|
+
return result
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The bot's invite link and QR code file. */
|
|
117
|
+
async function invite() {
|
|
118
|
+
const { dc, accountId } = requireRuntime()
|
|
119
|
+
return writeInvite(dc, accountId, dataDir())
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Secure-join someone's invite link (a contact or a group). */
|
|
123
|
+
async function joinInvite(link) {
|
|
124
|
+
const { dc, accountId } = requireRuntime()
|
|
125
|
+
const checked = await dc.rpc.checkQr(accountId, link)
|
|
126
|
+
if (!['askVerifyContact', 'askVerifyGroup', 'askJoinBroadcast'].includes(checked?.kind)) {
|
|
127
|
+
throw finalError(`Not an invite link (${checked?.kind ?? 'unknown'}).`)
|
|
128
|
+
}
|
|
129
|
+
const fingerprint = normalizeFingerprint(checked.fingerprint)
|
|
130
|
+
const allowed = allowedEntry(setting('allowedUsers', []), fingerprint)
|
|
131
|
+
if (allowed === undefined) {
|
|
132
|
+
throw finalError(`The invite belongs to key ${fingerprint || 'unknown'}, which is not in allowedUsers. Add it first.`)
|
|
133
|
+
}
|
|
134
|
+
const chatId = await dc.rpc.secureJoin(accountId, link)
|
|
135
|
+
info(`joining ${allowed.name || fingerprint} through their invite (chat ${chatId})`)
|
|
136
|
+
return { chatId, name: allowed.name, fingerprint }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Add someone to `allowedUsers` through the Host's config editor, which
|
|
141
|
+
* writes the profile's patch the same way the settings page does.
|
|
142
|
+
* @param fingerprint - their key fingerprint.
|
|
143
|
+
* @param label - how their messages are labelled.
|
|
144
|
+
*/
|
|
145
|
+
async function allowUser(fingerprint, label) {
|
|
146
|
+
const normalized = normalizeFingerprint(fingerprint)
|
|
147
|
+
if (normalized === '') throw finalError('A key fingerprint has 40 hex characters.')
|
|
148
|
+
const entry = ctx.fiber?.entry
|
|
149
|
+
const editor = ctx.get('configEditor')
|
|
150
|
+
if (entry === undefined || editor === undefined) throw new Error('This profile cannot save the bot\'s configuration; add the fingerprint to allowedUsers by hand.')
|
|
151
|
+
await editor.edit(entry, (current) => {
|
|
152
|
+
const list = Array.isArray(current?.allowedUsers) ? current.allowedUsers.filter((item) => normalizeFingerprint(item?.fingerprint) !== normalized) : []
|
|
153
|
+
return { ...current, allowedUsers: [...list, { fingerprint: normalized, name: String(label ?? '').trim() }] }
|
|
154
|
+
})
|
|
155
|
+
await storeReady
|
|
156
|
+
await getStore().clearPending([normalized])
|
|
157
|
+
return { fingerprint: normalized, name: String(label ?? '').trim() }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Runtime facts for `/status` and the command line. */
|
|
161
|
+
async function describe() {
|
|
162
|
+
await storeReady
|
|
163
|
+
const store = getStore()
|
|
164
|
+
const runtime = getRuntime()
|
|
165
|
+
if (runtime === undefined) return { running: false, state: getStatus().state, detail: getStatus().detail ?? '' }
|
|
166
|
+
const { dc, accountId, startedAt } = runtime
|
|
167
|
+
const transports = await dc.rpc.listTransports(accountId).catch(() => [])
|
|
168
|
+
const connectivity = await dc.rpc.getConnectivity(accountId).catch(() => undefined)
|
|
169
|
+
const selfAddr = primaryAddress(await dc.rpc.getChatSecurejoinQrCode(accountId, null).catch(() => '')) ?? transports[0]?.addr ?? null
|
|
170
|
+
return {
|
|
171
|
+
running: true,
|
|
172
|
+
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
|
|
173
|
+
primary: selfAddr,
|
|
174
|
+
relays: transports.map((transport) => transport.addr),
|
|
175
|
+
connectivity,
|
|
176
|
+
allowedUsers: setting('allowedUsers', []).length,
|
|
177
|
+
pending: Object.values(store.data.pending).map((entry) => ({ fingerprint: entry.fingerprint, displayName: entry.displayName, lastSeen: new Date(entry.lastSeen).toISOString(), count: entry.count })),
|
|
178
|
+
chats: Object.keys(store.data.chats).length,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { sendToChat, listChats, invite, joinInvite, allowUser, describe }
|
|
183
|
+
}
|