@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.
@@ -0,0 +1,79 @@
1
+ /** Sending into chats: text made plain and split, files, notices, in order per chat. */
2
+ import { stat } from 'node:fs/promises'
3
+ import { basename, isAbsolute } from 'node:path'
4
+ import { MAX_FILE_BYTES } from './constants.js'
5
+ import { messageData, viewTypeOf } from './core.js'
6
+ import { splitText, toPlainText } from './text.js'
7
+
8
+ /** An error that a retry will not fix. */
9
+ export function finalError(message) {
10
+ return Object.assign(new Error(message), { retryable: false })
11
+ }
12
+
13
+ /**
14
+ * Create the sender.
15
+ * @param deps - `{ requireRuntime, chatState, reportError, messageOf }`.
16
+ * @returns `{ queueOut, sendText, checkFiles, sendFiles, notice }`.
17
+ */
18
+ export function createOutbound({ requireRuntime, chatState, reportError, messageOf }) {
19
+ /** Run output for a chat in order. */
20
+ function queueOut(chatId, task) {
21
+ const chat = chatState(chatId)
22
+ const run = chat.out.catch(() => {}).then(task)
23
+ chat.out = run.catch((error) => reportError('send', error))
24
+ return run
25
+ }
26
+
27
+ // ---------------------------------------------------------------- outbound
28
+
29
+ /**
30
+ * Send text (Markdown is made plain) into a chat.
31
+ * @param chatId - chat id.
32
+ * @param text - Markdown text.
33
+ * @param options - `{ quote, plain }`: message id to quote on the first part;
34
+ * `plain` skips the Markdown conversion.
35
+ * @returns the sent message ids.
36
+ */
37
+ async function sendText(chatId, text, { quote, plain = false } = {}) {
38
+ const { dc, accountId } = requireRuntime()
39
+ const ids = []
40
+ for (const [index, part] of splitText(plain ? text : toPlainText(text)).entries()) {
41
+ ids.push(await dc.rpc.sendMsg(accountId, chatId, messageData({ text: part, ...(index === 0 && Number.isSafeInteger(quote) ? { quotedMessageId: quote } : {}) })))
42
+ }
43
+ return ids
44
+ }
45
+
46
+ /**
47
+ * Check files before anything is sent.
48
+ * @param paths - absolute paths.
49
+ * @returns `{ path, name, size }` entries; throws on the first bad one.
50
+ */
51
+ async function checkFiles(paths) {
52
+ const files = []
53
+ for (const path of paths) {
54
+ if (!isAbsolute(path)) throw finalError(`File paths must be absolute: ${path}`)
55
+ const info = await stat(path).catch((error) => { throw finalError(`${path}: ${messageOf(error)}`) })
56
+ if (!info.isFile()) throw finalError(`${path} is not a file`)
57
+ if (info.size > MAX_FILE_BYTES) throw finalError(`${path} is larger than ${MAX_FILE_BYTES / 1024 / 1024} MB`)
58
+ files.push({ path, name: basename(path), size: info.size })
59
+ }
60
+ return files
61
+ }
62
+
63
+ /** Send checked files; the core copies each into its blob directory. */
64
+ async function sendFiles(chatId, files) {
65
+ const { dc, accountId } = requireRuntime()
66
+ const ids = []
67
+ for (const file of files) {
68
+ ids.push(await dc.rpc.sendMsg(accountId, chatId, messageData({ file: file.path, filename: file.name, viewtype: viewTypeOf(file.path) })))
69
+ }
70
+ return ids
71
+ }
72
+
73
+ /** A short bot notice (already plain text) into a chat. */
74
+ function notice(chatId, text) {
75
+ return queueOut(chatId, () => sendText(chatId, text, { plain: true }))
76
+ }
77
+
78
+ return { queueOut, sendText, checkFiles, sendFiles, notice }
79
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Approval requests and agent questions for turns started from Delta Chat.
3
+ * Delta Chat has no buttons, so a question lists its options as numbers and
4
+ * the next message answers it; an approval waits for /allow or /deny.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ /**
9
+ * Parse a reply to a question with options.
10
+ * @param text - the reply.
11
+ * @param question - `{ options, multiSelect }`.
12
+ * @returns `{ selected, custom }`: option labels picked by number, or the
13
+ * whole reply as a free answer.
14
+ */
15
+ export function parseAnswer(text, question) {
16
+ const options = Array.isArray(question?.options) ? question.options : []
17
+ const trimmed = String(text ?? '').trim()
18
+ if (options.length > 0 && /^\d+(?:[\s,]+\d+)*$/u.test(trimmed)) {
19
+ const numbers = trimmed.split(/[\s,]+/u).map(Number)
20
+ if (numbers.every((number) => number >= 1 && number <= options.length)) {
21
+ const picked = [...new Set(numbers)].map((number) => options[number - 1].label)
22
+ return { selected: question.multiSelect === true ? picked : picked.slice(0, 1), custom: undefined }
23
+ }
24
+ }
25
+ return { selected: [], custom: trimmed }
26
+ }
27
+
28
+ /**
29
+ * Text of one question.
30
+ * @param question - user question.
31
+ * @param index - its position.
32
+ * @param total - questions in the batch.
33
+ */
34
+ export function questionText(question, index, total) {
35
+ const lines = []
36
+ if (question.header) lines.push(question.header)
37
+ lines.push(`❓ ${question.question}`)
38
+ if (question.detail) lines.push('', String(question.detail).slice(0, 3000))
39
+ const options = Array.isArray(question.options) ? question.options : []
40
+ if (options.length > 0) {
41
+ lines.push('')
42
+ for (const [number, option] of options.entries()) {
43
+ lines.push(`${number + 1}. ${option.label}${option.description ? ` — ${option.description}` : ''}`)
44
+ }
45
+ lines.push('', question.multiSelect === true
46
+ ? 'Reply with one or more numbers (like "1 3"), or write your own answer.'
47
+ : 'Reply with a number, or write your own answer.')
48
+ } else {
49
+ lines.push('', 'Reply with your answer.')
50
+ }
51
+ if (total > 1) lines.push(`(Question ${index + 1} of ${total}; /stop cancels.)`)
52
+ return lines.join('\n')
53
+ }
54
+
55
+ /**
56
+ * Create the prompt broker.
57
+ * @param options - `{ chatOfAgent, send, warn }`: `chatOfAgent(agent)` gives
58
+ * the chat that should answer for a turn, or undefined to leave it to DSH.
59
+ * @returns `{ attach, consumeReply, settleApproval, cancelChat, cancelAll, pending }`.
60
+ */
61
+ export function createPrompts({ chatOfAgent, send, warn }) {
62
+ const attached = new WeakSet()
63
+ /** Pending question batches by chat id. */
64
+ const questions = new Map()
65
+ /** Pending approvals by chat id (a list, oldest first). */
66
+ const approvals = new Map()
67
+
68
+ function finishQuestion(chatId, batch, error) {
69
+ if (questions.get(chatId) !== batch) return
70
+ questions.delete(chatId)
71
+ batch.signal?.removeEventListener('abort', batch.onAbort)
72
+ if (error === undefined) batch.resolve({ answers: batch.answers })
73
+ else batch.reject(error)
74
+ }
75
+
76
+ async function askQuestions(chatId, req, next) {
77
+ const list = Array.isArray(req.questions) ? req.questions : []
78
+ if (list.length === 0 || questions.has(chatId)) return next()
79
+ const signal = req.signal
80
+ if (signal?.aborted) return next()
81
+ return new Promise((resolve, reject) => {
82
+ const batch = { id: randomUUID(), questions: list, index: 0, answers: [], resolve, reject, signal, onAbort: undefined }
83
+ batch.onAbort = () => {
84
+ finishQuestion(chatId, batch, signal.reason ?? new Error('question cancelled'))
85
+ void send(chatId, '⏹ Question cancelled.')
86
+ }
87
+ signal?.addEventListener('abort', batch.onAbort, { once: true })
88
+ questions.set(chatId, batch)
89
+ Promise.resolve(send(chatId, questionText(list[0], 0, list.length))).catch((error) => {
90
+ warn(`question not sent: ${error?.message ?? error}`)
91
+ finishQuestion(chatId, batch, error)
92
+ })
93
+ })
94
+ }
95
+
96
+ async function askApproval(chatId, req) {
97
+ if (req.signal?.aborted) return 'cancelled'
98
+ const reason = req.displayReason?.en ?? req.reason ?? 'The tool asked for approval.'
99
+ return new Promise((resolve) => {
100
+ const entry = { resolve, toolName: req.toolName, signal: req.signal, onAbort: undefined }
101
+ entry.onAbort = () => settle(chatId, entry, 'cancelled')
102
+ req.signal?.addEventListener('abort', entry.onAbort, { once: true })
103
+ const list = approvals.get(chatId) ?? []
104
+ list.push(entry)
105
+ approvals.set(chatId, list)
106
+ Promise.resolve(send(chatId, `🔐 Approval needed for ${req.toolName}:\n${reason}\n\nReply /allow or /deny.`)).catch(() => settle(chatId, entry, 'unavailable'))
107
+ })
108
+ }
109
+
110
+ function settle(chatId, entry, outcome) {
111
+ const list = approvals.get(chatId) ?? []
112
+ const index = list.indexOf(entry)
113
+ if (index === -1) return false
114
+ list.splice(index, 1)
115
+ if (list.length === 0) approvals.delete(chatId)
116
+ entry.signal?.removeEventListener('abort', entry.onAbort)
117
+ entry.resolve(outcome)
118
+ return true
119
+ }
120
+
121
+ return {
122
+ /** Answer approval and question requests of an agent while its turn belongs to a chat. */
123
+ attach(agent) {
124
+ if (attached.has(agent) || typeof agent?.ctx?.on !== 'function') return
125
+ attached.add(agent)
126
+ agent.ctx.on('approval/request', (req, next) => {
127
+ const chatId = chatOfAgent(agent)
128
+ if (chatId === undefined) return next()
129
+ return askApproval(chatId, req)
130
+ }, { prepend: true })
131
+ agent.ctx.on('user-questions/request', (req, next) => {
132
+ const chatId = chatOfAgent(agent)
133
+ if (chatId === undefined) return next()
134
+ return askQuestions(chatId, req, next)
135
+ }, { prepend: true })
136
+ },
137
+ /**
138
+ * Take a message as the answer to the chat's open question.
139
+ * @returns true when the message answered a question.
140
+ */
141
+ consumeReply(chatId, text) {
142
+ const batch = questions.get(chatId)
143
+ if (batch === undefined || text.length === 0 || text.startsWith('/')) return false
144
+ const question = batch.questions[batch.index]
145
+ const { selected, custom } = parseAnswer(text, question)
146
+ batch.answers.push({ id: question.id, selected, ...(custom ? { custom } : {}) })
147
+ batch.index += 1
148
+ if (batch.index < batch.questions.length) {
149
+ void send(chatId, questionText(batch.questions[batch.index], batch.index, batch.questions.length))
150
+ } else {
151
+ finishQuestion(chatId, batch)
152
+ }
153
+ return true
154
+ },
155
+ /**
156
+ * Resolve the oldest approval of a chat.
157
+ * @returns the tool name, or undefined when none was waiting.
158
+ */
159
+ settleApproval(chatId, allowed) {
160
+ const entry = approvals.get(chatId)?.[0]
161
+ if (entry === undefined) return undefined
162
+ settle(chatId, entry, allowed ? 'allowed-once' : 'rejected')
163
+ return entry.toolName
164
+ },
165
+ /** Cancel what a chat was asked (on /stop). */
166
+ cancelChat(chatId) {
167
+ const batch = questions.get(chatId)
168
+ if (batch !== undefined) finishQuestion(chatId, batch, new Error('Question cancelled by /stop'))
169
+ for (const entry of [...(approvals.get(chatId) ?? [])]) settle(chatId, entry, 'cancelled')
170
+ },
171
+ cancelAll() {
172
+ for (const chatId of [...questions.keys(), ...approvals.keys()]) this.cancelChat(chatId)
173
+ },
174
+ /** @returns what a chat is being asked: `{ question, approvals }`. */
175
+ pending(chatId) {
176
+ return { question: questions.has(chatId), approvals: approvals.get(chatId)?.length ?? 0 }
177
+ },
178
+ }
179
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Working signs: Delta Chat has no typing indicator, so a message the bot
3
+ * works on carries a reaction from the bot (⏳ for a new or queued turn, 👀
4
+ * when it went into the running turn) until the turn that took it has ended
5
+ * and its answer was sent. Reacted messages are kept in `state.json`, so a
6
+ * sign left by a turn that a Host restart cut off is removed at the next start.
7
+ */
8
+ import { REQUEST_PREFIX, STEER_REACTION } from './constants.js'
9
+ import { inInbox } from './sessions.js'
10
+
11
+ /** Reaction on a message that starts a turn or waits for one. */
12
+ export const WORKING_REACTION = '⏳'
13
+
14
+ /**
15
+ * Create the working-sign tracker.
16
+ * @param deps - `{ ctx, getStore, getRuntime, reportError }`.
17
+ * @returns `{ emojiFor, mark, admitted, attach, finishTurn, sweep, resume, release }`.
18
+ */
19
+ export function createReactions({ ctx, getStore, getRuntime, reportError }) {
20
+ /** Reacted messages by message id: `{ chatId, admitted, turn }`. */
21
+ const tracked = new Map()
22
+ let loaded = false
23
+
24
+ /** Take over the messages a previous run left reacted. */
25
+ function load() {
26
+ if (loaded) return
27
+ const store = getStore()
28
+ if (store === undefined) return
29
+ loaded = true
30
+ for (const [msgId, chatId] of Object.entries(store.reactions())) {
31
+ if (!tracked.has(Number(msgId))) tracked.set(Number(msgId), { chatId, admitted: true, turn: undefined })
32
+ }
33
+ }
34
+
35
+ /** @returns the live agent of a chat's session, or undefined. */
36
+ function agentOf(chatId) {
37
+ const sessionId = getStore()?.chat(chatId)?.sessionId
38
+ return typeof sessionId === 'string' ? ctx.get('agents')?.get(sessionId) : undefined
39
+ }
40
+
41
+ /**
42
+ * The sign for a message about to go to a session.
43
+ * @param running - whether the session runs a turn.
44
+ * @param steer - whether a message during a turn goes into it.
45
+ */
46
+ function emojiFor(running, steer) {
47
+ return running && steer ? STEER_REACTION : WORKING_REACTION
48
+ }
49
+
50
+ /** React to a message and remember it. */
51
+ async function mark(chatId, msgId, emoji) {
52
+ load()
53
+ const runtime = getRuntime()
54
+ if (runtime === undefined) return
55
+ tracked.set(msgId, { chatId, admitted: false, turn: undefined })
56
+ await getStore()?.noteReaction(msgId, chatId)
57
+ await runtime.dc.rpc.sendReaction(runtime.accountId, msgId, [emoji]).catch((error) => reportError(`reaction on ${msgId}`, error))
58
+ }
59
+
60
+ /** The message reached the session, so a sweep may judge it. */
61
+ function admitted(msgId) {
62
+ const entry = tracked.get(msgId)
63
+ if (entry !== undefined) entry.admitted = true
64
+ }
65
+
66
+ /**
67
+ * The message is part of a turn now.
68
+ * @param msgId - Delta Chat message id.
69
+ * @param turn - key of the turn (session id and turn number).
70
+ */
71
+ function attach(msgId, turn) {
72
+ const entry = tracked.get(msgId)
73
+ if (entry !== undefined) entry.turn = turn
74
+ }
75
+
76
+ /** Remove the bot's reaction from messages. */
77
+ async function release(msgIds) {
78
+ const runtime = getRuntime()
79
+ if (msgIds.length === 0 || runtime === undefined) return
80
+ for (const msgId of msgIds) tracked.delete(msgId)
81
+ await getStore()?.dropReactions(msgIds)
82
+ for (const msgId of msgIds) {
83
+ await runtime.dc.rpc.sendReaction(runtime.accountId, msgId, []).catch((error) => reportError(`reaction on ${msgId}`, error))
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Messages of a chat that will get no turn: admitted, not taken into a
89
+ * turn, and no longer waiting in the agent's inbox (dropped by /stop or
90
+ * /clear, or a duplicate).
91
+ */
92
+ function dropped(chatId, { ignoreTurn = false } = {}) {
93
+ const agent = agentOf(chatId)
94
+ return [...tracked].filter(([msgId, entry]) => entry.chatId === chatId && entry.admitted
95
+ && (ignoreTurn || entry.turn === undefined) && !inInbox(agent, `${REQUEST_PREFIX}${chatId}:${msgId}`)).map(([msgId]) => msgId)
96
+ }
97
+
98
+ /**
99
+ * A turn ended and its answer went out: clear its messages (whatever chat
100
+ * the session is bound to now) and the chat's dropped ones.
101
+ */
102
+ async function finishTurn(chatId, turn) {
103
+ load()
104
+ const own = [...tracked].filter(([, entry]) => entry.turn === turn).map(([msgId]) => msgId)
105
+ await release([...own, ...dropped(chatId)])
106
+ }
107
+
108
+ /** Clear the signs of a chat's dropped messages. */
109
+ async function sweep(chatId) {
110
+ load()
111
+ await release(dropped(chatId))
112
+ }
113
+
114
+ /** Clear signs whose session runs no turn, e.g. after a restart. */
115
+ async function resume() {
116
+ load()
117
+ const idle = new Set([...tracked.values()].map((entry) => entry.chatId).filter((chatId) => {
118
+ const agent = agentOf(chatId)
119
+ return agent === undefined || agent.status === undefined || agent.status === 'idle'
120
+ }))
121
+ for (const chatId of idle) await release(dropped(chatId, { ignoreTurn: true }))
122
+ }
123
+
124
+ return { emojiFor, mark, admitted, attach, finishTurn, sweep, resume, release }
125
+ }